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

# Escrow actions

> Every escrow action: the amount it takes, the window it is legal in, the state it leaves behind, and how to confirm it.

Six actions, one resource. `POST /dapi/v1/escrow/{escrowId}/actions` prepares one and returns a payload to sign; `POST /dapi/v1/escrow/{escrowId}/actions/{idempotencyKey}/confirm` submits the signature. This page is the matrix.

## The matrix

| Action      | `amount` | `feeBps`                 | Legal when                                                            | Leaves the deal                      | Wallet it signs on |
| ----------- | -------- | ------------------------ | --------------------------------------------------------------------- | ------------------------------------ | ------------------ |
| `authorize` | required | ignored                  | before `approveBy`, nothing collected yet, `0 < amount ≤ maxAmount`   | `authorized`                         | yours              |
| `charge`    | required | up to the deal's ceiling | before `approveBy`, nothing collected yet, `0 < amount ≤ maxAmount`   | `charged`                            | yours              |
| `capture`   | required | up to the deal's ceiling | before `holdUntil`, `0 < amount ≤ capturableAmount`, repeatable       | `partiallyCaptured`, then `captured` | yours              |
| `void`      | refused  | ignored                  | any time while `capturableAmount > 0`                                 | `voided`                             | yours              |
| `reclaim`   | refused  | ignored                  | after `holdUntil`, while `capturableAmount > 0`                       | `reclaimed`                          | the buyer's        |
| `refund`    | required | ignored                  | before `refundableUntil`, `0 < amount ≤ refundableAmount`, repeatable | `partiallyRefunded`, then `refunded` | yours              |

`approve` is not a verb you send: it is prepared for you by `POST /dapi/v1/escrow` and confirmed under the create's own key. Sending an `amount` with `void` or `reclaim` answers `409 amount_not_allowed`; omitting it on the four that need it answers `422 validation_failed`.

`charge` is `authorize` and `capture` in one on-chain action — the money goes straight to the seller with no hold in between. Use it when there is nothing to wait for.

## Preparing an action

```json Request — POST /dapi/v1/escrow/esc_5Qb4e17c9d/actions, Idempotency-Key: order-A-1187-capture theme={null}
{ "action": "capture", "amount": "40.00", "feeBps": 250 }
```

`201` for a new action, `200` with `Idempotency-Replayed: true` when the key replays one. A replay returns the same record — with its approval if that is still open, without it once the action was submitted. The same key with a different body answers `409 idempotency_key_reused`.

```json Response — 201 theme={null}
{
  "deal": { "id": "esc_5Qb4e17c9d", "status": "authorized",
            "capturableAmount": "100.000000", "refundableAmount": "0.000000",
            "availableActions": ["capture", "void"] },
  "action": { "action": "capture", "idempotencyKey": "order-A-1187-capture",
              "status": "awaitingSignature", "amount": "40.000000", "feeBps": 250,
              "approval": { "id": "b1d0…", "payload": "eyJ…",
                            "expiresAt": "2026-09-17T10:31:02Z" } }
}
```

The idempotency key you sent **is** the action's address. Send no key and HEVN derives one, echoes it as `action.idempotencyKey`, and that derived value works in the confirm URL exactly the same way. Either way, a lost prepare response costs you nothing: you already know where to confirm.

## Confirming an action

Sign the decoded `approval.payload` bytes — never the base64 string, never a re-serialized JSON object.

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

```json Request — POST /dapi/v1/escrow/esc_5Qb4e17c9d/actions/order-A-1187-capture/confirm theme={null}
{ "signature": "MEUCIQDk3f8Zc0xq2w1Yv7pB9nH4mJ6sR2tL5uD8aE0cQ1oXgAIg…" }
```

`publicKey` is optional and only skips a scan of your registered keys. HEVN verifies the signature locally, against the payload it stored, **before** it consumes the approval — a wrong signature costs nothing but the call.

This confirm needs the `escrow:sign` scope; a key without it answers `403 forbidden` with `details.requiredScope`, before anything is verified. HEVN then re-checks the developer key behind the session immediately before it co-signs: a key deleted between prepare and confirm, or a confirm sent from an IP outside the key's allowlist, answers `403` and signs nothing. The prepared action is untouched, so a confirm from an allowlisted host still works.

