Skip to main content

Facilitator

2 operations under /v1/facilitator/x402/executions (POST GET): parameters, status codes, refusal bodies, and the same request in curl, HTTP, Python and TypeScript.

9 min read
View MarkdownEdit on GitHub

Base URL: https://api.codespar.dev

Every operation below requires a Bearer token. See Authentication.

POST /v1/facilitator/x402/executions

POSThttps://api.codespar.dev/v1/facilitator/x402/executions
Moves money

A 200 here means USDC MOVED.

Ingest and execute a foreign x402 authorization

Takes an EIP-3009 transferWithAuthorization signed OUTSIDE CodeSpar by a foreign wallet, verifies it, translates it onto an internal mandate, runs it through the same cap gate every other spend passes, settles it on-chain and seals a receipt. A 200 here means USDC MOVED. Requires the facilitator:execute scope.

THE CAPS ARE DECLARED BY THE CALLER. intent.max_amount_minor and intent.total_cap_minor come from this request, so they are limits the caller states for itself, not a ceiling the organization sets. The amount charged is the one the payer signed (authorization.value), and on mainnet an operator hard cap applies on top.

OMITTING intent.total_cap_minor DOES NOT REMOVE THE LIFETIME CAP — it sets that cap equal to max_amount_minor. The translation reads totalCap = totalCapAtomic ?? perTxCap, and this route never marks the intent pay-per-access, so the gate compares the signer's PRIOR EXECUTED spend in this organization plus this payment against that cap. The practical consequence: leave total_cap_minor out and the SECOND execution by the same signer is refused with 422 total_cap_exceeded, even though the first succeeded and each authorization was independently signed. Send total_cap_minor explicitly whenever a signer may pay more than once.

AMOUNTS ARE MINOR ON THE WIRE AND ATOMIC INSIDE. max_amount_minor and total_cap_minor are cents; they are converted to atomic USDC at the boundary, and the cap gate then compares them against authorization.value, which is already atomic. So the authorization's value is not scaled to match the caps — the caps are scaled to match it.

The evidence row is inserted verified only AFTER the payee binding, the replay guard and the amount guard have all passed — not when the signature verifies. A refusal at any of those three leaves NO row, so an authorization that verified cryptographically may have no record here at all. From that point every refusal marks the row rejected with its code in reject_code, and success marks it executed in the same transaction that seals the receipt, so the two commit together.

error.details IS ALWAYS PRESENT on a failure, and is {} on most of them. It carries foreign_authorization_id in exactly two situations: when a row was created and then marked rejected (the cap gate, the mainnet ceiling, a settlement failure), and on the 409 replay — where the id is the PRIOR EXECUTED row's, not a new one. The 400 raised by body validation carries issues instead.

test credentials always settle on base-sepolia; no flag can send them to mainnet. A live credential settles on base only when the operator has enabled mainnet, and is refused with 501 otherwise, before any wallet is touched.

Request body

FieldTypeRequiredDescription
authorizationobjectyes—
domainobjectyes—
intentobjectyes—
signaturestringyes—

Responses

StatusBodyDescription
200objectOK
400objectBad Request. missing_required_field is the body failing validation, with the Zod issues in error.details.issues. malformed_authorization is the verifier or the payee binding rejecting a field the schema let through — the schema's address regex accepts any 40 hex digits, including a mixed-case address whose EIP-55 checksum is wrong, and the on-chain address parser does not. It covers authorization.from, authorization.to, intent.payee and domain.verifyingContract, and an out-of-range domain.chainId. invalid_payload is the cap gate refusing the translated mandate's shape.
401objectUnauthorized — the SIGNATURE, not the credential. bad_signature is a signature that does not recover; signer_mismatch is one that recovers to an address other than authorization.from. A rejected API key or access token never reaches this handler.
409objectConflict — this (signer, nonce) has already been executed, so it settles once. error.details.foreign_authorization_id names the row that ALREADY executed; no new row was created. The same code answers the race, where a concurrent execution won and the unique index on executed nonces refused the loser — in that case the id is the loser's own row.
422objectUnprocessable — the request is well-formed and the signature is valid, but a bound refuses it. authorization_not_yet_valid and authorization_expired are the authorization's own window. payee_authorization_mismatch is intent.payee differing from the recipient the signature authorizes on-chain, compared after both are checksummed. per_tx_cap_exceeded and total_cap_exceeded are the cap gate; see the note above on what omitting total_cap_minor does. expired is the translated mandate outliving its own window between the two checks. x402_mainnet_cap_exceeded is the operator's per-payment mainnet ceiling, a second gate on top of the mandate's own. The pipeline also guards against a non-positive amount with amount_below_minimum, which is NOT in this list: authorization.value is validated as strictly greater than zero before the handler runs, so that guard is defence in depth for other callers of the pipeline and is not a result an HTTP caller can produce.
500objectInternal error from the cap gate, surfaced rather than swallowed. Reached when the gate fails for a reason that should be impossible on this path — the mandate secret vanishing between the moment it was resolved and the moment the gate looked it up, say. error.code is left open here because it echoes whatever the gate reported.
501objectNot Implemented — a live credential asked for mainnet settlement and the operator has not enabled it. Refused before any signature work, any database write or any wallet call, so nothing is left dangling. A test credential cannot reach this: it always settles on testnet.
502objectBad Gateway — on-chain settlement failed. The evidence row is marked rejected with reject_code = 'settlement_failed' and its id is in error.details.foreign_authorization_id. A broadcast is not a settlement: a transaction that reverts or is not confirmed in time lands here too, so a 502 does NOT mean the money is in flight.

