Skip to main content
Two calls move money: 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.
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 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…
Four decisions live here:
  • 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.

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.
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.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

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. 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.
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 terms200 with Idempotency-Replayed: true, the same po_…, 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 terms409 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, 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:
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. 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.