Skerry/ Guides

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

  1. GET /whoami: the account, environment and scopes behind your key
  2. GET /capabilities: what the platform offers and what is switched on, with the payment-method matrix
  3. GET /customers/{id}/payment-methods: what one business can be paid with and pay with right now

Bring a business on

  1. POST /customers: record the business
  2. POST /customers/{id}/onboarding-link: a link its owner opens to finish verification on hosted pages
  3. GET /customers/{id}: read can and requirements until what you need is enabled

Take a payment

  1. POST /checkout-sessions: start a checkout; send the payer to url, or use payment_instructions for stablecoin and bank transfer
  2. GET /checkout-sessions/{id}: where the session stands
  3. GET /payments/{id}: the payment, once there is one; build orders from the payment.succeeded event

Pay somebody

  1. POST /customers/{id}/payees: add where the money goes
  2. POST /payouts/quote: what it would cost and which rail it would leave on
  3. POST /payouts: send it, with an Idempotency-Key
  4. GET /payouts/{id}: follow it to paid

Convert a balance

  1. POST /conversions/quote: a priced offer, valid for a short time and usable once
  2. POST /conversions: execute the quote, with an Idempotency-Key
  3. GET /conversions/{id}: whether it has settled

Refund a payment

  1. POST /payments/{id}/refunds: all or part, with an Idempotency-Key
  2. GET /payments/{id}/refunds: the refunds made against it

Charge for a resource by the call

  1. POST /customers/{id}/priced-resources: say what a call costs
  2. POST /machine-payments/challenge: what to answer an unpaid request with: status 402 and its headers
  3. POST /machine-payments/verify: check and settle the payment a client sent; returns the receipt headers

Let agents buy from a business

  1. POST /customers/{id}/agentic/pricing-callback: where line items, totals, tax and fulfilment are asked for
  2. POST /customers/{id}/agentic/credentials: a bearer token for the agent platform
  3. POST /agentic/{customer_id}/checkout_sessions: the agent platform's first call, under that token

Let platforms buy from a business through the Universal Commerce Protocol

  1. POST /customers/{id}/agentic/pricing-callback: where line items, totals and tax are asked for; the same one agentic checkout uses
  2. GET /customers/{id}/ucp/settings: what the business has set; a PUT to the same address saves the platforms it accepts and what its profile publishes
  3. GET /ucp/{customer_id}/.well-known/ucp: the profile the business points its own /.well-known/ucp at
  4. POST /ucp/{customer_id}/checkout-sessions: the platform's first call, signed with a key its own profile publishes

Wait for a person

  1. GET /approvals/{id}: read a held operation until it is executed, denied or expired
  2. GET /events: or read the approval.decided event

Quote, then commit

Where the amount matters, ask first and act second. A quote changes nothing; the second call is the one that moves money.

  • POST /payouts/quote, then POST /payouts: What a payout would cost, as a quote that binds the instruction. The answer carries quote_id and valid_until (120 seconds by default); POST /payouts with that quote_id executes exactly the quoted customer, amount, source, destination and rail, once. For a bank payout it says which rail it would leave on and why (rail, rail_reason) and carries the name check on the payee (payee_check). fee, rate and the receive amount are what the provider's dry run reported when the quote was made; the provider is not asked to hold them, so this is not a guaranteed rate. amount is a decimal string and amount_minor the same in integer minor units of the source currency; send either. fee_minor is in minor units of fee_currency, receive_amount_minor of receive_currency.
  • POST /conversions/quote, then POST /conversions: A priced offer to convert, valid for a short time and usable once.

Dry run

Send Skerry-Dry-Run: true (or dry_run: true in the body) on a money-moving call to check it without doing it: payouts, batches, conversions, refunds, off-session charges, settlements, subscriptions, checkout sessions and payment links. Validation, ownership, the freeze, policies, budgets, the customer's risk rules and payee rules are evaluated, and for payouts and conversions the provider's own dry run is asked. The answer is 200 with dry_run: true, next (run, hold or refuse), checks (each check and its result), object (what would be created, where the operation can state it) and, for hold or refuse, why. Nothing is reserved, sent or created. With the header, no Idempotency-Key is needed and no idempotency record is kept.

Idempotency

Send Idempotency-Key on every POST. A repeat returns the first response rather than acting twice; a repeat with a different body is refused. This is how a retry after a timeout stays safe. Calls that move money require it, POST /payouts included.

Approvals

A money-moving call can answer HTTP 202 with status: "requires_approval" and an approval_id. Not an error: the operation is held for a person. The body names the approval.

Nothing has moved. Do not retry the call. Read GET /approvals/{id} until its status is executed, denied or expired; the result of an executed operation is on the approval. The key that asked cannot approve.

Amounts

Every amount is one of two forms, and the reference says which. Integer minor units: a whole number of the currency's smallest unit (cents for USD and EUR, six places for a stablecoin) beside a lowercase currency. Payments, refunds, settlements, conversions, ledger entries, budgets and every field named *_minor use this. Decimal strings: "12.50", used by amount and fee on payouts and by wallet balances, which predate the rule. Wherever a decimal string is returned, the same amount is returned beside it as amount_minor (or fee_minor) with currency; POST /payouts and POST /payouts/quote accept amount_minor in place of amount. A rate is a ratio, not an amount, and stays a decimal string. Limits on a spend policy are integer minor units of the unit its scope names.

Pagination

List endpoints return newest first and take ?limit= and ?cursor=. A response carrying next_cursor has more.

Rate limits

Reads are limited to 600 and writes to 120 per minute per key. Past that, 429 rate_limited with Retry-After. Every response carries RateLimit-Limit and RateLimit-Remaining.

References

Send external_ref (up to 200 characters) with external_kind (invoice, bill, order, booking or other) on a checkout session, payment link, payment, refund, payout, conversion or subscription to name the document it belongs to in your own system. Skerry stores it, returns it on the object, on every event about it and on the ledger entry, and lists by it (?external_ref=). On a bank payout it is also what the payee's bank shows, cut to what the rail carries.

Receipts

Every movement has a receipt at GET /receipts/{id}, and the movement.pending, movement.posted and movement.reversed events carry it. A receipt is signed with Ed25519 over its canonical JSON (keys sorted, no whitespace) without the signature field; verify it with the key named by signature.key_id at /.well-known/skerry-receipt-keys.

Errors

Failures are { error: { code, message, detail, request_id } }. Branch on code, never on message — messages are written for people and will change.