Response 200

FieldTypeRequiredDescription
execution_idstringyes—
foreign_authorization_idstringyesfauth_-prefixed. The id GET /v1/facilitator/x402/executions/{id} takes.
mandate_idstringyes—
receiptobjectyes—
settlementobjectyes—
signerstringyesThe address recovered from the signature.
status"executed"yes—
Example request
curl -X POST https://api.codespar.dev/v1/facilitator/x402/executions \
  -H "Authorization: Bearer $CODESPAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
       "authorization": {
         "from": "string",
         "to": "string",
         "value": "string",
         "validAfter": "string",
         "validBefore": "string",
         "nonce": "string"
       },
       "domain": {
         "name": "Example",
         "version": "string",
         "chainId": 0,
         "verifyingContract": "string"
       },
       "signature": "string",
       "intent": {
         "payee": "string",
         "resource": "string",
         "seller": "string",
         "purpose": "string",
         "max_amount_minor": 1000,
         "total_cap_minor": 1,
         "resource_url": "https://example.com/hook"
       }
     }'
POST /v1/facilitator/x402/executions HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
Content-Type: application/json

{
  "authorization": {
    "from": "string",
    "to": "string",
    "value": "string",
    "validAfter": "string",
    "validBefore": "string",
    "nonce": "string"
  },
  "domain": {
    "name": "Example",
    "version": "string",
    "chainId": 0,
    "verifyingContract": "string"
  },
  "signature": "string",
  "intent": {
    "payee": "string",
    "resource": "string",
    "seller": "string",
    "purpose": "string",
    "max_amount_minor": 1000,
    "total_cap_minor": 1,
    "resource_url": "https://example.com/hook"
  }
}
import os
import requests

res = requests.post(
    "https://api.codespar.dev/v1/facilitator/x402/executions",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
    json={
      "authorization": {
        "from": "string",
        "to": "string",
        "value": "string",
        "validAfter": "string",
        "validBefore": "string",
        "nonce": "string"
      },
      "domain": {
        "name": "Example",
        "version": "string",
        "chainId": 0,
        "verifyingContract": "string"
      },
      "signature": "string",
      "intent": {
        "payee": "string",
        "resource": "string",
        "seller": "string",
        "purpose": "string",
        "max_amount_minor": 1000,
        "total_cap_minor": 1,
        "resource_url": "https://example.com/hook"
      }
    },
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/facilitator/x402/executions", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "authorization": {
      "from": "string",
      "to": "string",
      "value": "string",
      "validAfter": "string",
      "validBefore": "string",
      "nonce": "string"
    },
    "domain": {
      "name": "Example",
      "version": "string",
      "chainId": 0,
      "verifyingContract": "string"
    },
    "signature": "string",
    "intent": {
      "payee": "string",
      "resource": "string",
      "seller": "string",
      "purpose": "string",
      "max_amount_minor": 1000,
      "total_cap_minor": 1,
      "resource_url": "https://example.com/hook"
    }
  }),
});

const data = await res.json();
const r = await cs.api.response("post", "/v1/facilitator/x402/executions", {
  body: {
    authorization: {
      from: "string",
      to: "string",
      value: "string",
      validAfter: "string",
      validBefore: "string",
      nonce: "string"
    },
    domain: {
      name: "Example",
      version: "string",
      chainId: 0,
      verifyingContract: "string"
    },
    signature: "string",
    intent: {
      payee: "string",
      resource: "string",
      seller: "string",
      purpose: "string",
      max_amount_minor: 1000,
      total_cap_minor: 1,
      resource_url: "https://example.com/hook"
    }
  }
});
// r.status is one of the documented statuses (200, 422),
// each with its own body shape in r.data; nothing here throws on 422.
if (r.ok) {
  console.log(r.data);
}
Example response 200
application/json
{
  "status": "executed",
  "foreign_authorization_id": "foreignauthorization_0000000000000000",
  "mandate_id": "mandate_0000000000000000",
  "execution_id": "execution_0000000000000000",
  "signer": "string",
  "settlement": {
    "tx_hash": "string",
    "network": "string",
    "money_moved": true,
    "adapter": "string"
  },
  "receipt": {
    "id": "obj_0000000000000000",
    "state": "paid",
    "chain": "string"
  }
}

GET /v1/facilitator/x402/executions/{id}

