POST /dapi/v1/payouts prices and books it, POST /dapi/v1/payouts/{payoutId}/confirm signs it. 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.
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 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 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.
Move the money
1
Price and book the payout
One call quotes the payout, books it with the partner, and prepares the operation you sign.
Response — 201, Location: /dapi/v1/payouts/po_4c8a…
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).2
Check the debit, then sign
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 the client’s 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.Confirm needs the payout:sign scope. A key without it answers 403 forbidden with details.requiredScope, before the signature is read and before anything is consumed — see Developer key.3
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 you sign will move it until the request is answered.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 approval.payload, same confirm route, 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.
debit.address is the contact’s address, not a funding address. There is no conversion and no intermediary, so the address decoded out of the operation is the address the USDC lands on. Compare it against the contact you meant to pay before you sign — same habit, same reason.
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 confirm has to land inside it.
debit.address is the route’s one-time deposit address, not the contact’s. 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 confirmed 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).
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_…, the agreed price, and a fresh approval if the previous one expired. One recovery path for a lost response and for an expired approval alike. - 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, and each has exactly one correct response:
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. Its money comes from a shared faucet account: at most 100 tokens per credit, at least
0.10, and 60 money calls per account per hour. GET /dapi/v1/sandbox/treasury reports the live ceiling. Full table in Limits.Next: Balances and transactions
Read a client’s balance, walk its ledger with a cursor, export a statement.