StatusCodeWhat it means and what to do next
400invalid_requestThe body is wrong. detail.field names which part.
401unauthenticatedNo key, or not a key we issued. Send it as Authorization: Bearer sk_….
401key_revokedThe key was revoked. Issue another.
401key_expiredThe key's expires_at has passed. Issue another, or roll a key before it expires.
401signature_invalidThe request's HTTP Message Signature could not be accepted. detail.reason says why: malformed, expired, digest_mismatch, unknown_key, bad_signature and others.
401signature_requiredThis agent must sign its requests and this one was not signed.
401token_invalidThe access token was not accepted. detail.reason says why: wrong_audience, wrong_issuer, bad_signature, missing_claim and others.
401token_expiredThe access token has expired. Get another from the authorisation server.
401dpop_invalidThe token is bound to a key and the DPoP proof was missing, wrong for this request, or already used.
503oauth_unavailableThe authorisation server's keys could not be read, so tokens cannot be checked. API keys still work.
403agent_requiredSay which agent is acting: send Skerry-Agent or use a key bound to the agent. Policies on this account are bound to agents.
403agent_mismatchSkerry-Agent, the key, the signature or the token name different agents.
403agent_unknownNo such agent is registered in this environment. Once any agent is registered, Skerry-Agent must name one.
403agent_suspendedThe agent, or an agent it was delegated from, is suspended.
403agent_revokedThe agent, or an agent it was delegated from, has been revoked. Also 409 when changing a revoked agent.
403agent_expiredThe agent's delegation has expired.
403agent_not_permittedAn agent's own credential may not do this: it would widen or restore authority, or act for another agent.
403scope_not_heldA key cannot grant, or take over, scopes it does not hold itself. detail.missing lists them.
403delegation_not_narrowerA delegation asked for more than the delegating agent has. detail.field names which part.
403customer_frozenMoney movement is frozen for this customer. POST /customers/{id}/unfreeze lifts it.
403partner_frozenMoney movement is frozen for the whole account in this environment. POST /controls/unfreeze lifts it.
402not_in_planThe plan does not include this. Usage on the project page says where you stand.
403insufficient_scopeThe key is narrower than the call. Each operation names its scope.
403live_not_approvedA live key was used before live was approved. Sandbox still works.
403partner_suspendedThe account is suspended. Nothing will succeed until that is lifted.
404not_foundNo such object in this project — including one that belongs to somebody else.
409already_existsThe object is already there. Usually a retry without an Idempotency-Key.
409request_in_flightThe same Idempotency-Key is still running. Wait and read the first answer.
400idempotency_key_requiredThis call moves money; send an Idempotency-Key so a retry cannot act twice. POST /payouts requires it too.
402payment_failedThe payer's bank or card refused. detail.reason says why in plain terms.
403limit_exceededA 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.
403operation_not_allowedA spend policy on this key does not allow that operation. detail.allowed lists what it allows.
403currency_not_allowedA spend policy on this key does not allow that currency.
403customer_not_allowedA spend policy on this key is for a different customer.
403merchant_not_allowedThe merchant is not on the mandate's allow-list.
403wallet_not_allowedThe mandate does not cover the wallet the money would leave.
403mandate_not_yet_validThe mandate's valid_from has not come yet.
403consent_requiredThe 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.
403agent_cannot_approveAn agent cannot decide an approval or make approval links.
403approval_evidence_requiredThe approval needs passkey evidence from the approver, and none was sent or it could not be verified.
409approver_not_distinctThis key, member or approver has already decided the approval. A second approval must come from someone else.
409approval_decidedThe approval has already been decided.
409approval_expiredThe approval was not decided in time. The operation did not run.
409approval_not_usableSkerry-Approval did not name an approval that was approved for exactly this request, asked by this key, and not used yet.
403payee_not_allowedThe destination is not on this key's allow-list.
403mandate_expiredThe spend policy on this key has expired.
403policy_revokedEvery spend policy on this key was revoked.
403policy_requiredThis key may only act under a spend policy and none is attached.
403self_approvalThe key that asked for an operation cannot approve it.
409onboarding_incompleteThe customer has not finished the hosted onboarding this needs. detail.next_step says which.
409mandate_requiredThe payer has not agreed to be charged later.
409refund_not_possibleThe refund is more than what remains of the payment.
409quote_expiredThe conversion or payout quote's time is up. Ask for a new one.
409quote_movedThe market moved beyond the quote's tolerance. Ask for a new one.
409quote_usedThat conversion or payout quote has already been used.
202requires_approvalNot an error: the operation is held for a person. The body names the approval.
429rate_limitedToo many requests on this key. Retry-After says when to try again.
409books_not_connectedThe customer's books are not connected, so there is nothing to hand over to. detail.next_step says how to connect them.
409capability_unavailableNot available for this project. /capabilities says what is.
409capability_coming_soonBuilt, not yet switched on for this project.
403reliance_not_enabledreliance was sent by an account that is not approved to verify its own customers.
400refund_destination_requiredWhere the money came from is not on record for this deposit. Send destination with the refund: an address and chain, or a payee.
409refund_not_supportedPayments by this method cannot be refunded through the processor. Return the money another way.
409deposit_resolvedThe unmatched deposit has already been assigned or sent back. Read it to see which.
409currency_mismatchThe amount is in another currency than the object it is applied to. detail.currency says which is expected.
400price_below_minimumThe 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.
409credit_requiredThe resource's price is too small for a single payment. Buy credit with POST /machine-payments/credits and pay each call from it.
409dispute_without_paymentThe payment behind the dispute is not on record, so no evidence can be drafted from it.
502upstream_unavailableOn 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.
409resource_disabledThe priced resource has been disabled. Register it again to charge for it.
409dispute_closedThe dispute is no longer waiting for a response; evidence and files are refused.
409meter_inactiveThe meter is no longer active.
403outside_budgetThe mandate or agent has a budget and may only spend from the wallet and currency that budget is in. detail.budgets lists them.
403budget_exceededThe amount is more than what remains of the budget. detail.remaining_minor is in minor units of detail.currency.
403funds_earmarkedPart 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.
409insufficient_fundsThe wallet does not hold that much that is not already set aside. detail.available_minor says what is free.
409budget_releasedThe budget has been released and can no longer be topped up or spent from.
403destination_not_allowedThe destination is not on the customer's withdrawal allow-list.
403payee_cooling_offThe destination was trusted recently and can only be paid small amounts until detail.until. detail.max_minor is in cents of detail.unit.
403risk_refusedA risk rule on the customer refuses the operation. detail.rules says which and why.
409sweep_rule_limitThe customer already has as many active sweep rules as it may have.
409approver_limitThe customer already has as many approvers as it may have.
403sandbox_onlyA sandbox helper was called with a live key.
409quote_mismatchThe payout does not repeat the instruction its quote priced. detail.field names what differs: amount, from, to or rail.
429too_many_waitersThis key already has as many requests waiting (?wait=) as it may. Let one finish, or read without wait.
409payout_not_amendableThe 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.
409bank_link_finishedThat bank login has already been finished. Start another with POST /customers/{id}/bank-link.
409bank_link_expiredThe link token expired before the bank login was finished. Start another with POST /customers/{id}/bank-link.
409schedule_not_cancelableThe scheduled payout has already run, failed or been cancelled, or is running now.
409schedule_not_waitingOnly a conditional payout that is still waiting can be released.
409schedule_not_dueOnly a payout that is scheduled can be advanced in the sandbox; a waiting one is released.
409schedule_expiredThe conditional payout lapsed before it was released, and was cancelled.
403agent_payment_over_maxThe merchant asks for more than max_amount. Nothing was signed. detail.asked_minor is the lowest price asked, in US cents.
409no_payable_offerThe 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.
409agent_payment_already_attemptedA 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.
409offer_changedAn approved agent payment found the merchant asking for more, another currency or another address than was approved. Nothing was signed. Ask again.
409agent_customer_requiredThe agent acts for no customer, so it can hold no wallet and pay nothing. Register it with a customer_id.
409wallet_retiredThe wallet has been retired. Create another, or leave wallet_id out.
409wallet_policy_refusedThe policy on the agent's wallet refused the signature at the wallet provider. Nothing was signed.
409token_not_issuedA 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.
502merchant_unreachableThe merchant's address could not be reached, answered with a redirect, or answered too much, before anything was paid.
502platform_errorA provider failed. Safe to retry with the same Idempotency-Key.
500internal_errorOurs. Quote the request_id and we can find the call.

Capabilities

As this deployment is built. What is switched on for an account is read from GET /capabilities.

  • kyb (available): Business verification
  • identity_verification (available): Identity verification
  • custody (available): Custodied wallets
  • virtual_accounts (available): Virtual accounts
  • payouts_fiat (available): Fiat payouts
  • payouts_crypto (available): Stablecoin transfers
  • standing_orders (available): Standing conversion
  • treasury (available): Treasury
  • books (available): Books
  • fx (available): Exchange rates
  • bank_linking (available): Bank linking
  • static_memos (available): Static memos
  • issuance (request_only): Stablecoin as a service
  • cards (coming_soon): Cards
  • compliance_requests (coming_soon): Compliance requests
  • kyc (coming_soon): Individual verification
  • card_acceptance (available): Card and bank-debit acceptance
  • usage_meters (coming_soon): Usage meters
  • checkout_instructions (available): Stablecoin and bank transfer at checkout
  • agentic_checkout (preview): Agentic checkout
  • ucp_checkout (preview): Universal Commerce Protocol checkout
  • agent_tokens (preview): Delegated payment tokens
  • pay_by_call (preview): Paying by the call
  • agent_credentials (preview): Agents paying other merchants

Payment methods that are not plainly available

The whole matrix is in GET /capabilities under payment_methods. These rows are per customer, in preview, coming, or not offered:

  • card (per_customer, enabled_at_onboarding): Cards
  • wallet:link (per_customer, enabled_at_onboarding): Wallets (saved cards and device wallets)
  • buy_now_pay_later:klarna (per_customer, enabled_at_onboarding): Klarna
  • buy_now_pay_later:affirm (per_customer, enabled_at_onboarding): Affirm
  • buy_now_pay_later:afterpay_clearpay (per_customer, enabled_at_onboarding): Afterpay and Clearpay
  • local_method:ideal (per_customer, enabled_at_onboarding): iDEAL
  • local_method:bancontact (per_customer, enabled_at_onboarding): Bancontact
  • local_method:eps (per_customer, enabled_at_onboarding): EPS
  • local_method:p24 (per_customer, enabled_at_onboarding): Przelewy24
  • local_method:blik (per_customer, enabled_at_onboarding): BLIK
  • local_method:twint (per_customer, enabled_at_onboarding): TWINT
  • local_method:swish (per_customer, enabled_at_onboarding): Swish
  • local_method:mobilepay (per_customer, enabled_at_onboarding): MobilePay
  • local_method:pix (per_customer, enabled_at_onboarding): Pix
  • wallet:cashapp (per_customer, enabled_at_onboarding): Cash App Pay
  • wallet:amazon_pay (per_customer, enabled_at_onboarding): Amazon Pay
  • wallet:revolut_pay (per_customer, enabled_at_onboarding): Revolut Pay
  • local_method:multibanco (per_customer, enabled_at_onboarding): Multibanco
  • local_method:konbini (per_customer, enabled_at_onboarding): Konbini
  • local_method:boleto (per_customer, enabled_at_onboarding): Boleto
  • local_method:oxxo (per_customer, enabled_at_onboarding): OXXO
  • bank_debit:us_bank_account (per_customer, enabled_at_onboarding): ACH debit
  • bank_debit:sepa_debit (per_customer, enabled_at_onboarding): SEPA debit
  • bank_debit:bacs_debit (per_customer, enabled_at_onboarding): Bacs debit
  • bank_transfer_in:faster_payments (coming_soon, account_currency_not_switched_on): Faster Payments into a virtual account
  • bank_transfer_out:fednow (per_customer, payee_bank_dependent): FedNow payout
  • bank_transfer_out:rtp (not_offered, inbound_only): RTP payout
  • bank_transfer_out:faster_payments (per_customer, enabled_at_verification): Faster Payments payout
  • bank_transfer_out:spei (per_customer, enabled_at_verification): SPEI payout
  • bank_transfer_out:pix (per_customer, enabled_at_verification): Pix payout
  • bank_transfer_out:bre_b (per_customer, enabled_at_verification): Bre-B payout
  • bank_transfer_out:co_bank_transfer (per_customer, enabled_at_verification): Colombian bank transfer payout
  • bank_debit_on_demand (not_offered, provider_has_no_pull_api): Pulling money from a bank account into a wallet on demand
  • issued_card (coming_soon, card_issuing_not_switched_on): Cards issued against a balance
  • pay_by_call:stablecoin (preview, preview_off_by_default): Paying by the call in stablecoin (x402 and the Machine Payments Protocol)
  • pay_by_call:agent_token (preview, preview_off_by_default): Paying by the call with a delegated card token (the Machine Payments Protocol)
  • agent_token (preview, preview_off_by_default): A delegated payment token presented by an agent at checkout (seller side)
  • agent_credential (preview, agent_paying_preview): An agent paying other merchants (buyer side)
  • push_to_card (not_offered, not_served): Push to a debit card
  • cash (not_offered, not_served): Cash
  • cheque (not_offered, not_served): Cheque
  • mobile_money (not_offered, not_served): Mobile money
  • crypto_non_stablecoin (not_offered, not_served_non_stablecoin): Crypto assets other than stablecoins
  • stablecoin:{coin}:{chain} (per_customer, chain_coin_pair_varies): Each stablecoin on each chain

Operations

Getting started