GEThttps://api.codespar.dev/v1/facilitator/x402/executions/{id}

Read one ingested foreign authorization

The evidence row for a foreign authorization: the signed object's own facts, and the internal objects it produced. Requires the facilitator:execute scope — the same scope as the POST, so a credential that can read this lane can also execute on it.

The evidence row is inserted verified only AFTER the payee binding, the replay guard and the amount guard have all passed — not when the signature verifies. A refusal at any of those three leaves NO row, so an authorization that verified cryptographically may have no record here at all. From that point every refusal marks the row rejected with its code in reject_code, and success marks it executed in the same transaction that seals the receipt, so the two commit together.

This row is NOT the money's source of truth — the settlement transaction and the ledger are — and it is NOT the mandate. It is the bridge that lets a receipt point back to the foreign trust root that authorized the spend.

RESOLUTION IS SCOPED TO THE ORGANIZATION ONLY. The query predicates on org_id and the id, with no project term, so a row created under a sibling project in the same organization IS readable here — unlike the funding-source read in this same group, which is narrower.

The signed payload itself is stored on the row and is not returned: the authorization, the domain, the intent and the signature stay server-side.

Path parameters

NameTypeRequiredDescription
idstringyesfauth_-prefixed, as returned by the POST.

Responses

StatusBodyDescription
200objectOK
404objectNot Found. No foreign authorization with this id in the caller's organization — including the case where the authorization verified but was refused before any row was written.

Response 200

FieldTypeRequiredDescription
amount_minorintegeryesA COARSE cents figure, rounded UP from value_atomic, kept so older minor-denominated reads still work. It is NOT what the cap gate ran on and NOT what moved: the gate and the settlement both use atomic. Read value_atomic when the number has to be exact.
assetstring,nullyesThe token contract — the EIP-712 verifyingContract, checksummed.
chain_idinteger,nullyes—
created_atstring (date-time)yes—
execution_idstring,nullyesx402exec_-prefixed. Set on success.
idstringyesfauth_-prefixed.
kind"x402" | "ap2" | "acp"yesClosed by the kind CHECK in packages/api/src/migrations/0099_foreign_authorizations.sql, which is also where this table's status set is closed. Only x402 is written today; ap2 and acp are reserved for the ingestion paths that reuse the same machinery.
mandate_idstring,nullyesThe internal mandate the foreign authorization was translated onto. Set on success.
networkstring,nullyesbase-sepolia for a test credential, base for a live one. Stamped at insert time, so a rejected row carries it too.
noncestringyesThe EIP-3009 nonce, 32 bytes hex. Unique per authorization.
payeestringyesThe on-chain recipient, checksummed.
receipt_idstring,nullyesThe sealed Control Record receipt. Set on success.
reject_codestring,nullyesThe refusal ground on a rejected row: the cap-gate or mainnet-cap code, or the literal settlement_failed. It is the same string the POST answered with under error.code. Null otherwise, and an open string here because its source set is the cap gate's, not this table's.
settlement_txstring,nullyesThe on-chain settlement transaction hash. Set on success.
signerstringyesThe address RECOVERED from the signature, not the from the caller sent.
status"verified" | "executed" | "rejected"yesClosed by CHECK (status IN ('verified','executed','rejected')) in packages/api/src/migrations/0099_foreign_authorizations.sql. The evidence row is inserted verified only AFTER the payee binding, the replay guard and the amount guard have all passed — not when the signature verifies. A refusal at any of those three leaves NO row, so an authorization that verified cryptographically may have no record here at all. From that point every refusal marks the row rejected with its code in reject_code, and success marks it executed in the same transaction that seals the receipt, so the two commit together.
value_atomicstringyesThe exact authorized amount in atomic units (USDC, 6dp). This is what settles.
Example request
curl -X GET https://api.codespar.dev/v1/facilitator/x402/executions/{id} \
  -H "Authorization: Bearer $CODESPAR_API_KEY"
GET /v1/facilitator/x402/executions/{id} HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
import os
import requests

res = requests.get(
    "https://api.codespar.dev/v1/facilitator/x402/executions/{id}",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/facilitator/x402/executions/{id}", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
  },
});

const data = await res.json();
const result = await cs.api.get("/v1/facilitator/x402/executions/{id}", {
  path: {
    id: "execution_0000000000000000"
  }
});
Example response 200
application/json
{
  "id": "obj_0000000000000000",
  "kind": "x402",
  "signer": "string",
  "payee": "string",
  "nonce": "string",
  "chain_id": 0,
  "asset": "string",
  "value_atomic": "string",
  "amount_minor": 1000,
  "status": "verified",
  "reject_code": "string",
  "mandate_id": "mandate_0000000000000000",
  "execution_id": "execution_0000000000000000",
  "receipt_id": "receipt_0000000000000000",
  "settlement_tx": "string",
  "network": "string",
  "created_at": "2026-01-15T12:00:00.000Z"
}
Facilitator | CodeSpar