Skip to main content
One resource pays a bank account and a wallet alike — the contact decides which route runs, and you never pick. You pin one side of the payment and HEVN derives the other; the side you sent is never recalculated. Who signs depends on whose money it is: a client’s payout is approved by its holder in the sign-in window, and a payout from your own account is signed by your developer key.
Examples use the client from HTTP client. acting_as sets the one header that picks whose money moves: Send X-Hevn-Account: cl_… with your own access token to act for one client. On a resource route like GET /banks the header is optional: omit it and the call acts on your integrator account instead. The singular /client* routes require it — the header is the only thing that names the account — and /clients*, /connect/sessions and /escrow* refuse it.
The whole table — where the header is optional, where it is required and where it is refused — is in Sessions.

Save a contact

A payout always pays a contact — a payment destination you saved once and reuse. POST /dapi/v1/contacts takes the beneficiary’s account details in the same shape GET /dapi/v1/banks returns them, plus the beneficiary’s own address.
Response — 201, Location: /dapi/v1/contacts/ct_3K9…
Five decisions live here:
  • A bank contact names the beneficiary’s bank. bank.bankName is required for SEPA — a contact without it answers 422 validation_failed — and is what the partner prints on the payment.
  • A bank contact needs a complete beneficiary address. Without country, city, streetAddress and zip the call answers 422 contact_details_invalid, listing them in details.fields. Which identifiers fields must carry depends on the method — see Rails.
  • The same account details create one contact. A second POST answers 200 with Idempotency-Replayed: true and the original ct_…, so a retried create never forks a contact.
  • Correct a contact with PATCH /dapi/v1/contacts/{contactId}, never by re-creating it. Name, address and holder are editable, the account identifiers are not; a misspelled beneficiary name is refused by the partner, not by the contact, so this is the repair path.
  • DELETE /dapi/v1/contacts/{contactId} forgets the contact and returns the record it removed. Payouts already booked keep their own contact snapshot, so a delete never rewrites history. GET /dapi/v1/contacts lists what you have, cursor-paged.
Two optional reads price a payout before you book one. GET /dapi/v1/contacts/{contactId}/capabilities?amount=500.00 reports one options row per payable account — method, currency, minAmount, maxAmount, feeBps, fixedFee, and whether purpose, a memo or documents are required; anything that would refuse a payout comes back in blockers[] (Errors has the codes). POST /dapi/v1/payouts/preview takes {"rail", "amount"} and prices a rail with no contact and no side effects. Neither is a gate: POST /dapi/v1/payouts refuses the same cases on its own, and its numbers are the ones you are held to.

Book the payout

One call quotes the payout, books it with the partner, and prepares what has to be signed.
Response — 201, Location: /dapi/v1/payouts/po_4c8a…
authorization: "client" means the account holder signs this payout, so the response carries no approval and no debit: there is nothing for your key to sign, and publicKey is not needed. Show your customer the quote and the contact, then send them to the window. Amounts are decimal strings in the major unit — "500.00", never 500 and never atomic units. More precision than the asset allows is refused, never rounded (Conventions). quote.sourceAccount is the account the payout is actually paid from. Read the account from it, not from fromCurrency: fromCurrency is the currency the partner prices the debit in, and a partner prices a USDC debit and an FDIC_USD debit alike in USD.

Approve it in the sign-in window

Create a sign session for the payout and open the popup from your page, as described in Sign in with HEVN. The session must name a payout of a client linked to you that is still awaitingSignature.
The session is created with your own token and no X-Hevn-Account. In the window the holder signs in, sees the amount, the contact’s name and your product’s name, and their own wallet signs the debit. Your callback page receives status=submitted and payout=po_…; read the payout from your server for anything you act on. Two things are different from a payout you sign yourself:
  • There is no approval window to race. The window session lives for about ten minutes. Open it right after you book: a routed payout’s price expires with its quote, and the window then answers unsupported.
  • A declined or abandoned window changes nothing. The payout stays awaitingSignature and nothing moved. Create a new sign session to ask again; the payout is the same po_….

Track it to completion

