> ## Documentation Index
> Fetch the complete documentation index at: https://hevninc.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> HEVN has two products and they authorize money differently. Self-serve (the General tab): the person who owns the email holds the only key and signs for themselves. Whitelabel (the Whitelabel and REST API tabs): the integrator's backend holds a developer key, signs on behalf of client accounts it created, and HEVN co-signs. Never mix the two models in an answer.
> Use HEVN's terms exactly. A rail is a bank route a client can request. A virtual account (bnk_) is an opened rail. Account details are what a payer needs in order to wire money, and arrive in a field named requisites. A client is an account the integrator created; the business behind it is the integrator's customer. A contact is a saved destination; a beneficiary is the party at the far end of a fiat payout. Every departure of money is a payout, to a bank account or to a wallet alike.
> Never name a banking partner. Say 'a licensed banking partner'.
> There are no published fee rates, spreads or minimums. Prices come from a quote on a specific payment; direct pricing questions to HEVN rather than inventing numbers.
> The whitelabel API is at /dapi/v1 and takes a developer-key session plus an X-Hevn-Account header. The CLI uses an app API key in X-Api-Key. They are different credentials and neither works on the other surface.

# Payouts

> Price and book a payout in one call, sign it in the next, and track it to settlement — to a bank account or to a wallet.

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.

<CodeGroup>
  ```python Python theme={null}
  northwind = hevn.acting_as(CLIENT_ID)

  payout = northwind.post("/payouts", {
      "contactId": "ct_3K9…",
      "amount": "500.00",
      "publicKey": key.public_key,
      "paymentReference": "INV-2026-114",
  }, idempotency_key="order-A-1187")

  signature = key.sign_payload(payout["approval"]["payload"])
  receipt = northwind.confirm_until_settled(f"/payouts/{payout['id']}/confirm", signature)
  ```

  ```javascript Node theme={null}
  const northwind = hevn.actingAs(clientId);

  const payout = await northwind.post("/payouts", {
    contactId: "ct_3K9…",
    amount: "500.00",
    publicKey: key.publicKey,
    paymentReference: "INV-2026-114",
  }, { idempotencyKey: "order-A-1187" });

  const signature = key.signPayload(payout.approval.payload);
  const receipt = await northwind.confirmUntilSettled(`/payouts/${payout.id}/confirm`, signature);
  ```

  ```go Go theme={null}
  northwind := api.ActingAs(clientID)

  payout, err := northwind.Post("/payouts", hevn.Body{
  	"contactId":        "ct_3K9…",
  	"amount":           "500.00",
  	"publicKey":        key.PublicKey,
  	"paymentReference": "INV-2026-114",
  }, hevn.IdempotencyKey("order-A-1187"))

  signature, err := key.SignPayload(payout.Str("approval.payload"))
  receipt, err := northwind.ConfirmUntilSettled("/payouts/"+payout.Str("id")+"/confirm", signature)
  ```
</CodeGroup>

