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

# Swaps

> Move a client's money between its own accounts: dollars to euros on the wallet, or the custodial balance to the wallet and back.

A swap moves money between two accounts the **same client** owns. Nobody else is paid, so there is no
contact and no beneficiary: `POST /dapi/v1/swaps` prices and reserves the move,
`POST /dapi/v1/swaps/{swapId}/confirm` authorizes it. A client holding euros that owes dollars swaps
before it pays out, and the money never leaves its own name.

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

  swap = northwind.post("/swaps", {
      "sourceAccount": "USDC",
      "destinationAccount": "EURC",
      "amount": "500.00",
      "publicKey": key.public_key,
  }, idempotency_key="swap-A-1187")

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

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

  const swap = await northwind.post("/swaps", {
    sourceAccount: "USDC",
    destinationAccount: "EURC",
    amount: "500.00",
    publicKey: key.publicKey,
  }, { idempotencyKey: "swap-A-1187" });

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

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

  swap, err := northwind.Post("/swaps", hevn.Body{
  	"sourceAccount":      "USDC",
  	"destinationAccount": "EURC",
  	"amount":             "500.00",
  	"publicKey":          key.PublicKey,
  }, hevn.IdempotencyKey("swap-A-1187"))

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

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

## The accounts

Both sides are named from one vocabulary, the same one the balance reads back and a payout's
`sourceAccount` takes. `USDC` and `EURC` are the stablecoins on the client's wallet; `FDIC_USD` is
dollars a partner bank holds for it:

| Account    | Where the money is                                    | Currency |
| ---------- | ----------------------------------------------------- | -------- |
| `USDC`     | the client's Base smart wallet                        | USD      |
| `EURC`     | the same smart wallet                                 | EUR      |
| `FDIC_USD` | a virtual account a partner bank holds for the client | USD      |

The two sides must differ, and both must be accounts that client actually holds. An account it does
not hold is `422 account_unavailable` — a client with no custodial virtual account cannot name
`FDIC_USD`. A pair nothing can serve right now is `422 swap_route_unavailable`.

```mermaid theme={null}
stateDiagram-v2
    [*] --> awaitingSignature: POST /dapi/v1/swaps
    awaitingSignature --> submitted: POST /confirm with your signature
    submitted --> settled
    submitted --> failed
```

## Price it first

`POST /dapi/v1/swaps/preview` takes the same accounts and amount without a `publicKey`, compares
every route that can serve the pair, and answers the best one. It reserves nothing, signs nothing and
returns no `swapId`, so it is safe to call on every keystroke of a form.

<CodeGroup>
  ```bash cURL theme={null}
  curl -s -X POST "$HEVN_API/swaps/preview" \
    -H "Authorization: Bearer $HEVN_ACCESS_TOKEN" \
    -H "X-Hevn-Account: $HEVN_CLIENT_ID" \
    -H "Content-Type: application/json" \
    -d '{"sourceAccount":"USDC","destinationAccount":"EURC","amount":"500.00"}'
  ```

  ```python Python theme={null}
  priced = northwind.post("/swaps/preview", {
      "sourceAccount": "USDC",
      "destinationAccount": "EURC",
      "amount": "500.00",
  })
  ```

  ```javascript Node theme={null}
  const priced = await northwind.post("/swaps/preview", {
    sourceAccount: "USDC",
    destinationAccount: "EURC",
    amount: "500.00",
  });
  ```

  ```go Go theme={null}
  priced, err := northwind.Post("/swaps/preview", hevn.Body{
  	"sourceAccount":      "USDC",
  	"destinationAccount": "EURC",
  	"amount":             "500.00",
  })
  ```
</CodeGroup>

```json Response — 200 theme={null}
{ "sourceAccount": "USDC",
  "destinationAccount": "EURC",
  "quote": { "fromAmount": "500.00", "fromCurrency": "USDC",
             "toAmount": "462.14", "toCurrency": "EURC",
             "rate": "0.924280", "feeAmount": "0.75", "feeCurrency": "USDC",
             "minimumToAmount": "461.21", "estimated": false,
             "expiresAt": "2026-09-20T18:12:41Z" } }
```

Pin one side and one only: `amount` is what leaves the source account, `amountTo` is what has to
arrive on the destination. Sending both, or neither, is `422 validation_failed`.