GET /dapi/v1/payouts/{payoutId} answers for the whole lifecycle, bank and wallet alike, and it is the route a pollUrl names. contact on the read is the snapshot fixed when the payout was booked, not a live read of the contact record.
Response — 200
transactionId appears once the payout is projected into the client’s ledger; read it with GET /dapi/v1/transactions/{transactionId} (Balances and transactions). attention: {"kind": "rfi", "url": …} means the partner is holding the transfer pending an information request — the payout is not lost, and nothing anyone signs will move it until the request is answered.

Pay from your own account

A payout from your integrator account — no X-Hevn-Account, or your own cl_… in it — is signed by your developer key. Name the key in publicKey, and the response carries the approval to sign:
Response — 201, Location: /dapi/v1/payouts/po_7e1b…
publicKey is required here, and it is a real choice while you rotate keys, which is why it is on the opening call and not on confirm. Leaving it out answers 422 validation_failed with details.field = "publicKey". debit is not an echo of your request. It is decoded out of the operation you are about to sign: address is the funding address for this payout, amount and amountAtomic are what will leave your wallet, in token on chain chainId. Compare it against your own record and raise if it drifted — this is the one cross-check a signer has.
Then confirm. The body is the signature over the decoded approval.payload bytes:
Response — 200
Three answers are possible, and all three are terminal for the call: publicKey is optional in the confirm body and only skips a key scan: HEVN verifies your signature locally, against the payload it stored, before it consumes anything. A wrong signature never burns the approval. Confirming a client’s payout answers 403 account_not_controlled: a client’s payout is never yours to sign.

Pay a wallet instead of a bank

There is one payout resource, and one mechanism behind it: every payout is a quote. POST /dapi/v1/payouts reads the contact and quotes it — a bank contact through a partner, a wallet contact on Base directly, a wallet contact on another chain across chains. Same body, same sign session for a client, same GET /dapi/v1/payouts/{payoutId}.

Save an address as a contact

A wallet contact needs no postal address on file — the chain is the address. An account on Base — USDC or EURC — pays straight from the client’s balance: nothing is converted, no partner is involved, and the address you saved is the address the money lands on. The quote is still there, priced at par: the deposit address it names is the contact’s own address. A contact on any other chain, or expecting any other token, is routed instead — still one POST /dapi/v1/payouts, still kind: "onchain", but priced as a cross-chain route (below). A contact saved by email also works when that email belongs to a HEVN account: HEVN resolves it to that account’s wallet.

What comes back

Response — 201, Location: /dapi/v1/payouts/po_6d21…
contact on an on-chain payout carries address, chainId and token alongside the contactId and the name. Two differences from a bank payout matter. quote carries no rate, no feeAmount and no expiresAt. Nothing is converted and no partner priced anything, so those fields are absent rather than filled with 1 and 0. fromAmount and toAmount are the same number because they are the same money. The debit pays the contact’s address directly, not a funding address. There is no conversion and no intermediary. On a payout from your own account that shows as debit.address equal to contact.address: compare it against the contact you meant to pay before you sign — same habit, same reason. For a client, the holder sees the contact in the window before their wallet signs. Both statements hold for a Base-and-USDC contact. A routed payout is the other shape.

A routed payout is priced

When the contact is on another chain, or expects another token, the balance cannot pay it directly and HEVN books a cross-chain route instead. The response is the same resource with two visible differences:
Response — 201, Location: /dapi/v1/payouts/po_6d21…
quote names both ends of the route. fromChainId is the network the debit leaves, toChainId the network the contact receives on, and feeAmount is the route’s cost in USD. rate stays absent — a route is not a currency conversion. The quote expires, so expiresAt is present here and the signature has to land inside it — open the sign-in window as soon as the payout is booked. The debit pays the route’s one-time deposit address, not the contact’s. On a payout from your own account that is debit.address, and the same rule as a bank payout applies: check amount, amountAtomic and token against your own record before you sign, and check contact.address against the contact you meant to pay. Routing has to be available for the account. When it is not, booking answers 409 payout_not_fundable and the fix is a contact the balance can pay directly. amount is a decimal string with at most six fractional digits, the precision USDC has on chain. More than that answers 422 validation_failed with details.fields[0].code = "amount_precision" — never a rounded payout. Two refusals belong to this route alone: 400 when the address resolves to the client’s own wallet — a payout cannot pay the account that sends it — and 409 insufficient_funds when the balance does not cover the amount (there is no fiat leg to absorb it). A settled wallet payout carries transactionHash, and the durable record is the client’s ledger: the row shows up with isIncome: false and type: "crypto" — or internal, when the address belongs to another HEVN account. Read it through GET /dapi/v1/transactions (Balances and transactions).

