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

# Sessions

> Log in with two calls, hold one access token, and act for any of your clients with one header.

One developer key, two calls to log in, one header to act as a client.

```mermaid theme={null}
sequenceDiagram
    participant S as Your server
    participant H as HEVN /dapi/v1/auth
    S->>H: POST /auth/challenge — email, publicKey, nonce, requestExpiry, signature
    Note over S,H: the proof is your signature over<br/>hevn-developer-key-login:email:nonce:requestExpiry
    H-->>S: challengeId, payload, expiresAt
    Note over S: sign base64decode(payload)
    S->>H: POST /auth/token — challengeId, signature
    Note over H: the challenge is single-use<br/>and expires within minutes
    H-->>S: accessToken (1 h), refreshToken (60 d), userId
    S->>H: GET /dapi/v1/banks — Authorization: Bearer …, X-Hevn-Account: cl_7YQ2Kf3mN8
    H-->>S: the rails of that client
```

## Log in

Two calls, two signatures, no browser.

**`POST /dapi/v1/auth/challenge`** proves you hold the key. Sign the proof string
`hevn-developer-key-login:{email}:{nonce}:{requestExpiry}` with your developer key and send it with
the public key of the key you created. `nonce` is any integer you have not used before; `requestExpiry` is a
millisecond timestamp at most five minutes in the future.

```json theme={null}
{
  "email": "integrator@example.com",
  "publicKey": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE…",
  "nonce": 481923756104,
  "requestExpiry": 1773679531000,
  "signature": "MEUCIQDk3f8Zc0xq2w1Yv7pB9nH4mJ6sR2tL5uD8aE0cQ1oXgAIg…"
}
```

The answer is a payload HEVN built for you:

```json theme={null}
{ "challengeId": "9f2c1ab8-4d7e-4f1f-a3c6-5b0e7d9a2c41", "payload": "eyJib2R5Ijp…", "expiresAt": "2026-09-17T10:06:10Z" }
```

**`POST /dapi/v1/auth/token`** exchanges your signature over that payload for a session. Sign the
*decoded* bytes of `payload`, exactly as at payment time — [Signing](/whitelabel/signing) is the same
mechanism.

```json theme={null}
{ "challengeId": "9f2c1ab8-4d7e-4f1f-a3c6-5b0e7d9a2c41", "signature": "MEQCIF3q…" }
```

```json Response theme={null}
{ "accessToken": "eyJ…", "refreshToken": "eyJ…", "expiresIn": 3600, "userId": "cl_9f2c1ab84d7e4f1fa3c65b0e7d9a2c41" }
```

The challenge is single-use and dies with its `expiresAt`: two `/auth/token` calls on one
`challengeId` answer `409 challenge_consumed`. `userId` is your own account's id, and you can use it
wherever a client id is accepted for your own account.

<CodeGroup>
  ```python Python theme={null}
  import os, secrets, time, httpx


  def login(key) -> dict:
      api, email = os.environ["HEVN_API"], os.environ["HEVN_EMAIL"]
      nonce = secrets.randbits(48)
      expiry = int(time.time() * 1000) + 120_000
      proof = f"hevn-developer-key-login:{email}:{nonce}:{expiry}".encode()

      started = httpx.post(f"{api}/auth/challenge", json={
          "email": email,
          "publicKey": public_key_b64(key),
          "nonce": nonce,
          "requestExpiry": expiry,
          "signature": sign_bytes(key, proof),
      })
      started.raise_for_status()
      challenge = started.json()

      minted = httpx.post(f"{api}/auth/token", json={
          "challengeId": challenge["challengeId"],
          "signature": sign_payload(key, challenge["payload"]),
      })
      minted.raise_for_status()
      return minted.json()  # accessToken, refreshToken, expiresIn, userId
  ```

  ```javascript Node theme={null}
  export async function login(key) {
    const api = process.env.HEVN_API;
    const email = process.env.HEVN_EMAIL;
    const nonce = Number(process.hrtime.bigint() % 281474976710656n);
    const expiry = Date.now() + 120_000;
    const proof = Buffer.from(`hevn-developer-key-login:${email}:${nonce}:${expiry}`);

    const post = async (path, body) => {
      const response = await fetch(`${api}${path}`, {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify(body),
      });
      if (!response.ok) throw new Error(`${path} ${response.status} ${await response.text()}`);
      return response.json();
    };

    const challenge = await post("/auth/challenge", {
      email,
      publicKey: publicKeyB64(key),
      nonce,
      requestExpiry: expiry,
      signature: signBytes(key, proof),
    });

    return post("/auth/token", {
      challengeId: challenge.challengeId,
      signature: signPayload(key, challenge.payload),
    });
  }
  ```

  ```go Go theme={null}
  func Login(key *ecdsa.PrivateKey) (Tokens, error) {
  	api, email := os.Getenv("HEVN_API"), os.Getenv("HEVN_EMAIL")
  	nonce := rand.Int63n(1 << 48)
  	expiry := time.Now().Add(2 * time.Minute).UnixMilli()

  	proof := fmt.Sprintf("hevn-developer-key-login:%s:%d:%d", email, nonce, expiry)
  	proofSignature, err := SignBytes(key, []byte(proof))
  	if err != nil {
  		return Tokens{}, err
  	}
  	publicKey, err := PublicKeyB64(key)
  	if err != nil {
  		return Tokens{}, err
  	}

  	var challenge struct{ ChallengeID, Payload string }
  	if err := PostJSON(api+"/auth/challenge", map[string]any{
  		"email": email, "publicKey": publicKey, "nonce": nonce,
  		"requestExpiry": expiry, "signature": proofSignature,
  	}, &challenge); err != nil {
  		return Tokens{}, err
  	}

  	payloadSignature, err := SignPayload(key, challenge.Payload)
  	if err != nil {
  		return Tokens{}, err
  	}
  	var tokens Tokens
  	err = PostJSON(api+"/auth/token", map[string]any{
  		"challengeId": challenge.ChallengeID, "signature": payloadSignature,
  	}, &tokens)
  	return tokens, err
  }
  ```
