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.
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…
- A bank contact names the beneficiary’s bank.
bank.bankNameis required for SEPA — a contact without it answers422 validation_failed— and is what the partner prints on the payment. - A bank contact needs a complete beneficiary address. Without
country,city,streetAddressandzipthe call answers422 contact_details_invalid, listing them indetails.fields. Which identifiersfieldsmust carry depends on the method — see Rails. - The same account details create one contact. A second
POSTanswers200withIdempotency-Replayed: trueand the originalct_…, 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 owncontactsnapshot, so a delete never rewrites history.GET /dapi/v1/contactslists what you have, cursor-paged.
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 asign 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.
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
awaitingSignatureand nothing moved. Create a newsignsession to ask again; the payout is the samepo_….
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 — noX-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.
approval.payload bytes:
Response — 200
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
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.
"authorization": "intent" and an intent object instead of debit, because
there is no on-chain debit to check:
Response — 201
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
bankIdon another rail is422 account_not_supported— the balance is where it is, and HEVN will not silently pay from somewhere else.
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.
- Same key, same terms →
200withIdempotency-Replayed: true, the samepo_…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 carriesauthorization: "client"again; create a newsignsession if the holder has not approved it yet. - Same key, different terms →
409 idempotency_key_reusedwithdetails.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.
When a payout is refused
Booking refusals arrive atPOST /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:
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.