# Skerry > Skerry is an API that instructs regulated providers to move money for businesses: verification, custodied stablecoin wallets, bank accounts, payouts, conversion, and card, bank-debit, stablecoin and bank-transfer payments. Skerry holds no funds and no keys, and cannot sign blockchain transactions. Base URL: https://api.skerry.xyz - [OpenAPI document](https://api.skerry.xyz/openapi.json) - [Capabilities and the payment-method matrix](https://api.skerry.xyz/capabilities) (needs a key) - [Index of operations](https://api.skerry.xyz/) ## Authentication Every call but `/` sends `Authorization: Bearer sk_test_…` or `sk_live_…`. The environment is in the key, not in a header, so the same code runs against sandbox and live by swapping one value. Where the deployment is configured with an authorisation server, a short-lived access token issued for this API is accepted in place of a key; `/.well-known/oauth-protected-resource` says whether and from whom. Agent keys: Register agents with `POST /agents` and give each its own key with `POST /agents/{id}/keys`: that key is the agent, and it can only move money under a spend policy. On a shared key, send `Skerry-Agent: ` so the agent's policies apply and the audit trail names it; where a policy is bound to an agent, a money-moving call that names no agent is refused with `agent_required`. Once any agent is registered the header must name a registered, active one. Signed requests: An agent may sign requests with a registered Ed25519 key using HTTP Message Signatures (RFC 9421): `Signature-Input` and `Signature`, covering `@method`, `@target-uri` and, when there is a body, `content-digest` (RFC 9530), with `created`, `expires` (at most 300 seconds later), `keyid` (the key's JWK thumbprint) and `tag="skerry-agent"`. The API key still identifies the account; a valid signature proves which agent sent the request. OAuth: where the deployment accepts access tokens, `https://api.skerry.xyz/.well-known/oauth-protected-resource` names the authorisation server. It answers 404 when tokens are not accepted. ## Environments A `sk_test_` key reaches only sandbox data and test money; a `sk_live_` key only live. Nothing is shared between them, ids from one are not found in the other, and sandbox-only helpers refuse a live key. ## The order of calls ### Find out what you may do 1. `GET /whoami`: the account, environment and scopes behind your key 2. `GET /capabilities`: what the platform offers and what is switched on, with the payment-method matrix 3. `GET /customers/{id}/payment-methods`: what one business can be paid with and pay with right now ### Bring a business on 1. `POST /customers`: record the business 2. `POST /customers/{id}/onboarding-link`: a link its owner opens to finish verification on hosted pages 3. `GET /customers/{id}`: read `can` and `requirements` until what you need is enabled ### Take a payment 1. `POST /checkout-sessions`: start a checkout; send the payer to `url`, or use `payment_instructions` for stablecoin and bank transfer 2. `GET /checkout-sessions/{id}`: where the session stands 3. `GET /payments/{id}`: the payment, once there is one; build orders from the `payment.succeeded` event ### Pay somebody 1. `POST /customers/{id}/payees`: add where the money goes 2. `POST /payouts/quote`: what it would cost and which rail it would leave on 3. `POST /payouts`: send it, with an Idempotency-Key 4. `GET /payouts/{id}`: follow it to `paid` ### Convert a balance 1. `POST /conversions/quote`: a priced offer, valid for a short time and usable once 2. `POST /conversions`: execute the quote, with an Idempotency-Key 3. `GET /conversions/{id}`: whether it has settled ### Refund a payment 1. `POST /payments/{id}/refunds`: all or part, with an Idempotency-Key 2. `GET /payments/{id}/refunds`: the refunds made against it ### Charge for a resource by the call 1. `POST /customers/{id}/priced-resources`: say what a call costs 2. `POST /machine-payments/challenge`: what to answer an unpaid request with: status 402 and its headers 3. `POST /machine-payments/verify`: check and settle the payment a client sent; returns the receipt headers ### Let agents buy from a business 1. `POST /customers/{id}/agentic/pricing-callback`: where line items, totals, tax and fulfilment are asked for 2. `POST /customers/{id}/agentic/credentials`: a bearer token for the agent platform 3. `POST /agentic/{customer_id}/checkout_sessions`: the agent platform's first call, under that token ### Let platforms buy from a business through the Universal Commerce Protocol 1. `POST /customers/{id}/agentic/pricing-callback`: where line items, totals and tax are asked for; the same one agentic checkout uses 2. `GET /customers/{id}/ucp/settings`: what the business has set; a PUT to the same address saves the platforms it accepts and what its profile publishes 3. `GET /ucp/{customer_id}/.well-known/ucp`: the profile the business points its own /.well-known/ucp at 4. `POST /ucp/{customer_id}/checkout-sessions`: the platform's first call, signed with a key its own profile publishes ### Wait for a person 1. `GET /approvals/{id}`: read a held operation until it is `executed`, `denied` or `expired` 2. `GET /events`: or read the `approval.decided` event ## Quote, then commit Where the amount matters, ask first and act second. A quote changes nothing; the second call is the one that moves money. - `POST /payouts/quote`, then `POST /payouts`: What a payout would cost, as a quote that binds the instruction. The answer carries `quote_id` and `valid_until` (120 seconds by default); `POST /payouts` with that `quote_id` executes exactly the quoted customer, amount, source, destination and rail, once. For a bank payout it says which rail it would leave on and why (`rail`, `rail_reason`) and carries the name check on the payee (`payee_check`). `fee`, `rate` and the receive amount are what the provider's dry run reported when the quote was made; the provider is not asked to hold them, so this is not a guaranteed rate. `amount` is a decimal string and `amount_minor` the same in integer minor units of the source currency; send either. `fee_minor` is in minor units of `fee_currency`, `receive_amount_minor` of `receive_currency`. - `POST /conversions/quote`, then `POST /conversions`: A priced offer to convert, valid for a short time and usable once. ## Dry run Send `Skerry-Dry-Run: true` (or `dry_run: true` in the body) on a money-moving call to check it without doing it: payouts, batches, conversions, refunds, off-session charges, settlements, subscriptions, checkout sessions and payment links. Validation, ownership, the freeze, policies, budgets, the customer's risk rules and payee rules are evaluated, and for payouts and conversions the provider's own dry run is asked. The answer is 200 with `dry_run: true`, `next` (run, hold or refuse), `checks` (each check and its result), `object` (what would be created, where the operation can state it) and, for hold or refuse, why. Nothing is reserved, sent or created. With the header, no Idempotency-Key is needed and no idempotency record is kept. ## Idempotency Send `Idempotency-Key` on every POST. A repeat returns the first response rather than acting twice; a repeat with a different body is refused. This is how a retry after a timeout stays safe. Calls that move money require it, `POST /payouts` included. ## Approvals A money-moving call can answer HTTP 202 with `status: "requires_approval"` and an `approval_id`. Not an error: the operation is held for a person. The body names the approval. Nothing has moved. Do not retry the call. Read `GET /approvals/{id}` until its status is `executed`, `denied` or `expired`; the result of an executed operation is on the approval. The key that asked cannot approve. ## Amounts Every amount is one of two forms, and the reference says which. Integer minor units: a whole number of the currency's smallest unit (cents for USD and EUR, six places for a stablecoin) beside a lowercase `currency`. Payments, refunds, settlements, conversions, ledger entries, budgets and every field named `*_minor` use this. Decimal strings: `"12.50"`, used by `amount` and `fee` on payouts and by wallet balances, which predate the rule. Wherever a decimal string is returned, the same amount is returned beside it as `amount_minor` (or `fee_minor`) with `currency`; `POST /payouts` and `POST /payouts/quote` accept `amount_minor` in place of `amount`. A rate is a ratio, not an amount, and stays a decimal string. Limits on a spend policy are integer minor units of the unit its `scope` names. ## Pagination List endpoints return newest first and take `?limit=` and `?cursor=`. A response carrying `next_cursor` has more. ## Rate limits Reads are limited to 600 and writes to 120 per minute per key. Past that, 429 `rate_limited` with `Retry-After`. Every response carries `RateLimit-Limit` and `RateLimit-Remaining`. ## References Send `external_ref` (up to 200 characters) with `external_kind` (invoice, bill, order, booking or other) on a checkout session, payment link, payment, refund, payout, conversion or subscription to name the document it belongs to in your own system. Skerry stores it, returns it on the object, on every event about it and on the ledger entry, and lists by it (`?external_ref=`). On a bank payout it is also what the payee's bank shows, cut to what the rail carries. ## Receipts Every movement has a receipt at `GET /receipts/{id}`, and the `movement.pending`, `movement.posted` and `movement.reversed` events carry it. A receipt is signed with Ed25519 over its canonical JSON (keys sorted, no whitespace) without the `signature` field; verify it with the key named by `signature.key_id` at `/.well-known/skerry-receipt-keys`. ## Errors Failures are `{ error: { code, message, detail, request_id } }`. Branch on `code`, never on `message` — messages are written for people and will change. | Status | Code | What it means and what to do next | |---|---|---| | 400 | `invalid_request` | The body is wrong. `detail.field` names which part. | | 401 | `unauthenticated` | No key, or not a key we issued. Send it as `Authorization: Bearer sk_…`. | | 401 | `key_revoked` | The key was revoked. Issue another. | | 401 | `key_expired` | The key's `expires_at` has passed. Issue another, or roll a key before it expires. | | 401 | `signature_invalid` | The request's HTTP Message Signature could not be accepted. `detail.reason` says why: malformed, expired, digest_mismatch, unknown_key, bad_signature and others. | | 401 | `signature_required` | This agent must sign its requests and this one was not signed. | | 401 | `token_invalid` | The access token was not accepted. `detail.reason` says why: wrong_audience, wrong_issuer, bad_signature, missing_claim and others. | | 401 | `token_expired` | The access token has expired. Get another from the authorisation server. | | 401 | `dpop_invalid` | The token is bound to a key and the DPoP proof was missing, wrong for this request, or already used. | | 503 | `oauth_unavailable` | The authorisation server's keys could not be read, so tokens cannot be checked. API keys still work. | | 403 | `agent_required` | Say which agent is acting: send `Skerry-Agent` or use a key bound to the agent. Policies on this account are bound to agents. | | 403 | `agent_mismatch` | `Skerry-Agent`, the key, the signature or the token name different agents. | | 403 | `agent_unknown` | No such agent is registered in this environment. Once any agent is registered, `Skerry-Agent` must name one. | | 403 | `agent_suspended` | The agent, or an agent it was delegated from, is suspended. | | 403 | `agent_revoked` | The agent, or an agent it was delegated from, has been revoked. Also 409 when changing a revoked agent. | | 403 | `agent_expired` | The agent's delegation has expired. | | 403 | `agent_not_permitted` | An agent's own credential may not do this: it would widen or restore authority, or act for another agent. | | 403 | `scope_not_held` | A key cannot grant, or take over, scopes it does not hold itself. `detail.missing` lists them. | | 403 | `delegation_not_narrower` | A delegation asked for more than the delegating agent has. `detail.field` names which part. | | 403 | `customer_frozen` | Money movement is frozen for this customer. `POST /customers/{id}/unfreeze` lifts it. | | 403 | `partner_frozen` | Money movement is frozen for the whole account in this environment. `POST /controls/unfreeze` lifts it. | | 402 | `not_in_plan` | The plan does not include this. Usage on the project page says where you stand. | | 403 | `insufficient_scope` | The key is narrower than the call. Each operation names its scope. | | 403 | `live_not_approved` | A live key was used before live was approved. Sandbox still works. | | 403 | `partner_suspended` | The account is suspended. Nothing will succeed until that is lifted. | | 404 | `not_found` | No such object in this project — including one that belongs to somebody else. | | 409 | `already_exists` | The object is already there. Usually a retry without an Idempotency-Key. | | 409 | `request_in_flight` | The same Idempotency-Key is still running. Wait and read the first answer. | | 400 | `idempotency_key_required` | This call moves money; send an Idempotency-Key so a retry cannot act twice. `POST /payouts` requires it too. | | 402 | `payment_failed` | The payer's bank or card refused. `detail.reason` says why in plain terms. | | 403 | `limit_exceeded` | A spend policy on this key refuses the operation. `detail.limit` is per_payment, daily, monthly or count; `detail.unit` names the currency the amounts are minor units of. | | 403 | `operation_not_allowed` | A spend policy on this key does not allow that operation. `detail.allowed` lists what it allows. | | 403 | `currency_not_allowed` | A spend policy on this key does not allow that currency. | | 403 | `customer_not_allowed` | A spend policy on this key is for a different customer. | | 403 | `merchant_not_allowed` | The merchant is not on the mandate's allow-list. | | 403 | `wallet_not_allowed` | The mandate does not cover the wallet the money would leave. | | 403 | `mandate_not_yet_valid` | The mandate's `valid_from` has not come yet. | | 403 | `consent_required` | The mandate is marked `consent_required` and has no verified consent evidence for its rule as it stands. Record it with `POST /spend-policies/{id}/consent`. | | 403 | `agent_cannot_approve` | An agent cannot decide an approval or make approval links. | | 403 | `approval_evidence_required` | The approval needs passkey evidence from the approver, and none was sent or it could not be verified. | | 409 | `approver_not_distinct` | This key, member or approver has already decided the approval. A second approval must come from someone else. | | 409 | `approval_decided` | The approval has already been decided. | | 409 | `approval_expired` | The approval was not decided in time. The operation did not run. | | 409 | `approval_not_usable` | `Skerry-Approval` did not name an approval that was approved for exactly this request, asked by this key, and not used yet. | | 403 | `payee_not_allowed` | The destination is not on this key's allow-list. | | 403 | `mandate_expired` | The spend policy on this key has expired. | | 403 | `policy_revoked` | Every spend policy on this key was revoked. | | 403 | `policy_required` | This key may only act under a spend policy and none is attached. | | 403 | `self_approval` | The key that asked for an operation cannot approve it. | | 409 | `onboarding_incomplete` | The customer has not finished the hosted onboarding this needs. `detail.next_step` says which. | | 409 | `mandate_required` | The payer has not agreed to be charged later. | | 409 | `refund_not_possible` | The refund is more than what remains of the payment. | | 409 | `quote_expired` | The conversion or payout quote's time is up. Ask for a new one. | | 409 | `quote_moved` | The market moved beyond the quote's tolerance. Ask for a new one. | | 409 | `quote_used` | That conversion or payout quote has already been used. | | 202 | `requires_approval` | Not an error: the operation is held for a person. The body names the approval. | | 429 | `rate_limited` | Too many requests on this key. `Retry-After` says when to try again. | | 409 | `books_not_connected` | The customer's books are not connected, so there is nothing to hand over to. `detail.next_step` says how to connect them. | | 409 | `capability_unavailable` | Not available for this project. `/capabilities` says what is. | | 409 | `capability_coming_soon` | Built, not yet switched on for this project. | | 403 | `reliance_not_enabled` | `reliance` was sent by an account that is not approved to verify its own customers. | | 400 | `refund_destination_required` | Where the money came from is not on record for this deposit. Send `destination` with the refund: an address and chain, or a payee. | | 409 | `refund_not_supported` | Payments by this method cannot be refunded through the processor. Return the money another way. | | 409 | `deposit_resolved` | The unmatched deposit has already been assigned or sent back. Read it to see which. | | 409 | `currency_mismatch` | The amount is in another currency than the object it is applied to. `detail.currency` says which is expected. | | 400 | `price_below_minimum` | The price is below the smallest single payment for a method (`detail.method`, `detail.minimum`), or has a part of a cent. Set `credit_only`, raise the price, or drop the method. | | 409 | `credit_required` | The resource's price is too small for a single payment. Buy credit with `POST /machine-payments/credits` and pay each call from it. | | 409 | `dispute_without_payment` | The payment behind the dispute is not on record, so no evidence can be drafted from it. | | 502 | `upstream_unavailable` | On the pay-by-the-call proxy: the payment was taken and the business's address could not be reached. The answer carries the receipt; the business can refund the payment. | | 409 | `resource_disabled` | The priced resource has been disabled. Register it again to charge for it. | | 409 | `dispute_closed` | The dispute is no longer waiting for a response; evidence and files are refused. | | 409 | `meter_inactive` | The meter is no longer active. | | 403 | `outside_budget` | The mandate or agent has a budget and may only spend from the wallet and currency that budget is in. `detail.budgets` lists them. | | 403 | `budget_exceeded` | The amount is more than what remains of the budget. `detail.remaining_minor` is in minor units of `detail.currency`. | | 403 | `funds_earmarked` | Part of the wallet is set aside in a budget and the amount is more than what is free. `detail.available_minor` and `detail.earmarked_minor` are in minor units of `detail.currency`. | | 409 | `insufficient_funds` | The wallet does not hold that much that is not already set aside. `detail.available_minor` says what is free. | | 409 | `budget_released` | The budget has been released and can no longer be topped up or spent from. | | 403 | `destination_not_allowed` | The destination is not on the customer's withdrawal allow-list. | | 403 | `payee_cooling_off` | The destination was trusted recently and can only be paid small amounts until `detail.until`. `detail.max_minor` is in cents of `detail.unit`. | | 403 | `risk_refused` | A risk rule on the customer refuses the operation. `detail.rules` says which and why. | | 409 | `sweep_rule_limit` | The customer already has as many active sweep rules as it may have. | | 409 | `approver_limit` | The customer already has as many approvers as it may have. | | 403 | `sandbox_only` | A sandbox helper was called with a live key. | | 409 | `quote_mismatch` | The payout does not repeat the instruction its quote priced. `detail.field` names what differs: amount, from, to or rail. | | 429 | `too_many_waiters` | This key already has as many requests waiting (`?wait=`) as it may. Let one finish, or read without wait. | | 409 | `payout_not_amendable` | The payout's funds have arrived, or it has moved on further, so its amount can no longer be changed. `detail.status` says where it is. | | 409 | `bank_link_finished` | That bank login has already been finished. Start another with `POST /customers/{id}/bank-link`. | | 409 | `bank_link_expired` | The link token expired before the bank login was finished. Start another with `POST /customers/{id}/bank-link`. | | 409 | `schedule_not_cancelable` | The scheduled payout has already run, failed or been cancelled, or is running now. | | 409 | `schedule_not_waiting` | Only a conditional payout that is still waiting can be released. | | 409 | `schedule_not_due` | Only a payout that is scheduled can be advanced in the sandbox; a waiting one is released. | | 409 | `schedule_expired` | The conditional payout lapsed before it was released, and was cancelled. | | 403 | `agent_payment_over_max` | The merchant asks for more than `max_amount`. Nothing was signed. `detail.asked_minor` is the lowest price asked, in US cents. | | 409 | `no_payable_offer` | The merchant asked for payment in no way the agent can pay. `detail.skipped` says why each offer was passed over: another network or asset, a transfer method that is not signed, no wallet, no payment method. | | 409 | `agent_payment_already_attempted` | A payment was already attempted with this Idempotency-Key, and is not signed twice. `detail.agent_payment_id` is the earlier attempt. Send a new key to pay again. | | 409 | `offer_changed` | An approved agent payment found the merchant asking for more, another currency or another address than was approved. Nothing was signed. Ask again. | | 409 | `agent_customer_required` | The agent acts for no customer, so it can hold no wallet and pay nothing. Register it with a `customer_id`. | | 409 | `wallet_retired` | The wallet has been retired. Create another, or leave `wallet_id` out. | | 409 | `wallet_policy_refused` | The policy on the agent's wallet refused the signature at the wallet provider. Nothing was signed. | | 409 | `token_not_issued` | A delegated payment token could not be issued ready to use, for instance because the payer would have to act. Nothing was sent to the merchant. | | 502 | `merchant_unreachable` | The merchant's address could not be reached, answered with a redirect, or answered too much, before anything was paid. | | 502 | `platform_error` | A provider failed. Safe to retry with the same Idempotency-Key. | | 500 | `internal_error` | Ours. Quote the `request_id` and we can find the call. | ## Capabilities As this deployment is built. What is switched on for an account is read from `GET /capabilities`. - `kyb` (available): Business verification - `identity_verification` (available): Identity verification - `custody` (available): Custodied wallets - `virtual_accounts` (available): Virtual accounts - `payouts_fiat` (available): Fiat payouts - `payouts_crypto` (available): Stablecoin transfers - `standing_orders` (available): Standing conversion - `treasury` (available): Treasury - `books` (available): Books - `fx` (available): Exchange rates - `bank_linking` (available): Bank linking - `static_memos` (available): Static memos - `issuance` (request_only): Stablecoin as a service - `cards` (coming_soon): Cards - `compliance_requests` (coming_soon): Compliance requests - `kyc` (coming_soon): Individual verification - `card_acceptance` (available): Card and bank-debit acceptance - `usage_meters` (coming_soon): Usage meters - `checkout_instructions` (available): Stablecoin and bank transfer at checkout - `agentic_checkout` (preview): Agentic checkout - `ucp_checkout` (preview): Universal Commerce Protocol checkout - `agent_tokens` (preview): Delegated payment tokens - `pay_by_call` (preview): Paying by the call - `agent_credentials` (preview): Agents paying other merchants ## Payment methods that are not plainly available The whole matrix is in `GET /capabilities` under `payment_methods`. These rows are per customer, in preview, coming, or not offered: - `card` (per_customer, `enabled_at_onboarding`): Cards - `wallet:link` (per_customer, `enabled_at_onboarding`): Wallets (saved cards and device wallets) - `buy_now_pay_later:klarna` (per_customer, `enabled_at_onboarding`): Klarna - `buy_now_pay_later:affirm` (per_customer, `enabled_at_onboarding`): Affirm - `buy_now_pay_later:afterpay_clearpay` (per_customer, `enabled_at_onboarding`): Afterpay and Clearpay - `local_method:ideal` (per_customer, `enabled_at_onboarding`): iDEAL - `local_method:bancontact` (per_customer, `enabled_at_onboarding`): Bancontact - `local_method:eps` (per_customer, `enabled_at_onboarding`): EPS - `local_method:p24` (per_customer, `enabled_at_onboarding`): Przelewy24 - `local_method:blik` (per_customer, `enabled_at_onboarding`): BLIK - `local_method:twint` (per_customer, `enabled_at_onboarding`): TWINT - `local_method:swish` (per_customer, `enabled_at_onboarding`): Swish - `local_method:mobilepay` (per_customer, `enabled_at_onboarding`): MobilePay - `local_method:pix` (per_customer, `enabled_at_onboarding`): Pix - `wallet:cashapp` (per_customer, `enabled_at_onboarding`): Cash App Pay - `wallet:amazon_pay` (per_customer, `enabled_at_onboarding`): Amazon Pay - `wallet:revolut_pay` (per_customer, `enabled_at_onboarding`): Revolut Pay - `local_method:multibanco` (per_customer, `enabled_at_onboarding`): Multibanco - `local_method:konbini` (per_customer, `enabled_at_onboarding`): Konbini - `local_method:boleto` (per_customer, `enabled_at_onboarding`): Boleto - `local_method:oxxo` (per_customer, `enabled_at_onboarding`): OXXO - `bank_debit:us_bank_account` (per_customer, `enabled_at_onboarding`): ACH debit - `bank_debit:sepa_debit` (per_customer, `enabled_at_onboarding`): SEPA debit - `bank_debit:bacs_debit` (per_customer, `enabled_at_onboarding`): Bacs debit - `bank_transfer_in:faster_payments` (coming_soon, `account_currency_not_switched_on`): Faster Payments into a virtual account - `bank_transfer_out:fednow` (per_customer, `payee_bank_dependent`): FedNow payout - `bank_transfer_out:rtp` (not_offered, `inbound_only`): RTP payout - `bank_transfer_out:faster_payments` (per_customer, `enabled_at_verification`): Faster Payments payout - `bank_transfer_out:spei` (per_customer, `enabled_at_verification`): SPEI payout - `bank_transfer_out:pix` (per_customer, `enabled_at_verification`): Pix payout - `bank_transfer_out:bre_b` (per_customer, `enabled_at_verification`): Bre-B payout - `bank_transfer_out:co_bank_transfer` (per_customer, `enabled_at_verification`): Colombian bank transfer payout - `bank_debit_on_demand` (not_offered, `provider_has_no_pull_api`): Pulling money from a bank account into a wallet on demand - `issued_card` (coming_soon, `card_issuing_not_switched_on`): Cards issued against a balance - `pay_by_call:stablecoin` (preview, `preview_off_by_default`): Paying by the call in stablecoin (x402 and the Machine Payments Protocol) - `pay_by_call:agent_token` (preview, `preview_off_by_default`): Paying by the call with a delegated card token (the Machine Payments Protocol) - `agent_token` (preview, `preview_off_by_default`): A delegated payment token presented by an agent at checkout (seller side) - `agent_credential` (preview, `agent_paying_preview`): An agent paying other merchants (buyer side) - `push_to_card` (not_offered, `not_served`): Push to a debit card - `cash` (not_offered, `not_served`): Cash - `cheque` (not_offered, `not_served`): Cheque - `mobile_money` (not_offered, `not_served`): Mobile money - `crypto_non_stablecoin` (not_offered, `not_served_non_stablecoin`): Crypto assets other than stablecoins - `stablecoin:{coin}:{chain}` (per_customer, `chain_coin_pair_varies`): Each stablecoin on each chain ## Operations ### Getting started The two calls worth making first. `/` needs no key and lists what exists; `/whoami` proves the key you were given works and says which environment it is in. - `GET /` (no key): The index. Public, so a new developer can look before they have a key. - `GET /openapi.json` (no key): This same reference as an OpenAPI 3.1 document, for Postman, a generator or an agent. Public. - `GET /whoami` (scope `*`): The partner, project, mode and scopes behind the key you sent. - `GET /llms.txt` (no key): The API described for a model, as plain Markdown: authentication, the order of calls for the common jobs, idempotency, approvals, every error with what to do next, and every operation. Generated from this reference. Public. - `GET /agents.md` (no key): The same description addressed to an agent that is about to act. Public. - `GET /capabilities` (scope `*`): What this project may do right now, and what is merely coming: capabilities, bank rails (and which are enabled customer by customer), the chains and coins the API accepts, `custody` (who holds the money, by name), and `payment_methods`: every way money moves, method by method and direction by direction, each with one status and a reason code. A capability with `test_mode_only: true` works in the sandbox only for now. - `POST /terms` (scope `customers:write`): Accept the terms a customer must agree to before they can be verified. Body: `{ customer_id }` ### Customers A customer is a business you bring to the platform. It is verified in its own right, and everything else — wallets, accounts, payees — hangs off one. - `POST /customers` (scope `customers:write`): Onboard a business, or a person with `type: "individual"` (hosted verification only; identity details and documents are refused for a person). `reliance` carries your own verification results and is accepted only from an account approved for it. Body: `{ legal_name, email, country, external_id?, type?, ownership_threshold?, reliance?: { kyc_screen, ofac_screen, verified_database_at?, verified_govid_at?, verified_proof_of_address_at? } }` - `GET /customers` (scope `customers:read`): The businesses this project has onboarded. - `GET /customers/{id}` (scope `customers:read`): One customer, with its verification state, the bank rails it is enabled for (`rails`), what it can be paid with (`card_acceptance.methods`) and everything still owed to either side as one list: `requirements[]` of `{ capability, provider, status, items[], due_by }`. - `PATCH /customers/{id}` (scope `customers:write`): Correct details on a business. Body: `{ legal_name?, address?, external_id? }` - `POST /customers/{id}/verification` (scope `customers:write`): Start or re-run verification. Body: `{ }` - `GET /customers/{id}/verification` (scope `customers:read`): Where verification has got to. - `POST /customers/{id}/onboarding-link` (scope `customers:write`): A link the business owner opens to finish verification (and card-acceptance setup) on hosted pages. You send no people, birth dates or documents. Body: `{ return_url, refresh_url?, steps? }` - `GET /onboard/{token}` (no key): The page behind an onboarding link. Public: the token is the credential. Redirects to the next hosted step, then back to your return_url. - `GET /customers/{id}/payment-methods` (scope `customers:read`): The payment-method matrix for one customer: every method and direction, each cell marked `enabled` from what the card processor reports active on its merchant account and what the banking partner enabled at verification. - `GET /customers/{id}/people` (scope `customers:read`): The owners, controllers and signers of a business customer, as the banking partner holds them: `{ id, first_name, last_name, email, title, owns, controls, signs, is_director, ownership_percent }`. Names and roles only; nothing from a person's documents is returned. - `POST /customers/{id}/people` (scope `customers:write`): Name another owner, controller or signer of a business customer, in the form `POST /customers` takes in `people[]`. At least one of `owns`, `controls` and `signs` is true; `address.country` is ISO 3166-1 alpha-3. Identity documents and files are passed on to the banking partner and never stored by Skerry. `reliance` is accepted only from an account approved for it. For example `{"first_name": "Ada", "last_name": "Lovelace", "email": "ada@example.com", "date_of_birth": "1985-12-10", "address": {"line1": "1 Main Street", "city": "Berlin", "postal_code": "10115", "country": "DEU"}, "owns": true, "ownership_percent": 40}`. Body: `{ first_name, last_name, email, date_of_birth, address: { line1, line2?, city, region?, postal_code?, country }, owns?, controls?, signs?, is_director?, ownership_percent?, title?, identity_documents?: [{ type, issuing_country, number?, expires?, front?, back? }], documents?: [{ purpose, file }], reliance? }` - `DELETE /customers/{id}/people/{person_id}` (scope `customers:write`): Remove a person from a business customer at the banking partner. - `GET /eligibility` (scope `eligibility:read`): Whether a business like this can be served, from where, for what, with machine-readable reasons. `?country=&business_type=¤cy=&industry=` ### Custody Where a customer's money sits. A wallet holds stablecoin; an account is fiat at a regulated institution; a card spends from one. - `POST /customers/{id}/wallets` (scope `wallets:write`): Create a stablecoin wallet, optionally with a label, a purpose and the agent it is for. Covered by mandates as `wallet.create`. Body: `{ chain, label?, purpose?, agent_id? }` - `GET /customers/{id}/wallets` (scope `wallets:read`): The customer's wallets and balances. Each balance carries the decimal `amount` and `amount_minor`, the same amount in integer minor units of `currency`; each wallet its `label`, `purpose`, `agent_id` and `floor`. - `GET /customers/{id}/wallets/{wallet_id}/history` (scope `wallets:read`): What has moved in and out of one wallet, newest first. `?limit=` - `POST /customers/{id}/accounts` (scope `accounts:write`): Open an account payers pay into. `currency` is what the payer sends (usd or eur); `wallet_id` is the customer's wallet the money lands in, converted to the stablecoin named in `delivers` (usdc for usd and eurc for eur unless named). For example `{"currency": "usd", "wallet_id": "", "delivers": "usdc"}`. The answer carries the account's `deposit_instructions`. Body: `{ currency, wallet_id, delivers? }` - `GET /customers/{id}/accounts` (scope `accounts:read`): The customer's fiat accounts. - `POST /customers/{id}/accounts/{account_id}/deactivate` (scope `accounts:write`): Stop an account accepting deposits. It keeps its details, money already received is not affected, and it can be reopened. Body: `{ }` - `POST /customers/{id}/accounts/{account_id}/reactivate` (scope `accounts:write`): Let a deactivated account accept deposits again, with the same details. Body: `{ }` - `POST /customers/{id}/bank-link` (scope `payees:write`): Start connecting a US bank by logging into it, instead of typing its numbers. Answers `{ link_token, expires_at }` for the bank login page; credentials never reach Skerry. Finish with `POST /customers/{id}/bank-link/exchange`. Body: `{ }` - `POST /customers/{id}/bank-link/exchange` (scope `payees:write`): Finish a bank login: send the `public_token` the bank login page returned, with the `link_token` it was started with. The link token must be one started for this customer, and is finished once (409 `bank_link_finished`, or `bank_link_expired` after its expiry). The answer is `{ data: [payee] }`, the payees the connection added, each announced as `payee.created`. Covered by mandates as `payee.create`; if it is held, send the same request again with `Skerry-Approval` once a person has approved it. Neither token is stored. Body: `{ link_token, public_token }` - `POST /customers/{id}/cards` (scope `cards:write`): Issue a card against an account. Body: `{ account_id }` - `GET /customers/{id}/cards` (scope `cards:read`): The customer's cards. - `POST /customers/{id}/memos` (scope `accounts:write`): A deposit memo, for chains where one shared address serves everybody. `wallet_id` is the customer's wallet the deposit is delivered to, and fixes the chain; `delivers` is the coin that arrives (usdc unless named) and `currency` what the payer sends (usd unless named). Body: `{ wallet_id, delivers?, currency? }` - `GET /customers/{id}/memos` (scope `accounts:read`): The customer's deposit memos. A payer who omits the memo cannot be attributed. ### Paying out A payee is somewhere money can go; a payout sends it there. Quote first when the amount matters — a quote fixes the rate for a short window. - `POST /customers/{id}/payees` (scope `payees:write`): Add a bank account as a destination for money. The body is the banking partner's external-account fields, passed on as sent: `currency`, `account_type` (`us`, `iban` or `gb`), `account_owner_name` as the bank holds it, `account_owner_type` (business or individual) with `business_name` for a business, `bank_name`, `address` (`street_line_1`, `street_line_2`, `city`, `state`, `postal_code`, and `country` as ISO 3166-1 alpha-3), and the account itself: `account: { routing_number, account_number, checking_or_savings }` for `us`, `iban: { account_number, bic, country }` for `iban`, `account: { sort_code, account_number }` for `gb`. For example `{"currency": "eur", "account_type": "iban", "account_owner_name": "Example GmbH", "account_owner_type": "business", "business_name": "Example GmbH", "address": {"street_line_1": "1 Hauptstrasse", "city": "Berlin", "postal_code": "10115", "country": "DEU"}, "iban": {"account_number": "DE89370400440532013000", "country": "DEU"}}`. Under a mandate that covers `payee.create` the operation allow-list and approval rules apply; if it is held, send the same request again with `Skerry-Approval` once a person has approved it. Body: `{ currency, account_type, account_owner_name, account_owner_type, business_name?, bank_name?, address: { street_line_1, street_line_2?, city, state?, postal_code?, country }, account?: { routing_number, account_number, checking_or_savings } | { sort_code, account_number }, iban?: { account_number, bic?, country } }` - `GET /customers/{id}/payees` (scope `payees:read`): The customer's payees, each with the receiving bank's name check (`name_check`) where the rail has one. - `GET /customers/{id}/payees/{payee_id}` (scope `payees:read`): One payee, with the name check as it stands now. - `DELETE /customers/{id}/payees/{payee_id}` (scope `payees:write`): Remove a payee. - `POST /customers/{id}/payees/{payee_id}/pause` (scope `payees:write`): Stop paying a payee for now without removing it. It reads `active: false` until resumed. Announced as `payee.updated`. Body: `{ }` - `POST /customers/{id}/payees/{payee_id}/resume` (scope `payees:write`): Pay a paused payee again. Refused with `customer_frozen` while the customer is frozen. Announced as `payee.updated`. Body: `{ }` - `POST /customers/{id}/payees/{payee_id}/check` (scope `payees:write`): Ask the receiving bank again whether the name on the account matches, and return the payee with the answer as `name_check`. Often `pending` at first; a later read or the `payee.updated` event carries the bank's answer. Body: `{ }` - `POST /payouts/quote` (scope `payouts:write`): What a payout would cost, as a quote that binds the instruction. The answer carries `quote_id` and `valid_until` (120 seconds by default); `POST /payouts` with that `quote_id` executes exactly the quoted customer, amount, source, destination and rail, once. For a bank payout it says which rail it would leave on and why (`rail`, `rail_reason`) and carries the name check on the payee (`payee_check`). `fee`, `rate` and the receive amount are what the provider's dry run reported when the quote was made; the provider is not asked to hold them, so this is not a guaranteed rate. `amount` is a decimal string and `amount_minor` the same in integer minor units of the source currency; send either. `fee_minor` is in minor units of `fee_currency`, `receive_amount_minor` of `receive_currency`. Body: `{ customer_id, amount | amount_minor, from, to, rail?, speed? }` - `POST /payouts` (scope `payouts:write`): Send money to a payee, a wallet or an address. Needs an Idempotency-Key. `amount` is a decimal string in `from.currency`; `amount_minor` is the same amount as integer minor units, and either may be sent. A bank payout may name a `rail` or a `speed` (fastest, cheapest, standard); otherwise the rail is chosen and returned with the reason. `quote_id` executes exactly what was quoted. `execute_at`, `schedule` or `release_on` create a scheduled payout instead of sending now. An outside address is checked for its chain's format and for the coin being on that chain (Base when no chain is named). `travel_rule` carries originator and beneficiary details for an outside address; it is passed on and not kept. The answer carries `amount_minor`, `fee_minor` and `authorisation`. Body: `{ customer_id, amount | amount_minor, from: { currency, wallet_id }, to: { currency, payee_id | wallet_id | address, chain? }, rail?, speed?, quote_id?, execute_at? | schedule?: { every, unit: day|week|month, end_at? | count? } | release_on?: { event, object_id } | "approval", expires_at?, travel_rule?: { originator, beneficiary }, reference?, external_ref?, external_kind?, remittance?: { invoice_number?, amount?, date? }, dry_run? }` - `POST /payouts/batches` (scope `payouts:write`): Up to 50 payouts in one call. All are checked first, and the batch's total per customer and currency is checked against the caller's daily and monthly limits before anything is sent; each then runs as its own payout and reports its own outcome. Needs an Idempotency-Key. A dry run checks every item and the totals and sends nothing. Body: `{ payouts: [ ...as POST /payouts ], reference?, dry_run? }` - `GET /payouts/batches` (scope `payouts:read`): Batches, newest first, each with the outcome of each payout in it. `?customer_id=` keeps the batches that hold a payout for that customer. - `GET /payouts/batches/{id}` (scope `payouts:read`): One batch, with the outcome of each payout in it. - `GET /customers/{id}/payout-fees` (scope `payouts:read`): Who bears the cost of a bank payout, per currency and rail: `fees.usd.wire` and the like, as the banking partner states them. It decides whether a payee receives the amount sent or that amount less a fee. - `PATCH /customers/{id}/payout-fees` (scope `customers:write`): Change who bears the cost of a bank payout. Only the entries sent change. An agent's own credential cannot change it. Body: `{ fees: { usd: { wire } } }` - `POST /customers/{id}/batch-settlement-schedules` (scope `payouts:write`): Collapse the customer's bank payouts on one currency and rail into a single settlement instead of one bank movement per payout. Kept by the banking partner; answers with the schedule's id. An agent's own credential cannot create one. Body: `{ currency, rail }` - `GET /payouts` (scope `payouts:read`): Payouts this project has made, with the reference each was given. Each carries `amount` (decimal string) and `amount_minor` (integer minor units of `currency`). `?customer_id=&external_ref=` - `GET /payouts/{id}` (scope `payouts:read`): One payout and where it has got to, with `amount_minor`, `fee_minor` and `authorisation`. While it is paused `status` is `on_hold` and `hold` gives `reason_code`, the reason in plain words and the deadline when there is one. `?wait=&until=` holds the answer until the payout is paid, returned, canceled, undeliverable or return_failed, or reaches the state named, or the wait ends. - `PATCH /payouts/{id}` (scope `payouts:write`): Change the amount of a payout that is still `awaiting_funds`; once its funds have arrived it is refused with 409 `payout_not_amendable`. Needs an Idempotency-Key. A higher amount is checked as the payout was, for the difference: the freeze, risk rules, mandate limits, budget and approval; if it is held, send the same request again with `Skerry-Approval` once a person has approved it. A lower amount is not checked, and what was already counted against a limit stays counted. The stored record takes the new amount and the difference is booked in the payout's movement. `amount` is a decimal string in the payout's currency; `amount_minor` may be sent instead. Body: `{ amount | amount_minor }` - `DELETE /payouts/{id}` (scope `payouts:write`): Cancel a payout that has not left yet. Its entries in the books are reversed and what it counted against a spend limit or budget is given back. - `GET /rates` (scope `*`): The rate between two currencies. `?from=&to=` ### Standing conversion A standing order converts on a schedule rather than on a call, so a business can hold one currency and settle in another without writing a cron job. - `POST /customers/{id}/standing-orders` (scope `standing_orders:write`): An address that converts and pays out whatever arrives. `return_instructions` says where a deposit that cannot be converted goes back to. Under a mandate that covers `standing_order.create` the operation allow-list and approval rules apply. Body: `{ chain, currency, delivers_to: { payee_id | wallet_id, currency?, rail? }, reference?, return_instructions?: { address, memo? } }` - `GET /customers/{id}/standing-orders` (scope `standing_orders:read`): The customer's standing orders. - `GET /customers/{id}/standing-orders/{standing_order_id}/conversions` (scope `standing_orders:read`): What one standing order has converted, newest first, as the banking partner reports it: each `{ id, amount, currency, status, standing_order_id, occurred_at }`. ### Events and webhooks Most state changes arrive here rather than in a response, because verification and settlement finish long after the call that started them. Replay is deliberate: a missed delivery is recoverable without asking us. - `GET /events` (scope `events:read`): What has happened, newest first. `?limit=&type=&types=a,b`. With `?after=` the order changes: the events recorded after that one, oldest first, with `next_after` to pass as the next `after`; `&wait=` then holds the request until there is a new event or the wait ends. - `POST /events/{id}/replay` (scope `webhooks:write`): Deliver an event again, to every endpoint that wanted it. - `GET /events/types` (scope `events:read`): The catalogue of event types: every type this API sends, the object it is about and when it is sent. `event_types` on an endpoint are checked against it. - `POST /webhooks` (scope `webhooks:write`): Register an endpoint. `url` must be https. `event_types` left out, empty or `*` means every event; a type the catalogue does not have is refused. The endpoint belongs to the project of the credential that registers it and counts against that project's plan. Body: `{ url, event_types? }` - `GET /webhooks` (scope `webhooks:read`): The endpoints registered in this environment, each with the `project_id` it belongs to. - `PATCH /webhooks/{id}` (scope `webhooks:write`): Change an endpoint's URL, events or status, held to the same checks as a new endpoint. Body: `{ url?, event_types?, status? }` - `DELETE /webhooks/{id}` (scope `webhooks:write`): Remove an endpoint. - `POST /webhooks/{id}/roll-secret` (scope `webhooks:write`): Replace an endpoint's signing secret. Deliveries carry a signature for both secrets (`v1=…,v1=…`) during the overlap. Body: `{ overlap_hours? }` ### Accepting payments Take card and wallet payments for a customer. The payer pays on a hosted page, so card details never reach Skerry or you. Amounts are integer minor units next to a currency code. Build orders from `payment.succeeded`, never from the payer's browser returning to your site. - `POST /checkout-sessions` (scope `payments:write`): Start a hosted checkout for a payer. `payment_instructions` adds stablecoin and bank transfer: the session then carries a deposit address and bank details with a reference to quote, and completes when the money arrives. A customer without card acceptance can open a session that has instructions only. Body: `{ customer_id, amount | line_items, currency, success_url, cancel_url?, methods?, payment_instructions?: { methods: [stablecoin | bank_transfer], wallet_id, chain?, coin? }, authentication?, capture?, save_method?, payer_id? | payer?, expires_at?, reference?, external_ref?, external_kind?, metadata?, ui?, dry_run? }` - `GET /checkout-sessions` (scope `payments:read`): Checkout sessions, newest first. `?customer_id=&status=&external_ref=` - `GET /checkout-sessions/{id}` (scope `payments:read`): One session. While open, this also asks the processor so a missed webhook cannot mislead. A session with payment instructions carries them, with `amount_received`, `amount_remaining` and `amount_overpaid`. - `POST /checkout-sessions/{id}/expire` (scope `payments:write`): Close an open session so it can no longer be paid. Body: `{ }` - `POST /payment-links` (scope `payments:write`): A reusable or single-use link to a hosted checkout for a fixed amount. Body: `{ customer_id, amount, currency, description?, reusable?, max_uses?, expires_at?, reference?, external_ref?, external_kind?, success_url?, dry_run? }` - `GET /payment-links` (scope `payments:read`): Payment links, newest first. `?customer_id=&external_ref=` - `GET /payment-links/{id}` (scope `payments:read`): One payment link, with how many times it has been used. - `PATCH /payment-links/{id}` (scope `payments:write`): Pause, resume or disable a link. Body: `{ status }` - `GET /payments` (scope `payments:read`): Payments, newest first. `?customer_id=&status=&reference=&external_ref=&payer_id=` - `GET /payments/{id}` (scope `payments:read`): One payment: status, fee, amounts received and refunded, and `risk` (level, score and how the payer's authentication ended) where the processor reports them. A payment that did not come by card also carries `method` (stablecoin, bank_transfer or machine), `payer_ref` and `amount_overpaid`; one an agent made carries `agent: { initiated, token_id, platform, token_limits, indicators }` and `agent_identity`. `?wait=&until=` holds the request until the payment succeeds, fails, is cancelled, refunded or disputed, or reaches the named state. - `POST /payments` (scope `payments:write`): Charge a saved method with the payer absent, under the mandate they gave; or, with `payment_token`, charge a delegated payment token an agent holds for its buyer (a preview: 409 `capability_coming_soon` until it is switched on for the customer). Needs an Idempotency-Key. Body: `{ customer_id, payer_id, payment_method_id, amount, currency, off_session: true, authentication?, capture?, reference?, external_ref?, external_kind?, description?, metadata? } | { customer_id, amount, currency, payment_token: { type: "shared_payment_token", id }, reference?, external_ref?, external_kind?, description?, metadata? }` - `POST /payments/{id}/capture` (scope `payments:write`): Collect funds that were held. May capture less than the hold. Body: `{ amount? }` - `POST /payments/{id}/cancel` (scope `payments:write`): Release a hold, or abandon a payment that has not succeeded. Body: `{ }` - `POST /payments/{id}/refunds` (scope `refunds:write`): Refund all or part of a payment. A payment made by stablecoin or bank transfer goes back to where it came from when that is on record, otherwise to `destination`. Needs an Idempotency-Key. Body: `{ amount?, reason?, destination?: { address, chain? } | { payee_id }, external_ref?, external_kind? }` - `GET /payments/{id}/refunds` (scope `payments:read`): Refunds made against a payment. - `GET /disputes` (scope `payments:read`): Disputes against payments, newest first. `?customer_id=&status=` - `GET /disputes/{id}` (scope `payments:read`): One dispute, with the date evidence is due. - `POST /disputes/{id}/evidence` (scope `payments:write`): Send evidence, and submit it when complete: text fields, file fields (the id of an uploaded file) and `enhanced_evidence`. Body: `{ evidence: { ... }, submit? }` - `POST /disputes/{id}/files` (scope `payments:write`): Upload a file to use as evidence: PDF, JPEG or PNG, up to 5 MB, base64-encoded. Body: `{ filename, content_type, data }` - `GET /disputes/{id}/files` (scope `payments:read`): Files uploaded for a dispute, newest first. - `GET /disputes/{id}/suggested-evidence` (scope `payments:read`): A draft of the text evidence from what is on record: the session, what was bought, the delegated token and its limits, how the calling agent identified itself, and the times. For a person to review and send; nothing is submitted. - `GET /customers/{id}/unmatched-deposits` (scope `payments:read`): Deposits that could not be credited to a checkout session (wrong coin or chain, no reference, an unknown reference, or late), waiting for a person. `?status=` - `POST /customers/{id}/unmatched-deposits/{deposit_id}/assign` (scope `payments:write`): Credit a deposit that was set aside to the checkout session it was meant for. Body: `{ checkout_session_id }` - `POST /customers/{id}/unmatched-deposits/{deposit_id}/refund` (scope `refunds:write`): Send a deposit that was set aside back, whole: to where it came from when that is on record, otherwise to `destination`. Needs an Idempotency-Key. Body: `{ destination?: { address, chain? } | { payee_id } }` - `GET /settlements` (scope `settlements:read`): Card proceeds on their way to the business's bank, newest first. `?customer_id=` - `GET /settlements/{id}` (scope `settlements:read`): One settlement. - `POST /settlements` (scope `settlements:write`): Send proceeds to the business's bank now. Needs an Idempotency-Key. Body: `{ customer_id, amount, currency }` - `GET /customers/{id}/balances` (scope `customers:read`): What the customer holds per currency: card proceeds (available, pending) and stablecoin. ### Payers, saved methods and subscriptions A payer is someone who pays your customer. Save a method once, with their agreement, and charge it later or on a schedule. The agreement is the mandate. Card numbers are never returned. - `POST /payers` (scope `payments:write`): Record someone who pays a customer. Body: `{ customer_id, email?, name?, external_id? }` - `GET /payers` (scope `payments:read`): Payers, newest first. `?customer_id=` - `GET /payers/{id}` (scope `payments:read`): One payer. - `GET /payers/{id}/payment-methods` (scope `payments:read`): Methods the payer saved: brand, last four, expiry, and the mandate. - `DELETE /payers/{id}/payment-methods/{method_id}` (scope `payments:write`): Forget a saved method and revoke its mandate. - `POST /customers/{id}/payment-method-setups` (scope `payments:write`): A hosted page where a payer saves a method for later, without paying now. Body: `{ payer_id | payer, success_url, cancel_url }` - `POST /subscriptions` (scope `payments:write`): Charge a payer on a schedule from a saved method. Needs an Idempotency-Key. Under a mandate that covers `subscription.create`, the amount of one period (integer minor units) is checked against the mandate and counted once. Body: `{ customer_id, payer_id, payment_method_id, currency, interval, interval_count?, items: [{ name, amount, quantity? }], trial_days?, reference?, external_ref?, external_kind?, dry_run? }` - `GET /subscriptions` (scope `payments:read`): Subscriptions, newest first. `?customer_id=&status=&payer_id=` - `GET /subscriptions/{id}` (scope `payments:read`): One subscription. - `POST /subscriptions/{id}/cancel` (scope `payments:write`): End a subscription now or at the end of the paid period. Body: `{ at_period_end? }` - `POST /meters` (scope `payments:write`): A usage meter on a customer's merchant account, for billing by use. Switched on account by account. Body: `{ customer_id, name, event_name, aggregation? }` - `GET /meters` (scope `payments:read`): Meters, newest first. `?customer_id=` - `POST /meters/{id}/events` (scope `payments:write`): Record usage by a payer against a meter. `identifier` makes a retry count once. Body: `{ payer_id, value, identifier?, timestamp? }` - `GET /meters/{id}/summary` (scope `payments:read`): What a payer has used over a period. `?payer_id=&from=&to=&group_by=hour|day` ### Limits, agents and approvals Constrain what a key, or an agent acting through it, can move. A policy names the business accountable and the person who approved it, caps amounts and counts, restricts destinations, wallets and currencies, and can require one or two people to approve. With its purpose and the evidence that a person agreed to it, a policy is a mandate: `mandate_id` is the policy id. A key that has ever had a policy stays restricted. Amount limits are integer minor units: of the family's fiat currency (cents) under `scope: "family"`, where USD is limited together with USDC, USDT, USDB, OUSD and PYUSD, and EUR with EURC; of each currency's own smallest unit under `scope: "currency"`. - `POST /spend-policies` (scope `policies:write`): Limit a key or an agent: a mandate. `scope` is required, `family` or `currency`, and decides the unit of every amount limit (see the group note); a request without it is refused with `invalid_request` naming `scope`. `operations` may name payment.charge, refund.create, payout.create, conversion.create, settlement.create, checkout.create, payment_link.create, subscription.create, standing_order.create, payee.create and wallet.create. `max_count` caps operations per `count_window` (day or month, rolling). `approve_operations` always wait for a person. `checkout_above` is the amount above which checkout sessions and payment links are checked. `merchant_allow_list` applies to operations that name a merchant; `wallet_allow_list` to the wallet money leaves. `evidence` records consent; with `consent_required` the mandate is inactive until verified evidence exists. The answer carries `mandate_id`, `summary` (the rule in sentences, generated from it) and `active`. Body: `{ customer_id, approved_by, scope: family | currency, key_id? | agent_id?, purpose?, max_amount?, daily_max?, monthly_max?, max_count?, count_window?, approval_above?, second_approval_above?, approvers_required?: 1 | 2, approval_evidence?: none | passkey, approval_expires_minutes?, approve_operations?, checkout_above?, currencies?, operations?, payee_allow_list?, merchant_allow_list?, wallet_allow_list?, valid_from?, expires_at?, consent_required?, evidence?: { method, subject, at, assertion?, attestation? } }` - `GET /spend-policies` (scope `policies:read`): Policies, newest first, each with `usage`: what it has let through in the last 24 hours and 30 days and what remains, per currency family or currency, and how many operations. `?customer_id=&key_id=&status=` - `GET /spend-policies/{id}` (scope `policies:read`): One policy, with `summary`, `consent` (the evidence attached to the rule as it stands) and `active`. - `PATCH /spend-policies/{id}` (scope `policies:write`): Change a policy's amounts, counts, approval rules, purpose or dates. Who it binds and its allow-lists cannot change. Changing the rule detaches the consent evidence recorded for it. Changing `scope` changes the unit of every amount, so each amount limit the policy has must be sent again in the same request. Body: `{ max_amount?, daily_max?, monthly_max?, max_count?, count_window?, approval_above?, second_approval_above?, approvers_required?, approval_evidence?, approval_expires_minutes?, checkout_above?, purpose?, scope?, consent_required?, valid_from?, expires_at?, name? }` - `POST /spend-policies/{id}/revoke` (scope `policies:write`): Revoke a policy, immediately. Body: `{ reason? }` - `POST /spend-policies/{id}/consent` (scope `policies:write`): Record that a person agreed to the mandate as it stands. The evidence is stored with the mandate's canonical text and its SHA-256. `partner_attested` is taken on your word; `passkey` and `oauth` are `verified` only with an `attestation` signed by the configured identity provider naming the subject and the mandate hash, and are `unverified` when no identity provider is configured; `console_session` from a partner key is `unverified`. Refuses an agent's own credential. Body: `{ method: console_session | passkey | oauth | partner_attested, subject, at, assertion?, attestation? }` - `GET /spend-policies/{id}/consent` (scope `policies:read`): The evidence recorded for a mandate, newest first, each with the text it was about and whether it is the one attached now. - `GET /spend-policies/{id}/usage` (scope `policies:read`): The operations counted under a mandate, newest first: `op`, `amount` (integer minor units of `currency`; 0 for an operation that is checked and never counted, such as a checkout session), `payee_display` (the destination as the operation named it: a payee, payer or wallet id, an address, or `own_bank`), `produced` (`{ type, id }` of the object it created, where that is on record), `counted` (false once the operation failed, was cancelled or was returned, so it no longer counts against the limits) and `created_at`. - `GET /mandates` (scope `policies:read`): A customer's spend policies as mandates (`object: "mandate"`, same ids), each with `usage`: per currency family or currency, what was spent in the last 24 hours and 30 days and what remains, in integer minor units of the unit named, and how many operations were let through. `?customer_id=` (required) `&status=` - `GET /approvals` (scope `approvals:read`): Operations held for a person to approve, newest first, each with `payee_display`: the destination as a person would recognise it, where there is one, and `decisions`: each decision recorded so far, oldest first, as `{ decision, actor_type, actor_ref, note, created_at }`. `actor_ref` is a key's id, a member's id or the reference an approver's links were made for; never a secret or a token. `?customer_id=&status=pending` - `GET /approvals/{id}` (scope `approvals:read`): One held operation, as it was asked, with its `decisions`. `?wait=&until=` holds the request until it is approved, denied, executed, failed or expired, or reaches the named state. - `POST /approvals/{id}/approve` (scope `approvals:write`): Approve a held operation and run it once. The approving key must not be the one that asked and must not be acting as an agent. Where two approvers are needed the first approval leaves it held (`status: pending`). Where the mandate asks for passkey evidence, send it as `evidence`. For an operation the route itself performs (checkout sessions, payment links, subscriptions, standing orders, payees, wallets) approval does not run it: the answer says to send the request again with `Skerry-Approval`. Body: `{ note?, evidence? }` - `POST /approvals/{id}/deny` (scope `approvals:write`): Refuse a held operation. Body: `{ note? }` - `POST /approvals/{id}/links` (scope `approvals:write`): Make an approve link and a deny link for one person. Each opens a Skerry page stating what will run, works once and expires (`expires_minutes`, 60 by default, at most 1440). The decision is recorded against `approver`, your reference for that person. Refuses an agent. Body: `{ approver, email?, expires_minutes? }` - `GET /approve/{token}` (no key): The page behind an approval link. Public: the token is the credential. States the operation, amount, currency, destination, agent, mandate and why it was held. Opening it decides nothing. - `POST /approve/{token}` (no key): The page's confirm button: uses the link, records the decision against its approver and, when the operation is now approved, runs it. Public: the token is the credential. - `GET /customers/{id}/limits` (scope `customers:read`): What a key or an agent may still move, per window. Without a name, the calling credential's own. With `?key_id=` or `?agent_id=`, another key's or agent's of the same account: that needs `policies:read` as well and is refused to an agent's own credential. ### Agents and credentials Register the agents that act through the API, give each its own key, and stop one without touching the others. A key bound to an agent is that agent on every call and can only move money under a spend policy. An agent can also sign its requests with a registered Ed25519 key (HTTP Message Signatures, RFC 9421), and can delegate a narrower part of its authority to a child agent. Where access tokens are accepted, an OAuth client approved for an agent can obtain tokens that act as it. - `POST /agents` (scope `agents:write`): Register an agent: who it is, who operates it, the customer it acts for and who authorised it. Body: `{ name, authorised_by, customer_id?, operator?, description?, signature_required?, public_keys? }` - `GET /agents` (scope `agents:read`): Agents, newest first. `?customer_id=&status=&parent_agent_id=` - `GET /agents/{id}` (scope `agents:read`): One agent, with its status and signing keys. - `PATCH /agents/{id}` (scope `agents:write`): Change an agent's name, operator, description or whether it must sign requests. Body: `{ name?, operator?, description?, signature_required? }` - `POST /agents/{id}/suspend` (scope `agents:write`): Suspend an agent. Its keys, tokens, signatures and policies are refused from the next call, and so are the agents it delegated to. Body: `{ reason? }` - `POST /agents/{id}/resume` (scope `agents:write`): Resume a suspended agent. Body: `{ reason? }` - `POST /agents/{id}/revoke` (scope `agents:write`): Revoke an agent permanently, with every agent it delegated to and all their keys. Body: `{ reason? }` - `POST /agents/{id}/keys` (scope `keys:write`): Mint a key bound to the agent. The secret is shown once. It may carry only scopes the calling key holds. Body: `{ scopes, name?, expires_at? }` - `POST /agents/{id}/public-keys` (scope `agents:write`): Register an Ed25519 public key (JWK) the agent signs requests with. Body: `{ jwk, expires_at? }` - `GET /agents/{id}/public-keys` (scope `agents:read`): The agent's signing keys in force. - `DELETE /agents/{id}/public-keys/{kid}` (scope `agents:write`): Remove a signing key. - `POST /agents/{id}/oauth-clients` (scope `agents:write`): Approve an OAuth client of the authorisation server to obtain access tokens that act as the agent with no person present. It may be given only scopes the calling key holds, and never `*`. Body: `{ client_id, scopes }` - `GET /agents/{id}/oauth-clients` (scope `agents:read`): The OAuth clients approved to act as the agent, newest first, as `oauth_clients: [{ client_id, issuer, scopes, created_at }]`. A withdrawn approval is not listed. - `DELETE /agents/{id}/oauth-clients` (scope `agents:write`): Withdraw an OAuth client's approval. `?client_id=`. The next token it asks for as this agent is refused. - `POST /agents/{id}/delegations` (scope `agents:write`): Called with the agent's own credential: create a child agent with its own key and a narrower policy that expires no later than the parent's. Body: `{ name, description?, scopes?, operations?, currencies?, payee_allow_list?, max_amount?, daily_max?, monthly_max?, approval_above?, expires_at? }` - `GET /keys` (scope `keys:read`): Keys in this environment, newest first, never with a secret, each with the `project_id` it belongs to. `?agent_id=&status=` - `GET /keys/{id}` (scope `keys:read`): One key: scopes, bound agent, last use, expiry. - `POST /keys/{id}/roll` (scope `keys:write`): A new secret for the same key. The old one keeps working for the overlap (24 hours by default, 0 allowed). Body: `{ overlap_hours? }` - `POST /keys/{id}/revoke` (scope `keys:write`): Revoke a key, immediately. Body: `{ reason? }` - `GET /.well-known/http-message-signatures-directory` (no key): Agents' public signing keys as a key directory (a JWK Set). Public. `?agent=` or `?partner=&mode=live|sandbox` - `GET /.well-known/oauth-protected-resource` (no key): OAuth protected-resource metadata (RFC 9728). Public. 404 when access tokens are not accepted on this deployment. ### Controls and audit Stop money leaving, for one customer or for the whole account, and read back who did what. Every audit entry is chained to the one before it by a hash, so the trail can be checked for edits. - `POST /customers/{id}/freeze` (scope `controls:write`): Freeze a customer: every money-out operation for it is refused with `customer_frozen`. Body: `{ reason? }` - `GET /customers/{id}/freeze` (scope `controls:read`): Whether one customer is frozen: `frozen`, `reason`, `frozen_at`, `frozen_by_key`, `frozen_by_agent`, and `account_frozen` when the whole account is frozen in this environment as well. - `POST /customers/{id}/unfreeze` (scope `controls:write`): Lift a customer's freeze. Body: `{ reason? }` - `POST /controls/freeze` (scope `controls:write`): Freeze the whole account in this environment: every money-out operation is refused with `partner_frozen`. Body: `{ reason? }` - `POST /controls/unfreeze` (scope `controls:write`): Lift the account's freeze. Body: `{ reason? }` - `GET /controls` (scope `controls:read`): What is frozen right now: the account and each frozen customer. - `GET /audit` (scope `audit:read`): The audit trail, newest first. `?object_type=&object_id=&action=&agent_id=&key_id=` - `GET /audit/verify` (scope `audit:read`): Walk the audit hash chain and report the first entry that does not fit. `?after=` continues a long walk. ### Conversion and ledger Convert a customer's own balance between currencies with a priced, expiring quote, and export the double-entry books for an accountant. - `POST /conversions/quote` (scope `conversions:write`): A priced offer to convert, valid for a short time and usable once. Body: `{ customer_id, from: { currency, wallet_id }, to: { currency, wallet_id | payee_id }, amount }` - `POST /conversions` (scope `conversions:write`): Execute a quote. Needs an Idempotency-Key. Body: `{ quote_id, external_ref?, external_kind? }` - `GET /conversions` (scope `conversions:read`): Conversions, newest first. `?customer_id=` - `GET /conversions/{id}` (scope `conversions:read`): One conversion and whether it has settled. `?wait=&until=` holds the request until it is completed or failed, or reaches the named state. - `GET /customers/{id}/ledger` (scope `ledger:read`): Double-entry entries for a period, as JSON or CSV, in booking order; `order=desc` reads newest first. Each JSON entry also carries `side`, `amount_minor`, `status` (pending or posted), `counterparty`, `rail`, `external_ref`, `external_kind`, `movement_id` (the receipt's id) and `reverses`. `?from=&to=&kind=&status=&external_ref=&order=desc&format=csv` ### Books and receipts Skerry keeps the money and a signed record of every movement. Invoices, bills, tax, statements, reconciliation and accountant exports are kept in the customer's books: connect the customer's Rivier project once, a person approves it there, and every movement is delivered to it. What Skerry is asked about those subjects it answers with a link into the books, or with 409 `books_not_connected` and the next step. - `POST /customers/{id}/books` (scope `books:write`): Start connecting a customer's books. Returns `connect_url`, where a signed-in person approves the connection. Body: `{ provider?, project_id?, return_url? }` - `GET /customers/{id}/books` (scope `books:read`): Whether the books are connected, to which project, and how delivery is going. `?probe=true` also asks the provider. - `DELETE /customers/{id}/books` (scope `books:write`): End the connection. Movements stop being delivered. - `POST /customers/{id}/books/backfill` (scope `books:write`): Send the movements of a period to the books again. Safe to repeat. Body: `{ from, to }` - `GET /customers/{id}/books/deliveries` (scope `books:read`): What was sent to the books and how it went, newest first. `?status=` - `GET /customers/{id}/books/links` (scope `books:read`): Where the statement, invoices, bills, reports and reconciliation are in the customer's books. `?from=&to=&external_ref=&external_kind=` - `POST /books/connections` (no key): Complete a connection. Called by the books provider's server after a person approved; the one-time token is the credential, and the answer carries the feed's signing secret once. Public. Body: `{ token, project_id, connection_id, feed_url, links: { statement, invoices, bills, reports, reconcile }, scopes? }` - `GET /receipts/{id}` (scope `ledger:read`): The signed receipt of one movement: amounts, fee, rate, the other party, rail, references, and the key, agent and policy that caused it. - `GET /.well-known/skerry-receipt-keys` (no key): The public keys receipts are signed with. Public. ### Budgets Set aside part of a wallet's balance for one mandate or one agent. A budget is an earmark kept by Skerry: nothing moves at the provider. An operation under a budgeted mandate, or by a budgeted agent, may only spend from its budget, and every other caller may only spend what is not set aside. Amounts are integer minor units of the budget's currency. - `POST /customers/{id}/budgets` (scope `budgets:write`): Set aside an amount of a wallet's balance for a mandate or an agent. `amount` and `low_below` are integer minor units of `currency`. Refused with `insufficient_funds` when the wallet does not hold that much free. Not for an agent's own credential. Body: `{ wallet_id, currency, amount, mandate_id | agent_id, name?, low_below? }` - `GET /customers/{id}/budgets` (scope `budgets:read`): The customer's budgets, newest first, each with `amount`, `spent` and `remaining` in minor units of its currency. `?status=active|released` - `GET /customers/{id}/budgets/{budget_id}` (scope `budgets:read`): One budget, with what was spent from it and what remains. - `POST /customers/{id}/budgets/{budget_id}/top-up` (scope `budgets:write`): Add to a budget. `amount` is integer minor units of the budget's currency. Re-arms `budget.low` and `budget.exhausted`. Body: `{ amount }` - `POST /customers/{id}/budgets/{budget_id}/release` (scope `budgets:write`): Release a budget: what was not spent is free for other callers again. Body: `{ }` ### Customer controls Controls set on one customer that apply to every caller, whatever its mandate: holding the first payout to a destination the customer has never paid, a cooling-off period, velocity and anomaly rules, a withdrawal allow-list, and the people who approve held operations by email. Every control is off until it is set, and an agent's own credential cannot change one. - `GET /customers/{id}/payee-controls` (scope `controls:read`): How a new destination is treated: `new_payee_hold`, `payee_cooling_off_minutes` and `cooling_off_max`. - `PUT /customers/{id}/payee-controls` (scope `controls:write`): Hold the first payout to a payee, wallet or address the customer has never paid (an approval with reason `new_payee`), and limit what a newly trusted destination can be paid during its cooling-off period. `cooling_off_max` is integer minor units of the fiat currency of the payout's unit family (cents); null means nothing. Body: `{ new_payee_hold?, payee_cooling_off_minutes?, cooling_off_max? }` - `POST /customers/{id}/trusted-payees` (scope `payees:write`): Trust a destination before it is first paid, so that payout is not held. Its cooling-off period starts now. A person's decision: refused for an agent's own credential. Body: `{ payee_id | wallet_id | address, chain? }` - `GET /customers/{id}/trusted-payees` (scope `payees:read`): Destinations the customer has paid or a person has trusted, newest first, with how each came to be trusted (`source`: paid, pre_approved or approved). - `GET /customers/{id}/risk-rules` (scope `controls:read`): The customer's velocity and anomaly rules. A rule that is null is off. - `PUT /customers/{id}/risk-rules` (scope `controls:write`): Replace the customer's risk rules. Each rule has `action`: `approve` turns an operation that trips it into an approval with the reason, `refuse` refuses it with `risk_refused`. Rules are counted from recorded operations, outside any model. Body: `{ rules: { velocity?: { max_count, window_minutes, action }, amount_anomaly?: { factor, min_samples?, action }, first_use?: { dimensions?, action }, lookalike_address?: { max_different?, action } } }` - `GET /customers/{id}/withdrawal-allow-list` (scope `controls:read`): The destinations this customer's payouts may go to. `destinations: null` means there is no list. - `PUT /customers/{id}/withdrawal-allow-list` (scope `controls:write`): Replace the withdrawal allow-list: payee ids, wallet ids and addresses, up to 500. While a list is in force every payout by any caller must go to a destination on it (`destination_not_allowed` otherwise), in addition to any mandate's own allow-list. `null` removes the list. Body: `{ destinations: [ ... ] | null }` - `POST /customers/{id}/approvers` (scope `approvals:write`): Name a person who is emailed an approve link and a deny link when one of this customer's operations is held. `reference` is your identifier for the person. `mail_configured` in the answer says whether this deployment sends mail. Body: `{ email, reference, name? }` - `GET /customers/{id}/approvers` (scope `approvals:read`): The customer's approvers, and whether this deployment sends mail. - `DELETE /customers/{id}/approvers` (scope `approvals:write`): Remove an approver. `?email=` ### Wallet controls A wallet with a purpose: a label, a purpose, the agent it is for and a floor, kept by Skerry beside the provider's wallet. Sweep rules keep a balance in range by moving the difference on a schedule; each run is an ordinary payout made as the key that created the rule, under that key's mandates. To confine an agent to named wallets, set `wallet_allow_list` on its mandate. - `GET /customers/{id}/wallets/{wallet_id}` (scope `wallets:read`): One wallet: balances (decimal `amount` and `amount_minor` in integer minor units of `currency`), `label`, `purpose`, `agent_id` and `floor`. - `PATCH /customers/{id}/wallets/{wallet_id}` (scope `wallets:write`): Change a wallet's label, purpose, agent or floor. `floor.amount` is integer minor units of `floor.currency`; `balance.low` is sent when the balance falls under it. Body: `{ label?, purpose?, agent_id?, floor?: { amount, currency } | null }` - `POST /customers/{id}/sweep-rules` (scope `sweeps:write`): Keep at most (`keep_at_most`, with `to`) or at least (`keep_at_least`, with `from`) an amount in a wallet, checked every `every_minutes` (5 or more). `amount` is integer minor units of `currency`. Both currencies must be of one unit family. Body: `{ wallet_id, currency, kind, amount, to?: { wallet_id | payee_id, currency }, from?: { wallet_id, currency }, every_minutes }` - `GET /customers/{id}/sweep-rules` (scope `sweeps:read`): The customer's sweep rules, newest first, with how each last ran (`last_result`). `?status=active|canceled` - `GET /customers/{id}/sweep-rules/{rule_id}` (scope `sweeps:read`): One sweep rule. - `DELETE /customers/{id}/sweep-rules/{rule_id}` (scope `sweeps:write`): Cancel a sweep rule. A payout it already made is not recalled. ### Scheduled payouts and waiting A payout can run later instead of now: send `POST /payouts` with `execute_at` (a future time), `schedule` (`{ every, unit: day | week | month, end_at? | count? }`) or `release_on` (`{ event, object_id }`, or `"approval"` for a person). It needs an Idempotency-Key and answers with a scheduled payout. Each run is an ordinary payout made as the key that scheduled it, under that key's mandate as it stands when it runs; a frozen customer, a revoked or expired key, a revoked agent or a revoked mandate cancels the schedule with the reason. A payout with `release_on` sets its amount aside in the source wallet until it is released, cancelled or lapses at `expires_at` (7 days by default, 90 at most). `travel_rule` cannot be scheduled. `amount_minor` on a scheduled payout is integer minor units of `currency`; `amount` is the same as a decimal string. To wait for an outcome without a webhook, add `?wait=` to a read. - `GET /payouts/scheduled` (scope `payouts:read`): Scheduled payouts, newest first. `?customer_id=&status=scheduled|waiting|running|completed|canceled|failed` - `GET /payouts/scheduled/{id}` (scope `payouts:read`): One scheduled payout: when it runs next, how many times it has run, the last payout it made and how the last run went. - `DELETE /payouts/scheduled/{id}` (scope `payouts:write`): Cancel a scheduled payout that has not run, or stop a recurring one. What was set aside is free again. Payouts already made are not recalled. Body: `{ reason? }` - `POST /payouts/{id}/release` (scope `payouts:write`): A person releases a conditional payout (`{id}` is the scheduled payout's id): it runs now, spending what was set aside for it. An agent's own credential is refused. Body: `{ }` ### Authorisation evidence What authorised a transaction. Payments, payouts, refunds, conversions, settlements, subscriptions, checkout sessions and payment links carry an `authorisation` block: `mandate_id` (the policy id), `policy_id`, `agent_id`, `key_id`, `consent_id` and `approval_id`. The routes here return the whole record behind those ids for a dispute. - `GET /payments/{id}/authorisation` (scope `audit:read`): The evidence for one payment: the mandate's text, hash and summary, the consent evidence, the approval with each decision and who made it, the agent and the agents it was delegated from, how the request was authenticated, and the audit entries about it. - `GET /payouts/{id}/authorisation` (scope `audit:read`): The same record for one payout. - `GET /refunds/{id}/authorisation` (scope `audit:read`): The same record for one refund. - `GET /conversions/{id}/authorisation` (scope `audit:read`): The same record for one conversion. ### Sandbox Helpers that exist only with a test key, so an integration can be built and exercised without a browser or a bank. - `POST /sandbox/checkout-sessions/{id}/complete` (scope `payments:write`): Act as the payer: succeed, decline, run short of funds, or open a dispute. Body: `{ outcome }` - `POST /sandbox/approvals/{id}/decide` (scope `approvals:write`): Decide a held operation as a person would, without a second key or a link. Where two approvers are needed, call it twice with different `approver` values. Body: `{ decision: approve | deny, approver? }` - `POST /sandbox/payouts/scheduled/{id}/advance` (scope `payouts:write`): Run a scheduled payout now instead of at its time, as the worker would. A recurring schedule moves on to its next run. Body: `{ }` - `POST /sandbox/events` (scope `events:write`): Record a named event about an object, marked `sandbox: true`, so a conditional payout waiting for it is released and a webhook receiver can be exercised. Body: `{ type, object_id, customer_id?, payload? }` - `POST /sandbox/customers/{id}/risk-rules/trip` (scope `controls:write`): Set one risk rule on a customer, with the history it needs, so that the next matching operation trips it. The answer says what to send. Body: `{ rule, action? }` - `POST /sandbox/budgets/{id}/exhaust` (scope `budgets:write`): Mark what remains of a budget as spent in Skerry's record, so `budget.exhausted` is emitted and the next operation under it is refused. Nothing is sent to a provider. Body: `{ }` - `POST /sandbox/checkout-sessions/{id}/deposit` (scope `payments:write`): Act as the payer on a session's stablecoin or bank-transfer instructions: pay it, underpay, overpay, pay late, or send the wrong coin, chain or reference. `amount` is integer minor units of the session's currency; left out, it is what is still owed. Body: `{ method?, amount?, coin?, chain?, reference?, state?, at?, deposit_ref? }` ### Agentic checkout: what a customer sets Let agent platforms buy from a customer through the Agentic Commerce Protocol. The customer issues each platform a bearer token, registers where its own system is asked for line items, totals, tax and fulfilment, and says what agents may do. Skerry holds no catalogue and computes no tax. A preview: sessions can be opened and priced, and completing one needs delegated payment tokens to be switched on for the customer. - `POST /customers/{id}/agentic/credentials` (scope `agentic:write`): Issue a bearer token to an agent platform. The secret, and the signing and webhook secrets when asked for, are shown once. The token is kept as a hash and the signing and webhook secrets sealed; a deployment with no sealing key refuses a credential that needs either with 503 `platform_unavailable`. Body: `{ name, platform?, webhook_url?, require_signature? }` - `GET /customers/{id}/agentic/credentials` (scope `agentic:read`): The customer's agent-platform credentials, never with a secret. - `DELETE /customers/{id}/agentic/credentials/{credential_id}` (scope `agentic:write`): Revoke a credential. - `POST /customers/{id}/agentic/pricing-callback` (scope `agentic:write`): Register the URL asked for line items, totals, tax and fulfilment each time a session is created or changed. Signed, with a short timeout. `url: null` removes it; without one only fixed-amount payment links can be sold. Body: `{ url }` - `GET /customers/{id}/agentic/settings` (scope `agentic:read`): What the customer allows agents, and the address agent platforms call. - `POST /customers/{id}/agentic/settings` (scope `agentic:write`): Change what the customer allows agents: verified agents only, the refunds an agent may start, the links a session shows, whether payment links answer automated clients with a challenge, and how long unused credit is refundable. Body: `{ require_verified_agent?, refunds?: { window_days?, max_amount?, needs_approval? }, links?, links_pay_by_call?, credit_refund_days? }` - `GET /customers/{id}/agentic/refund-requests` (scope `agentic:read`): Refunds agents have asked for, newest first. `?status=pending` - `POST /customers/{id}/agentic/refund-requests/{request_id}` (scope `refunds:write`): Approve or deny a refund an agent asked for. Approving runs it as a refund under the deciding key's policies. Body: `{ decision: approve | deny, note? }` - `POST /customers/{id}/agentic/orders/{session_id}` (scope `agentic:write`): Say where an order has got to; the agent platform is told with a signed order event. The event is recorded before it is sent; one that fails is tried again on the webhook backoff, and `webhook` says how the first attempt went. Body: `{ status, estimated_delivery? }` - `GET /customers/{id}/agentic/orders/{session_id}` (scope `agentic:read`): Where an order stands: the order as the agent platform was last told it, `session_status`, and `webhook`: how the last order event went (`status` pending, delivered or exhausted, with `attempts`, or `reason: no_webhook`). ### Agentic Commerce Protocol Called by an agent platform, not by a partner. Authentication is the bearer token the customer issued (`POST /customers/{id}/agentic/credentials`), not an API key. Every call sends `API-Version` (2026-04-17, or an earlier published version) and every POST sends `Idempotency-Key`. Paths, fields, statuses and messages follow the protocol's specification; errors are the protocol's `{ type, code, message, param }`, not Skerry's envelope. The refunds call is a Skerry extension and says so. - `POST /agentic/{customer_id}/checkout_sessions` (scope `agent_credential`): Create a checkout session. Answers 201 with the session. Body: `{ line_items: [{ id, quantity? }], currency, capabilities?, buyer?, fulfillment_details?, discounts? }` - `GET /agentic/{customer_id}/checkout_sessions/{checkout_session_id}` (scope `agent_credential`): Retrieve a checkout session. - `POST /agentic/{customer_id}/checkout_sessions/{checkout_session_id}` (scope `agent_credential`): Update a checkout session: items, buyer, fulfilment details, the fulfilment option chosen. It comes back re-priced. Body: `{ line_items?, buyer?, fulfillment_details?, selected_fulfillment_options?, discounts? }` - `POST /agentic/{customer_id}/checkout_sessions/{checkout_session_id}/complete` (scope `agent_credential`): Complete with a delegated payment token. A decline leaves the session payable with a message; a bank that wants the buyer to authenticate makes it `authentication_required`. Body: `{ payment_data: { handler_id, instrument: { type, credential: { type, token } } }, buyer?, authentication_result? }` - `POST /agentic/{customer_id}/checkout_sessions/{checkout_session_id}/cancel` (scope `agent_credential`): Cancel a session that has not completed. 405 afterwards. Body: `{ intent_trace? }` - `POST /agentic/{customer_id}/checkout_sessions/{checkout_session_id}/refunds` (scope `agent_credential`): A Skerry extension (`xyz.skerry.refunds`), not part of the protocol: ask for a refund of a completed session, within what the business allows agents. 202 when a person must approve, 201 when it was made. Body: `{ amount?, reason? }` ### Universal Commerce Protocol: what a customer sets Let platforms buy from a customer through the Universal Commerce Protocol, over the same checkout sessions, pricing callback, charging and refunds as agentic checkout. The customer lists the platforms it accepts by the address of their profile and says what its profile publishes; Skerry makes and keeps its signing key. A preview, off by default: every call answers 409 `capability_coming_soon` until it is switched on for the platform, and completing a checkout also needs delegated payment tokens. - `GET /customers/{id}/ucp/settings` (scope `agentic:read`): What the customer has set for the Universal Commerce Protocol, with `profile_url` (where its business profile is served), `endpoint`, and `signing_key`: the public half of its signing key, never the private half. - `PUT /customers/{id}/ucp/settings` (scope `agentic:write`): Replace the customer's settings: the platforms it accepts, its profile id at the card processor, the card brands and links it publishes, which wording the handler's environment uses, whether the AP2 mandates extension is on, and (sandbox only) whether unsigned requests are taken. A member left out goes back to its default. The signing key is made on the first save and kept sealed; a deployment with no sealing key answers 503 `platform_unavailable`. Body: `{ trusted_profiles?: [url], business_profile?, card_brands?, links?: [{ type, url }], environment_words?: handler | seller_guide, ap2_enabled?, allow_unsigned? }` ### Universal Commerce Protocol Called by a platform, not by a partner, and not with an API key. The platform names its profile in `UCP-Agent` and signs each request (RFC 9421, ES256) with a key that profile publishes; the customer must have listed that profile. Paths, fields, statuses and messages follow the protocol's 2026-08-25 version, the only one spoken. What the business decides about a checkout is a 200 with `messages`; a checkout or order that is not there is a 200 with `ucp.status: "error"` and `messages`; protocol errors are `{ code, content }`, not Skerry's envelope. A preview, off by default. - `GET /ucp/{customer_id}/.well-known/ucp` (no key): The customer's business profile: version, the shopping service and its endpoint, capabilities, the card processor's payment handler, and the customer's public signing key. Public, cacheable for a minute. The business serves or redirects its own `/.well-known/ucp` here. - `POST /ucp/{customer_id}/checkout-sessions` (scope `ucp_signature`): Create a checkout. Answers 201 with the checkout, priced. Body: `{ line_items: [{ item: { id }, quantity }], currency?, buyer?: { email, first_name, last_name } }` - `GET /ucp/{customer_id}/checkout-sessions/{id}` (scope `ucp_signature`): Read a checkout. Only the platform that opened it finds it. - `PUT /ucp/{customer_id}/checkout-sessions/{id}` (scope `ucp_signature`): Replace a checkout: the body is the whole of what the platform sets. It comes back priced again; one that is being completed or is over comes back unchanged with a message. Body: `{ line_items: [{ item: { id }, quantity }], buyer? }` - `POST /ucp/{customer_id}/checkout-sessions/{id}/complete` (scope `ucp_signature`): Complete with the card processor's payment token, for the checkout's exact total. A decline leaves the checkout `ready_for_complete` with a recoverable message; another handler or credential type is a recoverable `payment_failed`. Must carry the protocol's `Idempotency-Key`, and `ap2.checkout_mandate` under the AP2 mandates extension. Body: `{ payment: { instruments: [{ id, handler_id: "stripe_payments", type: "card", selected: true, credential: { type: "stripe_payment_token", token } }] }, signals?, ap2?: { checkout_mandate } }` - `POST /ucp/{customer_id}/checkout-sessions/{id}/cancel` (scope `ucp_signature`): Cancel a checkout that is neither completed nor canceled. - `GET /ucp/{customer_id}/orders/{id}` (scope `ucp_signature`): The order of a completed checkout, as its webhook carries it, for the platform that placed it. ### Paying by the call Price a resource and let automated clients pay for each call. Skerry builds the 402 for the customer's server in both x402 and the Machine Payments Protocol, checks the payment the client sends back, and settles it on the customer's own merchant account. Skerry runs no facilitator and holds no keys. A preview: every call answers 409 `capability_coming_soon` until it is switched on for the customer. - `POST /customers/{id}/priced-resources` (scope `machine:write`): Price a resource. A price below a method's minimum, or with a part of a cent, needs `credit_only`. Body: `{ url_pattern, price, currency?, description?, methods?: [stablecoin | agent_token], credit_only?, upstream_url? }` - `GET /customers/{id}/priced-resources` (scope `machine:read`): The customer's priced resources, with the minimum single payment per method. - `DELETE /customers/{id}/priced-resources/{resource_id}` (scope `machine:write`): Stop charging for a resource. - `POST /machine-payments/challenge` (scope `machine:write`): The 402 to send an unpaid request: status, headers and body, in both protocols. Body: `{ resource_id, url?, credit?: { amount } }` - `POST /machine-payments/verify` (scope `machine:write`): Check and settle the payment a client sent, or draw the call from its credit. Returns whether it is paid and the headers to answer with. Body: `{ resource_id, headers: { "PAYMENT-SIGNATURE"? | "Authorization"? }, url? } | { resource_id, credit_token, idempotency_key }` - `POST /machine-payments/credits` (scope `machine:write`): Buy prepaid credit for a resource with one payment. Without payment headers, the 402 for that amount; with them, the credit and its token, once. Body: `{ resource_id, amount?, headers? }` - `GET /machine-payments/credits` (scope `machine:read`): A customer's credits, newest first: bought, balance, refunded, expiry. Never a token. `?customer_id=&resource_id=` - `GET /machine-payments/credits/{id}` (scope `machine:read`): One credit: bought, balance, expiry. - `POST /machine-payments/credits/{id}/refund` (scope `refunds:write`): Send back the unused part of a credit, within the customer's policy. Needs an Idempotency-Key. Body: `{ }` ### Hosted addresses and the pay-by-the-call proxy Public addresses, with no key: the id in the address is the credential, as it is on a hosted checkout page. A checkout session's and a payment link's `share_url` send a browser on to the hosted page and answer a client that asks for JSON (`Accept: application/json`, or the address ending `.json`) with a description it can act on. `/pay/{resource_id}` fronts a priced resource: 402 until paid, then the request is forwarded to the address the business registered. Errors are `{ error: { code, message } }`, or the paying protocol's own on a 402. - `GET /checkout/{id}` (no key): A checkout session's own address: a redirect for a browser; for a client asking for JSON, the amount, currency, methods, payment instructions, the agentic endpoint when the business has one, and how to pay without a browser. - `GET /pay-link/{id}` (no key): A payment link's own address: a redirect for a browser; for an automated client, 402 with the pay-by-the-call challenge when the business has switched that on, otherwise 200 with a description. - `GET /pay/{resource_id}` (no key): The proxy in front of a priced resource: 402 until the request carries a payment or a credit token, then forwarded to the registered address. - `POST /pay/{resource_id}` (no key): The same, for a request with a body. ### Agents paying A registered agent pays another merchant. The agent's request is sent to the merchant; when it answers 402, one offer is paid and the request is sent once more. Two ways of paying are built: x402 version 2, the `exact` scheme, in USDC from a wallet whose key the wallet provider holds and whose policy lets it sign that one kind of message; and the Machine Payments Protocol's `stripe` method, with a delegated payment token issued for the challenge's amount and expiry. The payment is decided before anything is signed, as a payout is: freeze, withdrawal allow-list, first payment to a new destination, risk rules, the agent's mandates and budgets, approval. The merchant's host is the payee. A preview: every call answers 409 `capability_coming_soon` until agents paying is switched on and the wallet provider is connected. - `POST /agents/{id}/wallets` (scope `agents:write`): Create a wallet the agent signs payments from. The key is held by the wallet provider and never returned; a policy there lets it sign typed data for USDC on the wallet's network and nothing else. Sandbox takes `base-sepolia` only, live `base` only. Fund it by sending USDC to its address. Body: `{ network: base | base-sepolia, label? }` - `GET /agents/{id}/wallets` (scope `agents:read`): The agent's wallets, active and retired, with their addresses. - `GET /agents/{id}/wallets/{wallet_id}` (scope `agents:read`): One wallet: network, address, state and what its key may sign. - `DELETE /agents/{id}/wallets/{wallet_id}` (scope `agents:write`): Retire a wallet: nothing is signed with it again. USDC left at its address is not moved, by this or any other operation. - `POST /agent-payments` (scope `agent-payments:write`): Send the agent's request to a merchant and, when it answers 402, pay one offer and send the request once more. Returns the merchant's status, an excerpt of its body and what was paid. `max_amount` is a ceiling in US cents; the merchant's exact amount is what is paid. Under the agent's mandates and the customer's controls: it may be refused, or held for approval (202). Pays at most once per key. Needs an Idempotency-Key. Body: `{ agent_id, wallet_id?, url, method?, headers?, body?, max_amount, currency: usd, payment_method_id?, external_ref?, external_kind? }` - `GET /agent-payments` (scope `agent-payments:read`): Attempts to pay merchants, whatever became of them: paid, failed, refused or held. `?agent_id=&status=&external_ref=&limit=` - `GET /agent-payments/{id}` (scope `agent-payments:read`): One attempt: the merchant, the protocol and network, the amount, the reference, and the authorisation it ran under. ### Rewards and reporting What the platform pays on balances, and what this project has consumed against its plan. - `GET /coins` (scope `*`): The stablecoins this project can hold. - `POST /coins` (scope `*`): Ask for a stablecoin of your own. This records the request and nothing more: no coin is created or minted by it. Body: `{ ticker, name, chains?, loop_type?, note? }` - `GET /coins/{currency}/rewards` (scope `*`): What a balance in this coin earns. - `GET /customers/{id}/rewards/{currency}` (scope `customers:read`): What one customer's balance in a coin earned: `balance`, `earned` and `as_of`. Answers 404 where the coin earns nothing for this customer. Figures run two business days behind. - `GET /usage` (scope `*`): Usage against the plan's limits — the same count a call is refused against. - `GET /compliance-requests` (scope `compliance:read`): Requests for information about a customer or a payment, with the deadline. `?customer_id=&status=`. Answers 409 `capability_coming_soon` until the platform has been granted them. - `GET /compliance-requests/{id}` (scope `compliance:read`): One request for information: what was asked, the deadline, and what it holds.