Pay out of the custodial balance

An account that holds a custodial dollar balance can pay a beneficiary straight out of it. Send "sourceAccount": "FDIC_USD" and nothing leaves the wallet: the partner bank debits the account it already holds, so the payout is authorized by a signature over its own terms rather than by a debit. This works from your own account today. A client’s holder cannot approve such a payout in the sign-in window yet, so booking one for a client is refused: 409 payout_not_fundable with details.reason = "holder_signature_unsupported". Pay a client’s beneficiaries from USDC or EURC on its wallet.
The response carries "authorization": "intent" and an intent object instead of debit, because there is no on-chain debit to check:
Response — 201
What you check is the quote — sourceAccount is FDIC_USD, and fromCurrency is USD because that is the balance’s own currency — and everything after it is unchanged: sign approval.payload, post it to the same confirm route, poll the same read. Two rules are specific to this source:
  • The account has to hold the balance. Without one, the call answers 422 account_unavailable.
  • The beneficiary has to be reachable from the rail that holds the balance. A bankId on another rail is 422 account_not_supported — the balance is where it is, and HEVN will not silently pay from somewhere else.
To move that balance onto your wallet instead of paying someone with it, use Swaps.

Retries and idempotency

POST /dapi/v1/payouts books money at a partner, so a retry must not book twice. Idempotency-Key is how you say “this is the same payout”. Derive Idempotency-Key from an id your own system already owns, pass it in from the caller, and keep it identical across every retry of the same payment.
Never generate the key inside the helper that sends the request, and never rotate it on a retry — both turn one payment into two. The format and the replay windows are in Conventions.
  • Same key, same terms → 200 with Idempotency-Replayed: true, the same po_… and the agreed price. On your own account it carries a fresh approval if the previous one expired — one recovery path for a lost response and for an expired approval alike. For a client it carries authorization: "client" again; create a new sign session if the holder has not approved it yet.
  • Same key, different terms → 409 idempotency_key_reused with details.payoutId. Contact, account, pinned side and amount all have to match.
  • No key at all → HEVN derives one from the request and echoes it as idempotencyKey. Inside its window a retry replays instead of paying twice; outside it, two payouts happen. An explicit key holds far longer; Limits has both windows.
Re-open as often as you need: every re-opened approval prepares an operation in the same on-chain slot, and a slot is spendable once — why a retry cannot pay twice.
Never rotate the key on a retry, and never derive it from a clock or a random value. A fresh key on the retry of a payment you already sent is how the same invoice gets paid twice.

When a payout is refused

Booking refusals arrive at POST /dapi/v1/payouts and are about the contact, the amount or the route: A 400 or 422 lands before anything is booked, so the same key stays free for the corrected request. Funding refusals arrive at confirm on a payout from your own account, and each has exactly one correct response. A client’s payout is funded by its holder in the window, so none of these reach you for it; read the payout instead. That policy is one function, shared by every payout and by escrow actions, and stated once under Retrying a confirm:
The full slug registry, with every status code and message, is in Errors.

In the sandbox

The sandbox books and signs as production does; the emulated partner settles nothing on its own. Move a booked payout yourself:
status takes the payout vocabulary — submitted, settled, failed or refunded — and refunded returns the money to the client’s wallet, which is the cheapest way to test your reconciliation.
The sandbox is the same API against one emulated partner on Base Sepolia. It runs on shared testnet money, so it is capped: at most 1 per payout, escrow or payin, a faucet that sends at most 5, only to a wallet holding less than 1, and 60 money calls per account per hour. Full table in Limits.

Next: Balances and transactions

Read a client’s balance, walk its ledger with a cursor, export a statement.