The two calls worth making first. / needs no key and lists what exists; /whoami proves the key you were given works and says which environment it is in.

  • GET / (no key): The index. Public, so a new developer can look before they have a key.
  • GET /openapi.json (no key): This same reference as an OpenAPI 3.1 document, for Postman, a generator or an agent. Public.
  • GET /whoami (scope *): The partner, project, mode and scopes behind the key you sent.
  • GET /llms.txt (no key): The API described for a model, as plain Markdown: authentication, the order of calls for the common jobs, idempotency, approvals, every error with what to do next, and every operation. Generated from this reference. Public.
  • GET /agents.md (no key): The same description addressed to an agent that is about to act. Public.
  • GET /capabilities (scope *): What this project may do right now, and what is merely coming: capabilities, bank rails (and which are enabled customer by customer), the chains and coins the API accepts, custody (who holds the money, by name), and payment_methods: every way money moves, method by method and direction by direction, each with one status and a reason code. A capability with test_mode_only: true works in the sandbox only for now.
  • POST /terms (scope customers:write): Accept the terms a customer must agree to before they can be verified. Body: { customer_id }

Customers

A customer is a business you bring to the platform. It is verified in its own right, and everything else — wallets, accounts, payees — hangs off one.

  • POST /customers (scope customers:write): Onboard a business, or a person with type: "individual" (hosted verification only; identity details and documents are refused for a person). reliance carries your own verification results and is accepted only from an account approved for it. Body: { legal_name, email, country, external_id?, type?, ownership_threshold?, reliance?: { kyc_screen, ofac_screen, verified_database_at?, verified_govid_at?, verified_proof_of_address_at? } }
  • GET /customers (scope customers:read): The businesses this project has onboarded.
  • GET /customers/{id} (scope customers:read): One customer, with its verification state, the bank rails it is enabled for (rails), what it can be paid with (card_acceptance.methods) and everything still owed to either side as one list: requirements[] of { capability, provider, status, items[], due_by }.
  • PATCH /customers/{id} (scope customers:write): Correct details on a business. Body: { legal_name?, address?, external_id? }
  • POST /customers/{id}/verification (scope customers:write): Start or re-run verification. Body: { }
  • GET /customers/{id}/verification (scope customers:read): Where verification has got to.
  • POST /customers/{id}/onboarding-link (scope customers:write): A link the business owner opens to finish verification (and card-acceptance setup) on hosted pages. You send no people, birth dates or documents. Body: { return_url, refresh_url?, steps? }
  • GET /onboard/{token} (no key): The page behind an onboarding link. Public: the token is the credential. Redirects to the next hosted step, then back to your return_url.
  • GET /customers/{id}/payment-methods (scope customers:read): The payment-method matrix for one customer: every method and direction, each cell marked enabled from what the card processor reports active on its merchant account and what the banking partner enabled at verification.
  • GET /customers/{id}/people (scope customers:read): The owners, controllers and signers of a business customer, as the banking partner holds them: { id, first_name, last_name, email, title, owns, controls, signs, is_director, ownership_percent }. Names and roles only; nothing from a person's documents is returned.
  • POST /customers/{id}/people (scope customers:write): Name another owner, controller or signer of a business customer, in the form POST /customers takes in people[]. At least one of owns, controls and signs is true; address.country is ISO 3166-1 alpha-3. Identity documents and files are passed on to the banking partner and never stored by Skerry. reliance is accepted only from an account approved for it. For example {"first_name": "Ada", "last_name": "Lovelace", "email": "ada@example.com", "date_of_birth": "1985-12-10", "address": {"line1": "1 Main Street", "city": "Berlin", "postal_code": "10115", "country": "DEU"}, "owns": true, "ownership_percent": 40}. Body: { first_name, last_name, email, date_of_birth, address: { line1, line2?, city, region?, postal_code?, country }, owns?, controls?, signs?, is_director?, ownership_percent?, title?, identity_documents?: [{ type, issuing_country, number?, expires?, front?, back? }], documents?: [{ purpose, file }], reliance? }
  • DELETE /customers/{id}/people/{person_id} (scope customers:write): Remove a person from a business customer at the banking partner.
  • GET /eligibility (scope eligibility:read): Whether a business like this can be served, from where, for what, with machine-readable reasons. ?country=&business_type=&currency=&industry=

Custody

Where a customer's money sits. A wallet holds stablecoin; an account is fiat at a regulated institution; a card spends from one.

  • POST /customers/{id}/wallets (scope wallets:write): Create a stablecoin wallet, optionally with a label, a purpose and the agent it is for. Covered by mandates as wallet.create. Body: { chain, label?, purpose?, agent_id? }
  • GET /customers/{id}/wallets (scope wallets:read): The customer's wallets and balances. Each balance carries the decimal amount and amount_minor, the same amount in integer minor units of currency; each wallet its label, purpose, agent_id and floor.
  • GET /customers/{id}/wallets/{wallet_id}/history (scope wallets:read): What has moved in and out of one wallet, newest first. ?limit=
  • POST /customers/{id}/accounts (scope accounts:write): Open an account payers pay into. currency is what the payer sends (usd or eur); wallet_id is the customer's wallet the money lands in, converted to the stablecoin named in delivers (usdc for usd and eurc for eur unless named). For example {"currency": "usd", "wallet_id": "<wallet id>", "delivers": "usdc"}. The answer carries the account's deposit_instructions. Body: { currency, wallet_id, delivers? }
  • GET /customers/{id}/accounts (scope accounts:read): The customer's fiat accounts.
  • POST /customers/{id}/accounts/{account_id}/deactivate (scope accounts:write): Stop an account accepting deposits. It keeps its details, money already received is not affected, and it can be reopened. Body: { }
  • POST /customers/{id}/accounts/{account_id}/reactivate (scope accounts:write): Let a deactivated account accept deposits again, with the same details. Body: { }
  • POST /customers/{id}/bank-link (scope payees:write): Start connecting a US bank by logging into it, instead of typing its numbers. Answers { link_token, expires_at } for the bank login page; credentials never reach Skerry. Finish with POST /customers/{id}/bank-link/exchange. Body: { }
  • POST /customers/{id}/bank-link/exchange (scope payees:write): Finish a bank login: send the public_token the bank login page returned, with the link_token it was started with. The link token must be one started for this customer, and is finished once (409 bank_link_finished, or bank_link_expired after its expiry). The answer is { data: [payee] }, the payees the connection added, each announced as payee.created. Covered by mandates as payee.create; if it is held, send the same request again with Skerry-Approval once a person has approved it. Neither token is stored. Body: { link_token, public_token }
  • POST /customers/{id}/cards (scope cards:write): Issue a card against an account. Body: { account_id }
  • GET /customers/{id}/cards (scope cards:read): The customer's cards.
  • POST /customers/{id}/memos (scope accounts:write): A deposit memo, for chains where one shared address serves everybody. wallet_id is the customer's wallet the deposit is delivered to, and fixes the chain; delivers is the coin that arrives (usdc unless named) and currency what the payer sends (usd unless named). Body: { wallet_id, delivers?, currency? }
  • GET /customers/{id}/memos (scope accounts:read): The customer's deposit memos. A payer who omits the memo cannot be attributed.

Paying out

A payee is somewhere money can go; a payout sends it there. Quote first when the amount matters — a quote fixes the rate for a short window.

  • POST /customers/{id}/payees (scope payees:write): Add a bank account as a destination for money. The body is the banking partner's external-account fields, passed on as sent: currency, account_type (us, iban or gb), account_owner_name as the bank holds it, account_owner_type (business or individual) with business_name for a business, bank_name, address (street_line_1, street_line_2, city, state, postal_code, and country as ISO 3166-1 alpha-3), and the account itself: account: { routing_number, account_number, checking_or_savings } for us, iban: { account_number, bic, country } for iban, account: { sort_code, account_number } for gb. For example {"currency": "eur", "account_type": "iban", "account_owner_name": "Example GmbH", "account_owner_type": "business", "business_name": "Example GmbH", "address": {"street_line_1": "1 Hauptstrasse", "city": "Berlin", "postal_code": "10115", "country": "DEU"}, "iban": {"account_number": "DE89370400440532013000", "country": "DEU"}}. Under a mandate that covers payee.create the operation allow-list and approval rules apply; if it is held, send the same request again with Skerry-Approval once a person has approved it. Body: { currency, account_type, account_owner_name, account_owner_type, business_name?, bank_name?, address: { street_line_1, street_line_2?, city, state?, postal_code?, country }, account?: { routing_number, account_number, checking_or_savings } | { sort_code, account_number }, iban?: { account_number, bic?, country } }
  • GET /customers/{id}/payees (scope payees:read): The customer's payees, each with the receiving bank's name check (name_check) where the rail has one.
  • GET /customers/{id}/payees/{payee_id} (scope payees:read): One payee, with the name check as it stands now.
  • DELETE /customers/{id}/payees/{payee_id} (scope payees:write): Remove a payee.
  • POST /customers/{id}/payees/{payee_id}/pause (scope payees:write): Stop paying a payee for now without removing it. It reads active: false until resumed. Announced as payee.updated. Body: { }
  • POST /customers/{id}/payees/{payee_id}/resume (scope payees:write): Pay a paused payee again. Refused with customer_frozen while the customer is frozen. Announced as payee.updated. Body: { }
  • POST /customers/{id}/payees/{payee_id}/check (scope payees:write): Ask the receiving bank again whether the name on the account matches, and return the payee with the answer as name_check. Often pending at first; a later read or the payee.updated event carries the bank's answer. Body: { }
  • POST /payouts/quote (scope payouts:write): What a payout would cost, as a quote that binds the instruction. The answer carries quote_id and valid_until (120 seconds by default); POST /payouts with that quote_id executes exactly the quoted customer, amount, source, destination and rail, once. For a bank payout it says which rail it would leave on and why (rail, rail_reason) and carries the name check on the payee (payee_check). fee, rate and the receive amount are what the provider's dry run reported when the quote was made; the provider is not asked to hold them, so this is not a guaranteed rate. amount is a decimal string and amount_minor the same in integer minor units of the source currency; send either. fee_minor is in minor units of fee_currency, receive_amount_minor of receive_currency. Body: { customer_id, amount | amount_minor, from, to, rail?, speed? }
  • POST /payouts (scope payouts:write): Send money to a payee, a wallet or an address. Needs an Idempotency-Key. amount is a decimal string in from.currency; amount_minor is the same amount as integer minor units, and either may be sent. A bank payout may name a rail or a speed (fastest, cheapest, standard); otherwise the rail is chosen and returned with the reason. quote_id executes exactly what was quoted. execute_at, schedule or release_on create a scheduled payout instead of sending now. An outside address is checked for its chain's format and for the coin being on that chain (Base when no chain is named). travel_rule carries originator and beneficiary details for an outside address; it is passed on and not kept. The answer carries amount_minor, fee_minor and authorisation. Body: { customer_id, amount | amount_minor, from: { currency, wallet_id }, to: { currency, payee_id | wallet_id | address, chain? }, rail?, speed?, quote_id?, execute_at? | schedule?: { every, unit: day|week|month, end_at? | count? } | release_on?: { event, object_id } | "approval", expires_at?, travel_rule?: { originator, beneficiary }, reference?, external_ref?, external_kind?, remittance?: { invoice_number?, amount?, date? }, dry_run? }
  • POST /payouts/batches (scope payouts:write): Up to 50 payouts in one call. All are checked first, and the batch's total per customer and currency is checked against the caller's daily and monthly limits before anything is sent; each then runs as its own payout and reports its own outcome. Needs an Idempotency-Key. A dry run checks every item and the totals and sends nothing. Body: { payouts: [ ...as POST /payouts ], reference?, dry_run? }
  • GET /payouts/batches (scope payouts:read): Batches, newest first, each with the outcome of each payout in it. ?customer_id= keeps the batches that hold a payout for that customer.
  • GET /payouts/batches/{id} (scope payouts:read): One batch, with the outcome of each payout in it.
  • GET /customers/{id}/payout-fees (scope payouts:read): Who bears the cost of a bank payout, per currency and rail: fees.usd.wire and the like, as the banking partner states them. It decides whether a payee receives the amount sent or that amount less a fee.
  • PATCH /customers/{id}/payout-fees (scope customers:write): Change who bears the cost of a bank payout. Only the entries sent change. An agent's own credential cannot change it. Body: { fees: { usd: { wire } } }
  • POST /customers/{id}/batch-settlement-schedules (scope payouts:write): Collapse the customer's bank payouts on one currency and rail into a single settlement instead of one bank movement per payout. Kept by the banking partner; answers with the schedule's id. An agent's own credential cannot create one. Body: { currency, rail }
  • GET /payouts (scope payouts:read): Payouts this project has made, with the reference each was given. Each carries amount (decimal string) and amount_minor (integer minor units of currency). ?customer_id=&external_ref=
  • GET /payouts/{id} (scope payouts:read): One payout and where it has got to, with amount_minor, fee_minor and authorisation. While it is paused status is on_hold and hold gives reason_code, the reason in plain words and the deadline when there is one. ?wait=<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} (scope payouts:write): Change the amount of a payout that is still awaiting_funds; once its funds have arrived it is refused with 409 payout_not_amendable. Needs an Idempotency-Key. A higher amount is checked as the payout was, for the difference: the freeze, risk rules, mandate limits, budget and approval; if it is held, send the same request again with Skerry-Approval once a person has approved it. A lower amount is not checked, and what was already counted against a limit stays counted. The stored record takes the new amount and the difference is booked in the payout's movement. amount is a decimal string in the payout's currency; amount_minor may be sent instead. Body: { amount | amount_minor }
  • DELETE /payouts/{id} (scope payouts:write): Cancel a payout that has not left yet. Its entries in the books are reversed and what it counted against a spend limit or budget is given back.
  • GET /rates (scope *): The rate between two currencies. ?from=&to=