| Answer                                            | Meaning                                                                                                                                           | What to do                                                                                                                                                                        |
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `200`, action `confirmed`                         | Mined. The body carries the projected deal and the `events` it produced.                                                                          | Done.                                                                                                                                                                             |
| `200`, action `reverted`                          | Mined, but the contract rejected it.                                                                                                              | Read the deal; nothing moved. Prepare again with a **new** key.                                                                                                                   |
| `202`, `pollUrl`                                  | With the bundler, no receipt yet.                                                                                                                 | `pollUrl` is the deal read, `GET /dapi/v1/escrow/{escrowId}`; `POST /dapi/v1/escrow/{escrowId}/sync` is the one that reconciles against the chain. Sync until the action settles. |
| `400 signature_invalid`                           | The signature does not verify.                                                                                                                    | The approval is untouched. Sign the decoded payload again.                                                                                                                        |
| `403 forbidden`                                   | The developer key was deleted, the confirm left an IP outside its allowlist, or the key lacks `escrow:sign` — `details.requiredScope` says which. | Nothing was signed. Use a key that covers this host and this scope.                                                                                                               |
| `410 approval_expired`                            | The 60-second approval closed, or the action has no open approval left.                                                                           | Prepare the action again with the **same** key; you get a fresh approval on the same record.                                                                                      |
| `409 approval_consumed`                           | That approval already signed something.                                                                                                           | Prepare again with the same key, then read the deal before you sign anything else.                                                                                                |
| `409 action_in_flight`                            | Another action of yours is still on chain.                                                                                                        | Sync this deal, then retry. The refusal carries no `details`; the deal to sync is the one you are acting on.                                                                      |
| `409 escrow_state_changed`                        | The deal moved since prepare — nothing capturable, or the payment was already collected.                                                          | Re-read the deal and pick from `availableActions`.                                                                                                                                |
| `502 bundler_rejected`, `502 bundler_unavailable` | Chain infrastructure.                                                                                                                             | Confirm again with backoff.                                                                                                                                                       |

<CodeGroup>
  ```python Python theme={null}
  def confirm_action(hevn, escrow_id, key, payload, signing_key):
      signature = signing_key.sign_payload(payload)
      receipt = hevn.post(f"/escrow/{escrow_id}/actions/{key}/confirm", {"signature": signature})
      if receipt["action"]["status"] in ("confirmed", "reverted"):
          return receipt
      return poll_until(
          lambda: hevn.post(f"/escrow/{escrow_id}/sync"),
          lambda deal: deal["action"]["status"] not in ("signing", "submitted"),
          first=15.0,
      )
  ```

  ```javascript Node theme={null}
  export async function confirmAction(hevn, escrowId, key, payload, signingKey) {
    const signature = signingKey.signPayload(payload);
    const receipt = await hevn.post(`/escrow/${escrowId}/actions/${key}/confirm`, { signature });
    if (["confirmed", "reverted"].includes(receipt.action.status)) return receipt;
    return pollUntil(
      () => hevn.post(`/escrow/${escrowId}/sync`),
      (deal) => !["signing", "submitted"].includes(deal.action.status),
      { first: 15_000 },
    );
  }
  ```

  ```go Go theme={null}
  var pending = map[string]bool{"signing": true, "submitted": true}

  func ConfirmAction(c *hevn.Client, escrowID, idemKey, payload string, key *hevn.Key) (hevn.Payload, error) {
  	signature, err := key.SignPayload(payload)
  	if err != nil {
  		return nil, err
  	}
  	receipt, err := c.Post("/escrow/"+escrowID+"/actions/"+idemKey+"/confirm",
  		hevn.Body{"signature": signature})
  	if err != nil || !pending[receipt.Str("action.status")] {
  		return receipt, err
  	}
  	return hevn.PollUntil(
  		func() (hevn.Payload, error) { return c.Post("/escrow/"+escrowID+"/sync", nil) },
  		func(deal hevn.Payload) bool { return !pending[deal.Str("action.status")] },
  		2*time.Minute,
  	)
  }
  ```
</CodeGroup>

### One action in flight

A signing wallet runs one on-chain action at a time — across every deal it signs for, not just this one. While one of yours is `signing` or `submitted`, the next prepare or confirm answers `409 action_in_flight`. The refusal names no deal, so a worker that drives many deals has to know which one it left in flight: serialise your actions per signing wallet and keep the in-flight deal id on your side, then `POST /dapi/v1/escrow/{escrowId}/sync` it before retrying.

