SDK reference
Source: packages/sdk
A thin, typed HTTP client. Every method here is a direct wrapper around one REST call — nothing here is a framework, a UI, or a required flow.
Install and instantiate
@waysafe/sdk is real, public source — packages/sdk in this repo, the same package the quickstart above already ran, straight from a clone. It isn’t published to npm yet because there’s no hosted API yet for a published package to point at — nothing to publish for, not something being withheld. Once a hosted deployment exists, this is what installing and pointing it at one will look like:
# not yet published -- this is what it will look like once a hosted API exists
npm install @waysafe/sdk
import { Waysafe } from "@waysafe/sdk";
const waysafe = new Waysafe({
baseUrl: "https://api.your-waysafe-deployment.example",
apiKey: process.env.WAYSAFE_API_KEY, // an org or agent credential
});Method reference
The public surface of @waysafe/sdk. For the full request/response shapes on the calls that decide whether money moves, see the raw REST reference below.
| Method | Description | Throws |
|---|---|---|
compileMandate(request: CompileMandateRequest): Promise<CompileMandateResponse> | Compiles a natural-language instruction into a policy. Does not create a mandate — a compiled result must be shown to the principal and authenticated before it can authorize anything. | ValidationError, NetworkError, UnauthorizedError |
createPrincipal(request: CreatePrincipalRequest): Promise<PrincipalDetail> | Registers the person (or org) a mandate delegates authority over. | ValidationError, UnauthorizedError, NetworkError |
getPrincipal(principalId: string): Promise<PrincipalDetail> | Fetches a principal by id. | NotFoundError, UnauthorizedError, NetworkError |
createMandate(request: CreateMandateRequest): Promise<CreatedMandate> | Persists a compiled, confirmed policy as a mandate, status PENDING_AUTHENTICATION. Not usable by authorize() until the principal authenticates it. | ValidationError, NotFoundError, NetworkError |
getMandate(mandateId: string): Promise<MandateDetail> | Fetches the full mandate: policy, intent text, assumptions, and bound agents. | NotFoundError, NetworkError |
listMandates(options?: { limit?: number }): Promise<MandateListItem[]> | Lists mandates in your organization, most-recent-first. | UnauthorizedError, NetworkError |
getInstrument(instrumentId: string): Promise<InstrumentDetail> | Fetches a rail-specific spend instrument — the actor a rail-initiated decision is attributed to when actor_kind is "instrument". | NotFoundError, UnauthorizedError, NetworkError |
getMandateAuthenticationOptions(mandateId: string): Promise<MandateAuthenticationOptions> | Returns whether the principal needs to register a first passkey or sign with one on file — hand challenge/rp_id/origin to your own frontend's WebAuthn call. | NotFoundError, NetworkError |
verifyMandateAuthentication(mandateId: string, request: VerifyMandateAuthenticationRequest): Promise<MandateAuthenticationResult> | Completes the registration or authentication ceremony your frontend started. Only mode "authenticate" activates the mandate. | ValidationError, NotFoundError, NetworkError |
authorize(request: AuthorizeRequest): Promise<AuthorizationDecision> | Asks whether an agent may take an action under a mandate. DENY and STEP_UP are normal return values, not errors — this only throws when the request couldn't be decided at all. | NoActiveMandateError, IdempotencyConflictError, ValidationError, NetworkError |
execute(decision: ExecutableDecision, params: ExecuteParams): Promise<AuthorizationDecision> | Executes an authorized action against a payment rail. Only accepts what asExecutable() returns — a DENIED or still-pending decision can't be passed here even by mistake. | ExecutionRejectedError, UnknownRailError, AuthorizationStatusConflictError, NetworkError |
verify(authorizationId: string): Promise<AuthorizationDecision> | Fetches the current state of an authorization — the receipt. | NotFoundError, NetworkError |
resolveStepUp(authorizationId: string, approver: { agentId: string; principalId: string; mandateId?: string; idempotencyKey?: string }): Promise<AuthorizationDecision> | Resolves a needs-higher-authority step-up as an approver mandate. Never a bare approve/decline flag — the real evaluate() engine runs against the approver's own policy. ALLOW resolves to STEP_UP_APPROVED; DENY or STEP_UP resolves to STEP_UP_DECLINED. Already resolved or expired: replays the recorded outcome, never re-evaluates. | StepUpResolutionRejectedError, AuthorizationStatusConflictError, NetworkError |
listAuthorizations(options?: { limit?: number }): Promise<AuthorizationDecision[]> | Lists authorizations in your organization, most-recent-first. | UnauthorizedError, NetworkError |
listReasonCodes(): Promise<{ code: ReasonCode; description: string }[]> | Returns the reason-code dictionary with operator-facing prose. Branch on the code, not this text. | UnauthorizedError, NetworkError |
createAgent(request: CreateAgentRequest): Promise<CreatedAgent> | Registers an agent. | ValidationError, UnauthorizedError, NetworkError |
listAgents(): Promise<AgentSummary[]> | Lists agents in your organization. | UnauthorizedError, NetworkError |
createAgentKey(agentId: string, request: { name: string }): Promise<CreatedAgentKey> | Mints an agent API key. The full key is shown exactly once in this return value — it is never retrievable again. | NotFoundError, ValidationError, NetworkError |
listKeys(): Promise<AgentKeySummary[]> | Lists every key in your organization — agent keys and org credentials alike, never the full key. | UnauthorizedError, NetworkError |
listEvidence(options?: { subject?: string }): Promise<EvidenceRecord[]> | Fetches the evidence chain for your organization, optionally filtered to one subject. | UnauthorizedError, NetworkError |
getEvidencePublicKey(): Promise<EvidencePublicKey> | The Ed25519 public key every evidence event's signature is checked against. No credential required — a third party auditing a receipt has none. Pin this value once verified out-of-band. | NetworkError |
verifyEvidenceChain(): Promise<ChainVerificationResult> | Asks the server to verify its own chain — hash consistency plus every signature. A convenience call that still trusts the server's own judgment. | UnauthorizedError, NetworkError |
asExecutable(decision: AuthorizationDecision): ExecutableDecision | null | The only constructor for ExecutableDecision. Returns null for any status other than AUTHORIZED or STEP_UP_APPROVED — passing a DENIED decision to execute() is a compile error, not a runtime check. | Never throws. |
verifyEvidenceIndependently(events: EvidenceRecord[], publicKeyBase64: string): ChainVerificationResult | Verifies a chain's hash consistency and every signature locally — no network call, no trust in this server's own judgment. Runs on events from listEvidence() and the key from getEvidencePublicKey(). | Throws if publicKeyBase64 is not a valid Ed25519 SPKI key. |
Raw REST reference
The SDK is a thin wrapper — this is the wire format underneath, for the two calls that decide whether money moves. Shapes below are transcribed from apps/api/src/server.ts and packages/core/src/domain.ts.
POST /v1/authorizations
Request body:
{
"agent_id": "agt_...",
"principal_id": "prin_...",
"mandate_id": "mdt_...", // optional -- pins evaluation to one mandate
"action": {
"amount": 4200, // integer minor units -- $42.00, never a float
"currency": "USD",
"merchant": {
"name": "Staples", // agent's own claims -- optional, any/all of these
"domain": "staples.com",
"psp_account": "acct_...",
"network_mid": "...",
"mcc": "5943",
"onchain_address": "0x..."
},
"category": "office_supplies", // optional
"description": "Copier paper, 10 reams", // optional, shown on the receipt
"attestations": {} // optional agent claims, evaluated as claims not facts
},
"idempotency_key": "a-client-generated-key-8-255-chars", // optional, required in production
"context": {} // optional, arbitrary caller context recorded on the receipt
}merchant carries only what the agent asserts on this call. Which field a value sits in never upgrades its trust — a rail-attested psp_account or network_mid (one the payment rail’s own callback supplied, not one the agent typed here) verifies; the same field asserted directly on this request caps out at STEP_UP, exactly like an unverified name.
Trust belongs to each identifier, not to the merchant as a whole. Sending a real domain alongside an account id of your own does not launder the account id: a match produces ALLOW only when the identifier that matched is itself verified and no identifier on the request is unverified. The STEP_UP_MERCHANT_UNVERIFIED reason names which one failed, so a receipt distinguishes a laundered sibling from a wholly unknown merchant.
On the WebAuthn ceremony routes, a challenge carries the purpose it was issued for and that purpose is authoritative: answering an authentication challenge with a registration response (or the reverse) is a 400 ({"error":"challenge_purpose_mismatch"}), never silently accepted. Enrolling a second passkey for a principal that already has one additionally requires a fresh prior authentication with an existing credential.
Response status: 201 on a fresh decision, 200 if idempotency_key matches a prior request and the stored result is replayed, 404 ({"error":"no_active_mandate"}) if no active mandate covers this request, 409 ({"error":"idempotency_conflict"}) if the same key was reused with a different body.
The three decision shapes, all the same receipt envelope:
// decision: "ALLOW"
{
"id": "auth_...",
"actor_kind": "agent", // or "instrument" for a rail-initiated decision
"agent_id": "agt_...",
"instrument_id": null,
"principal_id": "prin_...",
"mandate_id": "mdt_...",
"mandate_version_id": "mdv_...",
"policy_hash": "...",
"decision": "ALLOW",
"status": "AUTHORIZED",
"reasons": [{ "code": "ALLOW_WITHIN_MANDATE", "message": "..." }],
"action": { /* echoes the request's action */ },
"merchant": { /* the engine's resolved view -- trust level, refs */ },
"idempotency_key": "...",
"step_up_expires_at": null,
"created_at": "2026-09-16T03:14:03.431Z",
"decided_at": "2026-09-16T03:14:03.431Z"
}
// decision: "DENY" -- same envelope, status: "DENIED".
// reasons can carry more than one code: a request can violate several
// constraints at once (e.g. an unlisted merchant AND over the per-
// transaction limit), and every one that applies is returned, not just
// the first.
"reasons": [
{ "code": "DENY_MERCHANT_NOT_ALLOWLISTED", "message": "..." },
{ "code": "DENY_TRANSACTION_LIMIT_EXCEEDED", "message": "..." }
]
// decision: "STEP_UP" -- status: "PENDING_STEP_UP", step_up_expires_at
// set. Resolved at POST /v1/authorizations/:id/step-up (approve/decline);
// approving does not execute -- POST .../execute is the separate step.
"status": "PENDING_STEP_UP",
"step_up_expires_at": "2026-09-16T03:29:03.431Z"POST /v1/mandates/compile
Request body:
{
"intent_text": "Spend up to $500/month on office supplies from Amazon or Staples.",
"timezone": "America/New_York", // optional, defaults from the environment
"currency": "USD", // optional
"default_ttl_hours": 720 // optional
}Three response shapes, all HTTP 200 except failed:
// status: "compiled"
{ "status": "compiled", "policy": { /* ... */ }, "policy_hash": "...",
"confirmation": { /* ... */ }, "diagnostics": { "compiler": "...", "attempts": 1, "duration_ms": 812 } }
// status: "needs_clarification" -- HTTP 200, not an error. Asking is a
// valid, expected outcome: the compiler never invents a spending ceiling
// the principal didn't state.
{ "status": "needs_clarification",
"clarifications": [
{ "path": "/limits/per_transaction", "question": "What's the most this can spend in one transaction?",
"suggested_default": 15000, "rationale": "The instruction gave a monthly cap but no per-transaction limit." }
],
"draft": { /* best-effort partial policy */ }, "diagnostics": { /* ... */ } }
// status: "failed" -- HTTP 422, the only error status of the three.
{ "status": "failed", "error": "policy_compilation_failed", "issues": [ /* ... */ ], "diagnostics": { /* ... */ } }GET /v1/evidence/public-key
No credential required — a third party auditing a receipt has none. One signing key covers every organization on a given deployment, so there’s one key to publish, not one per tenant. Pin this value once verified out-of-band; a server that could change it at will could sign anything.
{ "algorithm": "Ed25519", "public_key": "MCowBQYDK2VwAyEA..." }Pair with GET /v1/evidence (returns the same EvidenceRecord[] shape listEvidence() does) and verifyEvidenceIndependently — or its self-contained node:crypto equivalent — to verify locally, with zero trust in the server that produced the data. See /proof for a worked example against one real captured run.
Reason codes
Source: packages/core/src/reason-codes.ts
A public API (additive-only, never renamed) — every decision carries at least one of these. Branch on the code, not the description. Source: packages/core/src/reason-codes.ts.
| Decision | Code | Description |
|---|---|---|
| ALLOW | ALLOW_WITHIN_MANDATE | The action is within the delegated authority. |
| DENY | DENY_NO_ACTIVE_MANDATE | No active mandate delegates this authority to this agent. |
| DENY | DENY_MANDATE_EXPIRED | The mandate has expired. |
| DENY | DENY_MANDATE_REVOKED | The mandate was revoked by the principal. |
| DENY | DENY_MANDATE_SUPERSEDED | The mandate version referenced has been replaced by a newer version. |
| DENY | DENY_MANDATE_NOT_AUTHENTICATED | The mandate was never authenticated by the principal. |
| DENY | DENY_AGENT_NOT_BOUND | This agent is not bound to the mandate. |
| DENY | DENY_AGENT_SUSPENDED | This agent is suspended. |
| DENY | DENY_PRINCIPAL_MISMATCH | The mandate belongs to a different principal than the one named. |
| DENY | DENY_CURRENCY_NOT_PERMITTED | The mandate does not permit this currency. |
| DENY | DENY_TRANSACTION_LIMIT_EXCEEDED | The amount exceeds the per-transaction limit. |
| DENY | DENY_CUMULATIVE_LIMIT_EXCEEDED | The amount would exceed a cumulative spending limit. |
| DENY | DENY_VELOCITY_LIMIT_EXCEEDED | The action would exceed a transaction-count limit. |
| DENY | DENY_MERCHANT_BLOCKED | The merchant is explicitly blocked by the mandate. |
| DENY | DENY_MERCHANT_NOT_ALLOWLISTED | The merchant is not on the mandate's allowlist. |
| DENY | DENY_CATEGORY_BLOCKED | The category is explicitly blocked by the mandate. |
| DENY | DENY_CATEGORY_NOT_ALLOWLISTED | The category is not on the mandate's allowlist. |
| DENY | DENY_OUTSIDE_TIME_WINDOW | The action falls outside the mandate's permitted time window. |
| DENY | DENY_CONSTRAINT_NOT_SATISFIED | A required constraint of the mandate is not satisfied by this action. |
| DENY | DENY_MERCHANT_UNRESOLVED | The merchant could not be resolved to a verifiable identity. |
| DENY | DENY_STEP_UP_SELF_APPROVAL | A mandate cannot resolve its own step-up; the resolving credential must belong to a different, authorized approver mandate. |
| DENY | DENY_MANDATE_NOT_AN_APPROVER | This mandate is not named in the principal mandate's list of approvers. |
| DENY | DENY_APPROVER_WOULD_ESCALATE | The approver's own policy also requires escalation for this action; approval authority is single-level and cannot chain to a further approver. |
| DENY | DENY_APPROVER_CYCLE | This mandate's approvers would form a cycle with a mandate that already exists. |
| DENY | DENY_STEP_UP_EXPIRED | The step-up's time limit had already passed, so it can no longer be approved. Distinct from DENY_MANDATE_EXPIRED: the mandate's authority is intact, and the window to resolve this particular step-up has closed. |
| DENY | DENY_ASSET_NOT_IN_REGISTRY | The (chain, token contract address) pair is not one this deployment can evaluate or settle. Matching is by address, never by symbol. |
| DENY | DENY_ASSET_DECIMALS_MISMATCH | The counterparty declared a decimal scale that disagrees with the asset registry's. A loud denial, never a silent correction. |
| DENY | DENY_SETTLED_WITHOUT_AUTHORIZATION | The payment rail settled this transaction without ever asking Waysafe to approve it -- a card-network force capture. Recorded as a DENY on a row whose status is EXECUTED: what Waysafe would have said, and the fact that the money moved anyway. |
| DENY | DENY_SETTLED_ABOVE_AUTHORIZATION | The settled amount exceeded what Waysafe authorized -- an overcapture, real on amount-controllable categories such as fuel and restaurants. The authorized portion settles normally; the excess carries this code. |
| DENY | DENY_DECISION_NOT_RECORDED | The decision could not be durably recorded, so it was not authorized. The decision, its ledger hold and its evidence event commit in one transaction; if that fails, nothing survives and the rail is answered with a decline. |
| DENY | DENY_RESOURCE_URL_NOT_PERMITTED | Waysafe refused to fetch the resource URL supplied, so no payment requirement was ever read. Production permits HTTPS to public addresses only, with the connection pinned to the address it validated and every redirect re-checked. |
| DENY | DENY_ASSET_UNSPECIFIED | The payment requirement named no token contract address, so no asset could be resolved. |
| STEP_UP | STEP_UP_AMOUNT_THRESHOLD | The amount is above the mandate's step-up threshold. |
| STEP_UP | STEP_UP_CUMULATIVE_THRESHOLD | Cumulative spend is above the mandate's step-up threshold. |
| STEP_UP | STEP_UP_MERCHANT_NOT_ALLOWLISTED | The merchant is not on the allowlist and the mandate requires approval for unlisted merchants. |
| STEP_UP | STEP_UP_CATEGORY_NOT_ALLOWLISTED | The category is not on the allowlist and the mandate requires approval for unlisted categories. |
| STEP_UP | STEP_UP_MERCHANT_UNVERIFIED | The merchant identity was asserted but not verified, so human approval is required. |
| STEP_UP | STEP_UP_FIRST_TIME_MERCHANT | This is the first transaction with this merchant under this mandate. |
| STEP_UP | STEP_UP_MANDATE_REQUIRES_REAUTH | The mandate requires re-authentication before further actions. |
Evidence event types
Every type this codebase appends to the signed chain, and what each attaches to. A chain is organization-scoped: one sequence, one hash link per entry, with subject_type and subject_id naming what the entry is about.
Decisions made through the API are in this chain too. That was not true until a second adversarial review found it: the rail-initiated paths each appended their own decision evidence, and POST /v1/authorizations appended nothing. A step-up that expires still writes no event. That is a known gap, named in the threat model.
| Type | Subject | What it records |
|---|---|---|
| authorization.decided | authorization | An agent-path decision: POST /v1/authorizations returning ALLOW, DENY or STEP_UP. Carries the decision, its reason codes, the amount, the mandate version and policy hash it was decided against, the resolved merchant, whether it reserved budget, and any step-up expiry. Appended in the same transaction as the decision and its hold. |
| agent_key.verified | agent | An agent credential was presented and matched the agent it claimed to be. |
| agent_key.rejected | agent | A credential was presented and did not match. Written unconditionally, before any decision, so an attempt against a mandate that does not exist still leaves a record. |
| step_up.approved | mandate | A pending step-up was approved by a separate, principal-named approver mandate's own credential. One event per mandate involved, so both chains show it. |
| step_up.declined | mandate | A pending step-up was declined. Any reservation is released. |
| step_up.resolution_rejected | mandate | A resolution attempt was refused — self-approval, an expired step-up, or a spending mandate that lapsed while the step-up was open. |
| execution.completed | authorization | An authorized payment was executed on a rail. Carries the provider, its reference, and the fee it took. |
| execution.rejected | authorization | A payment adapter refused the execution. No capture is written. |
| refund.applied | authorization | A refund was credited back to the budget. The provider's cumulative refunded total arrives on every update; only the delta is credited. |
| mandate.authenticated | mandate_version | The principal authenticated this policy version over WebAuthn, freezing it. |
| mandate.authentication_rejected | mandate_version | A WebAuthn ceremony for this version was refused — a purpose mismatch, a bad origin, or an unknown credential. |
| mandate.card_issuing_terms_accepted | mandate_version | The cardholder's own acceptance of Stripe's Issuing terms, sourced only from a real authentication ceremony and never synthesized. |
| enforcement.stripe_issuing.decision | authorization | A card authorization decided in the network's real-time window. |
| enforcement.stripe_issuing.released | authorization | A reversed or expired card authorization's hold released, exactly once. |
| enforcement.stripe_issuing.captured | authorization | A card authorization settled. Records the amount that SETTLED, which a partial capture makes smaller than the amount authorized. |
| enforcement.stripe_issuing.unauthorized_settlement | authorization | A force capture: the network cleared a settlement it never presented for approval. Carries the raw merchant_data a dispute needs and would_have_decided — what the engine returns when the settlement is put to it after the fact. |
| enforcement.stripe_issuing.over_authorized_settlement | authorization | An overcapture: the settled amount exceeded what was approved. Same shape as above, with the excess named. |
| enforcement.x402.decision | authorization | An on-chain payment decided before any co-signature. Carries the atomic amount alongside the cents the budget was charged, since cents round up. |