</CodeGroup>

## Two tokens, two jobs

| Token          | Lifetime                       | Audience           | What it is for                                                           |
| -------------- | ------------------------------ | ------------------ | ------------------------------------------------------------------------ |
| `accessToken`  | **1 hour** (`expiresIn: 3600`) | `platform`         | Every call: `Authorization: Bearer <accessToken>`                        |
| `refreshToken` | **60 days**                    | `platform-refresh` | One route only: `POST /dapi/v1/auth/refresh`, to mint a new access token |

Both carry a `developer_key_id` claim naming the developer key that established the session, and
the two audiences do not cross. `/dapi/v1` refuses anything that is not a platform access token
carrying that claim: no bearer at all is `401 unauthenticated`, and an ordinary app token, a refresh
token on a business route or a token with no key behind it is `403 forbidden` with
`details.reason = "developerSessionRequired"`. The ordinary `/api/v1` refuses a platform token
symmetrically, which is why a developer key is created in the app rather than over either API — see
[Developer key](/whitelabel/developer-key#create-it-in-the-app).

Re-mint with the refresh token as the bearer and an empty body:

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST "$HEVN_API/auth/refresh" \
    -H "Authorization: Bearer $HEVN_REFRESH_TOKEN" \
    -H "Content-Type: application/json" -d '{}'
  ```

  ```python Python theme={null}
  minted = httpx.post(f"{os.environ['HEVN_API']}/auth/refresh", json={},
                      headers={"Authorization": f"Bearer {refresh_token}"})
  minted.raise_for_status()
  access_token = minted.json()["accessToken"]
  ```

  ```javascript Node theme={null}
  const minted = await fetch(`${process.env.HEVN_API}/auth/refresh`, {
    method: "POST",
    headers: { authorization: `Bearer ${refreshToken}`, "content-type": "application/json" },
    body: "{}",
  });
  if (!minted.ok) throw new Error(`refresh ${minted.status}`);
  const { accessToken } = await minted.json();
  ```

  ```go Go theme={null}
  var minted hevn.Tokens
  err := hevn.PostJSON(os.Getenv("HEVN_API")+"/auth/refresh", map[string]any{},
  	&minted, hevn.Bearer(refreshToken))
  accessToken := minted.AccessToken
  ```
</CodeGroup>

```json Response theme={null}
{ "accessToken": "eyJ…", "expiresIn": 3600, "userId": "cl_9f2c…" }
```

This is the one call that does not go through the client: it carries the refresh token, not the
access token. [The reference client](/whitelabel/reference/client#hold-the-session) wraps it in a
`Session` so the request layer never thinks about expiry.

Log in once per process, keep the refresh token in your secret store, and re-mint the access token
about a minute before it expires. A refresh token is not an API credential: sent to any other route
it is refused.

## Acting as a client

Send `X-Hevn-Account: cl_…` alongside your own access token, and the call reads and writes that
client. On most routes you may omit it, and the same call acts on your integrator account; on the
singular `/client*` routes the header is required, so reading your *own* account there means sending
your own `cl_…`. One token covers every client you control, so there is nothing to cache per client.

<CodeGroup>
  ```bash cURL theme={null}
  curl "$HEVN_API/transactions?limit=20" \
    -H "Authorization: Bearer $HEVN_ACCESS_TOKEN" \
    -H "X-Hevn-Account: cl_7YQ2Kf3mN8"
  ```

  ```python Python theme={null}
  rows = hevn.acting_as("cl_7YQ2Kf3mN8").get("/transactions", {"limit": 20})
  ```

  ```javascript Node theme={null}
  const rows = await hevn.actingAs("cl_7YQ2Kf3mN8").get("/transactions", { limit: 20 });
  ```

  ```go Go theme={null}
  rows, err := api.ActingAs("cl_7YQ2Kf3mN8").Get("/transactions", hevn.Query{"limit": "20"})
  ```
</CodeGroup>

No path names a client any more — there is no `{clientId}` parameter left in the API — so the header
is the only thing that selects an account, and each route treats it one of four ways:

| Routes                                                                                                                   | The header                                                                                                                       |
| ------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
| `/banks*`, `/payins*`, `/payouts*`, `/contacts*`, `/transactions*`, `/documents*`, and the `/sandbox/*` routes           | **Accepted, optional.** Without it the call acts on your account                                                                 |
| `GET /client`, `PATCH /client`, `GET /client/balance`, `GET /client/kyb`, `PUT /client/kyb`, `POST /client/kyb/complete` | **Required.** These are the singular, acting client; the header is the selector, including when the account you want is your own |
| `POST /clients`, `GET /clients`                                                                                          | **Refused.** The collection is yours by definition                                                                               |
| All of `/escrow*`                                                                                                        | **Refused.** A deal names `senderClientId` and `receiverClientId` in the body; the operator is always you                        |
| `/auth/*`                                                                                                                | **Ignored.** A login has no account to act for yet, so the header is neither read nor refused                                    |

Sending the header where it is refused is an error rather than a silent no-op, so you find out in
development:

| Status | `code`                   | Cause                                                                                                                                                                                   |
| ------ | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400    | `account_header_invalid` | The value is not a `cl_…` client id                                                                                                                                                     |
| 400    | `account_scope_conflict` | The route is self-scoped: you sent the header to `/clients` or `/escrow*`, or you used a token that was already acting for another account there. `details.reason` is `selfScopedRoute` |
| 422    | `validation_failed`      | The header is missing on a `/client*` route, where it is required. `details.fields` names it                                                                                            |
| 404    | `account_not_found`      | Not one of your clients — unknown and foreign are indistinguishable on purpose                                                                                                          |
| 403    | `account_forbidden`      | The client exists and is yours, but your integrator standing or the relationship is not current                                                                                         |

<Note>
  `POST /dapi/v1/auth/refresh` also accepts `{"userId": "cl_…"}` and mints an access token scoped to
  that one client. It still works, and it is no longer the documented path: it costs one token mint per
  client per hour and buys nothing the header does not give you.
</Note>

## What a session is bound to

* **An environment.** Sandbox tokens are refused in production and the reverse.
* **The key behind it, on every single request.** The `developer_key_id` in the token is re-read
  before the request is served: the key must still exist, and the call must come from an address
  inside its immutable allowlist. Deleting a key therefore **does** cut the tokens already minted
  from it — the next call is a `403`, and so is the refresh that would have replaced it. See
  [Developer key](/whitelabel/developer-key#rotate-a-key).
* **A set of source addresses.** A deploy from a new egress IP fails every call with `403` even
  though the key, the signature and the token are all correct.
* **The key's scopes.** A session may call the confirm routes and the contact writes its key was
  given, and no others: a route the key lacks the scope for is `403 forbidden` with
  `details.requiredScope` and `details.scope`. Scopes are fixed when the key is created and they
  limit *which* routes, not *how much* money those routes may move. The four values and the routes
  each one opens are on [Developer key](/whitelabel/developer-key#scopes).
* **A budget.** Reads and writes are limited per credential, and login has its own per-IP and
  per-email windows — over them is `429` with `Retry-After`. The numbers are in
  [Limits](/whitelabel/reference/limits#rate-limits). Logging in on a loop is the fastest way to lock
  yourself out; log in once and re-mint.

<Card title="Next: Conventions" icon="list-checks" href="/whitelabel/conventions">
  The seven rules that hold for every operation: money, ids, casing, idempotency, pagination, status
  codes, errors.
</Card>