Standing conversion

A standing order converts on a schedule rather than on a call, so a business can hold one currency and settle in another without writing a cron job.

  • POST /customers/{id}/standing-orders (scope standing_orders:write): An address that converts and pays out whatever arrives. return_instructions says where a deposit that cannot be converted goes back to. Under a mandate that covers standing_order.create the operation allow-list and approval rules apply. Body: { chain, currency, delivers_to: { payee_id | wallet_id, currency?, rail? }, reference?, return_instructions?: { address, memo? } }
  • GET /customers/{id}/standing-orders (scope standing_orders:read): The customer's standing orders.
  • GET /customers/{id}/standing-orders/{standing_order_id}/conversions (scope standing_orders:read): What one standing order has converted, newest first, as the banking partner reports it: each { id, amount, currency, status, standing_order_id, occurred_at }.

Events and webhooks

Most state changes arrive here rather than in a response, because verification and settlement finish long after the call that started them. Replay is deliberate: a missed delivery is recoverable without asking us.

  • GET /events (scope events:read): What has happened, newest first. ?limit=&type=&types=a,b. With ?after=<event id> the order changes: the events recorded after that one, oldest first, with next_after to pass as the next after; &wait=<seconds up to 30> then holds the request until there is a new event or the wait ends.
  • POST /events/{id}/replay (scope webhooks:write): Deliver an event again, to every endpoint that wanted it.
  • GET /events/types (scope events:read): The catalogue of event types: every type this API sends, the object it is about and when it is sent. event_types on an endpoint are checked against it.
  • POST /webhooks (scope webhooks:write): Register an endpoint. url must be https. event_types left out, empty or * means every event; a type the catalogue does not have is refused. The endpoint belongs to the project of the credential that registers it and counts against that project's plan. Body: { url, event_types? }
  • GET /webhooks (scope webhooks:read): The endpoints registered in this environment, each with the project_id it belongs to.
  • PATCH /webhooks/{id} (scope webhooks:write): Change an endpoint's URL, events or status, held to the same checks as a new endpoint. Body: { url?, event_types?, status? }
  • DELETE /webhooks/{id} (scope webhooks:write): Remove an endpoint.
  • POST /webhooks/{id}/roll-secret (scope webhooks:write): Replace an endpoint's signing secret. Deliveries carry a signature for both secrets (v1=…,v1=…) during the overlap. Body: { overlap_hours? }

Accepting payments

