Enforcement
The two endpoints a payment rail calls before money moves. Neither depends on the agent calling anything first — that’s the difference between enforcement and the preflight SDK (see Reference). Shapes are transcribed from apps/api/src/server.ts, apps/api/src/enforcement/, and packages/sdk/src/index.ts.
POST /v1/enforcement/stripe-issuing
Source: apps/api/src/enforcement/stripe-issuing.ts
Stripe’s real-time authorization webhook. Stripe calls it when a card is presented. The agent does not. The inbound body is a standard Stripe event; only issuing_authorization.request is a synchronous decision this route owes a same-request answer to. The request must carry a valid Stripe-Signature header, verified against the Issuing webhook secret before anything is decided.
// inbound: a Stripe event, signature-verified before evaluation
{
"type": "issuing_authorization.request",
"data": {
"object": { // Stripe.Issuing.Authorization
"id": "iauth_...",
"amount": 4200, // integer minor units, Stripe's own field
"currency": "usd",
"card": { "id": "ic_...", ... }, // resolved to a Waysafe Instrument
"merchant_data": {
"name": "STAPLES",
"network_id": "...", // rail-attested -- this is what makes it verifiable
"category_code": "5943"
}
}
}
}
// Waysafe's response -- exactly these two fields, nothing else
{ "approved": true, "reason_codes": ["ALLOW_WITHIN_MANDATE"] }Stripe reads only approved. reason_codes is Waysafe’s own addition for observability (Stripe ignores unknown fields), so a caller can see which code a decline carried without a second round trip for evidence. Every response also carries the library’s configured Stripe-Version header.
The window is about two seconds. Miss it, or answer with anything malformed, and Stripe declines on its own. That timeout behavior is a Stripe account setting (“decline on timeout,” turned on for this account) — not something this codebase asserts or could enforce if it were ever switched off. Independent of that setting, the route fails closed on its own side in three specific ways:
- Unrecognized event type →
{ "approved": false, "reason_codes": [] }. Deliberately empty: fail closed without fabricating a reason code for a decisionevaluate()never made. - No resolvable Waysafe instrument for the presented card → an explicit
DENY_NO_ACTIVE_MANDATE. A card Waysafe never provisioned cannot buy anything, even if it was created directly against Stripe. - A decision needing a human (
STEP_UP) → also a decline. There is no channel to reach a person inside a two-second synchronous window.
What the card rail does not stop
Waysafe decides every authorization the network presents for real-time approval. A card network can also clear a settlement it never presented — a force capture, which happens when a terminal was offline at the time of sale — or settle above the amount that was approved. Neither asks anyone first, so neither can be declined.
What Waysafe does instead is refuse to ignore them. On issuing_transaction.created, the settled amount is written to the ledger and charged against the mandate’s cumulative cap, so the next real payment sees the money as gone. The receipt is recorded as a DENY on a row that is EXECUTED, which records both facts, carrying DENY_SETTLED_WITHOUT_AUTHORIZATION or DENY_SETTLED_ABOVE_AUTHORIZATION, the network’s own merchant_data (trading name, city, terminal id), and would_have_decided — what the engine returns when the forced settlement is put to it after the fact. That last field is the dispute basis: it is the difference between “money moved” and “money moved that this mandate would have refused.”
A reversal or an expiry releases the hold; a partial capture records the settled amount, not the authorized one; an incremental request is decided as a new decision on the delta, with the per-transaction ceiling checked against the running total.
docs/diagrams/card.mmd.Status: this path runs the real engine against recorded Stripe Issuing authorization requests today — real payloads, real decisions, replayed. Live sandbox authorization is pending Stripe’s live Issuing onboarding.
POST /v1/enforcement/x402
Source: apps/api/src/enforcement/x402.ts
On-chain, there is no third-party network to call Waysafe, so the caller is the agent’s own runtime presenting its ordinary Bearer credential. That is not a weaker position, because of the rule that makes it safe: Waysafe never accepts payment requirements from the caller. The request supplies a resource_url; Waysafe fetches that resource itself and evaluates only what its own fetch returned. An agent cannot get itself co-signed by asserting what it’s paying for.
docs/diagrams/x402.mmd.Merchant verification is weaker on this rail than on the card rail, and that is worth stating outright. On a card, identity is an acquirer-assigned merchant id that arrives inside the network’s own authorization payload. On x402, both the payee address and the merchant’s domain come from Waysafe’s own fetch — so the agent cannot forge either — but the host serving that resource is what declares its own payee address, and nothing outside that host corroborates that the address belongs to whoever owns the domain. So here, VERIFIED means Waysafe fetched this resource and this host asked for this payment, not this host is who it claims to be. A mandate that names real merchants is unaffected; a mandate leaning on unlisted: ALLOW plus “the merchant was verified” gets materially less assurance here than the same words buy on a card. This is a known limitation, and it is listed as one in the threat model.
// request
{
"instrument_id": "inst_...",
"resource_url": "https://merchant.example/paid-endpoint", // Waysafe fetches this itself
"session_signature": { // optional -- omit for a decision + co-signature only
"nonce": 7,
"signer": "0x...", // the agent's session key address
"data": "0x..." // its partial Safe signature, never its private key
}
}
// response
{
"decision": "ALLOW", // or "DENY" / "STEP_UP"
"reason_codes": ["ALLOW_WITHIN_MANDATE"],
"co_signature": { // null unless the decision was ALLOW
"pay_to": "0x...",
"asset": "0x...",
"network": "polygon-amoy",
"amount_atomic": "100000",
"resource": "https://merchant.example/paid-endpoint",
"expires_at": "2026-09-16T03:29:03.431Z",
"authorization_id": "auth_...", // traces back to the evidence event
"signature": "..." // Ed25519, over the canonical payload above
},
"mandate_id": "mdt_...",
"safe_address": "0x...",
"settlement": { "tx_hash": "0x..." } // or { "error": "..." }, or null if not attempted
}co_signature is null for DENY and STEP_UP alike — same constraint as the card rail: no channel to put a human in front of a decision inside this window. The co-signature is an Ed25519 attestation over the payment intent; it has no EVM address and cannot itself move funds. Treating it as proof that money moved is trusting an off-chain claim, not on-chain enforcement.
What actually stops an unauthorized transfer is the 2-of-2 Safe deployed per mandate: the agent’s session key is one owner, Waysafe’s secp256k1 cosigner address is the other. Waysafe’s signature is one of the two the Safe’s own execTransaction requires to accept the call at all. A real session-key signature submitted alone is rejected by the contract itself, on-chain — broadcast and mined reverted, not simulated; see /proof for the transaction hashes and revert reasons.
What you build on your side
Cards: point your Stripe Issuing real-time authorization webhook at POST /v1/enforcement/stripe-issuing, share the Issuing webhook secret, and turn on “decline on timeout.” That is the whole integration — no agent-side code, and nothing for an agent to skip.
On-chain: deploy the 2-of-2 Safe per mandate with Waysafe’s cosigner address as the second owner (POST /v1/instruments/x402 provisions it), and have the agent’s runtime call POST /v1/enforcement/x402 with its session signature. The agent’s cooperation buys it nothing it didn’t already have: one signature against a threshold of two.
What’s still open
Everything above is what these two rails enforce. What they do not — the signing key that has no rotation plan or kill switch, the lack of anything outside Waysafe that proves the record is complete, the limitation above, and the rest — is inventoried surface by surface in docs/THREAT-MODEL.md, with an attack map of all ten and a status on each. Two independent adversarial reviews have been run against this repository; the findings and fixes are in SECURITY.md.