Examples use the client from [HTTP client](/whitelabel/reference/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.

<CodeGroup>
  ```bash cURL theme={null}
  curl "$HEVN_API/banks" \
    -H "Authorization: Bearer $HEVN_ACCESS_TOKEN" \
    -H "X-Hevn-Account: $HEVN_CLIENT_ID"
  ```

  ```python Python theme={null}
  northwind = hevn.acting_as(CLIENT_ID)
  rails = northwind.get("/banks")
  ```

  ```javascript Node theme={null}
  const northwind = hevn.actingAs(clientId);
  const rails = await northwind.get("/banks");
  ```

  ```go Go theme={null}
  northwind := api.ActingAs(clientID)
  rails, err := northwind.Get("/banks")
  ```
</CodeGroup>

The whole table — where the header is optional, where it is required and where it is refused — is
in [Sessions](/whitelabel/sessions#acting-as-a-client).

```mermaid theme={null}
stateDiagram-v2
    [*] --> awaitingSignature: POST /dapi/v1/payouts
    awaitingSignature --> awaitingSignature: approval expired — re-open with the same key, at the same price
    awaitingSignature --> submitted: POST /confirm with your signature
    submitted --> settled
    submitted --> failed: retry with POST /dapi/v1/payouts and the same Idempotency-Key
    settled --> refunded
```

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

<CodeGroup>
  ```bash cURL theme={null}
  curl -s -X POST "$HEVN_API/contacts" \
    -H "Authorization: Bearer $HEVN_ACCESS_TOKEN" \
    -H "X-Hevn-Account: $HEVN_CLIENT_ID" \
    -H "Content-Type: application/json" \
    -d '{"name":"Ardenne Fabrication SARL",
         "bank":{"method":"sepa","currency":"EUR",
                 "fields":{"iban":"FR7630006000011234567890189"},
                 "holder":{"type":"business","businessName":"Ardenne Fabrication SARL"}},
         "address":{"country":"FR","city":"Lyon","streetAddress":"12 Rue Garibaldi","zip":"69006"}}'
  ```

  ```python Python theme={null}
  contact = northwind.post("/contacts", {
      "name": "Ardenne Fabrication SARL",
      "bank": {
          "method": "sepa",
          "currency": "EUR",
          "fields": {"iban": "FR7630006000011234567890189"},
          "holder": {"type": "business", "businessName": "Ardenne Fabrication SARL"},
      },
      "address": {"country": "FR", "city": "Lyon", "streetAddress": "12 Rue Garibaldi", "zip": "69006"},
  })
  ```

  ```javascript Node theme={null}
  const contact = await northwind.post("/contacts", {
    name: "Ardenne Fabrication SARL",
    bank: {
      method: "sepa",
      currency: "EUR",
      fields: { iban: "FR7630006000011234567890189" },
      holder: { type: "business", businessName: "Ardenne Fabrication SARL" },
    },
    address: { country: "FR", city: "Lyon", streetAddress: "12 Rue Garibaldi", zip: "69006" },
  });
  ```

  ```go Go theme={null}
  contact, err := northwind.Post("/contacts", hevn.Body{
  	"name": "Ardenne Fabrication SARL",
  	"bank": hevn.Body{
  		"method":   "sepa",
  		"currency": "EUR",
  		"fields":   hevn.Body{"iban": "FR7630006000011234567890189"},
  		"holder":   hevn.Body{"type": "business", "businessName": "Ardenne Fabrication SARL"},
  	},
  	"address": hevn.Body{"country": "FR", "city": "Lyon", "streetAddress": "12 Rue Garibaldi", "zip": "69006"},
  })
  ```
</CodeGroup>

```json Response — 201, Location: /dapi/v1/contacts/ct_3K9… theme={null}
{ "id": "ct_3K9…", "name": "Ardenne Fabrication SARL", "isExternal": true,
  "createdAt": "2026-09-17T10:04:11Z",
  "bank": { "method": "sepa", "currency": "EUR",
            "fields": { "iban": "FR7630006000011234567890189" },
            "holder": { "type": "business", "businessName": "Ardenne Fabrication SARL" },
            "state": "active" },
  "address": { "country": "FR", "city": "Lyon", "streetAddress": "12 Rue Garibaldi", "zip": "69006" } }
```

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](/whitelabel/reference/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](/whitelabel/reference/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

<Steps>
  <Step title="Price and book the payout">
    One call quotes the payout, books it with the partner, and prepares the operation you sign.

    <CodeGroup>
      ```bash cURL theme={null}
      curl -s -X POST "$HEVN_API/payouts" \
        -H "Authorization: Bearer $HEVN_ACCESS_TOKEN" \
        -H "X-Hevn-Account: $HEVN_CLIENT_ID" \
        -H "Idempotency-Key: order-A-1187" \
        -H "Content-Type: application/json" \
        -d '{"contactId":"ct_3K9…","amount":"500.00",
             "publicKey":"'"$HEVN_PUBLIC_KEY"'","paymentReference":"INV-2026-114"}'
      ```

      ```python Python theme={null}
      payout = northwind.post("/payouts", {
          "contactId": contact["id"],
          "amount": "500.00",
          "publicKey": key.public_key,
          "paymentReference": "INV-2026-114",
      }, idempotency_key="order-A-1187")
      ```

      ```javascript Node theme={null}
      const payout = await northwind.post("/payouts", {
        contactId: contact.id,
        amount: "500.00",
        publicKey: key.publicKey,
        paymentReference: "INV-2026-114",
      }, { idempotencyKey: "order-A-1187" });
      ```

      ```go Go theme={null}
      payout, err := northwind.Post("/payouts", hevn.Body{
      	"contactId":        contact.Str("id"),
      	"amount":           "500.00",
      	"publicKey":        key.PublicKey,
      	"paymentReference": "INV-2026-114",
      }, hevn.IdempotencyKey("order-A-1187"))
      ```
    </CodeGroup>

    ```json Response — 201, Location: /dapi/v1/payouts/po_4c8a… theme={null}
    {
      "id": "po_4c8a…",
      "kind": "fiat",
      "status": "awaitingSignature",
      "idempotencyKey": "order-A-1187",
      "quote": { "fromAmount": "500.00", "fromCurrency": "USD",
                 "toAmount": "459.31", "toCurrency": "EUR",
                 "rate": "0.9247", "feeAmount": "1.00", "feeCurrency": "USD",
                 "expiresAt": "2026-09-17T10:06:11Z" },
      "approval": { "id": "…", "payload": "eyJ…", "expiresAt": "2026-09-17T10:06:10Z" },
      "debit": { "address": "0x8f3b…", "amount": "500.00", "amountAtomic": "500000000",
                 "token": "USDC", "chainId": 8453 },
      "contact": { "contactId": "ct_3K9…", "name": "Ardenne Fabrication SARL" }
    }
    ```

    | Decision                             | Field                                                                                                                                                                                                                                    |
    | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
    | Which side of the conversion you pin | `amount` is what leaves the client's balance, `amountTo` is what the beneficiary receives. Send exactly one. HEVN never adjusts the side you sent — it derives the other one through the partner's own fee formula.                      |
    | Which of your keys will sign         | `publicKey`. A real choice while you rotate keys, which is why it is on the opening call and not on confirm.                                                                                                                             |
    | What the beneficiary sees            | `paymentReference`, up to 140 characters.                                                                                                                                                                                                |
    | What the destination rail demands    | `purpose` when `capabilities` reports `purposeRequired`, and up to ten `documentIds` from [document upload](/whitelabel/onboarding) when it reports `documentsRequired`.                                                                 |
    | Which of several accounts to pay     | `bankId` (`bnk_…`), the same id `capabilities` returns on each option, when a contact has more than one. Omit it and HEVN picks the account `capabilities` would pick.                                                                   |
    | Which account pays for it            | `sourceAccount`, `USDC` (the default) or `EURC` — the client account debited. A wallet contact must be able to receive it: asking to pay a USDC address in EURC is refused with `400`, and so is a token the rail cannot be funded with. |

    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](/whitelabel/conventions#money)).
  </Step>

  <Step title="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](/whitelabel/signing): `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.

    <CodeGroup>
      ```python Python theme={null}
      import base64
      from cryptography.hazmat.primitives import hashes
      from cryptography.hazmat.primitives.asymmetric import ec


      def sign_bytes(key, message: bytes) -> str:
          """base64(DER ECDSA P-256 / SHA-256) over exactly these bytes."""
          return base64.b64encode(key.sign(message, ec.ECDSA(hashes.SHA256()))).decode()


      def sign_payload(key, payload_b64: str) -> str:
          return sign_bytes(key, base64.b64decode(payload_b64))
      ```

      ```javascript Node theme={null}
      import { sign } from "node:crypto";

      export function signBytes(key, message) {
        return sign("sha256", message, { key, dsaEncoding: "der" }).toString("base64");
      }

      export function signPayload(key, payloadB64) {
        return signBytes(key, Buffer.from(payloadB64, "base64"));
      }
      ```

      ```go Go theme={null}
      func SignBytes(key *ecdsa.PrivateKey, message []byte) (string, error) {
      	digest := sha256.Sum256(message)
      	der, err := ecdsa.SignASN1(rand.Reader, key, digest[:])
      	if err != nil {
      		return "", err
      	}
      	return base64.StdEncoding.EncodeToString(der), nil
      }

      func SignPayload(key *ecdsa.PrivateKey, payloadB64 string) (string, error) {
      	message, err := base64.StdEncoding.DecodeString(payloadB64)
      	if err != nil {
      		return "", err
      	}
      	return SignBytes(key, message)
      }
      ```
    </CodeGroup>

    Then confirm. The body is the signature over the **decoded** `approval.payload` bytes:

    <CodeGroup>
      ```python Python theme={null}
      if payout["debit"]["amount"] != order.usdc_total or payout["debit"]["token"] != "USDC":
          raise DebitDrift(payout["id"], payout["debit"])

      signature = key.sign_payload(payout["approval"]["payload"])
      receipt = northwind.post(f"/payouts/{payout['id']}/confirm", {"signature": signature})
      ```

      ```javascript Node theme={null}
      if (payout.debit.amount !== order.usdcTotal || payout.debit.token !== "USDC") {
        throw new DebitDrift(payout.id, payout.debit);
      }
      const signature = key.signPayload(payout.approval.payload);
      const receipt = await northwind.post(`/payouts/${payout.id}/confirm`, { signature });
      ```

      ```go Go theme={null}
      if payout.Str("debit.amount") != order.USDCTotal || payout.Str("debit.token") != "USDC" {
      	return fmt.Errorf("debit drift on %s", payout.Str("id"))
      }
      signature, err := key.SignPayload(payout.Str("approval.payload"))
      receipt, err := northwind.Post("/payouts/"+payout.Str("id")+"/confirm",
      	hevn.Body{"signature": signature})
      ```
    </CodeGroup>

    ```json Response — 200 theme={null}
    { "id": "po_4c8a…", "kind": "fiat", "status": "settled",
      "transactionHash": "0x4f1c…" }
    ```

    Three answers are possible, and all three are terminal for the call:

    | Status | Body                                                  | What it means                                                                                                              |
    | ------ | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
    | `200`  | `status: "settled"`, `transactionHash`                | The debit landed on chain and the payout is with the partner.                                                              |
    | `200`  | `status: "failed"`, `transactionHash`, `failure.code` | The operation reverted on chain. Nothing moved. Book it again with `POST /dapi/v1/payouts` and the same `Idempotency-Key`. |
    | `202`  | `status: "submitted"`, `pollUrl`                      | Sent, no receipt yet. Read `pollUrl` or confirm again — both are safe.                                                     |

    `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](/whitelabel/developer-key#scopes).
  </Step>

  <Step title="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.

    ```json Response — 200 theme={null}
    { "id": "po_4c8a…", "kind": "fiat", "status": "settled",
      "quote": { "fromAmount": "500.00", "fromCurrency": "USD",
                 "toAmount": "459.31", "toCurrency": "EUR", "rate": "0.9247" },
      "transactionHash": "0x4f1c…", "transactionId": "txn_9e4…",
      "contact": { "contactId": "ct_3K9…", "name": "Ardenne Fabrication SARL" },
      "paymentReference": "INV-2026-114",
      "createdAt": "2026-09-17T10:04:11Z", "settledAt": "2026-09-17T10:04:39Z" }
    ```

    | `status`            | Meaning                                                                                                                |
    | ------------------- | ---------------------------------------------------------------------------------------------------------------------- |
    | `awaitingSignature` | Booked and priced. The approval is open and nothing has moved.                                                         |
    | `submitted`         | The funding operation was sent, or the partner is holding the transfer for a question (see `attention` below).         |
    | `settled`           | The debit landed and the partner accepted the payout. Delivery to the beneficiary's bank follows the rail's own clock. |
    | `failed`            | The operation reverted, or the partner refused or canceled the transfer. `failure.code` says which.                    |
    | `refunded`          | The partner sent the money back. It returns to the client's wallet.                                                    |

    `transactionId` appears once the payout is projected into the client's ledger; read it with `GET /dapi/v1/transactions/{transactionId}` ([Balances and transactions](/whitelabel/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.

    <CodeGroup>
      ```python Python theme={null}
      import random, time


      def poll_until(read, done, *, deadline=120.0, first=1.0, cap=8.0):
          """Call read() until done(value) is true. Returns the last value or raises TimeoutError."""
          started, delay = time.monotonic(), first
          while True:
              value = read()
              if done(value):
                  return value
              if time.monotonic() - started > deadline:
                  raise TimeoutError(f"still {value.get('status')} after {deadline:.0f}s")
              time.sleep(delay + random.uniform(0, delay / 2))
              delay = min(delay * 2, cap)
      ```

      ```javascript Node theme={null}
      export async function pollUntil(read, done, { deadline = 120_000, first = 1_000, cap = 8_000 } = {}) {
        const until = Date.now() + deadline;
        let delay = first;
        for (;;) {
          const value = await read();
          if (done(value)) return value;
          if (Date.now() > until) throw new Error(`still ${value.status} after ${deadline}ms`);
          await new Promise((wake) => setTimeout(wake, delay + Math.random() * (delay / 2)));
          delay = Math.min(delay * 2, cap);
        }
      }
      ```

      ```go Go theme={null}
      func PollUntil[T any](read func() (T, error), done func(T) bool, deadline time.Duration) (T, error) {
      	until, delay := time.Now().Add(deadline), time.Second
      	for {
      		value, err := read()
      		if err != nil {
      			return value, err
      		}
      		if done(value) {
      			return value, nil
      		}
      		if time.Now().After(until) {
      			return value, fmt.Errorf("hevn: still pending after %s", deadline)
      		}
      		time.Sleep(delay + time.Duration(rand.Int63n(int64(delay/2))))
      		if delay < 8*time.Second {
      			delay *= 2
      		}
      	}
      }
      ```
    </CodeGroup>
  </Step>
</Steps>

## 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}`.

<CodeGroup>
  ```python Python theme={null}
  payout = northwind.post("/payouts", {
      "contactId": "ct_9a…",
      "amount": "1.50",
      "publicKey": key.public_key,
  }, idempotency_key="payout-A-1187-onchain")

  signature = key.sign_payload(payout["approval"]["payload"])
  receipt = northwind.confirm_until_settled(f"/payouts/{payout['id']}/confirm", signature)
  ```

  ```javascript Node theme={null}
  const payout = await northwind.post("/payouts", {
    contactId: "ct_9a…",
    amount: "1.50",
    publicKey: key.publicKey,
  }, { idempotencyKey: "payout-A-1187-onchain" });

  const signature = key.signPayload(payout.approval.payload);
  const receipt = await northwind.confirmUntilSettled(`/payouts/${payout.id}/confirm`, signature);
  ```

  ```go Go theme={null}
  payout, err := northwind.Post("/payouts", hevn.Body{
  	"contactId": "ct_9a…",
  	"amount":    "1.50",
  	"publicKey": key.PublicKey,
  }, hevn.IdempotencyKey("payout-A-1187-onchain"))

  signature, err := key.SignPayload(payout.Str("approval.payload"))
  receipt, err := northwind.ConfirmUntilSettled("/payouts/"+payout.Str("id")+"/confirm", signature)
  ```
</CodeGroup>

### Save an address as a contact

<CodeGroup>
  ```bash cURL theme={null}
  curl -s -X POST "$HEVN_API/contacts" \
    -H "Authorization: Bearer $HEVN_ACCESS_TOKEN" \
    -H "X-Hevn-Account: $HEVN_CLIENT_ID" \
    -H "Content-Type: application/json" \
    -d '{"name":"Ardenne treasury","crypto":{"chain":"base","token":"USDC","address":"0x7c1d…"}}'
  ```

  ```python Python theme={null}
  contact = northwind.post("/contacts", {
      "name": "Ardenne treasury",
      "crypto": {"chain": "base", "token": "USDC", "address": "0x7c1d…"},
  })
  ```

  ```javascript Node theme={null}
  const contact = await northwind.post("/contacts", {
    name: "Ardenne treasury",
    crypto: { chain: "base", token: "USDC", address: "0x7c1d…" },
  });
  ```

  ```go Go theme={null}
  contact, err := northwind.Post("/contacts", hevn.Body{
  	"name":   "Ardenne treasury",
  	"crypto": hevn.Body{"chain": "base", "token": "USDC", "address": "0x7c1d…"},
  })
  ```
</CodeGroup>

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](/whitelabel/reference/rails#chain-codes), 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-routed-payout-is-priced)). 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

```json Response — 201, Location: /dapi/v1/payouts/po_6d21… theme={null}
{
  "id": "po_6d21…",
  "kind": "onchain",
  "status": "awaitingSignature",
  "idempotencyKey": "payout-A-1187-onchain",
  "quote": { "fromAmount": "1.50", "fromCurrency": "USDC",
             "toAmount": "1.50", "toCurrency": "USDC" },
  "approval": { "id": "…", "payload": "eyJ…", "expiresAt": "2026-09-17T10:06:10Z" },
  "debit": { "address": "0x7c1d…", "amount": "1.50", "amountAtomic": "1500000",
             "token": "USDC", "chainId": 8453 },
  "contact": { "contactId": "ct_9a…", "name": "Ardenne treasury",
               "address": "0x7c1d…", "chainId": "base", "token": "USDC" }
}
```

`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:

```json Response — 201, Location: /dapi/v1/payouts/po_6d21… theme={null}
{
  "id": "po_6d21…",
  "kind": "onchain",
  "status": "awaitingSignature",
  "quote": { "fromAmount": "1.50", "fromCurrency": "USDC",
             "toAmount": "1.4923", "toCurrency": "USDC",
             "feeAmount": "0.0077", "feeCurrency": "USD",
             "expiresAt": "2026-09-17T10:06:11Z",
             "fromChainId": "base", "toChainId": "arb" },
  "approval": { "id": "…", "payload": "eyJ…", "expiresAt": "2026-09-17T10:06:10Z" },
  "debit": { "address": "0x9b41…", "amount": "1.50", "amountAtomic": "1500000",
             "token": "USDC", "chainId": 8453 },
  "contact": { "contactId": "ct_9a…", "name": "Ardenne treasury",
               "address": "0x7c1d…", "chainId": "arb", "token": "USDC" }
}
```

**`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](/whitelabel/balances-and-transactions#walk-the-ledger)).

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

<CodeGroup>
  ```python Python theme={null}
  key = f"payout-{invoice.id}"  # "payout-INV-2026-114"
  northwind.post("/payouts", payout, idempotency_key=key)
  ```

  ```javascript Node theme={null}
  const key = `payout-${invoice.id}`;
  await northwind.post("/payouts", payout, { idempotencyKey: key });
  ```

  ```go Go theme={null}
  key := "payout-" + invoice.ID
  northwind.Post("/payouts", payout, hevn.IdempotencyKey(key))
  ```
</CodeGroup>

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](/whitelabel/conventions#idempotency-key).

* **Same key, same terms** → `200` 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 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](/whitelabel/reference/limits#idempotency) 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](/whitelabel/conventions#why-a-retry-cannot-pay-twice).

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

## When a payout is refused

Booking refusals arrive at `POST /dapi/v1/payouts` and are about the contact, the amount or the route:

| `code`                            | Status | What to do                                                                                                                                                                                                    |
| --------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `contact_not_found`               | 404    | The `ct_…` is unknown, deleted, or belongs to another account.                                                                                                                                                |
| `contact_not_payable`             | 409    | The contact cannot be paid on this rail. `details.blockers` is the same array `capabilities` returns.                                                                                                         |
| `contact_payment_details_invalid` | 422    | The partner refused the saved account identifiers. Repair the contact with `PATCH /dapi/v1/contacts/{contactId}`.                                                                                             |
| `invalid_request`                 | 400    | The route refused these terms and `message` says which — below the route's minimum, a direction it cannot price, account details the method does not satisfy. Fix the input and call again with the same key. |
| `validation_failed`               | 422    | The body itself is wrong: both `amount` and `amountTo`, neither of them, more precision than the asset allows, or a key the schema does not know. `details.fields` names each one.                            |
| `payout_not_fundable`             | 409    | Booked, but the route cannot be funded with a developer key. Re-quote with a new key on a rail that can.                                                                                                      |
| `provider_unavailable`            | 503    | The partner is down. Retry with the same key.                                                                                                                                                                 |

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:

| `code`                                    | Status | What to do                                                                                                                                                                                                            |
| ----------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `already_funded`                          | 409    | **Treat as success.** The money moved; `details.transactionHash` is the proof. Never re-open.                                                                                                                         |
| `funding_in_progress`                     | 409    | Wait, then confirm again. An attempt is still in flight.                                                                                                                                                              |
| `funding_attempt_expired`                 | 409    | The approval expired. Re-open with `POST /dapi/v1/payouts` and the same key, then sign the new payload.                                                                                                               |
| `payment_slot_consumed`                   | 409    | An operation for this payout already landed on chain, and a landed operation — settled or reverted — spends the slot for good. Read the payout: `settled` needs nothing, `failed` needs a new payout under a new key. |
| `insufficient_funds`                      | 409    | The client's balance does not cover the debit. `details.available` and `details.required` are in atomic units. Fund the wallet and confirm again.                                                                     |
| `bundler_rejected`, `bundler_unavailable` | 502    | Chain infrastructure. Confirm again with backoff.                                                                                                                                                                     |
| anything else                             | —      | Stop. Take it to support with the `X-Request-ID` of the response.                                                                                                                                                     |

That policy is one function, shared by every payout and by escrow actions, and stated once under [Retrying a confirm](/whitelabel/signing#retrying-a-confirm):

<CodeGroup>
  ```python Python theme={null}
  CONFIRM_AGAIN = {"funding_in_progress", "bundler_rejected", "bundler_unavailable"}


  class ReopenRequired(Exception):
      """The approval expired. Re-open the payment with the same Idempotency-Key."""


  def confirm_until_settled(self, confirm_path: str, signature: str, *, deadline=180.0):
      """A method on Hevn. confirm_path is "/payouts/po_…/confirm" or an escrow action's confirm."""
      started, delay = time.monotonic(), 1.0
      while True:
          try:
              receipt = self.post(confirm_path, {"signature": signature})
              if receipt["status"] != "submitted":
                  return receipt
              reason = "submitted"
          except HevnError as error:
              if error.code == "already_funded":
                  return {"status": "settled", "transactionHash": error.details.get("transactionHash")}
              if error.code == "funding_attempt_expired":
                  raise ReopenRequired(confirm_path) from error
              if error.code not in CONFIRM_AGAIN:
                  raise
              reason = error.code
          if time.monotonic() - started > deadline:
              raise TimeoutError(f"{confirm_path} still {reason} after {deadline:.0f}s")
          time.sleep(delay)
          delay = min(delay * 2, 8.0)
  ```

  ```javascript Node theme={null}
  const CONFIRM_AGAIN = new Set(["funding_in_progress", "bundler_rejected", "bundler_unavailable"]);

  export class ReopenRequired extends Error {}

  // A method on Hevn. confirmPath is "/payouts/po_…/confirm" or an escrow action's confirm.
  export async function confirmUntilSettled(confirmPath, signature, { deadline = 180_000 } = {}) {
    const until = Date.now() + deadline;
    let delay = 1_000;
    for (;;) {
      let reason = "submitted";
      try {
        const receipt = await this.post(confirmPath, { signature });
        if (receipt.status !== "submitted") return receipt;
      } catch (error) {
        if (error.code === "already_funded") {
          return { status: "settled", transactionHash: error.details.transactionHash };
        }
        if (error.code === "funding_attempt_expired") throw new ReopenRequired(confirmPath);
        if (!CONFIRM_AGAIN.has(error.code)) throw error;
        reason = error.code;
      }
      if (Date.now() > until) throw new Error(`${confirmPath} still ${reason}`);
      await new Promise((wake) => setTimeout(wake, delay));
      delay = Math.min(delay * 2, 8_000);
    }
  }
  ```

  ```go Go theme={null}
  var confirmAgain = map[string]bool{
  	"funding_in_progress": true, "bundler_rejected": true, "bundler_unavailable": true,
  }

  var ErrReopenRequired = errors.New("hevn: approval expired, re-open with the same Idempotency-Key")

  // confirmPath is "/payouts/po_…/confirm" or an escrow action's confirm.
  func (c *Client) ConfirmUntilSettled(confirmPath, signature string) (Payload, error) {
  	until, delay := time.Now().Add(3*time.Minute), time.Second
  	for {
  		receipt, err := c.Post(confirmPath, Body{"signature": signature})
  		var apiErr *APIError
  		switch {
  		case err == nil && receipt.Str("status") != "submitted":
  			return receipt, nil
  		case err == nil: // submitted: no receipt yet, confirm again
  		case !errors.As(err, &apiErr):
  			return nil, err
  		case apiErr.Code == "already_funded":
  			return Payload{"status": "settled", "transactionHash": apiErr.Details["transactionHash"]}, nil
  		case apiErr.Code == "funding_attempt_expired":
  			return nil, ErrReopenRequired
  		case !confirmAgain[apiErr.Code]:
  			return nil, err
  		}
  		if time.Now().After(until) {
  			return nil, fmt.Errorf("hevn: %s did not settle in time", confirmPath)
  		}
  		time.Sleep(delay)
  		if delay < 8*time.Second {
  			delay *= 2
  		}
  	}
  }
  ```
</CodeGroup>

The full slug registry, with every status code and message, is in [Errors](/whitelabel/reference/errors#payouts).

## In the sandbox

The sandbox books and signs as production does; the emulated partner settles nothing on its own. Move a booked payout yourself:

<CodeGroup>
  ```bash cURL theme={null}
  curl -s -X POST "$HEVN_API/sandbox/payouts/po_4c8a13d7f2/status" \
    -H "Authorization: Bearer $HEVN_ACCESS_TOKEN" \
    -H "X-Hevn-Account: $HEVN_CLIENT_ID" \
    -H "Content-Type: application/json" \
    -d '{"status":"settled"}'
  ```

  ```python Python theme={null}
  moved = northwind.post(f"/sandbox/payouts/{payout['id']}/status", {"status": "settled"})
  ```

  ```javascript Node theme={null}
  const moved = await northwind.post(`/sandbox/payouts/${payout.id}/status`, { status: "settled" });
  ```

  ```go Go theme={null}
  moved, err := northwind.Post("/sandbox/payouts/"+payout.Str("id")+"/status",
  	hevn.Body{"status": "settled"})
  ```
</CodeGroup>

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

<Note>
  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](/whitelabel/reference/limits#sandbox).
</Note>

<Card title="Next: Balances and transactions" icon="arrow-right" href="/whitelabel/balances-and-transactions">
  Read a client's balance, walk its ledger with a cursor, export a statement.
</Card>