`minimumToAmount` is the floor a route with slippage guarantees; `estimated: true` marks a move whose
delivered amount is only known on arrival, and then `etaHours` says how long that takes. A locked
price carries `expiresAt` and nothing else has to be read.

## Reserve and sign

`POST /dapi/v1/swaps` prices the move again, fixes it, and returns the approval to sign. The response
tells you what that approval authorizes:

| `authorization` | What it means                                                                                   | What else is on the response                                  |
| --------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
| `debit`         | the move is funded by sending tokens out of the client's wallet                                 | `debit` — the exact address, amount and token that will leave |
| `intent`        | the move is settled by HEVN against the client's own signature, with nothing leaving the wallet | `intent` — the EIP-712 `digest` of the terms being authorized |

Either way you sign `approval.payload` and post it to the same confirm route. The difference is what
HEVN does with the co-signature, not what your code does with the approval.

```json Response — 201, Location: /dapi/v1/swaps/swp_9f2c1ab84d theme={null}
{ "id": "swp_9f2c1ab84d",
  "status": "awaitingSignature",
  "idempotencyKey": "swap-A-1187",
  "sourceAccount": "USDC",
  "destinationAccount": "EURC",
  "quote": { "fromAmount": "500.00", "fromCurrency": "USDC",
             "toAmount": "462.14", "toCurrency": "EURC",
             "rate": "0.924280", "feeAmount": "0.75", "feeCurrency": "USDC" },
  "authorization": "debit",
  "approval": { "id": "b1e7…", "payload": "eyJib2R5Ijp7…", "expiresAt": "2026-09-20T18:10:59Z" },
  "debit": { "address": "0x8c41…", "amount": "500.00", "amountAtomic": "500000000",
             "token": "USDC", "chainId": 8453 } }
```

When `authorization` is `debit`, check `debit` before you sign — it is decoded out of the operation
itself, not echoed from your request, and it is the one cross-check a signer has
([Signing](/whitelabel/signing)).

<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, and keep confirming while it is still in flight:

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

A confirm answers `200` when settlement landed inside the request and `202 submitted` when it did
not; in the second case `pollUrl` names the read route. Confirm is single use: the same signature a
second time is `409 funding_attempt_expired` on a `debit` swap and `410 approval_expired` on an
`intent` one. Either way the move already happened, and the way to check on it is
`GET /dapi/v1/swaps/{swapId}`, never another confirm.

## Read it back

`GET /dapi/v1/swaps/{swapId}` carries the lifecycle, the accounts it ran between, the terms it was
fixed at, and `transactionId` once the move is on the client's ledger.

```json Response — 200 theme={null}
{ "id": "swp_9f2c1ab84d",
  "status": "settled",
  "sourceAccount": "USDC",
  "destinationAccount": "EURC",
  "quote": { "fromAmount": "500.00", "fromCurrency": "USDC",
             "toAmount": "462.14", "toCurrency": "EURC",
             "rate": "0.924280", "feeAmount": "0.75", "feeCurrency": "USDC" },
  "transactionHash": "0x7d2b…",
  "transactionId": "txn_5c9e2f…",
  "createdAt": "2026-09-20T18:08:44Z",
  "settledAt": "2026-09-20T18:09:12Z" }
```

A swap is not a second source of truth about money: both sides of it are ordinary ledger rows, so
`GET /dapi/v1/transactions` shows the result without reading this route at all
([Balances and transactions](/whitelabel/balances-and-transactions)).

## Retries

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

The key names the move. A retry with the same key returns the swap it already created, with
`Idempotency-Replayed: true`; the same key with different accounts or a different amount is
`409 idempotency_key_reused`, which is the guarantee that a repeated call is never a second
conversion.

## Paying straight out of the custodial balance

A client that holds `FDIC_USD` does not have to swap before it pays. `POST /dapi/v1/payouts` with
`"sourceAccount": "FDIC_USD"` spends that balance directly: the payout is authorized by an intent
signature instead of a debit, so the response carries `authorization: "intent"` and no `debit`, and
confirm is unchanged ([Payouts](/whitelabel/payouts)). Use a swap when the client wants the money on
its wallet, and a payout when it wants a beneficiary paid.

## In the sandbox

Swaps are the one money route the sandbox does not serve. The emulated chain has no exchange venue
behind it and the emulator issues no custodial dollar balance, so `POST /dapi/v1/swaps` there answers
a refusal rather than a quote. Build against the contract on this page and exercise it in production
([Sandbox](/whitelabel/sandbox)).

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