Skip to content

API reference

The details a production integration needs: what errors look like, which webhook events fire, how idempotency works, and the rate limits.

Every non-2xx response is a typed error with a stable machine-readable code and the matching HTTP status. The SDKs throw it as AgentbankError (err.code, err.status) — catch that one class to handle failures uniformly.

{ "error": { "code": "PAYMENT_DECLINED", "message": "card declined", "details": { "reason": "insufficient_funds" } } }

Branch on code, never on the message text (messages may change; codes are the contract).

In the SDKs, catch the single AgentbankError class and read err.code / err.status. Prefer the static AgentbankError.is(err) over err instanceof AgentbankError: at a package boundary instanceof silently fails when two copies of the SDK’s core coexist in one node_modules (different class objects), whereas is() checks the shape and recognizes an error from any version. A transport failure (no HTTP response — timeout, abort, DNS) surfaces as AgentbankError with status === 0 and code timeout, aborted, or network_error.

try {
await agentbank.checkout.openACP({ currency, items });
} catch (err) {
if (AgentbankError.is(err) && err.status === 429) {
// back off and retry — see Rate limits below
}
}
code HTTP Meaning
VALIDATION_ERROR 400 Malformed request — bad/missing field. details carries the field errors.
UNAUTHORIZED 401 Missing or invalid credential.
PAYMENT_DECLINED 402 The rail declined the charge. details.reason has the rail’s reason.
FORBIDDEN 403 Authenticated, but not allowed to touch this resource.
SANCTIONS_BLOCKED 403 Blocked by sanctions screening.
NOT_FOUND 404 No such resource — or one you’re not scoped to see.
CONFLICT 409 State conflict — e.g. capturing an expired checkout, or an idempotency key reused while in flight.
GONE 410 The resource existed but is no longer available.
RATE_LIMITED 429 Too many requests — see Rate limits.
INVARIANT_VIOLATION 500 A server-side consistency check failed. Report it.
UPSTREAM_ERROR 502 A dependency (rail, Curless) failed. May be retryable.
CIRCUIT_OPEN 503 A dependency’s circuit breaker is open (fast-failing after repeated failures). Retry after a short back-off.

Register an endpoint (POST /v1/webhook-endpoints) and subscribe to event types (subscribe to none = receive all). Each delivery is { id, type, data }, signed x-agentbank-signature: t=…,v1=…. Verify with the merchant SDK’s verifyWebhook — see the Webhooks section for verification and at-least-once dedup.

These are the events a merchant endpoint receives:

Event Fires when
order.paid A checkout was charged successfully.
order.refunded An order was refunded (full or partial).
order.canceled An order ended without payment — checkout expired, canceled, or the charge failed.
payment_intent.captured The underlying PaymentIntent captured (fine-grained twin of order.paid).
payment_intent.settled Funds settled (moved from pending to payable).
payment_intent.refunded The PaymentIntent was refunded.
payment_intent.failed A charge was attempted and failed.
payment_intent.expired An unpaid checkout passed its deadline — terminal, never charged.
payment_intent.canceled The PaymentIntent was called off before any charge.

Order-level events (order.*) are what most integrations want; the payment_intent.* twins carry the same lifecycle at finer grain for reconciling against the PaymentIntent directly.

Deliveries are at-least-once — dedupe on event.id, and ack retries with a 2xx (an error re-queues the event). Full guidance in the merchant SDK Webhooks section.

Any mutating request that moves money — opening a checkout, capturing, refunding — can be retried safely by sending an Idempotency-Key header (Stripe-style). A retry with the same key replays the original response instead of doing the work twice.

Terminal window
curl -X POST https://mcp.curless.ai/v1/... \
-H "Authorization: Bearer $KEY" \
-H "Idempotency-Key: order-8f3a-2026-07-19" \
-H "content-type: application/json" \
-d '{ ... }'

Behavior:

  • Same key, same request → the stored response is replayed (same status and body), with an Idempotent-Replayed: true header. The work runs once.
  • Same key, different request body400 VALIDATION_ERROR (“Idempotency-Key was already used with different request parameters”). A key is bound to the exact request it first saw.
  • Same key, original still in flight409 CONFLICT (“still being processed; retry shortly”). Retry after a moment.
  • Handler failed (5xx / crash) → the claim is released, so a retry with the same key is treated as a fresh request (a failure never gets “stuck”).

Keys are scoped per credential and mode, so a test key and a live key never share an idempotency record (both come from your Curless wallet — test mode moves no real money, for end-to-end testing). Choose a key unique to the logical operation (e.g. your own order id), not per HTTP attempt.

Note: several protocol flows also accept a protocol-level idempotency key (e.g. the x402 Idempotency-Key), and the gateway dedupes concurrent same-key PaymentIntent creates internally — but the header above is the general mechanism for the REST API.

A token-bucket limit of 120 requests per 60 seconds (RATE_LIMIT_MAX / RATE_LIMIT_WINDOW_MS), with burst up to the full 120.

  • Authenticated routes/v1/* and the protocol paths (/acp, /x402, /a2a, /ap2, /visa-tap, /skyfire, /mpp) — are limited per API key (per principal), so one merchant’s traffic never eats another’s budget.
  • Public endpoints (OAuth token/authorize/register, the /mcp resource server, hosted /pay, /v1/integrations) are limited per IP.

Exceeding the limit returns 429 RATE_LIMITED. Back off and retry; combine with an Idempotency-Key so a retried write can’t double-charge.

Health/readiness probes (/health, /ready) and the Curless inbound webhook are intentionally not rate-limited (limiting them would false-down load balancers or drop real refund events).

These are the defaults; talk to us if your integration needs a higher ceiling.