Skerry, for agents
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
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: <agent id> 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
GET /whoami: the account, environment and scopes behind your keyGET /capabilities: what the platform offers and what is switched on, with the payment-method matrixGET /customers/{id}/payment-methods: what one business can be paid with and pay with right now
Bring a business on
POST /customers: record the businessPOST /customers/{id}/onboarding-link: a link its owner opens to finish verification on hosted pagesGET /customers/{id}: readcanandrequirementsuntil what you need is enabled
Take a payment
POST /checkout-sessions: start a checkout; send the payer tourl, or usepayment_instructionsfor stablecoin and bank transferGET /checkout-sessions/{id}: where the session standsGET /payments/{id}: the payment, once there is one; build orders from thepayment.succeededevent
Pay somebody
POST /customers/{id}/payees: add where the money goesPOST /payouts/quote: what it would cost and which rail it would leave onPOST /payouts: send it, with an Idempotency-KeyGET /payouts/{id}: follow it topaid
Convert a balance
POST /conversions/quote: a priced offer, valid for a short time and usable oncePOST /conversions: execute the quote, with an Idempotency-KeyGET /conversions/{id}: whether it has settled
Refund a payment
POST /payments/{id}/refunds: all or part, with an Idempotency-KeyGET /payments/{id}/refunds: the refunds made against it
Charge for a resource by the call
POST /customers/{id}/priced-resources: say what a call costsPOST /machine-payments/challenge: what to answer an unpaid request with: status 402 and its headersPOST /machine-payments/verify: check and settle the payment a client sent; returns the receipt headers
Let agents buy from a business
POST /customers/{id}/agentic/pricing-callback: where line items, totals, tax and fulfilment are asked forPOST /customers/{id}/agentic/credentials: a bearer token for the agent platformPOST /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
POST /customers/{id}/agentic/pricing-callback: where line items, totals and tax are asked for; the same one agentic checkout usesGET /customers/{id}/ucp/settings: what the business has set; a PUT to the same address saves the platforms it accepts and what its profile publishesGET /ucp/{customer_id}/.well-known/ucp: the profile the business points its own /.well-known/ucp atPOST /ucp/{customer_id}/checkout-sessions: the platform's first call, signed with a key its own profile publishes
Wait for a person
GET /approvals/{id}: read a held operation until it isexecuted,deniedorexpiredGET /events: or read theapproval.decidedevent
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, thenPOST /payouts: What a payout would cost, as a quote that binds the instruction. The answer carriesquote_idandvalid_until(120 seconds by default);POST /payoutswith thatquote_idexecutes 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,rateand 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.amountis a decimal string andamount_minorthe same in integer minor units of the source currency; send either.fee_minoris in minor units offee_currency,receive_amount_minorofreceive_currency.POST /conversions/quote, thenPOST /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 verificationidentity_verification(available): Identity verificationcustody(available): Custodied walletsvirtual_accounts(available): Virtual accountspayouts_fiat(available): Fiat payoutspayouts_crypto(available): Stablecoin transfersstanding_orders(available): Standing conversiontreasury(available): Treasurybooks(available): Booksfx(available): Exchange ratesbank_linking(available): Bank linkingstatic_memos(available): Static memosissuance(request_only): Stablecoin as a servicecards(coming_soon): Cardscompliance_requests(coming_soon): Compliance requestskyc(coming_soon): Individual verificationcard_acceptance(available): Card and bank-debit acceptanceusage_meters(coming_soon): Usage meterscheckout_instructions(available): Stablecoin and bank transfer at checkoutagentic_checkout(preview): Agentic checkoutucp_checkout(preview): Universal Commerce Protocol checkoutagent_tokens(preview): Delegated payment tokenspay_by_call(preview): Paying by the callagent_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): Cardswallet:link(per_customer,enabled_at_onboarding): Wallets (saved cards and device wallets)buy_now_pay_later:klarna(per_customer,enabled_at_onboarding): Klarnabuy_now_pay_later:affirm(per_customer,enabled_at_onboarding): Affirmbuy_now_pay_later:afterpay_clearpay(per_customer,enabled_at_onboarding): Afterpay and Clearpaylocal_method:ideal(per_customer,enabled_at_onboarding): iDEALlocal_method:bancontact(per_customer,enabled_at_onboarding): Bancontactlocal_method:eps(per_customer,enabled_at_onboarding): EPSlocal_method:p24(per_customer,enabled_at_onboarding): Przelewy24local_method:blik(per_customer,enabled_at_onboarding): BLIKlocal_method:twint(per_customer,enabled_at_onboarding): TWINTlocal_method:swish(per_customer,enabled_at_onboarding): Swishlocal_method:mobilepay(per_customer,enabled_at_onboarding): MobilePaylocal_method:pix(per_customer,enabled_at_onboarding): Pixwallet:cashapp(per_customer,enabled_at_onboarding): Cash App Paywallet:amazon_pay(per_customer,enabled_at_onboarding): Amazon Paywallet:revolut_pay(per_customer,enabled_at_onboarding): Revolut Paylocal_method:multibanco(per_customer,enabled_at_onboarding): Multibancolocal_method:konbini(per_customer,enabled_at_onboarding): Konbinilocal_method:boleto(per_customer,enabled_at_onboarding): Boletolocal_method:oxxo(per_customer,enabled_at_onboarding): OXXObank_debit:us_bank_account(per_customer,enabled_at_onboarding): ACH debitbank_debit:sepa_debit(per_customer,enabled_at_onboarding): SEPA debitbank_debit:bacs_debit(per_customer,enabled_at_onboarding): Bacs debitbank_transfer_in:faster_payments(coming_soon,account_currency_not_switched_on): Faster Payments into a virtual accountbank_transfer_out:fednow(per_customer,payee_bank_dependent): FedNow payoutbank_transfer_out:rtp(not_offered,inbound_only): RTP payoutbank_transfer_out:faster_payments(per_customer,enabled_at_verification): Faster Payments payoutbank_transfer_out:spei(per_customer,enabled_at_verification): SPEI payoutbank_transfer_out:pix(per_customer,enabled_at_verification): Pix payoutbank_transfer_out:bre_b(per_customer,enabled_at_verification): Bre-B payoutbank_transfer_out:co_bank_transfer(per_customer,enabled_at_verification): Colombian bank transfer payoutbank_debit_on_demand(not_offered,provider_has_no_pull_api): Pulling money from a bank account into a wallet on demandissued_card(coming_soon,card_issuing_not_switched_on): Cards issued against a balancepay_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 cardcash(not_offered,not_served): Cashcheque(not_offered,not_served): Chequemobile_money(not_offered,not_served): Mobile moneycrypto_non_stablecoin(not_offered,not_served_non_stablecoin): Crypto assets other than stablecoinsstablecoin:{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), andpayment_methods: every way money moves, method by method and direction by direction, each with one status and a reason code. A capability withtest_mode_only: trueworks in the sandbox only for now.POST /terms(scopecustomers: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(scopecustomers:write): Onboard a business, or a person withtype: "individual"(hosted verification only; identity details and documents are refused for a person).reliancecarries 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(scopecustomers:read): The businesses this project has onboarded.GET /customers/{id}(scopecustomers: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}(scopecustomers:write): Correct details on a business. Body:{ legal_name?, address?, external_id? }POST /customers/{id}/verification(scopecustomers:write): Start or re-run verification. Body:{ }GET /customers/{id}/verification(scopecustomers:read): Where verification has got to.POST /customers/{id}/onboarding-link(scopecustomers: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(scopecustomers:read): The payment-method matrix for one customer: every method and direction, each cell markedenabledfrom what the card processor reports active on its merchant account and what the banking partner enabled at verification.GET /customers/{id}/people(scopecustomers: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(scopecustomers:write): Name another owner, controller or signer of a business customer, in the formPOST /customerstakes inpeople[]. At least one ofowns,controlsandsignsis true;address.countryis ISO 3166-1 alpha-3. Identity documents and files are passed on to the banking partner and never stored by Skerry.relianceis 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}(scopecustomers:write): Remove a person from a business customer at the banking partner.GET /eligibility(scopeeligibility: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(scopewallets:write): Create a stablecoin wallet, optionally with a label, a purpose and the agent it is for. Covered by mandates aswallet.create. Body:{ chain, label?, purpose?, agent_id? }GET /customers/{id}/wallets(scopewallets:read): The customer's wallets and balances. Each balance carries the decimalamountandamount_minor, the same amount in integer minor units ofcurrency; each wallet itslabel,purpose,agent_idandfloor.GET /customers/{id}/wallets/{wallet_id}/history(scopewallets:read): What has moved in and out of one wallet, newest first.?limit=POST /customers/{id}/accounts(scopeaccounts:write): Open an account payers pay into.currencyis what the payer sends (usd or eur);wallet_idis the customer's wallet the money lands in, converted to the stablecoin named indelivers(usdc for usd and eurc for eur unless named). For example{"currency": "usd", "wallet_id": "<wallet id>", "delivers": "usdc"}. The answer carries the account'sdeposit_instructions. Body:{ currency, wallet_id, delivers? }GET /customers/{id}/accounts(scopeaccounts:read): The customer's fiat accounts.POST /customers/{id}/accounts/{account_id}/deactivate(scopeaccounts: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(scopeaccounts:write): Let a deactivated account accept deposits again, with the same details. Body:{ }POST /customers/{id}/bank-link(scopepayees: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 withPOST /customers/{id}/bank-link/exchange. Body:{ }POST /customers/{id}/bank-link/exchange(scopepayees:write): Finish a bank login: send thepublic_tokenthe bank login page returned, with thelink_tokenit was started with. The link token must be one started for this customer, and is finished once (409bank_link_finished, orbank_link_expiredafter its expiry). The answer is{ data: [payee] }, the payees the connection added, each announced aspayee.created. Covered by mandates aspayee.create; if it is held, send the same request again withSkerry-Approvalonce a person has approved it. Neither token is stored. Body:{ link_token, public_token }POST /customers/{id}/cards(scopecards:write): Issue a card against an account. Body:{ account_id }GET /customers/{id}/cards(scopecards:read): The customer's cards.POST /customers/{id}/memos(scopeaccounts:write): A deposit memo, for chains where one shared address serves everybody.wallet_idis the customer's wallet the deposit is delivered to, and fixes the chain;deliversis the coin that arrives (usdc unless named) andcurrencywhat the payer sends (usd unless named). Body:{ wallet_id, delivers?, currency? }GET /customers/{id}/memos(scopeaccounts: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(scopepayees: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,ibanorgb),account_owner_nameas the bank holds it,account_owner_type(business or individual) withbusiness_namefor a business,bank_name,address(street_line_1,street_line_2,city,state,postal_code, andcountryas ISO 3166-1 alpha-3), and the account itself:account: { routing_number, account_number, checking_or_savings }forus,iban: { account_number, bic, country }foriban,account: { sort_code, account_number }forgb. 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 coverspayee.createthe operation allow-list and approval rules apply; if it is held, send the same request again withSkerry-Approvalonce 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(scopepayees: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}(scopepayees:read): One payee, with the name check as it stands now.DELETE /customers/{id}/payees/{payee_id}(scopepayees:write): Remove a payee.POST /customers/{id}/payees/{payee_id}/pause(scopepayees:write): Stop paying a payee for now without removing it. It readsactive: falseuntil resumed. Announced aspayee.updated. Body:{ }POST /customers/{id}/payees/{payee_id}/resume(scopepayees:write): Pay a paused payee again. Refused withcustomer_frozenwhile the customer is frozen. Announced aspayee.updated. Body:{ }POST /customers/{id}/payees/{payee_id}/check(scopepayees:write): Ask the receiving bank again whether the name on the account matches, and return the payee with the answer asname_check. Oftenpendingat first; a later read or thepayee.updatedevent carries the bank's answer. Body:{ }POST /payouts/quote(scopepayouts:write): What a payout would cost, as a quote that binds the instruction. The answer carriesquote_idandvalid_until(120 seconds by default);POST /payoutswith thatquote_idexecutes 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,rateand 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.amountis a decimal string andamount_minorthe same in integer minor units of the source currency; send either.fee_minoris in minor units offee_currency,receive_amount_minorofreceive_currency. Body:{ customer_id, amount | amount_minor, from, to, rail?, speed? }POST /payouts(scopepayouts:write): Send money to a payee, a wallet or an address. Needs an Idempotency-Key.amountis a decimal string infrom.currency;amount_minoris the same amount as integer minor units, and either may be sent. A bank payout may name arailor aspeed(fastest, cheapest, standard); otherwise the rail is chosen and returned with the reason.quote_idexecutes exactly what was quoted.execute_at,scheduleorrelease_oncreate 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_rulecarries originator and beneficiary details for an outside address; it is passed on and not kept. The answer carriesamount_minor,fee_minorandauthorisation. 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(scopepayouts: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(scopepayouts: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}(scopepayouts:read): One batch, with the outcome of each payout in it.GET /customers/{id}/payout-fees(scopepayouts:read): Who bears the cost of a bank payout, per currency and rail:fees.usd.wireand 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(scopecustomers: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(scopepayouts: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(scopepayouts:read): Payouts this project has made, with the reference each was given. Each carriesamount(decimal string) andamount_minor(integer minor units ofcurrency).?customer_id=&external_ref=GET /payouts/{id}(scopepayouts:read): One payout and where it has got to, withamount_minor,fee_minorandauthorisation. While it is pausedstatusison_holdandholdgivesreason_code, the reason in plain words and the deadline when there is one.?wait=<seconds up to 30>&until=<state>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}(scopepayouts:write): Change the amount of a payout that is stillawaiting_funds; once its funds have arrived it is refused with 409payout_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 withSkerry-Approvalonce 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.amountis a decimal string in the payout's currency;amount_minormay be sent instead. Body:{ amount | amount_minor }DELETE /payouts/{id}(scopepayouts: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(scopestanding_orders:write): An address that converts and pays out whatever arrives.return_instructionssays where a deposit that cannot be converted goes back to. Under a mandate that coversstanding_order.createthe 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(scopestanding_orders:read): The customer's standing orders.GET /customers/{id}/standing-orders/{standing_order_id}/conversions(scopestanding_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(scopeevents:read): What has happened, newest first.?limit=&type=&types=a,b. With?after=<event id>the order changes: the events recorded after that one, oldest first, withnext_afterto pass as the nextafter;&wait=<seconds up to 30>then holds the request until there is a new event or the wait ends.POST /events/{id}/replay(scopewebhooks:write): Deliver an event again, to every endpoint that wanted it.GET /events/types(scopeevents:read): The catalogue of event types: every type this API sends, the object it is about and when it is sent.event_typeson an endpoint are checked against it.POST /webhooks(scopewebhooks:write): Register an endpoint.urlmust be https.event_typesleft 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(scopewebhooks:read): The endpoints registered in this environment, each with theproject_idit belongs to.PATCH /webhooks/{id}(scopewebhooks: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}(scopewebhooks:write): Remove an endpoint.POST /webhooks/{id}/roll-secret(scopewebhooks: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(scopepayments:write): Start a hosted checkout for a payer.payment_instructionsadds 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(scopepayments:read): Checkout sessions, newest first.?customer_id=&status=&external_ref=GET /checkout-sessions/{id}(scopepayments:read): One session. While open, this also asks the processor so a missed webhook cannot mislead. A session with payment instructions carries them, withamount_received,amount_remainingandamount_overpaid.POST /checkout-sessions/{id}/expire(scopepayments:write): Close an open session so it can no longer be paid. Body:{ }POST /payment-links(scopepayments: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(scopepayments:read): Payment links, newest first.?customer_id=&external_ref=GET /payment-links/{id}(scopepayments:read): One payment link, with how many times it has been used.PATCH /payment-links/{id}(scopepayments:write): Pause, resume or disable a link. Body:{ status }GET /payments(scopepayments:read): Payments, newest first.?customer_id=&status=&reference=&external_ref=&payer_id=GET /payments/{id}(scopepayments:read): One payment: status, fee, amounts received and refunded, andrisk(level, score and how the payer's authentication ended) where the processor reports them. A payment that did not come by card also carriesmethod(stablecoin, bank_transfer or machine),payer_refandamount_overpaid; one an agent made carriesagent: { initiated, token_id, platform, token_limits, indicators }andagent_identity.?wait=<seconds up to 30>&until=<state>holds the request until the payment succeeds, fails, is cancelled, refunded or disputed, or reaches the named state.POST /payments(scopepayments:write): Charge a saved method with the payer absent, under the mandate they gave; or, withpayment_token, charge a delegated payment token an agent holds for its buyer (a preview: 409capability_coming_soonuntil 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(scopepayments:write): Collect funds that were held. May capture less than the hold. Body:{ amount? }POST /payments/{id}/cancel(scopepayments:write): Release a hold, or abandon a payment that has not succeeded. Body:{ }POST /payments/{id}/refunds(scoperefunds: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 todestination. Needs an Idempotency-Key. Body:{ amount?, reason?, destination?: { address, chain? } | { payee_id }, external_ref?, external_kind? }GET /payments/{id}/refunds(scopepayments:read): Refunds made against a payment.GET /disputes(scopepayments:read): Disputes against payments, newest first.?customer_id=&status=GET /disputes/{id}(scopepayments:read): One dispute, with the date evidence is due.POST /disputes/{id}/evidence(scopepayments:write): Send evidence, and submit it when complete: text fields, file fields (the id of an uploaded file) andenhanced_evidence. Body:{ evidence: { ... }, submit? }POST /disputes/{id}/files(scopepayments: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(scopepayments:read): Files uploaded for a dispute, newest first.GET /disputes/{id}/suggested-evidence(scopepayments: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(scopepayments: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(scopepayments: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(scoperefunds:write): Send a deposit that was set aside back, whole: to where it came from when that is on record, otherwise todestination. Needs an Idempotency-Key. Body:{ destination?: { address, chain? } | { payee_id } }GET /settlements(scopesettlements:read): Card proceeds on their way to the business's bank, newest first.?customer_id=GET /settlements/{id}(scopesettlements:read): One settlement.POST /settlements(scopesettlements:write): Send proceeds to the business's bank now. Needs an Idempotency-Key. Body:{ customer_id, amount, currency }GET /customers/{id}/balances(scopecustomers: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(scopepayments:write): Record someone who pays a customer. Body:{ customer_id, email?, name?, external_id? }GET /payers(scopepayments:read): Payers, newest first.?customer_id=GET /payers/{id}(scopepayments:read): One payer.GET /payers/{id}/payment-methods(scopepayments:read): Methods the payer saved: brand, last four, expiry, and the mandate.DELETE /payers/{id}/payment-methods/{method_id}(scopepayments:write): Forget a saved method and revoke its mandate.POST /customers/{id}/payment-method-setups(scopepayments: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(scopepayments:write): Charge a payer on a schedule from a saved method. Needs an Idempotency-Key. Under a mandate that coverssubscription.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(scopepayments:read): Subscriptions, newest first.?customer_id=&status=&payer_id=GET /subscriptions/{id}(scopepayments:read): One subscription.POST /subscriptions/{id}/cancel(scopepayments:write): End a subscription now or at the end of the paid period. Body:{ at_period_end? }POST /meters(scopepayments: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(scopepayments:read): Meters, newest first.?customer_id=POST /meters/{id}/events(scopepayments:write): Record usage by a payer against a meter.identifiermakes a retry count once. Body:{ payer_id, value, identifier?, timestamp? }GET /meters/{id}/summary(scopepayments: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(scopepolicies:write): Limit a key or an agent: a mandate.scopeis required,familyorcurrency, and decides the unit of every amount limit (see the group note); a request without it is refused withinvalid_requestnamingscope.operationsmay 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_countcaps operations percount_window(day or month, rolling).approve_operationsalways wait for a person.checkout_aboveis the amount above which checkout sessions and payment links are checked.merchant_allow_listapplies to operations that name a merchant;wallet_allow_listto the wallet money leaves.evidencerecords consent; withconsent_requiredthe mandate is inactive until verified evidence exists. The answer carriesmandate_id,summary(the rule in sentences, generated from it) andactive. 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(scopepolicies:read): Policies, newest first, each withusage: 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}(scopepolicies:read): One policy, withsummary,consent(the evidence attached to the rule as it stands) andactive.PATCH /spend-policies/{id}(scopepolicies: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. Changingscopechanges 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(scopepolicies:write): Revoke a policy, immediately. Body:{ reason? }POST /spend-policies/{id}/consent(scopepolicies: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_attestedis taken on your word;passkeyandoauthareverifiedonly with anattestationsigned by the configured identity provider naming the subject and the mandate hash, and areunverifiedwhen no identity provider is configured;console_sessionfrom a partner key isunverified. Refuses an agent's own credential. Body:{ method: console_session | passkey | oauth | partner_attested, subject, at, assertion?, attestation? }GET /spend-policies/{id}/consent(scopepolicies: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(scopepolicies:read): The operations counted under a mandate, newest first:op,amount(integer minor units ofcurrency; 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, orown_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) andcreated_at.GET /mandates(scopepolicies:read): A customer's spend policies as mandates (object: "mandate", same ids), each withusage: 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(scopeapprovals:read): Operations held for a person to approve, newest first, each withpayee_display: the destination as a person would recognise it, where there is one, anddecisions: each decision recorded so far, oldest first, as{ decision, actor_type, actor_ref, note, created_at }.actor_refis 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=pendingGET /approvals/{id}(scopeapprovals:read): One held operation, as it was asked, with itsdecisions.?wait=<seconds up to 30>&until=<state>holds the request until it is approved, denied, executed, failed or expired, or reaches the named state.POST /approvals/{id}/approve(scopeapprovals: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 asevidence. 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 withSkerry-Approval. Body:{ note?, evidence? }POST /approvals/{id}/deny(scopeapprovals:write): Refuse a held operation. Body:{ note? }POST /approvals/{id}/links(scopeapprovals: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 againstapprover, 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(scopecustomers: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 needspolicies:readas 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(scopeagents: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(scopeagents:read): Agents, newest first.?customer_id=&status=&parent_agent_id=GET /agents/{id}(scopeagents:read): One agent, with its status and signing keys.PATCH /agents/{id}(scopeagents:write): Change an agent's name, operator, description or whether it must sign requests. Body:{ name?, operator?, description?, signature_required? }POST /agents/{id}/suspend(scopeagents: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(scopeagents:write): Resume a suspended agent. Body:{ reason? }POST /agents/{id}/revoke(scopeagents:write): Revoke an agent permanently, with every agent it delegated to and all their keys. Body:{ reason? }POST /agents/{id}/keys(scopekeys: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(scopeagents:write): Register an Ed25519 public key (JWK) the agent signs requests with. Body:{ jwk, expires_at? }GET /agents/{id}/public-keys(scopeagents:read): The agent's signing keys in force.DELETE /agents/{id}/public-keys/{kid}(scopeagents:write): Remove a signing key.POST /agents/{id}/oauth-clients(scopeagents: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(scopeagents:read): The OAuth clients approved to act as the agent, newest first, asoauth_clients: [{ client_id, issuer, scopes, created_at }]. A withdrawn approval is not listed.DELETE /agents/{id}/oauth-clients(scopeagents:write): Withdraw an OAuth client's approval.?client_id=. The next token it asks for as this agent is refused.POST /agents/{id}/delegations(scopeagents: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(scopekeys:read): Keys in this environment, newest first, never with a secret, each with theproject_idit belongs to.?agent_id=&status=GET /keys/{id}(scopekeys:read): One key: scopes, bound agent, last use, expiry.POST /keys/{id}/roll(scopekeys: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(scopekeys: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=<agent id>or?partner=<partner id>&mode=live|sandboxGET /.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(scopecontrols:write): Freeze a customer: every money-out operation for it is refused withcustomer_frozen. Body:{ reason? }GET /customers/{id}/freeze(scopecontrols:read): Whether one customer is frozen:frozen,reason,frozen_at,frozen_by_key,frozen_by_agent, andaccount_frozenwhen the whole account is frozen in this environment as well.POST /customers/{id}/unfreeze(scopecontrols:write): Lift a customer's freeze. Body:{ reason? }POST /controls/freeze(scopecontrols:write): Freeze the whole account in this environment: every money-out operation is refused withpartner_frozen. Body:{ reason? }POST /controls/unfreeze(scopecontrols:write): Lift the account's freeze. Body:{ reason? }GET /controls(scopecontrols:read): What is frozen right now: the account and each frozen customer.GET /audit(scopeaudit:read): The audit trail, newest first.?object_type=&object_id=&action=&agent_id=&key_id=GET /audit/verify(scopeaudit: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(scopeconversions: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(scopeconversions:write): Execute a quote. Needs an Idempotency-Key. Body:{ quote_id, external_ref?, external_kind? }GET /conversions(scopeconversions:read): Conversions, newest first.?customer_id=GET /conversions/{id}(scopeconversions:read): One conversion and whether it has settled.?wait=<seconds up to 30>&until=<state>holds the request until it is completed or failed, or reaches the named state.GET /customers/{id}/ledger(scopeledger:read): Double-entry entries for a period, as JSON or CSV, in booking order;order=descreads newest first. Each JSON entry also carriesside,amount_minor,status(pending or posted),counterparty,rail,external_ref,external_kind,movement_id(the receipt's id) andreverses.?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(scopebooks:write): Start connecting a customer's books. Returnsconnect_url, where a signed-in person approves the connection. Body:{ provider?, project_id?, return_url? }GET /customers/{id}/books(scopebooks:read): Whether the books are connected, to which project, and how delivery is going.?probe=truealso asks the provider.DELETE /customers/{id}/books(scopebooks:write): End the connection. Movements stop being delivered.POST /customers/{id}/books/backfill(scopebooks:write): Send the movements of a period to the books again. Safe to repeat. Body:{ from, to }GET /customers/{id}/books/deliveries(scopebooks:read): What was sent to the books and how it went, newest first.?status=GET /customers/{id}/books/links(scopebooks: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}(scopeledger: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(scopebudgets:write): Set aside an amount of a wallet's balance for a mandate or an agent.amountandlow_beloware integer minor units ofcurrency. Refused withinsufficient_fundswhen 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(scopebudgets:read): The customer's budgets, newest first, each withamount,spentandremainingin minor units of its currency.?status=active|releasedGET /customers/{id}/budgets/{budget_id}(scopebudgets:read): One budget, with what was spent from it and what remains.POST /customers/{id}/budgets/{budget_id}/top-up(scopebudgets:write): Add to a budget.amountis integer minor units of the budget's currency. Re-armsbudget.lowandbudget.exhausted. Body:{ amount }POST /customers/{id}/budgets/{budget_id}/release(scopebudgets: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(scopecontrols:read): How a new destination is treated:new_payee_hold,payee_cooling_off_minutesandcooling_off_max.PUT /customers/{id}/payee-controls(scopecontrols:write): Hold the first payout to a payee, wallet or address the customer has never paid (an approval with reasonnew_payee), and limit what a newly trusted destination can be paid during its cooling-off period.cooling_off_maxis 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(scopepayees: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(scopepayees: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(scopecontrols:read): The customer's velocity and anomaly rules. A rule that is null is off.PUT /customers/{id}/risk-rules(scopecontrols:write): Replace the customer's risk rules. Each rule hasaction:approveturns an operation that trips it into an approval with the reason,refuserefuses it withrisk_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(scopecontrols:read): The destinations this customer's payouts may go to.destinations: nullmeans there is no list.PUT /customers/{id}/withdrawal-allow-list(scopecontrols: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_allowedotherwise), in addition to any mandate's own allow-list.nullremoves the list. Body:{ destinations: [ ... ] | null }POST /customers/{id}/approvers(scopeapprovals:write): Name a person who is emailed an approve link and a deny link when one of this customer's operations is held.referenceis your identifier for the person.mail_configuredin the answer says whether this deployment sends mail. Body:{ email, reference, name? }GET /customers/{id}/approvers(scopeapprovals:read): The customer's approvers, and whether this deployment sends mail.DELETE /customers/{id}/approvers(scopeapprovals: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}(scopewallets:read): One wallet: balances (decimalamountandamount_minorin integer minor units ofcurrency),label,purpose,agent_idandfloor.PATCH /customers/{id}/wallets/{wallet_id}(scopewallets:write): Change a wallet's label, purpose, agent or floor.floor.amountis integer minor units offloor.currency;balance.lowis sent when the balance falls under it. Body:{ label?, purpose?, agent_id?, floor?: { amount, currency } | null }POST /customers/{id}/sweep-rules(scopesweeps:write): Keep at most (keep_at_most, withto) or at least (keep_at_least, withfrom) an amount in a wallet, checked everyevery_minutes(5 or more).amountis integer minor units ofcurrency. 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(scopesweeps:read): The customer's sweep rules, newest first, with how each last ran (last_result).?status=active|canceledGET /customers/{id}/sweep-rules/{rule_id}(scopesweeps:read): One sweep rule.DELETE /customers/{id}/sweep-rules/{rule_id}(scopesweeps: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(scopepayouts:read): Scheduled payouts, newest first.?customer_id=&status=scheduled|waiting|running|completed|canceled|failedGET /payouts/scheduled/{id}(scopepayouts: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}(scopepayouts: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(scopepayouts: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(scopeaudit: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(scopeaudit:read): The same record for one payout.GET /refunds/{id}/authorisation(scopeaudit:read): The same record for one refund.GET /conversions/{id}/authorisation(scopeaudit: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(scopepayments:write): Act as the payer: succeed, decline, run short of funds, or open a dispute. Body:{ outcome }POST /sandbox/approvals/{id}/decide(scopeapprovals: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 differentapprovervalues. Body:{ decision: approve | deny, approver? }POST /sandbox/payouts/scheduled/{id}/advance(scopepayouts: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(scopeevents:write): Record a named event about an object, markedsandbox: 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(scopecontrols: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(scopebudgets:write): Mark what remains of a budget as spent in Skerry's record, sobudget.exhaustedis emitted and the next operation under it is refused. Nothing is sent to a provider. Body:{ }POST /sandbox/checkout-sessions/{id}/deposit(scopepayments: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.amountis 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(scopeagentic: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 503platform_unavailable. Body:{ name, platform?, webhook_url?, require_signature? }GET /customers/{id}/agentic/credentials(scopeagentic:read): The customer's agent-platform credentials, never with a secret.DELETE /customers/{id}/agentic/credentials/{credential_id}(scopeagentic:write): Revoke a credential.POST /customers/{id}/agentic/pricing-callback(scopeagentic: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: nullremoves it; without one only fixed-amount payment links can be sold. Body:{ url }GET /customers/{id}/agentic/settings(scopeagentic:read): What the customer allows agents, and the address agent platforms call.POST /customers/{id}/agentic/settings(scopeagentic: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(scopeagentic:read): Refunds agents have asked for, newest first.?status=pendingPOST /customers/{id}/agentic/refund-requests/{request_id}(scoperefunds: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}(scopeagentic: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, andwebhooksays how the first attempt went. Body:{ status, estimated_delivery? }GET /customers/{id}/agentic/orders/{session_id}(scopeagentic:read): Where an order stands: the order as the agent platform was last told it,session_status, andwebhook: how the last order event went (statuspending, delivered or exhausted, withattempts, orreason: 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(scopeagent_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}(scopeagent_credential): Retrieve a checkout session.POST /agentic/{customer_id}/checkout_sessions/{checkout_session_id}(scopeagent_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(scopeagent_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 itauthentication_required. Body:{ payment_data: { handler_id, instrument: { type, credential: { type, token } } }, buyer?, authentication_result? }POST /agentic/{customer_id}/checkout_sessions/{checkout_session_id}/cancel(scopeagent_credential): Cancel a session that has not completed. 405 afterwards. Body:{ intent_trace? }POST /agentic/{customer_id}/checkout_sessions/{checkout_session_id}/refunds(scopeagent_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(scopeagentic:read): What the customer has set for the Universal Commerce Protocol, withprofile_url(where its business profile is served),endpoint, andsigning_key: the public half of its signing key, never the private half.PUT /customers/{id}/ucp/settings(scopeagentic: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 503platform_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/ucphere.POST /ucp/{customer_id}/checkout-sessions(scopeucp_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}(scopeucp_signature): Read a checkout. Only the platform that opened it finds it.PUT /ucp/{customer_id}/checkout-sessions/{id}(scopeucp_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(scopeucp_signature): Complete with the card processor's payment token, for the checkout's exact total. A decline leaves the checkoutready_for_completewith a recoverable message; another handler or credential type is a recoverablepayment_failed. Must carry the protocol'sIdempotency-Key, andap2.checkout_mandateunder 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(scopeucp_signature): Cancel a checkout that is neither completed nor canceled.GET /ucp/{customer_id}/orders/{id}(scopeucp_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(scopemachine:write): Price a resource. A price below a method's minimum, or with a part of a cent, needscredit_only. Body:{ url_pattern, price, currency?, description?, methods?: [stablecoin | agent_token], credit_only?, upstream_url? }GET /customers/{id}/priced-resources(scopemachine:read): The customer's priced resources, with the minimum single payment per method.DELETE /customers/{id}/priced-resources/{resource_id}(scopemachine:write): Stop charging for a resource.POST /machine-payments/challenge(scopemachine: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(scopemachine: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(scopemachine: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(scopemachine:read): A customer's credits, newest first: bought, balance, refunded, expiry. Never a token.?customer_id=&resource_id=GET /machine-payments/credits/{id}(scopemachine:read): One credit: bought, balance, expiry.POST /machine-payments/credits/{id}/refund(scoperefunds: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(scopeagents: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 takesbase-sepoliaonly, livebaseonly. Fund it by sending USDC to its address. Body:{ network: base | base-sepolia, label? }GET /agents/{id}/wallets(scopeagents:read): The agent's wallets, active and retired, with their addresses.GET /agents/{id}/wallets/{wallet_id}(scopeagents:read): One wallet: network, address, state and what its key may sign.DELETE /agents/{id}/wallets/{wallet_id}(scopeagents: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(scopeagent-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_amountis 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(scopeagent-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}(scopeagent-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}(scopecustomers:read): What one customer's balance in a coin earned:balance,earnedandas_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(scopecompliance:read): Requests for information about a customer or a payment, with the deadline.?customer_id=&status=. Answers 409capability_coming_soonuntil the platform has been granted them.GET /compliance-requests/{id}(scopecompliance:read): One request for information: what was asked, the deadline, and what it holds.
Written and kept by Skerry. Read from https://api.skerry.xyz/agents.md.