Take card and wallet payments for a customer. The payer pays on a hosted page, so card details never reach Skerry or you. Amounts are integer minor units next to a currency code. Build orders from payment.succeeded, never from the payer's browser returning to your site.

  • POST /checkout-sessions (scope payments:write): Start a hosted checkout for a payer. payment_instructions adds stablecoin and bank transfer: the session then carries a deposit address and bank details with a reference to quote, and completes when the money arrives. A customer without card acceptance can open a session that has instructions only. Body: { customer_id, amount | line_items, currency, success_url, cancel_url?, methods?, payment_instructions?: { methods: [stablecoin | bank_transfer], wallet_id, chain?, coin? }, authentication?, capture?, save_method?, payer_id? | payer?, expires_at?, reference?, external_ref?, external_kind?, metadata?, ui?, dry_run? }
  • GET /checkout-sessions (scope payments:read): Checkout sessions, newest first. ?customer_id=&status=&external_ref=
  • GET /checkout-sessions/{id} (scope payments:read): One session. While open, this also asks the processor so a missed webhook cannot mislead. A session with payment instructions carries them, with amount_received, amount_remaining and amount_overpaid.
  • POST /checkout-sessions/{id}/expire (scope payments:write): Close an open session so it can no longer be paid. Body: { }
  • POST /payment-links (scope payments:write): A reusable or single-use link to a hosted checkout for a fixed amount. Body: { customer_id, amount, currency, description?, reusable?, max_uses?, expires_at?, reference?, external_ref?, external_kind?, success_url?, dry_run? }
  • GET /payment-links (scope payments:read): Payment links, newest first. ?customer_id=&external_ref=
  • GET /payment-links/{id} (scope payments:read): One payment link, with how many times it has been used.
  • PATCH /payment-links/{id} (scope payments:write): Pause, resume or disable a link. Body: { status }
  • GET /payments (scope payments:read): Payments, newest first. ?customer_id=&status=&reference=&external_ref=&payer_id=
  • GET /payments/{id} (scope payments:read): One payment: status, fee, amounts received and refunded, and risk (level, score and how the payer's authentication ended) where the processor reports them. A payment that did not come by card also carries method (stablecoin, bank_transfer or machine), payer_ref and amount_overpaid; one an agent made carries agent: { initiated, token_id, platform, token_limits, indicators } and agent_identity. ?wait=<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 (scope payments:write): Charge a saved method with the payer absent, under the mandate they gave; or, with payment_token, charge a delegated payment token an agent holds for its buyer (a preview: 409 capability_coming_soon until it is switched on for the customer). Needs an Idempotency-Key. Body: { customer_id, payer_id, payment_method_id, amount, currency, off_session: true, authentication?, capture?, reference?, external_ref?, external_kind?, description?, metadata? } | { customer_id, amount, currency, payment_token: { type: "shared_payment_token", id }, reference?, external_ref?, external_kind?, description?, metadata? }
  • POST /payments/{id}/capture (scope payments:write): Collect funds that were held. May capture less than the hold. Body: { amount? }
  • POST /payments/{id}/cancel (scope payments:write): Release a hold, or abandon a payment that has not succeeded. Body: { }
  • POST /payments/{id}/refunds (scope refunds:write): Refund all or part of a payment. A payment made by stablecoin or bank transfer goes back to where it came from when that is on record, otherwise to destination. Needs an Idempotency-Key. Body: { amount?, reason?, destination?: { address, chain? } | { payee_id }, external_ref?, external_kind? }
  • GET /payments/{id}/refunds (scope payments:read): Refunds made against a payment.
  • GET /disputes (scope payments:read): Disputes against payments, newest first. ?customer_id=&status=
  • GET /disputes/{id} (scope payments:read): One dispute, with the date evidence is due.
  • POST /disputes/{id}/evidence (scope payments:write): Send evidence, and submit it when complete: text fields, file fields (the id of an uploaded file) and enhanced_evidence. Body: { evidence: { ... }, submit? }
  • POST /disputes/{id}/files (scope payments:write): Upload a file to use as evidence: PDF, JPEG or PNG, up to 5 MB, base64-encoded. Body: { filename, content_type, data }
  • GET /disputes/{id}/files (scope payments:read): Files uploaded for a dispute, newest first.
  • GET /disputes/{id}/suggested-evidence (scope payments:read): A draft of the text evidence from what is on record: the session, what was bought, the delegated token and its limits, how the calling agent identified itself, and the times. For a person to review and send; nothing is submitted.
  • GET /customers/{id}/unmatched-deposits (scope payments:read): Deposits that could not be credited to a checkout session (wrong coin or chain, no reference, an unknown reference, or late), waiting for a person. ?status=
  • POST /customers/{id}/unmatched-deposits/{deposit_id}/assign (scope payments:write): Credit a deposit that was set aside to the checkout session it was meant for. Body: { checkout_session_id }
  • POST /customers/{id}/unmatched-deposits/{deposit_id}/refund (scope refunds:write): Send a deposit that was set aside back, whole: to where it came from when that is on record, otherwise to destination. Needs an Idempotency-Key. Body: { destination?: { address, chain? } | { payee_id } }
  • GET /settlements (scope settlements:read): Card proceeds on their way to the business's bank, newest first. ?customer_id=
  • GET /settlements/{id} (scope settlements:read): One settlement.
  • POST /settlements (scope settlements:write): Send proceeds to the business's bank now. Needs an Idempotency-Key. Body: { customer_id, amount, currency }
  • GET /customers/{id}/balances (scope customers:read): What the customer holds per currency: card proceeds (available, pending) and stablecoin.

Payers, saved methods and subscriptions

A payer is someone who pays your customer. Save a method once, with their agreement, and charge it later or on a schedule. The agreement is the mandate. Card numbers are never returned.

  • POST /payers (scope payments:write): Record someone who pays a customer. Body: { customer_id, email?, name?, external_id? }
  • GET /payers (scope payments:read): Payers, newest first. ?customer_id=
  • GET /payers/{id} (scope payments:read): One payer.
  • GET /payers/{id}/payment-methods (scope payments:read): Methods the payer saved: brand, last four, expiry, and the mandate.
  • DELETE /payers/{id}/payment-methods/{method_id} (scope payments:write): Forget a saved method and revoke its mandate.
  • POST /customers/{id}/payment-method-setups (scope payments:write): A hosted page where a payer saves a method for later, without paying now. Body: { payer_id | payer, success_url, cancel_url }
  • POST /subscriptions (scope payments:write): Charge a payer on a schedule from a saved method. Needs an Idempotency-Key. Under a mandate that covers subscription.create, the amount of one period (integer minor units) is checked against the mandate and counted once. Body: { customer_id, payer_id, payment_method_id, currency, interval, interval_count?, items: [{ name, amount, quantity? }], trial_days?, reference?, external_ref?, external_kind?, dry_run? }
  • GET /subscriptions (scope payments:read): Subscriptions, newest first. ?customer_id=&status=&payer_id=
  • GET /subscriptions/{id} (scope payments:read): One subscription.
  • POST /subscriptions/{id}/cancel (scope payments:write): End a subscription now or at the end of the paid period. Body: { at_period_end? }
  • POST /meters (scope payments:write): A usage meter on a customer's merchant account, for billing by use. Switched on account by account. Body: { customer_id, name, event_name, aggregation? }
  • GET /meters (scope payments:read): Meters, newest first. ?customer_id=
  • POST /meters/{id}/events (scope payments:write): Record usage by a payer against a meter. identifier makes a retry count once. Body: { payer_id, value, identifier?, timestamp? }
  • GET /meters/{id}/summary (scope payments:read): What a payer has used over a period. ?payer_id=&from=&to=&group_by=hour|day

Limits, agents and approvals

Constrain what a key, or an agent acting through it, can move. A policy names the business accountable and the person who approved it, caps amounts and counts, restricts destinations, wallets and currencies, and can require one or two people to approve. With its purpose and the evidence that a person agreed to it, a policy is a mandate: mandate_id is the policy id. A key that has ever had a policy stays restricted. Amount limits are integer minor units: of the family's fiat currency (cents) under scope: "family", where USD is limited together with USDC, USDT, USDB, OUSD and PYUSD, and EUR with EURC; of each currency's own smallest unit under scope: "currency".

  • POST /spend-policies (scope policies:write): Limit a key or an agent: a mandate. scope is required, family or currency, and decides the unit of every amount limit (see the group note); a request without it is refused with invalid_request naming scope. operations may name payment.charge, refund.create, payout.create, conversion.create, settlement.create, checkout.create, payment_link.create, subscription.create, standing_order.create, payee.create and wallet.create. max_count caps operations per count_window (day or month, rolling). approve_operations always wait for a person. checkout_above is the amount above which checkout sessions and payment links are checked. merchant_allow_list applies to operations that name a merchant; wallet_allow_list to the wallet money leaves. evidence records consent; with consent_required the mandate is inactive until verified evidence exists. The answer carries mandate_id, summary (the rule in sentences, generated from it) and active. Body: { customer_id, approved_by, scope: family | currency, key_id? | agent_id?, purpose?, max_amount?, daily_max?, monthly_max?, max_count?, count_window?, approval_above?, second_approval_above?, approvers_required?: 1 | 2, approval_evidence?: none | passkey, approval_expires_minutes?, approve_operations?, checkout_above?, currencies?, operations?, payee_allow_list?, merchant_allow_list?, wallet_allow_list?, valid_from?, expires_at?, consent_required?, evidence?: { method, subject, at, assertion?, attestation? } }
  • GET /spend-policies (scope policies:read): Policies, newest first, each with usage: what it has let through in the last 24 hours and 30 days and what remains, per currency family or currency, and how many operations. ?customer_id=&key_id=&status=
  • GET /spend-policies/{id} (scope policies:read): One policy, with summary, consent (the evidence attached to the rule as it stands) and active.
  • PATCH /spend-policies/{id} (scope policies:write): Change a policy's amounts, counts, approval rules, purpose or dates. Who it binds and its allow-lists cannot change. Changing the rule detaches the consent evidence recorded for it. Changing scope changes the unit of every amount, so each amount limit the policy has must be sent again in the same request. Body: { max_amount?, daily_max?, monthly_max?, max_count?, count_window?, approval_above?, second_approval_above?, approvers_required?, approval_evidence?, approval_expires_minutes?, checkout_above?, purpose?, scope?, consent_required?, valid_from?, expires_at?, name? }
  • POST /spend-policies/{id}/revoke (scope policies:write): Revoke a policy, immediately. Body: { reason? }
  • POST /spend-policies/{id}/consent (scope policies:write): Record that a person agreed to the mandate as it stands. The evidence is stored with the mandate's canonical text and its SHA-256. partner_attested is taken on your word; passkey and oauth are verified only with an attestation signed by the configured identity provider naming the subject and the mandate hash, and are unverified when no identity provider is configured; console_session from a partner key is unverified. Refuses an agent's own credential. Body: { method: console_session | passkey | oauth | partner_attested, subject, at, assertion?, attestation? }
  • GET /spend-policies/{id}/consent (scope policies:read): The evidence recorded for a mandate, newest first, each with the text it was about and whether it is the one attached now.
  • GET /spend-policies/{id}/usage (scope policies:read): The operations counted under a mandate, newest first: op, amount (integer minor units of currency; 0 for an operation that is checked and never counted, such as a checkout session), payee_display (the destination as the operation named it: a payee, payer or wallet id, an address, or own_bank), produced ({ type, id } of the object it created, where that is on record), counted (false once the operation failed, was cancelled or was returned, so it no longer counts against the limits) and created_at.
  • GET /mandates (scope policies:read): A customer's spend policies as mandates (object: "mandate", same ids), each with usage: per currency family or currency, what was spent in the last 24 hours and 30 days and what remains, in integer minor units of the unit named, and how many operations were let through. ?customer_id= (required) &status=
  • GET /approvals (scope approvals:read): Operations held for a person to approve, newest first, each with payee_display: the destination as a person would recognise it, where there is one, and decisions: each decision recorded so far, oldest first, as { decision, actor_type, actor_ref, note, created_at }. actor_ref is a key's id, a member's id or the reference an approver's links were made for; never a secret or a token. ?customer_id=&status=pending
  • GET /approvals/{id} (scope approvals:read): One held operation, as it was asked, with its decisions. ?wait=<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 (scope approvals:write): Approve a held operation and run it once. The approving key must not be the one that asked and must not be acting as an agent. Where two approvers are needed the first approval leaves it held (status: pending). Where the mandate asks for passkey evidence, send it as evidence. For an operation the route itself performs (checkout sessions, payment links, subscriptions, standing orders, payees, wallets) approval does not run it: the answer says to send the request again with Skerry-Approval. Body: { note?, evidence? }
  • POST /approvals/{id}/deny (scope approvals:write): Refuse a held operation. Body: { note? }
  • POST /approvals/{id}/links (scope approvals:write): Make an approve link and a deny link for one person. Each opens a Skerry page stating what will run, works once and expires (expires_minutes, 60 by default, at most 1440). The decision is recorded against approver, your reference for that person. Refuses an agent. Body: { approver, email?, expires_minutes? }
  • GET /approve/{token} (no key): The page behind an approval link. Public: the token is the credential. States the operation, amount, currency, destination, agent, mandate and why it was held. Opening it decides nothing.
  • POST /approve/{token} (no key): The page's confirm button: uses the link, records the decision against its approver and, when the operation is now approved, runs it. Public: the token is the credential.
  • GET /customers/{id}/limits (scope customers:read): What a key or an agent may still move, per window. Without a name, the calling credential's own. With ?key_id= or ?agent_id=, another key's or agent's of the same account: that needs policies:read as well and is refused to an agent's own credential.

Agents and credentials

Register the agents that act through the API, give each its own key, and stop one without touching the others. A key bound to an agent is that agent on every call and can only move money under a spend policy. An agent can also sign its requests with a registered Ed25519 key (HTTP Message Signatures, RFC 9421), and can delegate a narrower part of its authority to a child agent. Where access tokens are accepted, an OAuth client approved for an agent can obtain tokens that act as it.

  • POST /agents (scope agents:write): Register an agent: who it is, who operates it, the customer it acts for and who authorised it. Body: { name, authorised_by, customer_id?, operator?, description?, signature_required?, public_keys? }
  • GET /agents (scope agents:read): Agents, newest first. ?customer_id=&status=&parent_agent_id=
  • GET /agents/{id} (scope agents:read): One agent, with its status and signing keys.
  • PATCH /agents/{id} (scope agents:write): Change an agent's name, operator, description or whether it must sign requests. Body: { name?, operator?, description?, signature_required? }
  • POST /agents/{id}/suspend (scope agents:write): Suspend an agent. Its keys, tokens, signatures and policies are refused from the next call, and so are the agents it delegated to. Body: { reason? }
  • POST /agents/{id}/resume (scope agents:write): Resume a suspended agent. Body: { reason? }
  • POST /agents/{id}/revoke (scope agents:write): Revoke an agent permanently, with every agent it delegated to and all their keys. Body: { reason? }
  • POST /agents/{id}/keys (scope keys:write): Mint a key bound to the agent. The secret is shown once. It may carry only scopes the calling key holds. Body: { scopes, name?, expires_at? }
  • POST /agents/{id}/public-keys (scope agents:write): Register an Ed25519 public key (JWK) the agent signs requests with. Body: { jwk, expires_at? }
  • GET /agents/{id}/public-keys (scope agents:read): The agent's signing keys in force.
  • DELETE /agents/{id}/public-keys/{kid} (scope agents:write): Remove a signing key.
  • POST /agents/{id}/oauth-clients (scope agents:write): Approve an OAuth client of the authorisation server to obtain access tokens that act as the agent with no person present. It may be given only scopes the calling key holds, and never *. Body: { client_id, scopes }
  • GET /agents/{id}/oauth-clients (scope agents:read): The OAuth clients approved to act as the agent, newest first, as oauth_clients: [{ client_id, issuer, scopes, created_at }]. A withdrawn approval is not listed.
  • DELETE /agents/{id}/oauth-clients (scope agents:write): Withdraw an OAuth client's approval. ?client_id=. The next token it asks for as this agent is refused.
  • POST /agents/{id}/delegations (scope agents:write): Called with the agent's own credential: create a child agent with its own key and a narrower policy that expires no later than the parent's. Body: { name, description?, scopes?, operations?, currencies?, payee_allow_list?, max_amount?, daily_max?, monthly_max?, approval_above?, expires_at? }
  • GET /keys (scope keys:read): Keys in this environment, newest first, never with a secret, each with the project_id it belongs to. ?agent_id=&status=
  • GET /keys/{id} (scope keys:read): One key: scopes, bound agent, last use, expiry.
  • POST /keys/{id}/roll (scope keys:write): A new secret for the same key. The old one keeps working for the overlap (24 hours by default, 0 allowed). Body: { overlap_hours? }
  • POST /keys/{id}/revoke (scope keys:write): Revoke a key, immediately. Body: { reason? }
  • GET /.well-known/http-message-signatures-directory (no key): Agents' public signing keys as a key directory (a JWK Set). Public. ?agent=<agent id> or ?partner=<partner id>&mode=live|sandbox
  • GET /.well-known/oauth-protected-resource (no key): OAuth protected-resource metadata (RFC 9728). Public. 404 when access tokens are not accepted on this deployment.

Controls and audit

Stop money leaving, for one customer or for the whole account, and read back who did what. Every audit entry is chained to the one before it by a hash, so the trail can be checked for edits.

  • POST /customers/{id}/freeze (scope controls:write): Freeze a customer: every money-out operation for it is refused with customer_frozen. Body: { reason? }
  • GET /customers/{id}/freeze (scope controls:read): Whether one customer is frozen: frozen, reason, frozen_at, frozen_by_key, frozen_by_agent, and account_frozen when the whole account is frozen in this environment as well.
  • POST /customers/{id}/unfreeze (scope controls:write): Lift a customer's freeze. Body: { reason? }
  • POST /controls/freeze (scope controls:write): Freeze the whole account in this environment: every money-out operation is refused with partner_frozen. Body: { reason? }
  • POST /controls/unfreeze (scope controls:write): Lift the account's freeze. Body: { reason? }
  • GET /controls (scope controls:read): What is frozen right now: the account and each frozen customer.
  • GET /audit (scope audit:read): The audit trail, newest first. ?object_type=&object_id=&action=&agent_id=&key_id=
  • GET /audit/verify (scope audit:read): Walk the audit hash chain and report the first entry that does not fit. ?after= continues a long walk.

Conversion and ledger

Convert a customer's own balance between currencies with a priced, expiring quote, and export the double-entry books for an accountant.

  • POST /conversions/quote (scope conversions:write): A priced offer to convert, valid for a short time and usable once. Body: { customer_id, from: { currency, wallet_id }, to: { currency, wallet_id | payee_id }, amount }
  • POST /conversions (scope conversions:write): Execute a quote. Needs an Idempotency-Key. Body: { quote_id, external_ref?, external_kind? }
  • GET /conversions (scope conversions:read): Conversions, newest first. ?customer_id=
  • GET /conversions/{id} (scope conversions:read): One conversion and whether it has settled. ?wait=<seconds up to 30>&until=<state> holds the request until it is completed or failed, or reaches the named state.
  • GET /customers/{id}/ledger (scope ledger:read): Double-entry entries for a period, as JSON or CSV, in booking order; order=desc reads newest first. Each JSON entry also carries side, amount_minor, status (pending or posted), counterparty, rail, external_ref, external_kind, movement_id (the receipt's id) and reverses. ?from=&to=&kind=&status=&external_ref=&order=desc&format=csv

Books and receipts

Skerry keeps the money and a signed record of every movement. Invoices, bills, tax, statements, reconciliation and accountant exports are kept in the customer's books: connect the customer's Rivier project once, a person approves it there, and every movement is delivered to it. What Skerry is asked about those subjects it answers with a link into the books, or with 409 books_not_connected and the next step.

  • POST /customers/{id}/books (scope books:write): Start connecting a customer's books. Returns connect_url, where a signed-in person approves the connection. Body: { provider?, project_id?, return_url? }
  • GET /customers/{id}/books (scope books:read): Whether the books are connected, to which project, and how delivery is going. ?probe=true also asks the provider.
  • DELETE /customers/{id}/books (scope books:write): End the connection. Movements stop being delivered.
  • POST /customers/{id}/books/backfill (scope books:write): Send the movements of a period to the books again. Safe to repeat. Body: { from, to }
  • GET /customers/{id}/books/deliveries (scope books:read): What was sent to the books and how it went, newest first. ?status=
  • GET /customers/{id}/books/links (scope books:read): Where the statement, invoices, bills, reports and reconciliation are in the customer's books. ?from=&to=&external_ref=&external_kind=
  • POST /books/connections (no key): Complete a connection. Called by the books provider's server after a person approved; the one-time token is the credential, and the answer carries the feed's signing secret once. Public. Body: { token, project_id, connection_id, feed_url, links: { statement, invoices, bills, reports, reconcile }, scopes? }
  • GET /receipts/{id} (scope ledger:read): The signed receipt of one movement: amounts, fee, rate, the other party, rail, references, and the key, agent and policy that caused it.
  • GET /.well-known/skerry-receipt-keys (no key): The public keys receipts are signed with. Public.

Budgets

Set aside part of a wallet's balance for one mandate or one agent. A budget is an earmark kept by Skerry: nothing moves at the provider. An operation under a budgeted mandate, or by a budgeted agent, may only spend from its budget, and every other caller may only spend what is not set aside. Amounts are integer minor units of the budget's currency.

  • POST /customers/{id}/budgets (scope budgets:write): Set aside an amount of a wallet's balance for a mandate or an agent. amount and low_below are integer minor units of currency. Refused with insufficient_funds when the wallet does not hold that much free. Not for an agent's own credential. Body: { wallet_id, currency, amount, mandate_id | agent_id, name?, low_below? }
  • GET /customers/{id}/budgets (scope budgets:read): The customer's budgets, newest first, each with amount, spent and remaining in minor units of its currency. ?status=active|released
  • GET /customers/{id}/budgets/{budget_id} (scope budgets:read): One budget, with what was spent from it and what remains.
  • POST /customers/{id}/budgets/{budget_id}/top-up (scope budgets:write): Add to a budget. amount is integer minor units of the budget's currency. Re-arms budget.low and budget.exhausted. Body: { amount }
  • POST /customers/{id}/budgets/{budget_id}/release (scope budgets:write): Release a budget: what was not spent is free for other callers again. Body: { }

Customer controls

Controls set on one customer that apply to every caller, whatever its mandate: holding the first payout to a destination the customer has never paid, a cooling-off period, velocity and anomaly rules, a withdrawal allow-list, and the people who approve held operations by email. Every control is off until it is set, and an agent's own credential cannot change one.

  • GET /customers/{id}/payee-controls (scope controls:read): How a new destination is treated: new_payee_hold, payee_cooling_off_minutes and cooling_off_max.
  • PUT /customers/{id}/payee-controls (scope controls:write): Hold the first payout to a payee, wallet or address the customer has never paid (an approval with reason new_payee), and limit what a newly trusted destination can be paid during its cooling-off period. cooling_off_max is integer minor units of the fiat currency of the payout's unit family (cents); null means nothing. Body: { new_payee_hold?, payee_cooling_off_minutes?, cooling_off_max? }
  • POST /customers/{id}/trusted-payees (scope payees:write): Trust a destination before it is first paid, so that payout is not held. Its cooling-off period starts now. A person's decision: refused for an agent's own credential. Body: { payee_id | wallet_id | address, chain? }
  • GET /customers/{id}/trusted-payees (scope payees:read): Destinations the customer has paid or a person has trusted, newest first, with how each came to be trusted (source: paid, pre_approved or approved).
  • GET /customers/{id}/risk-rules (scope controls:read): The customer's velocity and anomaly rules. A rule that is null is off.
  • PUT /customers/{id}/risk-rules (scope controls:write): Replace the customer's risk rules. Each rule has action: approve turns an operation that trips it into an approval with the reason, refuse refuses it with risk_refused. Rules are counted from recorded operations, outside any model. Body: { rules: { velocity?: { max_count, window_minutes, action }, amount_anomaly?: { factor, min_samples?, action }, first_use?: { dimensions?, action }, lookalike_address?: { max_different?, action } } }
  • GET /customers/{id}/withdrawal-allow-list (scope controls:read): The destinations this customer's payouts may go to. destinations: null means there is no list.
  • PUT /customers/{id}/withdrawal-allow-list (scope controls:write): Replace the withdrawal allow-list: payee ids, wallet ids and addresses, up to 500. While a list is in force every payout by any caller must go to a destination on it (destination_not_allowed otherwise), in addition to any mandate's own allow-list. null removes the list. Body: { destinations: [ ... ] | null }
  • POST /customers/{id}/approvers (scope approvals:write): Name a person who is emailed an approve link and a deny link when one of this customer's operations is held. reference is your identifier for the person. mail_configured in the answer says whether this deployment sends mail. Body: { email, reference, name? }
  • GET /customers/{id}/approvers (scope approvals:read): The customer's approvers, and whether this deployment sends mail.
  • DELETE /customers/{id}/approvers (scope approvals:write): Remove an approver. ?email=

Wallet controls

A wallet with a purpose: a label, a purpose, the agent it is for and a floor, kept by Skerry beside the provider's wallet. Sweep rules keep a balance in range by moving the difference on a schedule; each run is an ordinary payout made as the key that created the rule, under that key's mandates. To confine an agent to named wallets, set wallet_allow_list on its mandate.

  • GET /customers/{id}/wallets/{wallet_id} (scope wallets:read): One wallet: balances (decimal amount and amount_minor in integer minor units of currency), label, purpose, agent_id and floor.
  • PATCH /customers/{id}/wallets/{wallet_id} (scope wallets:write): Change a wallet's label, purpose, agent or floor. floor.amount is integer minor units of floor.currency; balance.low is sent when the balance falls under it. Body: { label?, purpose?, agent_id?, floor?: { amount, currency } | null }
  • POST /customers/{id}/sweep-rules (scope sweeps:write): Keep at most (keep_at_most, with to) or at least (keep_at_least, with from) an amount in a wallet, checked every every_minutes (5 or more). amount is integer minor units of currency. Both currencies must be of one unit family. Body: { wallet_id, currency, kind, amount, to?: { wallet_id | payee_id, currency }, from?: { wallet_id, currency }, every_minutes }
  • GET /customers/{id}/sweep-rules (scope sweeps:read): The customer's sweep rules, newest first, with how each last ran (last_result). ?status=active|canceled
  • GET /customers/{id}/sweep-rules/{rule_id} (scope sweeps:read): One sweep rule.
  • DELETE /customers/{id}/sweep-rules/{rule_id} (scope sweeps:write): Cancel a sweep rule. A payout it already made is not recalled.

Scheduled payouts and waiting

A payout can run later instead of now: send POST /payouts with execute_at (a future time), schedule ({ every, unit: day | week | month, end_at? | count? }) or release_on ({ event, object_id }, or "approval" for a person). It needs an Idempotency-Key and answers with a scheduled payout. Each run is an ordinary payout made as the key that scheduled it, under that key's mandate as it stands when it runs; a frozen customer, a revoked or expired key, a revoked agent or a revoked mandate cancels the schedule with the reason. A payout with release_on sets its amount aside in the source wallet until it is released, cancelled or lapses at expires_at (7 days by default, 90 at most). travel_rule cannot be scheduled. amount_minor on a scheduled payout is integer minor units of currency; amount is the same as a decimal string. To wait for an outcome without a webhook, add ?wait= to a read.

  • GET /payouts/scheduled (scope payouts:read): Scheduled payouts, newest first. ?customer_id=&status=scheduled|waiting|running|completed|canceled|failed
  • GET /payouts/scheduled/{id} (scope payouts:read): One scheduled payout: when it runs next, how many times it has run, the last payout it made and how the last run went.
  • DELETE /payouts/scheduled/{id} (scope payouts:write): Cancel a scheduled payout that has not run, or stop a recurring one. What was set aside is free again. Payouts already made are not recalled. Body: { reason? }
  • POST /payouts/{id}/release (scope payouts:write): A person releases a conditional payout ({id} is the scheduled payout's id): it runs now, spending what was set aside for it. An agent's own credential is refused. Body: { }

Authorisation evidence

What authorised a transaction. Payments, payouts, refunds, conversions, settlements, subscriptions, checkout sessions and payment links carry an authorisation block: mandate_id (the policy id), policy_id, agent_id, key_id, consent_id and approval_id. The routes here return the whole record behind those ids for a dispute.

  • GET /payments/{id}/authorisation (scope audit:read): The evidence for one payment: the mandate's text, hash and summary, the consent evidence, the approval with each decision and who made it, the agent and the agents it was delegated from, how the request was authenticated, and the audit entries about it.
  • GET /payouts/{id}/authorisation (scope audit:read): The same record for one payout.
  • GET /refunds/{id}/authorisation (scope audit:read): The same record for one refund.
  • GET /conversions/{id}/authorisation (scope audit:read): The same record for one conversion.

Sandbox

Helpers that exist only with a test key, so an integration can be built and exercised without a browser or a bank.

  • POST /sandbox/checkout-sessions/{id}/complete (scope payments:write): Act as the payer: succeed, decline, run short of funds, or open a dispute. Body: { outcome }
  • POST /sandbox/approvals/{id}/decide (scope approvals:write): Decide a held operation as a person would, without a second key or a link. Where two approvers are needed, call it twice with different approver values. Body: { decision: approve | deny, approver? }
  • POST /sandbox/payouts/scheduled/{id}/advance (scope payouts:write): Run a scheduled payout now instead of at its time, as the worker would. A recurring schedule moves on to its next run. Body: { }
  • POST /sandbox/events (scope events:write): Record a named event about an object, marked sandbox: true, so a conditional payout waiting for it is released and a webhook receiver can be exercised. Body: { type, object_id, customer_id?, payload? }
  • POST /sandbox/customers/{id}/risk-rules/trip (scope controls:write): Set one risk rule on a customer, with the history it needs, so that the next matching operation trips it. The answer says what to send. Body: { rule, action? }
  • POST /sandbox/budgets/{id}/exhaust (scope budgets:write): Mark what remains of a budget as spent in Skerry's record, so budget.exhausted is emitted and the next operation under it is refused. Nothing is sent to a provider. Body: { }
  • POST /sandbox/checkout-sessions/{id}/deposit (scope payments:write): Act as the payer on a session's stablecoin or bank-transfer instructions: pay it, underpay, overpay, pay late, or send the wrong coin, chain or reference. amount is integer minor units of the session's currency; left out, it is what is still owed. Body: { method?, amount?, coin?, chain?, reference?, state?, at?, deposit_ref? }

Agentic checkout: what a customer sets

Let agent platforms buy from a customer through the Agentic Commerce Protocol. The customer issues each platform a bearer token, registers where its own system is asked for line items, totals, tax and fulfilment, and says what agents may do. Skerry holds no catalogue and computes no tax. A preview: sessions can be opened and priced, and completing one needs delegated payment tokens to be switched on for the customer.

  • POST /customers/{id}/agentic/credentials (scope agentic:write): Issue a bearer token to an agent platform. The secret, and the signing and webhook secrets when asked for, are shown once. The token is kept as a hash and the signing and webhook secrets sealed; a deployment with no sealing key refuses a credential that needs either with 503 platform_unavailable. Body: { name, platform?, webhook_url?, require_signature? }
  • GET /customers/{id}/agentic/credentials (scope agentic:read): The customer's agent-platform credentials, never with a secret.
  • DELETE /customers/{id}/agentic/credentials/{credential_id} (scope agentic:write): Revoke a credential.
  • POST /customers/{id}/agentic/pricing-callback (scope agentic:write): Register the URL asked for line items, totals, tax and fulfilment each time a session is created or changed. Signed, with a short timeout. url: null removes it; without one only fixed-amount payment links can be sold. Body: { url }
  • GET /customers/{id}/agentic/settings (scope agentic:read): What the customer allows agents, and the address agent platforms call.
  • POST /customers/{id}/agentic/settings (scope agentic:write): Change what the customer allows agents: verified agents only, the refunds an agent may start, the links a session shows, whether payment links answer automated clients with a challenge, and how long unused credit is refundable. Body: { require_verified_agent?, refunds?: { window_days?, max_amount?, needs_approval? }, links?, links_pay_by_call?, credit_refund_days? }
  • GET /customers/{id}/agentic/refund-requests (scope agentic:read): Refunds agents have asked for, newest first. ?status=pending
  • POST /customers/{id}/agentic/refund-requests/{request_id} (scope refunds:write): Approve or deny a refund an agent asked for. Approving runs it as a refund under the deciding key's policies. Body: { decision: approve | deny, note? }
  • POST /customers/{id}/agentic/orders/{session_id} (scope agentic:write): Say where an order has got to; the agent platform is told with a signed order event. The event is recorded before it is sent; one that fails is tried again on the webhook backoff, and webhook says how the first attempt went. Body: { status, estimated_delivery? }
  • GET /customers/{id}/agentic/orders/{session_id} (scope agentic:read): Where an order stands: the order as the agent platform was last told it, session_status, and webhook: how the last order event went (status pending, delivered or exhausted, with attempts, or reason: no_webhook).

Agentic Commerce Protocol

Called by an agent platform, not by a partner. Authentication is the bearer token the customer issued (POST /customers/{id}/agentic/credentials), not an API key. Every call sends API-Version (2026-04-17, or an earlier published version) and every POST sends Idempotency-Key. Paths, fields, statuses and messages follow the protocol's specification; errors are the protocol's { type, code, message, param }, not Skerry's envelope. The refunds call is a Skerry extension and says so.

  • POST /agentic/{customer_id}/checkout_sessions (scope agent_credential): Create a checkout session. Answers 201 with the session. Body: { line_items: [{ id, quantity? }], currency, capabilities?, buyer?, fulfillment_details?, discounts? }
  • GET /agentic/{customer_id}/checkout_sessions/{checkout_session_id} (scope agent_credential): Retrieve a checkout session.
  • POST /agentic/{customer_id}/checkout_sessions/{checkout_session_id} (scope agent_credential): Update a checkout session: items, buyer, fulfilment details, the fulfilment option chosen. It comes back re-priced. Body: { line_items?, buyer?, fulfillment_details?, selected_fulfillment_options?, discounts? }
  • POST /agentic/{customer_id}/checkout_sessions/{checkout_session_id}/complete (scope agent_credential): Complete with a delegated payment token. A decline leaves the session payable with a message; a bank that wants the buyer to authenticate makes it authentication_required. Body: { payment_data: { handler_id, instrument: { type, credential: { type, token } } }, buyer?, authentication_result? }
  • POST /agentic/{customer_id}/checkout_sessions/{checkout_session_id}/cancel (scope agent_credential): Cancel a session that has not completed. 405 afterwards. Body: { intent_trace? }
  • POST /agentic/{customer_id}/checkout_sessions/{checkout_session_id}/refunds (scope agent_credential): A Skerry extension (xyz.skerry.refunds), not part of the protocol: ask for a refund of a completed session, within what the business allows agents. 202 when a person must approve, 201 when it was made. Body: { amount?, reason? }

Universal Commerce Protocol: what a customer sets

Let platforms buy from a customer through the Universal Commerce Protocol, over the same checkout sessions, pricing callback, charging and refunds as agentic checkout. The customer lists the platforms it accepts by the address of their profile and says what its profile publishes; Skerry makes and keeps its signing key. A preview, off by default: every call answers 409 capability_coming_soon until it is switched on for the platform, and completing a checkout also needs delegated payment tokens.

  • GET /customers/{id}/ucp/settings (scope agentic:read): What the customer has set for the Universal Commerce Protocol, with profile_url (where its business profile is served), endpoint, and signing_key: the public half of its signing key, never the private half.
  • PUT /customers/{id}/ucp/settings (scope agentic:write): Replace the customer's settings: the platforms it accepts, its profile id at the card processor, the card brands and links it publishes, which wording the handler's environment uses, whether the AP2 mandates extension is on, and (sandbox only) whether unsigned requests are taken. A member left out goes back to its default. The signing key is made on the first save and kept sealed; a deployment with no sealing key answers 503 platform_unavailable. Body: { trusted_profiles?: [url], business_profile?, card_brands?, links?: [{ type, url }], environment_words?: handler | seller_guide, ap2_enabled?, allow_unsigned? }

Universal Commerce Protocol

Called by a platform, not by a partner, and not with an API key. The platform names its profile in UCP-Agent and signs each request (RFC 9421, ES256) with a key that profile publishes; the customer must have listed that profile. Paths, fields, statuses and messages follow the protocol's 2026-08-25 version, the only one spoken. What the business decides about a checkout is a 200 with messages; a checkout or order that is not there is a 200 with ucp.status: "error" and messages; protocol errors are { code, content }, not Skerry's envelope. A preview, off by default.

  • GET /ucp/{customer_id}/.well-known/ucp (no key): The customer's business profile: version, the shopping service and its endpoint, capabilities, the card processor's payment handler, and the customer's public signing key. Public, cacheable for a minute. The business serves or redirects its own /.well-known/ucp here.
  • POST /ucp/{customer_id}/checkout-sessions (scope ucp_signature): Create a checkout. Answers 201 with the checkout, priced. Body: { line_items: [{ item: { id }, quantity }], currency?, buyer?: { email, first_name, last_name } }
  • GET /ucp/{customer_id}/checkout-sessions/{id} (scope ucp_signature): Read a checkout. Only the platform that opened it finds it.
  • PUT /ucp/{customer_id}/checkout-sessions/{id} (scope ucp_signature): Replace a checkout: the body is the whole of what the platform sets. It comes back priced again; one that is being completed or is over comes back unchanged with a message. Body: { line_items: [{ item: { id }, quantity }], buyer? }
  • POST /ucp/{customer_id}/checkout-sessions/{id}/complete (scope ucp_signature): Complete with the card processor's payment token, for the checkout's exact total. A decline leaves the checkout ready_for_complete with a recoverable message; another handler or credential type is a recoverable payment_failed. Must carry the protocol's Idempotency-Key, and ap2.checkout_mandate under the AP2 mandates extension. Body: { payment: { instruments: [{ id, handler_id: "stripe_payments", type: "card", selected: true, credential: { type: "stripe_payment_token", token } }] }, signals?, ap2?: { checkout_mandate } }
  • POST /ucp/{customer_id}/checkout-sessions/{id}/cancel (scope ucp_signature): Cancel a checkout that is neither completed nor canceled.
  • GET /ucp/{customer_id}/orders/{id} (scope ucp_signature): The order of a completed checkout, as its webhook carries it, for the platform that placed it.

Paying by the call

Price a resource and let automated clients pay for each call. Skerry builds the 402 for the customer's server in both x402 and the Machine Payments Protocol, checks the payment the client sends back, and settles it on the customer's own merchant account. Skerry runs no facilitator and holds no keys. A preview: every call answers 409 capability_coming_soon until it is switched on for the customer.

  • POST /customers/{id}/priced-resources (scope machine:write): Price a resource. A price below a method's minimum, or with a part of a cent, needs credit_only. Body: { url_pattern, price, currency?, description?, methods?: [stablecoin | agent_token], credit_only?, upstream_url? }
  • GET /customers/{id}/priced-resources (scope machine:read): The customer's priced resources, with the minimum single payment per method.
  • DELETE /customers/{id}/priced-resources/{resource_id} (scope machine:write): Stop charging for a resource.
  • POST /machine-payments/challenge (scope machine:write): The 402 to send an unpaid request: status, headers and body, in both protocols. Body: { resource_id, url?, credit?: { amount } }
  • POST /machine-payments/verify (scope machine:write): Check and settle the payment a client sent, or draw the call from its credit. Returns whether it is paid and the headers to answer with. Body: { resource_id, headers: { "PAYMENT-SIGNATURE"? | "Authorization"? }, url? } | { resource_id, credit_token, idempotency_key }
  • POST /machine-payments/credits (scope machine:write): Buy prepaid credit for a resource with one payment. Without payment headers, the 402 for that amount; with them, the credit and its token, once. Body: { resource_id, amount?, headers? }
  • GET /machine-payments/credits (scope machine:read): A customer's credits, newest first: bought, balance, refunded, expiry. Never a token. ?customer_id=&resource_id=
  • GET /machine-payments/credits/{id} (scope machine:read): One credit: bought, balance, expiry.
  • POST /machine-payments/credits/{id}/refund (scope refunds:write): Send back the unused part of a credit, within the customer's policy. Needs an Idempotency-Key. Body: { }

Hosted addresses and the pay-by-the-call proxy

Public addresses, with no key: the id in the address is the credential, as it is on a hosted checkout page. A checkout session's and a payment link's share_url send a browser on to the hosted page and answer a client that asks for JSON (Accept: application/json, or the address ending .json) with a description it can act on. /pay/{resource_id} fronts a priced resource: 402 until paid, then the request is forwarded to the address the business registered. Errors are { error: { code, message } }, or the paying protocol's own on a 402.

  • GET /checkout/{id} (no key): A checkout session's own address: a redirect for a browser; for a client asking for JSON, the amount, currency, methods, payment instructions, the agentic endpoint when the business has one, and how to pay without a browser.
  • GET /pay-link/{id} (no key): A payment link's own address: a redirect for a browser; for an automated client, 402 with the pay-by-the-call challenge when the business has switched that on, otherwise 200 with a description.
  • GET /pay/{resource_id} (no key): The proxy in front of a priced resource: 402 until the request carries a payment or a credit token, then forwarded to the registered address.
  • POST /pay/{resource_id} (no key): The same, for a request with a body.

Agents paying

A registered agent pays another merchant. The agent's request is sent to the merchant; when it answers 402, one offer is paid and the request is sent once more. Two ways of paying are built: x402 version 2, the exact scheme, in USDC from a wallet whose key the wallet provider holds and whose policy lets it sign that one kind of message; and the Machine Payments Protocol's stripe method, with a delegated payment token issued for the challenge's amount and expiry. The payment is decided before anything is signed, as a payout is: freeze, withdrawal allow-list, first payment to a new destination, risk rules, the agent's mandates and budgets, approval. The merchant's host is the payee. A preview: every call answers 409 capability_coming_soon until agents paying is switched on and the wallet provider is connected.

  • POST /agents/{id}/wallets (scope agents:write): Create a wallet the agent signs payments from. The key is held by the wallet provider and never returned; a policy there lets it sign typed data for USDC on the wallet's network and nothing else. Sandbox takes base-sepolia only, live base only. Fund it by sending USDC to its address. Body: { network: base | base-sepolia, label? }
  • GET /agents/{id}/wallets (scope agents:read): The agent's wallets, active and retired, with their addresses.
  • GET /agents/{id}/wallets/{wallet_id} (scope agents:read): One wallet: network, address, state and what its key may sign.
  • DELETE /agents/{id}/wallets/{wallet_id} (scope agents:write): Retire a wallet: nothing is signed with it again. USDC left at its address is not moved, by this or any other operation.
  • POST /agent-payments (scope agent-payments:write): Send the agent's request to a merchant and, when it answers 402, pay one offer and send the request once more. Returns the merchant's status, an excerpt of its body and what was paid. max_amount is a ceiling in US cents; the merchant's exact amount is what is paid. Under the agent's mandates and the customer's controls: it may be refused, or held for approval (202). Pays at most once per key. Needs an Idempotency-Key. Body: { agent_id, wallet_id?, url, method?, headers?, body?, max_amount, currency: usd, payment_method_id?, external_ref?, external_kind? }
  • GET /agent-payments (scope agent-payments:read): Attempts to pay merchants, whatever became of them: paid, failed, refused or held. ?agent_id=&status=&external_ref=&limit=
  • GET /agent-payments/{id} (scope agent-payments:read): One attempt: the merchant, the protocol and network, the amount, the reference, and the authorisation it ran under.

Rewards and reporting

What the platform pays on balances, and what this project has consumed against its plan.

  • GET /coins (scope *): The stablecoins this project can hold.
  • POST /coins (scope *): Ask for a stablecoin of your own. This records the request and nothing more: no coin is created or minted by it. Body: { ticker, name, chains?, loop_type?, note? }
  • GET /coins/{currency}/rewards (scope *): What a balance in this coin earns.
  • GET /customers/{id}/rewards/{currency} (scope customers:read): What one customer's balance in a coin earned: balance, earned and as_of. Answers 404 where the coin earns nothing for this customer. Figures run two business days behind.
  • GET /usage (scope *): Usage against the plan's limits — the same count a call is refused against.
  • GET /compliance-requests (scope compliance:read): Requests for information about a customer or a payment, with the deadline. ?customer_id=&status=. Answers 409 capability_coming_soon until the platform has been granted them.
  • GET /compliance-requests/{id} (scope compliance:read): One request for information: what was asked, the deadline, and what it holds.

Written and kept by Skerry. Read from https://api.skerry.xyz/agents.md.

This page as Markdown