## Statuses

The deal's status is a label for the last thing that landed. Decide with `availableActions`, `capturableAmount` and `refundableAmount`.

| Deal status                      | Meaning                                                               |
| -------------------------------- | --------------------------------------------------------------------- |
| `draft`                          | Created. Nothing held. The buyer's approval does not change this.     |
| `authorized`                     | The on-chain hold is in the contract. `capturableAmount` is positive. |
| `charged`                        | Held and paid out in one action. Only a refund moves it back.         |
| `partiallyCaptured`              | Part of the hold reached the seller, the rest is still capturable.    |
| `captured`                       | Nothing is capturable any more.                                       |
| `voided`                         | The hold went back to the buyer.                                      |
| `reclaimed`                      | The buyer took the hold back after `holdUntil`.                       |
| `partiallyRefunded` / `refunded` | You sent money back out of your own wallet.                           |

| Action status       | Meaning                       | What to do                                            |
| ------------------- | ----------------------------- | ----------------------------------------------------- |
| `awaitingSignature` | Prepared, approval open       | Sign and confirm within 60 seconds                    |
| `signing`           | Confirm in progress           | Wait. Do not prepare again                            |
| `submitted`         | With the bundler, no receipt  | Sync, no more than every 15 seconds                   |
| `confirmed`         | Mined and projected           | Done                                                  |
| `reverted`          | Mined, the call reverted      | Read the deal, prepare again with a new key           |
| `failed`            | Never reached the bundler     | Prepare again with the **same** key                   |
| `dropped`           | No receipt within the timeout | Keep syncing: it can still confirm for up to 24 hours |
| `superseded`        | A later prepare replaced it   | Ignore                                                |

## Reads

`GET /dapi/v1/escrow/{escrowId}` returns one deal with its last action. `GET /dapi/v1/escrow` lists the deals you operate, newest first, cursor-paged:

<CodeGroup>
  ```bash cURL theme={null}
  curl "$HEVN_API/escrow?status=authorized&status=partiallyCaptured&limit=50" \
    -H "Authorization: Bearer $HEVN_ACCESS_TOKEN"
  ```

  ```python Python theme={null}
  open_deals = hevn.get("/escrow?status=authorized&status=partiallyCaptured&limit=50")["items"]
  ```

  ```javascript Node theme={null}
  const { items: openDeals } =
    await hevn.get("/escrow?status=authorized&status=partiallyCaptured&limit=50");
  ```

  ```go Go theme={null}
  page, err := api.Get("/escrow?status=authorized&status=partiallyCaptured&limit=50")
  openDeals := page.List("items")
  ```
</CodeGroup>

`status` repeats, so it goes in the path rather than the client's parameter map, which holds one
value per name. Filters are `status`, `senderClientId` and `receiverClientId`. The envelope is `{ "items": [...], "nextCursor": "…" }`; pass `nextCursor` back as `cursor` until it is absent. A cursor from another query answers `400 invalid_cursor`.

`POST /dapi/v1/escrow/{escrowId}/sync` reconciles the deal against the chain and returns the same shape plus `consistent`. It reads the chain at most once every 15 seconds per deal; calls in between answer the stored projection, which is why polling it in a tight loop buys nothing.

There is no separate action list: the deal read carries the last action, and every action is addressable by the key you chose.

## Refusals

`action_in_flight`, `escrow_state_changed` and `escrow_action_not_found` are the three that change your control flow, and all three are handled above. The full registry, with every status code, is in [Errors](/whitelabel/reference/errors#escrow).

One shape worth knowing: a closed window or an over-large amount arrives today as a bare `400 invalid_request` — the contract-level reason (`preApprovalExpiry has been reached`, `amount exceeds available amount`) is logged on our side but not published, so `message` is the generic one and `details` is absent. Treat it exactly like `window_closed` and `amount_out_of_range`: re-read the deal and pick from `availableActions`, which is computed from the same windows and amounts.

<Card title="Next: Sandbox" icon="flask" href="/whitelabel/sandbox">
  The same 45 operations against an emulated partner on Base Sepolia.
</Card>
