The API is served by @checkout/api (Hono) on http://localhost:8787 by default
(API_PORT). All request and response bodies are JSON.
Auth:
POST /authissues a session JWT after a wallet-signed SEP-10 challenge; a seller row is created for the wallet on first login./linksand/webhooksnow require it — every route under those two prefixes needsAuthorization: Bearer <token>(or the httpOnlysessioncookie set byPOST /auth) and returns401without one. There is currently no web UI to obtain a token (that needs a wallet-connect button — tracked separately), so the demo dashboard will need one wired up before it can create/list links again post-upgrade. Seeapps/web/lib/api.ts'sgetAuthChallenge/submitAuthChallengefor the client-side pieces already in place.
CORS is restricted to the origins in CORS_ORIGINS (comma-separated), with
credentials: true (required for the browser to send/receive the session cookie
cross-origin — so CORS_ORIGINS can't be * while auth is in use).
Every route marked Requires auth below needs Authorization: Bearer <token>
(from POST /auth) or the httpOnly session cookie it also sets.
- 401
unauthorized— you're not authenticated at all: no token, or one that's missing, malformed, tampered, expired, or revoked (POST /auth/logoutput itsjtion the revocation list). The response body always has this shape:{ "error": "unauthorized", "message": "<why>" }. - 403
forbidden— you are authenticated, but the credential itself does not carry the required scope (e.g. an API key withoutlinks:write).{ "error": "forbidden", "message": "<why>" }. - 404
not_found— the object does not exist or does not belong to you. These are deliberately the same answer: a 403 on someone else's link would confirm that the id is real, which is a leak in itself. Do not read a 404 as proof that an id is unused.
These are deliberately different failure modes: 401 means "prove who you are again"; 403 means "you did, and this credential still is not allowed to do that"; 404 means "nothing here that is yours."
- Money amounts are decimal strings (e.g.
"10.50"), validated to at most 7 decimals. Internally compared in integer stroops, never floats. - Errors return
{ "error": "<code>", ... }with an appropriate HTTP status. Validation failures return400with{ "error": "invalid_body", "issues": [...] }. erroris a machine-readable code; a human-readablemessagemay accompany it. Two codes cover failures we do not control the wording of:anchor_error(502) — the anchor answered a SEP call with an error, was unreachable, or sent something unusable.messageis fixed text such as"The anchor returned an error (SEP-6 withdraw, HTTP 400).". The anchor's own response body is never returned: it is third-party content that can echo the seller's KYC or bank fields, so it is written, truncated to about 2 KB, to the server log (event: "anchor.error") only. Returned by the cash-out, cash-out quote,offramp-preview,offramp-requirements,/seller/kyc,/seller/anchor-authand/offramp/inforoutes.internal_error(500) — an unhandled server error. The body is exactly{ "error": "internal_error" }: no message, no stack. Details are in the log.
POST /links and POST /links/:id/cash-out support the Idempotency-Key header.
Idempotency-Key: <unique-string-per-logical-request>
- Same key + same body — the original response is replayed byte-for-byte with an
Idempotent-Replayed: trueheader. No second link is created; no second anchor job is started. - Same key + different body —
409 idempotency_key_reuse. - Key still in-flight —
409 request_in_progress. - Keys expire after 24 hours.
Use a UUID or any sufficiently random string. Retry safely on network timeouts by resending the same key and body.
Liveness + basic config echo, plus the watcher's Horizon health and a re-check of the seller's own USDC trustline (cached up to 60s — see the account/trustline preflight note below) so a revoked trustline shows up in ops without anyone touching logs.
200
{
"ok": true,
"network": "testnet",
"sellerWallet": "G...",
"usdcTrustline": { "ok": true },
"horizon": { "degraded": false, "usingFallback": false, "consecutiveFailures": 0 }
}ok is pure liveness (the process is up) — check horizon.degraded for whether
the ledger watcher is actually keeping up. Every Horizon call (packages/stellar)
goes through a retry policy first — 3 attempts, exponential backoff with full
jitter, honoring Retry-After on 429 — so a single blip never surfaces here at
all. horizon.degraded only flips to true once retries have been exhausted
HORIZON_DEGRADED_THRESHOLD times in a row (default 3): a transient 429 that
recovers on retry #2 never trips this, but a sustained outage does, instead of
silently degrading to "nothing is ever marked paid." usingFallback is true
once it has switched to HORIZON_URL_FALLBACK (if configured); it switches back
to the primary automatically on the next successful call. 400/404 responses
(e.g. an account that doesn't exist yet) are treated as a normal, prompt answer —
never retried, never counted against consecutiveFailures.
When the seller's own wallet can't currently receive USDC:
{
"ok": true,
"network": "testnet",
"sellerWallet": "G...",
"usdcTrustline": {
"ok": false,
"reason": "no_trustline",
"message": "Account G... has no trustline for USDC (issuer G...) — add one before this link can be paid.",
"trustlineUri": "web+stellar:tx?xdr=...&network_passphrase=..."
}
}Step 1 of SEP-10 wallet login: builds a challenge transaction for the given account to sign.
200
{ "transaction": "AAAAAgAAAAA...", "network_passphrase": "Test SDF Network ; September 2015" }400 — { "error": "missing_account" } or { "error": "account must be a valid Stellar G-address" }
Step 2: submit the challenge transaction signed by the account's wallet(s). Verifies the server's own signature, timebounds, domain fields, and that signature weight from the account's actual signers (via Horizon, M-of-N aware) meets its medium threshold — the account's master key if it isn't funded yet. Each challenge can be redeemed exactly once.
On success, a seller row is created for the wallet if one doesn't exist yet (the wallet address is the identity).
Request
{ "transaction": "AAAAAgAAAAA..." }200
{ "token": "<session JWT>" }401 — { "error": "<reason>" }, e.g. signature verification failed, challenge
already used, or the transaction doesn't match what we issued.
SEP-1
descriptor advertising SIGNING_KEY, WEB_AUTH_ENDPOINT, and NETWORK_PASSPHRASE
so wallets can discover this service's SEP-10 endpoint — the server-side mirror of
how SellerAnchorAuth (packages/offramp/src/anchor-session.ts) discovers an anchor
and verifies its challenge before the seller's wallet signs it.
Prometheus text-format metrics (content-type: text/plain; version=0.0.4).
Guarded by METRICS_TOKEN — pass Authorization: Bearer <token> or ?token=.
If METRICS_TOKEN isn't set, the API generates an ephemeral one at boot and
prints it once (so the endpoint is never open by default, even locally).
401 — wrong or missing token: unauthorized
Counters
| Metric | Labels |
|---|---|
payments_matched_total |
outcome (paid, underpaid, asset_mismatch, no_memo, unknown_reference) |
link_status_transitions_total |
to |
wallet_submissions_total |
outcome (submitted, invalid_xdr, rejected) |
webhook_attempts_total |
result (ok, error) — every retry counts separately |
anchor_calls_total |
method (quote~SEP-38, initiate/status~SEP-6), status (ok, error, rejected) |
Histograms
| Metric | Labels |
|---|---|
watcher_tick_duration_seconds |
— |
payment_to_paid_latency_seconds |
— |
anchor_call_duration_seconds |
method |
quote_to_settlement_duration_seconds |
outcome (settled, failed) |
Gauges
| Metric | Meaning |
|---|---|
accounts_watched |
distinct destinations the watcher is currently polling |
pending_cash_outs |
links in offramp_pending |
webhook_deliveries_in_flight |
webhook deliveries currently being attempted, including retries |
offramp_circuit_breaker_state |
0=closed, 1=half_open, 2=open — see below |
watcher_lag_seconds |
seconds since the watcher's last completed poll tick |
A ready-made dashboard for these is at docs/grafana-dashboard.json
(import directly into Grafana, point it at your Prometheus data source).
Off-ramp circuit breaker: every off-ramp adapter call (mock or
testanchor) is routed through CircuitBreakerOffRamp
(apps/api/src/services/circuit-breaker.ts), which opens after 3 consecutive
failures and stays open for 30s before allowing a half-open trial call — so a
down anchor can't be hammered by every cash-out poll. This is also the single
seam anchor_calls_total / anchor_call_duration_seconds are recorded from.
Step 1 of SEP-10 wallet login: builds a challenge transaction for the given account to sign.
200
{ "transaction": "AAAAAgAAAAA...", "network_passphrase": "Test SDF Network ; September 2015" }400 — { "error": "missing_account" } or { "error": "account must be a valid Stellar G-address" }
Step 2: submit the challenge transaction signed by the account's wallet(s). Verifies the server's own signature, timebounds, domain fields, and that signature weight from the account's actual signers (via Horizon, M-of-N aware) meets its medium threshold — the account's master key if it isn't funded yet. Each challenge can be redeemed exactly once.
On success, a seller row is created for the wallet if one doesn't exist yet (the wallet address is the identity).
Request
{ "transaction": "AAAAAgAAAAA..." }200 — also sets an httpOnly session cookie (Secure unless COOKIE_SECURE=false,
SameSite=Lax), for server-side/SSR requests that can't hold the token in JS memory.
{ "token": "<session JWT>", "expiresAt": 1750003600 }token— an HS256 JWT (sub=wallet G-address,sellerId,jti,exp≤ 24h from now). Keep it in memory only on the client — neverlocalStorage/sessionStorage.- There is no refresh token by design: renew by re-signing a fresh challenge
(
GET /auth→POST /authagain) beforeexpiresAt.
401 — { "error": "<reason>" }, e.g. signature verification failed, challenge
already used, or the transaction doesn't match what we issued.
Requires auth. Revokes the current token's jti (rejected by every
protected route from then on, even though it hasn't expired yet) and clears
the session cookie.
200
{ "ok": true }401 — same as any protected route: missing/invalid/expired/already-revoked token.
SEP-1
descriptor advertising SIGNING_KEY, WEB_AUTH_ENDPOINT, and NETWORK_PASSPHRASE
so wallets can discover this service's SEP-10 endpoint — the server-side mirror of
how SellerAnchorAuth (packages/offramp/src/anchor-session.ts) discovers an anchor
and verifies its challenge before the seller's wallet signs it.
Requires auth. Creates the link under the authenticated seller (the
one the token's sellerId resolves to).
Request
{
"title": "T-shirt",
"amount": "10.50",
"assetCode": "USDC",
"expiresInMinutes": 60
}title— required, 1–120 chars.amount— required, positive, ≤ 7 decimals.assetCode—"USDC"(default) or"XLM". The USDC issuer is resolved server-side from config.expiresInMinutes— optional positive integer (≤ 43200). Omit for no expiry.
201
{
"link": {
"id": "lnk_...",
"reference": "...",
"status": "pending",
"title": "T-shirt",
"amount": "10.50",
"asset": { "code": "USDC", "issuer": "G..." },
"destination": "G...",
"expiresAt": 1750000000000
},
"request": {
"uri": "web+stellar:pay?destination=...&amount=...&memo=...",
"memo": "...",
"memoType": "text"
}
}The request.uri is a spec-correct SEP-7 payment URI for the buyer's wallet/QR.
The buyer must pay with the given memo — that is how the watcher correlates
the on-chain payment back to this link.
422 — the destination (the seller's wallet) can't currently receive this
asset: not yet created/funded on-chain, no trustline for an issued asset, the
trustline is unauthorized (frozen by the issuer), or it's already at its limit.
Checked on every POST /links (an unfunded/trustline-less seller wallet used to
mean a checkout page that could never actually be paid — see
docs/ARCHITECTURE.md for why this matters). Cached 60s per
(account, asset) so this stays cheap.
{
"error": "destination_cannot_receive",
"message": "Account G... has no trustline for USDC (issuer G...) — add one before this link can be paid.",
"reason": "no_trustline",
"asset": { "code": "USDC", "issuer": "G..." },
"trustlineUri": "web+stellar:tx?xdr=...&network_passphrase=..."
}reason—account_not_found|no_trustline|trustline_not_authorized|trustline_limit_exceeded.trustlineUri— present forno_trustline(and any reason where a trustline exists to sign for): a SEP-7txdeep link wrapping an unsignedchangeTrustoperation. The seller's wallet can sign it directly — nothing server-side ever touches their key. Absent foraccount_not_found(no account yet, so no sequence number to build a transaction from).
Requires auth. Lists the authenticated seller's own links only.
200
{ "links": [ { "id": "lnk_...", "status": "paid", "...": "..." } ] }Public — no auth. Fetch one link plus its payment request (used by the
checkout page). The link id is the bearer capability; seller-only detail remains
behind GET /links/:id/detail.
200 — same shape as the POST /links response.
404 — { "error": "not_found" }
Requires auth. Fetch full link details for the merchant/dashboard, including payment request, webhook delivery history, and the current cash-out poll status. Scoped to the authenticated seller (returns 404 for links belonging to other sellers).
200
{
"link": {
"id": "lnk_...",
"reference": "...",
"status": "offramp_pending",
"title": "T-shirt",
"amount": "10.50",
"asset": { "code": "USDC", "issuer": "G..." },
"destination": "G...",
"expiresAt": 1750000000000
},
"request": {
"uri": "web+stellar:pay?destination=...&amount=...&memo=...",
"memo": "...",
"memoType": "text"
},
"deliveries": [
{
"webhookId": "whk_...",
"linkId": "lnk_...",
"event": "link.paid",
"statusCode": 200,
"ok": true,
"error": null,
"createdAt": 1750000000000
}
],
"offrampPoll": {
"reason": "anchor_unreachable",
"message": "The anchor service is temporarily unavailable or returned an error.",
"at": 1750000005000
}
}offrampPoll— present when the link is inofframp_pendingand the background cash-out poller encountered an error during its last poll attempt (e.g.anchor_unreachable,circuit_open,anchor_auth_required,unknown). It isnullwhen no error occurred or once a subsequent poll succeeds. Never leaks raw anchor response bodies, tokens, or payout fields.
404 — { "error": "not_found" }
Public — no auth. The buyer submits a transaction signed by their own wallet.
The API parses and validates the complete XDR before relaying it to Horizon. It
accepts exactly one payment operation and verifies the destination, asset, amount,
and required MEMO_TEXT against the link. It never marks the link paid directly;
the ledger watcher remains the source of truth.
Request
{ "signedXdr": "AAAAAgAAAAA..." }200
{ "txHash": "b166269ace8a96efe..." }400 — malformed XDR, fee-bump envelope, or a transaction containing anything other than exactly one payment operation.
409 — the link is unavailable, or the transaction does not match the link.
A Horizon rejection returns { "error": "payment_rejected", "reason": "...", "detail": "..." },
where reason can distinguish insufficient_balance, missing_trustline, or a
generic payment_rejected.
Public — no auth. The buyer-facing receipt, keyed by the payment reference
(the same value carried in the Stellar memo). Deliberately narrow: it returns
only what is safe to hand a stranger holding the link. It never includes
sellerId, isDemo, or any of the off-ramp economics (offrampRate,
offrampFeeAmount, offrampNetTargetAmount) — a buyer must not learn the
seller's realized FX rate or anchor fees.
Only settled links resolve. An active, expired or cancelled link returns
404: an unpaid link is not a receipt.
200
{
"reference": "pl_0eipnodm7s2o",
"title": "Order #1042",
"amount": "3",
"asset": { "code": "XLM", "issuer": null },
"status": "paid",
"txHash": "b166269ace8a96efe...",
"payer": "G...",
"paidAmount": "3",
"createdAt": 1786094880000,
"updatedAt": 1786094898582
}Quay saying a link is paid is a claim about its own database. You do not have
to accept it: the payment itself is on the public ledger, and the receipt hands
you everything needed to check it.
txHashis the classic Stellar transaction that delivered the payment. Look it up on any Horizon instance or block explorer — it carries the amount, the asset and its issuer, the destination, and the ledger it settled in. Nothing in that lookup goes through Quay.- The transaction's memo is the link's
reference. That is what binds the payment to this invoice, and it is part of the signed transaction, so it is not something this API can invent after the fact. assetnames the issuer as well as the code. Check it: an asset codedUSDCfrom an issuer that is not Circle's is a different token and worth nothing.
If those three agree with what this page told you, the receipt is real regardless of who is running the API.
REF=pl_0eipnodm7s2o
REFHASH=$(node -e "console.log(require('crypto').createHash('sha256').update('$REF').digest('hex'))")
stellar contract invoke \
--id CD6AFLZTNUKC6CWXWLAVOEH3FY4ZN47SVX6DPYQBZBTPBBSN6LEFIFZ3 \
--source <any-funded-account> --network testnet --send=no \
-- verify --ref_hash $REFHASH404 — { "error": "not_found" }: unknown reference, or the link is not
settled.
Requires auth (404 if the link belongs to a different seller — see the error table above). Seller-initiated off-ramp of a paid link to local currency. Runs
quote → initiate against the off-ramp adapter and moves the link to
offramp_pending; a background poller advances it to offramp_settled /
offramp_failed.
The default adapter is
MockAnchorOffRamp— it simulates an FX quote and payout and moves no money.
Request
{
"targetCurrency": "NGN",
"payoutFields": { "bank": "...", "accountNumber": "..." },
"quoteId": "quote_..."
"withdrawType": "bank_account"
}targetCurrency— 3-letter code, defaults toNGN.payoutFields— opaque string map handed to the anchor adapter.quoteId— optional. The firm quote ID returned byGET /links/:id/cash-out/quote. When supplied, the withdrawal initiates against that exact quote with no re-quote. If omitted, a fresh quote is fetched and initiated atomically.withdrawType— optional SEP-6 withdrawal type (bank_account,cash, …) when the anchor offers several rails; the seller's choice, discovered fromGET /links/:id/offramp-requirements. Omitted, the adapter falls back to the operator-wideOFFRAMP_TYPEdefault, or the anchor's only type. An unknown type is 400{ "error": "unknown_withdraw_type", "availableTypes": ["bank_account", "cash"] }— the caller's mistake, never the 502 a dead anchor gets.
200
{
"job": {
"jobId": "ofr_...",
"linkId": "lnk_...",
"status": "awaiting_transfer",
"targetCurrency": "NGN",
"targetAmount": "17325.00",
"rate": "1650",
"quoteExpiresAt": 1750000000000,
"quoteExpiresInSeconds": 300,
"quoteKind": "indicative"
},
"interactiveUrl": "https://anchor.example.com/sep24/interactive?id=...",
"transfer": {
"destination": "GANCHOR...",
"amount": "10.5",
"asset": { "code": "USDC", "issuer": "GA5Z..." },
"memo": "4242",
"memoType": "id"
}
}quoteKind—firmwhen the anchor quoted the number itself (it implements SEP-38),indicativewhen the anchor has no quote server and Quay computed it from a configured rate source plus the fees the anchor published in/sep6/info(issue 3.22). Real anchors mostly have no SEP-38, so expectindicativein production. An indicativetargetAmountis not the anchor's final say, so surface it as an estimate before the seller commits — which is what the dashboard does. It is a real quote either way; the difference is whose promise it is.job.status/link.offrampStatus—"awaiting_transfer"(anchor is waiting for seller's on-chain transfer),"pending"(anchor is processing payout to local rails),"settled"(completed), or"failed".transfer— present when the anchor is waiting for the asset (SEP-6, once it has named its deposit account). The seller's own wallet sendsamountofassettodestinationwith exactly this memo; the anchor pays out only after it sees that payment. Quay cannot send it — no account Quay controls is in the path. The dashboard builds, signs and submits it from the browser.interactiveUrl— present only when the anchor requires the seller in a browser (SEP-24). Absent for field-driven anchors (SEP-6), which is every adapter shipped today. When present, open it and keep pollingstatus()exactly as before — the link is alreadyofframp_pendingeither way, so the field is additive and a client that ignores it behaves as it did previously. It is alwayshttps; the dashboard refuses any other scheme.
409 — link is not in paid or offramp_failed state: { "error": "Link must be paid to cash out (is \"pending\")" }. offramp_settled and every in-flight state are refused.
409 — { "error": "previous_withdrawal_active" }. A retry from offramp_failed is refused while the previous job's stored status is not failed, so a slow anchor cannot end up with two live withdrawals for one payment.
Retrying a failed cash-out. A link in offramp_failed can be quoted (GET /links/:id/cash-out/quote) and cashed out again with this same endpoint; it moves back to offramp_pending with a new job id. The rate, fee and net-amount fields of the failed attempt are cleared and replaced by the new attempt's values. A job whose state is no longer stored (the job_state_lost repair) has nothing left to be live and may be retried. The webhook payloads are unchanged; the API log carries event: "cashout.retry" with previousJobId, and link.transition carries retry: true. The dashboard shows "Retry cash-out" on failed links.
404 — { "error": "Link not found" }
403 — { "error": "anchor_auth_required" }. Only possible with a real anchor
(OFFRAMP=testanchor|anchor): the seller has no live SEP-10 session with the
anchor — see /seller/anchor-auth below. Only their wallet can fix this.
502 — { "error": "anchor_error", "message": "The anchor returned an error (SEP-6 withdraw, HTTP 400)." }.
The anchor failed or was unreachable; see Conventions. Nothing from the anchor's
response body is included.
422 — { "error": "offramp_rejected", "message", "limits": { "minAmount", "maxAmount" }, "availableTypes": [] }.
The anchor refused the request on its merits: the amount is outside the limits it publishes
(SEP-6 /info), the asset is not withdrawable, or a withdraw type is needed. This is the
seller's to fix (change the amount), it is not an anchor outage, and it does not count towards the
circuit breaker. The same 422 is returned by GET /links/:id/cash-out/quote.
403 — { "error": "kyc_required" }. Only possible with a real anchor: the
seller's SEP-12 KYC (see below) hasn't reached ACCEPTED yet. payoutFields is
bank/routing info only — it is never used as a source of identity data.
Requires auth and the offramp:initiate scope (404 if the link belongs to a
different seller). Returns the non-custodial transfer instructions for an
offramp_pending link, so the seller can finish a withdrawal whose send step was
lost (modal closed, tab reloaded, wallet popup failed). Called when the seller
clicks "Send USDC to finish cash-out" on the dashboard or the link page, never
from the dashboard refresh loop. Quay only relays the instructions; the payment
is built and signed in the seller's browser.
The instructions are read from the anchor with the seller's own session. If the anchor cannot be reached right now (offline, circuit breaker open), the copy stored the last time it answered is returned instead.
200
{
"transfer": {
"destination": "GANCHOR...",
"amount": "10.5",
"asset": { "code": "USDC", "issuer": "GA5Z..." },
"memo": "4242",
"memoType": "id"
}
}asset is the asset the withdrawal was quoted for (stored on the job), not
something the anchor's transaction response names. amount is the anchor's
amount_in when it states one, otherwise the quoted sell amount.
404 — { "error": "not_found" }: another seller's link, an unknown link, or
the anchor has not published instructions yet (a SEP-6 anchor still reviewing
KYC).
409 — the link is not offramp_pending.
403 — { "error": "anchor_auth_required" }: the seller has no live anchor
session; reconnect from the Identity panel.
Sending twice is two payments: the anchor credits the withdrawal once and the second transfer would need a manual refund. The web client therefore remembers the hash of a completed send for the browser session and shows "Payment sent, waiting for the anchor to see it" instead of the send button.
Requires auth and the offramp:initiate scope (404 if the link belongs to a
different seller). The seller's browser calls it right after sending the
on-chain transfer to the anchor, with the transaction hash:
{ "hash": "<64 hex characters>" }The hash is stored on the cash-out job (lowercased) as a claim: the server
does not treat it as proof. The reconciliation report checks it on Horizon
(docs/RUNBOOK.md, "Reconciliation report").
200 — { "ok": true, "jobId": "...", "hash": "..." }. Repeating the same hash is a no-op.
400 — invalid_body: not a 64-character hex hash.
404 — unknown link or another seller's link.
409 — no_cashout_in_progress (the link has no cash-out job), or
transfer_already_recorded (a different hash was already reported; the first claim is kept so a duplicate transfer stays visible).
Requires auth and the offramp:initiate scope. The seller's own SEP-10
session with the anchor. An anchor knows its customer by the Stellar account
that signed in, so each seller signs the anchor's challenge with their own
wallet: the anchor's customer is the seller, never Quay. Quay fetches and
verifies the challenge (issued by the anchor's published SIGNING_KEY, for this
seller's account, on our network), relays the signed transaction, and keeps the
anchor's JWT encrypted at rest so the cash-out poller can follow withdrawals.
The JWT can read/update the seller's KYC and start a withdrawal; it cannot move
funds. It is never returned to the client.
The two POST routes below use the strict per-seller rate limit
(RATE_LIMIT_STRICT_WINDOW_MS / RATE_LIMIT_STRICT_MAX, default 20 requests
per 60 seconds). The bucket is keyed by the authenticated seller ID, so one
seller cannot consume another seller's budget by changing IPs or API keys. When
REDIS_URL is configured, the counters are shared across API instances. GET
and DELETE remain on the global per-IP limit.
GET /seller/anchor-auth→{ "required": true, "connected": false, "anchor": "testanchor.stellar.org", "expiresAt": null }.required: falsemeans the deployment has no real anchor (mock/none).POST /seller/anchor-auth/challenge→{ "transaction": "<XDR>", "networkPassphrase": "..." }— sign it with the wallet, never submit it.POST /seller/anchor-auth{ "transaction": "<signed XDR>" }→{ "connected": true, "anchor": "...", "expiresAt": 1750000000000 }. 400challenge_rejectedif it is not the anchor's challenge for this seller's account.challenge_rejectedcarries a fixedmessageper failure (wrong network, wrong account, anchor refused the signed challenge, anchor could not be verified); the anchor's own wording is logged, not returned. An anchor HTTP failure while fetching the challenge is 502anchor_error.DELETE /seller/anchor-auth→ 204, forgets the session.
403 { "error": "anchor_auth_required" } until the seller has signed in to
the anchor (above).
502 { "error": "anchor_error", "message": "..." } when the anchor fails or
is unreachable (also on PUT); never a 500, and never the anchor's own text.
Current SEP-12 requirements and status for the seller, re-synced from the anchor
(OFFRAMP=mock always reports ACCEPTED — there's no real anchor to satisfy).
200
{
"status": "NEEDS_INFO",
"requiredFields": [
{ "name": "first_name", "type": "string", "optional": false },
{ "name": "email_address", "type": "string", "optional": false }
],
"providedFields": { "first_name": "Ada" },
"message": null,
"lastSyncedAt": 1750000000000
}status is one of unsubmitted | NEEDS_INFO | PROCESSING | ACCEPTED | REJECTED.
unsubmitted means the anchor has no customer for the seller's account. If a stored customer id is no longer
known to the anchor (it returns 404 for it), Quay retries by account, keeps the id the anchor returns, and logs
kyc.customer_id.stale (no PII) rather than reporting the seller as new. A status the anchor sends that Quay does not
model is treated as PROCESSING and logged as kyc.status.unknown with the raw status string only.
Submit or update identity fields. Values are sent to the anchor exactly as
given — no field is ever defaulted or fabricated. Call with {} to kick off
discovery before any fields are known.
Request
{ "first_name": "Ada", "email_address": "ada@example.org" }200 — same shape as GET /seller/kyc, reflecting the anchor's response
(which may reveal further required fields — SEP-12 discovery is progressive).
422
{ "error": "kyc_required", "missingFields": ["email_address"] }Returned when a field the anchor is already known to require is missing — naming exactly which ones, never silently substituting a placeholder.
List the authenticated seller's recorded SEP-12 disclosures by anchor. Requires a
seller session and offramp:initiate scope; API keys are rejected. Each entry
contains anchorDomain, status, consent (grantedAt and revokedAt), and
fields with only name, sentAt (epoch milliseconds), and anchorStatus.
The response never contains field values or anchor error text. Send times are
recorded for successful submissions made after this endpoint is deployed;
older sends cannot be dated reliably and are omitted.
Revoke consent, request SEP-12 customer deletion
at the configured anchor using the seller's own SEP-10 session, then erase the
local KYC record and disclosure metadata for that anchor. Requires a seller
session and offramp:initiate scope; API keys are rejected. The path must equal
the configured anchor domain; arbitrary anchor URLs are never accepted. A
successful response contains anchorResult (deleted or not_found) and
localDataErased: true. If anchor authentication or deletion fails, local
erasure is not reported as complete. This does not erase the seller's separate
reusable profile or data held by another anchor.
List all consent records (active and revoked) for the authenticated seller.
Requires auth. Session authentication only — API keys are rejected.
200
{
"consents": [
{
"id": "cnc_1",
"anchorDomain": "testanchor.stellar.org",
"fields": ["first_name", "last_name", "email_address"],
"grantedAt": 1750000000000,
"revokedAt": null,
"grantedVia": "session",
"noticeVersion": "1.0"
}
]
}revokedAt is null for active consents, an epoch-ms timestamp when revoked.
Grant consent to share specific identity fields with an anchor. The server re-derives the fields the anchor currently requires from the latest KYC record and refuses any mismatch — the UI cannot consent to fields the anchor did not ask for. All required fields must be covered; optional fields may be omitted.
Requires auth. Session authentication only — API keys are rejected.
Request
{ "anchorDomain": "testanchor.stellar.org", "fields": ["first_name", "last_name", "email_address"] }201 — consent granted
{
"id": "cnc_2",
"anchorDomain": "testanchor.stellar.org",
"fields": ["first_name", "last_name", "email_address"],
"grantedAt": 1750000001000,
"revokedAt": null,
"grantedVia": "session",
"noticeVersion": "1.0"
}400 — validation errors
{ "error": "invalid_fields", "message": "Consent request includes fields the anchor does not currently require", "fields": ["phone_number"] }{ "error": "missing_required_fields", "message": "Consent must cover all required fields", "fields": ["last_name"] }
Revoke the active consent for an anchor. Revocation stops future data sends to that anchor; it does not erase data already held by the anchor (see issue 4.28).
Requires auth. Session authentication only — API keys are rejected.
200
{
"revoked": true,
"anchorDomain": "testanchor.stellar.org",
"note": "Revocation stops future data sends to this anchor. It does not erase data already held by the anchor (see issue 4.28)."
}Submit or update identity fields. Before any field is sent to the anchor, the
server checks that every field about to be sent is covered by an active consent
for that anchor. If any field lacks consent, the request is rejected with
403 consent_required.
403
{ "error": "consent_required", "anchorDomain": "testanchor.stellar.org", "fields": ["last_name", "email_address"] }The fields array names exactly which fields lack consent. The seller must
grant consent via POST /seller/kyc/consent before retrying.
422 — same as before when a required field is missing from the request body.
The seller's reusable identity profile: the values they own, independent of any
anchor. One entry per SEP-9 field, using the canonical SEP-9 name (an alias such
as first_name is stored as given_name).
Requires auth. Session authentication only — API keys get 403, because the
profile is the seller's own identity data. Values are stored encrypted
(AES-256-GCM, the same key as the SEP-12 KYC record) and are decrypted only to
answer this request.
200
{
"fields": [
{ "field": "birth_date", "value": "1815-12-10", "source": "seller", "updatedAt": 1750000000000 },
{ "field": "given_name", "value": "Ada", "source": "migrated_from_seller_kyc", "updatedAt": 1749000000000 }
]
}source is seller (typed by the seller) or migrated_from_seller_kyc (lifted
from the older per-seller KYC record at boot). updatedAt is the last time the
value changed; saving the same value again does not move it.
403 — { "error": "forbidden", ... } for API-key auth.
503 — { "error": "profile_unavailable", ... } when the deployment has no real
anchor or no KYC_ENCRYPTION_KEY, so there is nowhere to keep encrypted values.
Store or update profile fields. The body is a flat map of SEP-9 field name to
string value, the same shape as PUT /seller/kyc. Every value is validated with
the SEP-9 validators (country codes, dates, phone numbers, email, choices, ...)
and nothing is written unless every field is valid.
Requires auth. Session authentication only — API keys get 403.
Request
{ "given_name": "Ada", "birth_date": "1815-12-10", "address_country_code": "GBR" }200 — the full profile, same shape as GET /seller/profile.
422 — invalid_fields, with a reason for each rejected field:
{
"error": "invalid_fields",
"fields": {
"birth_date": "expected a real YYYY-MM-DD date",
"favourite_colour": "not a SEP-9 field",
"photo_id_front": "binary fields are not accepted"
}
}Unknown field names, binary-typed fields (binary data is never persisted) and empty values are rejected alongside format errors.
400 — invalid_body when the body is not a map of strings.
Receives the anchor's SEP-12 push when a seller's KYC status changes, so a decision made overnight
is visible without the seller reloading. Called by the anchor, not by a browser or an API client:
there is no session or API key. The anchor is given this URL when Quay registers it with
PUT [KYC_SERVER]/customer/callback, using the seller's own anchor session. Registration happens
after a successful PUT /seller/kyc and on the first status read that yields a customer id. It is
skipped, with a log line, when the API's public origin is localhost. Polling GET /seller/kyc
keeps working when no callback arrives.
:token is a random per-seller value. Only its SHA-256 hash is stored (on the seller_kyc row), so the
URL identifies the seller and cannot be guessed. :anchorDomain must be the anchor that token was issued for.
The request must carry the anchor's signature in Signature: t=<unix-seconds>, s=<base64> (the deprecated
X-Stellar-Signature header is also accepted), over <t>.<host>.<body>, verifiable with the SIGNING_KEY
in that anchor's stellar.toml. A timestamp more than 120 seconds from now is refused. The body is the
SEP-12 GET /customer response; its id must equal the stored customer id.
| Status | Body | Meaning |
|---|---|---|
| 200 | { "ok": true } |
KYC record updated: status, required fields, per-field status, message |
| 400 | { "error": "invalid_json" } / { "error": "invalid_body" } |
Signed, but not a usable SEP-12 customer body. Nothing is changed |
| 400 | { "error": "customer_id_mismatch" } |
Body id is not this seller's customer |
| 400 | { "error": "unknown_anchor" } / { "error": "missing_anchor_signing_key" } |
The anchor's stellar.toml or signing key could not be resolved |
| 401 | { "error": "missing_signature" } / { "error": "invalid_signature" } |
No signature, a bad or stale one |
| 404 | { "error": "invalid_callback_token" } |
Unknown token, or a token used under a different anchor. No stellar.toml is fetched in either case |
Register a webhook endpoint. The signing secret is returned once — store it.
It's encrypted at rest (not stored in plaintext); the API can never show it to you
again after this response, only a display-only secretLast4.
Requires auth. Register a webhook endpoint for the authenticated seller.
The signing secret is returned once — store it.
Request
{ "url": "https://example.com/hooks/checkout" }201
{ "id": "...", "url": "https://example.com/hooks/checkout", "secretLast4": "a1b2", "secret": "<hex>" }List registered webhooks. Secrets are not returned — only secretLast4 for
display. Deleted webhooks are excluded.
Requires auth. Lists the authenticated seller's registered webhooks.
Secrets are not returned.
200
{
"webhooks": [
{
"id": "...",
"url": "...",
"secretLast4": "a1b2",
"previousSecretLast4": null,
"previousSecretExpiresAt": null,
"deletedAt": null,
"createdAt": 1750000000000
}
]
}Removes a webhook (soft delete — it stops receiving events immediately, but its
delivery history remains readable via GET /webhooks/:id/deliveries).
204 — no body.
404 — { "error": "not_found" } if the id doesn't exist or isn't yours.
Issues a new signing secret, returned once just like at creation. The previous secret keeps signing deliveries for 24 hours after rotation (see "Webhook delivery" below), so you can redeploy your receiver with the new secret without dropping any events in between.
200
{ "id": "...", "url": "...", "secretLast4": "c3d4", "secret": "<hex>" }404 — { "error": "not_found" }
Paginated delivery history for one webhook, newest first. Works even after the
webhook has been deleted. limit defaults to 20, max 100.
200
{
"deliveries": [
{
"id": "whd_...",
"webhookId": "whk_...",
"linkId": "lnk_...",
"event": "link.paid",
"statusCode": 200,
"ok": true,
"error": null,
"createdAt": 1750000000000
}
],
"nextCursor": "b3RoZXI"
}Pass nextCursor back as ?cursor= to fetch the next page; null means there
are no more results.
404 — { "error": "not_found" } if the id doesn't exist or isn't yours.
Manually re-queue a webhook delivery for immediate redelivery. Useful for recovering dead-lettered entries or forcing a retry without waiting for the next backoff window.
:id is the queue entry id returned in delivery metadata, or visible in
webhook_queue.id. Scoped to the calling seller: an entry belonging to another
merchant's webhook is reported as 404, the same as one that does not exist.
Behaviour by current status
| Entry status | Effect |
|---|---|
dead |
Re-queued as pending, nextAttemptAt = now. |
pending |
nextAttemptAt reset to now (accelerates next attempt). |
delivered |
Re-queued as pending (re-sends an already-delivered event). |
claimed |
409 — delivery is in-flight; wait for it to settle. |
202
{
"id": "wqe_...",
"webhookId": "whk_...",
"linkId": "lnk_...",
"event": "link.paid",
"previousAttempts": 5,
"status": "pending",
"message": "Queued for immediate redelivery."
}404 — queue entry not found, or not yours. 409 — delivery is currently in-flight.
When a link changes state, the API writes a delivery row to the durable queue
and returns immediately — event emission never blocks a state transition. A
background WebhookWorker claims due rows and POSTs the event to each
registered URL.
| Event | Fired when |
|---|---|
link.paid |
a matching payment settled (exact or over) |
link.underpaid |
a payment arrived for less than requested |
offramp.settled |
a cash-out job settled |
offramp.failed |
a cash-out job failed |
offramp.transfer_required |
anchor has published deposit instructions; the seller's wallet must send USDC to the anchor to fund the withdrawal |
kyc.accepted |
anchor accepted seller's KYC submission |
kyc.rejected |
anchor rejected seller's KYC submission |
kyc.needs_info |
anchor requested additional/missing fields |
{
"event": "offramp.transfer_required",
"data": {
"linkId": "lnk_...",
"reference": "...",
"status": "offramp_pending",
"amount": "10.50",
"paidAmount": "10.50",
"asset": { "code": "USDC", "issuer": "G..." },
"txHash": null,
"transfer": {
"destination": "GANCHOR...",
"amount": "10.50",
"asset": { "code": "USDC", "issuer": "G..." },
"memo": "withdrawal_123",
"memoType": "text"
},
"jobId": "job_..."
},
"id": "lnk_...",
"sentAt": "2026-06-19T12:00:00.000Z"
}The transfer object contains the SEP-6 deposit instructions the seller's wallet must sign and submit. The integrator should display these to the seller or prompt their wallet to sign.
Note on sensitivity: The transfer instructions contain no secrets — they are the public anchor account and memo the payer needs. However, integrators should not expose them to anyone but the seller.
{
"event": "link.paid",
"data": {
"linkId": "lnk_...",
"reference": "...",
"status": "paid",
"amount": "10.50",
"paidAmount": "10.50",
"asset": { "code": "USDC", "issuer": "G..." },
"txHash": "...",
"overpaid": false
},
"id": "lnk_...",
"sentAt": "2026-06-19T12:00:00.000Z"
}For seller-level KYC events, id is the seller ID and the payload contains no PII (only status metadata and field names). message is populated only on kyc.rejected and is null for all other events:
{
"event": "kyc.needs_info",
"data": {
"anchorDomain": "testanchor.stellar.org",
"status": "NEEDS_INFO",
"previousStatus": "PROCESSING",
"missingFields": ["id_document_front", "id_document_back"],
"message": null
},
"id": "sel_...",
"sentAt": "2026-06-19T12:00:00.000Z"
}x-checkout-event— the event name.x-checkout-signature— one or moresha256=<hex>HMAC-SHA256 signatures of the exact raw body, comma-separated. Normally just one, signed with your current secret. For 24h after a secret rotation, two are sent (current + previous secret) — accept the delivery if any listed signature matches, so you can redeploy without dropping events.
- Durable: the event body is serialised once at write time and persisted in
webhook_queue, so every attempt re-sends byte-identical content. A process crash during backoff does not lose the event — it is delivered after restart, and a crash mid-delivery releases its claim so another worker picks it up. Signing happens per attempt, which is what lets a secret rotated between attempts take effect. - At-least-once: retried up to 5 attempts with exponential backoff + full jitter (base 5 s → max ceiling doubles per attempt). Make receivers idempotent.
- Per-attempt history: every attempt is written to
webhook_deliveries(attemptcolumn +queue_entry_id), so you can inspect exactly which attempts failed and why. - Transient failures (network errors,
5xx,429) are retried. - Permanent failures (
4xxexcept429) are dead-lettered immediately. - Dead letters are replayable via
POST /webhooks/deliveries/:id/replay.
Return 2xx quickly to acknowledge receipt. Long-running processing should be
done asynchronously.
For replay protection, reject events whose in-body sentAt is older than a
small window (e.g. 5 minutes). sentAt is inside the signed body and cannot be
forged without the secret.
Recompute over the raw body and compare in constant time:
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, header, secret) {
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
return header.split(",").some((part) => {
const a = Buffer.from(part.trim());
const b = Buffer.from(`sha256=${expected}`);
return a.length === b.length && timingSafeEqual(a, b);
});
}Data-subject export (NDPA 2023 right of access): a machine-readable copy of the personal data Quay holds about the authenticated seller.
Requires auth. Session authentication only. API keys get 403; an
integrator's key must not be able to pull a seller's full identity. The export
is scoped to the session's own seller and cannot name another.
Rate limit. 5 requests per seller per hour (429 rate_limited with a
Retry-After header afterwards). Requests that fail authentication do not
count.
200 is application/json, sent as an attachment with
Content-Disposition: attachment; filename="quay-export-<sellerId>-<YYYY-MM-DD>.json"
and Cache-Control: no-store. Timestamps are ISO 8601 strings.
{
"generatedAt": "2026-10-05T12:00:00.000Z",
"seller": { "id": "sel_...", "name": "...", "wallet": "G...", "createdAt": "..." },
"profile": [{ "field": "given_name", "value": "Ada", "source": "seller", "updatedAt": "..." }],
"kyc": [{
"anchorDomain": "testanchor.stellar.org", "account": "G...", "customerId": "...", "status": "ACCEPTED",
"message": null, "providedFields": { "first_name": "Ada" },
"providedFieldStatus": [{ "name": "first_name", "status": "ACCEPTED", "error": null }],
"sentFields": ["first_name"], "lastSyncedAt": "..."
}],
"disclosures": [{ "anchorDomain": "testanchor.stellar.org", "fieldName": "first_name", "sentAt": "..." }],
"consents": [{
"anchorDomain": "testanchor.stellar.org", "fields": ["first_name"],
"grantedAt": "...", "revokedAt": null, "noticeVersion": "..."
}],
"anchorConnections": [{ "anchorDomain": "testanchor.stellar.org", "account": "G...", "expiresAt": "..." }],
"payoutFields": { "bank_account_number": "..." },
"links": [{
"id": "...", "reference": "...", "title": "...", "amount": "10", "asset": "USDC",
"assetIssuer": "G...", "status": "paid", "createdAt": "..."
}],
"payments": [{
"linkId": "...", "txHash": "...", "payer": "G...", "amount": "10", "asset": "USDC",
"ledger": 123, "createdAt": "..."
}],
"offrampJobs": [{
"jobId": "...", "linkId": "...", "anchor": "...", "account": "G...", "targetCurrency": "NGN",
"targetAmount": "1000", "rate": "1500", "status": "completed", "sellAsset": "USDC:G...",
"sellAmount": "1", "sellerTxHash": null, "amountIn": null, "amountFee": null,
"createdAt": "...", "updatedAt": "..."
}],
"webhooks": [{ "id": "...", "url": "https://...", "createdAt": "..." }],
"apiKeys": [{ "id": "...", "name": "...", "scopes": ["links:read"], "createdAt": "...", "lastUsedAt": null }]
}profileandkycare decrypted for the export. They are omitted entirely (not returned empty) on a deployment without a real anchor andKYC_ENCRYPTION_KEY, because that is where the encrypted stores live. Every other section is always present; an empty array means nothing is held.payoutFieldsisnulluntil the seller has saved payout details.webhookslists live webhooks; deleted ones are not included.- Never included: the anchor SEP-10 token, webhook secrets, API key hashes or prefixes, the KYC callback token hash, the anchor's deposit instructions or raw error text on off-ramp jobs. Also left out because they are operational records rather than data about the seller: off-ramp quotes, webhook delivery logs, idempotency keys, watcher and telemetry state.
- Each list section is capped at 10,000 rows so one request cannot read an
unbounded table. A section that was cut is named in a top-level
"truncated": ["payments", ...]array (absent when nothing was cut).
Each export logs event: "privacy.export" with the sellerId only, never its
contents. The dashboard offers it as Download my data.
Right to erasure (NDPA 2023) for the authenticated seller's identity data.
Requires auth. Session authentication only. API keys get 403; the
request is scoped to the session's own seller and cannot name another.
Request
{ "confirm": "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN" }confirm must equal the seller's wallet address, otherwise 400
invalid_confirmation and nothing is changed.
What happens
- If the deployment has a real anchor, Quay asks it to erase the seller
(
DELETE {KYC_SERVER}/customer/{account}, SEP-12) using the seller's own anchor session, before that session is removed. Quay signs nothing for the seller. A missing session does not block local erasure. - In one database transaction Quay deletes the seller's
seller_profilerows,seller_kycrows (including the encrypted fields and the callback token hash), disclosure history,kyc_consentsrows andanchor_sessionsrows, and clears the saved payout fields.
200
{
"erased": ["profile", "kyc", "consents", "anchor_sessions", "payout_fields"],
"anchors": [{ "anchorDomain": "testanchor.stellar.org", "result": "erased" }],
"retained": [
{ "what": "payment history", "why": "public on the Stellar ledger" },
{ "what": "links and payment records", "why": "kept for the merchant's own accounting" },
{ "what": "seller account (wallet login identity)", "why": "deleting the account is a separate decision" },
{ "what": "database backups", "why": "expire after BACKUP_RETENTION_DAYS" }
]
}anchors[].result is erased, not_held (the anchor answered 404),
refused:<status> or not_attempted:no_session (connect to the anchor and
erase again to remove what it holds). anchors is empty when no real anchor
is configured. The anchor may be legally required to retain KYC records.