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

> ## Agent Instructions
> HEVN is payments software: an API and app over a Base smart wallet. HEVN is not a bank and holds no key to the wallet. Virtual accounts, fiat payins and fiat payouts are provided by partner banks, which hold fiat while it is in transit. Self-serve (the General tab): the person who controls the email signs for the wallet. Developers integrate /dapi/v1 in one of two ways. HEVN Connect (recommended, the HEVN Connect tab): the integrator's customer creates or links a HEVN account in a HEVN-hosted sign-in window on the integrator's site, the integrator's backend reads the linked account and books its payouts, and the account holder approves each payout in that window. External wallets (the External wallets tab, enabled per integrator): the integrator creates a client through the API, registers a wallet address the customer holds as an external account, and requests virtual accounts that deliver to that address. The customer's own wallet sends each transfer that funds a payout. The developer key signs for the integrator's wallet and its sub accounts and cannot sign for a client's wallet.
> Use HEVN's terms as written. A product is the region a client requests. A rail is the bank route HEVN picks for the product. A virtual account (bnk_) is the record a partner bank opens for a client. Account details are what a payer needs to send a bank transfer, returned in a field named requisites. A client (cl_) is a customer's account: linked to the integrator through Sign in with HEVN (POST /dapi/v1/connect/sessions, then POST /dapi/v1/clients/link), or created with POST /dapi/v1/clients for an external-wallets integration. An external account (acc_, kind external) is a wallet address the client holds and signs for. A sub account (acc_, kind sub) is a further wallet of the integrator's own account on Base, spent with the developer key. Write 'sub account' as two words. A contact is a saved destination. A beneficiary is the party that receives a fiat payout. Money leaving an account is a payout, whether it goes to a bank account or to a wallet.
> Do not name a bank, payment institution, card issuer, identity-verification vendor or stablecoin issuer. Say 'the partner bank'. Do not describe HEVN as licensed, regulated or insured.
> HEVN publishes no fee rates, spreads or minimums. Prices come from a quote on a specific payment. Direct pricing questions to HEVN and do not invent numbers.
> The API is at /dapi/v1 and takes a developer-key session plus an X-Hevn-Account header. Developer keys have an IP allowlist and no scopes. Remote transfers are at /api/v1/remote_spend_limit and take a remote transfer key (rsl_) in X-Api-Key, bounded by an onchain spend permission the account holder grants. The two credentials are separate and neither works on the other API. The HEVN CLI is discontinued.

# Authentication

> Create a developer key, log in for a session, refresh the access token, and act for a client.

The developer key is a P-256 keypair. It logs your backend in to `/dapi/v1` and signs payments from your integrator account. Create it in the HEVN app, then log in with `POST /dapi/v1/auth/challenge` and `POST /dapi/v1/auth/token`.

## Developer key

### Create it in the app

No route creates a key, in `/dapi/v1` or in the app's API (`/api/v1`).

1. Sign in to [`https://sandbox.gethevn.com`](https://sandbox.gethevn.com), or [`https://app.gethevn.com`](https://app.gethevn.com) for production. Go to **Settings → Partner program → Developer**.
2. Enter [the IP allowlist](#the-ip-allowlist). It cannot be changed after creation.
3. Save the private key. The browser generates the keypair and shows the private half once, and HEVN stores the public half. Write the private half to a file with mode `0600` on the machine that uses it.

The app adds the public half to your account's signer in the same action. An account holds up to five keys. One key covers all your clients, linked or created.

### Key format

The curve is P-256, also called `secp256r1` or `prime256v1`. The app shows the key's `dk_…` id and its fingerprint, `sha256:<hex of sha256(DER)>`. The public key is sent as base64 of the DER SubjectPublicKeyInfo, at most 4096 characters. The wire formats of the key and the signature are in [Signature format](https://docs.gethevn.com/developers/signing.md#signature-format) and on [Start a developer-key login](https://docs.gethevn.com/api-reference/auth/start-a-developer-key-login-and-receive-the-payload-to-sign.md).

### The IP allowlist

| Rule | Detail |
| - | - |
| Entries | The source addresses the key may be used from: 1 to 64 IPv4 addresses, IPv6 addresses or CIDR networks |
| Storage | Canonicalised, with duplicates dropped. `203.0.113.10` is stored as `203.0.113.10/32`. |
| Changes | Not possible. No route edits the list. A new egress range needs a new key. |
| Checked on | Each request, including `POST /dapi/v1/auth/refresh`, and again before HEVN co-signs a confirm |
| Address outside the list, or unknown to HEVN | `403`, on reads and on payments |

Include the addresses an autoscaler adds. Your edge must remove caller-supplied `X-Forwarded-For`, `X-Real-IP` and `CF-Connecting-IP` headers. Otherwise the caller controls the address that is checked.

### Load the key

Read the key from the file named in `HEVN_KEY_PEM`. The loader is in [HTTP client](https://docs.gethevn.com/developers/reference/client.md). A `200` from `POST /dapi/v1/auth/challenge` shows that the key exists and the allowlist covers the host.

### What a session from the key may do

A session from any of your keys can call any `/dapi/v1` route. A key has no scopes.

The key signs for your integrator wallet and for your [sub accounts](https://docs.gethevn.com/sub-accounts/overview.md). It does not sign for a client's wallet or for an external account. See [Who can spend](https://docs.gethevn.com/developers/overview.md#who-can-spend).

> [!WARNING]
>
> The key has no amount limit. It can spend the full balance of your integrator wallet and of each sub account. See [What this means for your product](https://docs.gethevn.com/developers/overview.md#what-this-means-for-your-product).

## Sessions

A session is an access token and a refresh token issued to your developer key.

### Log in

Sign this proof string with your developer key:

```
hevn-developer-key-login:{email}:{nonce}:{requestExpiry}
```

Send it to `POST /dapi/v1/auth/challenge`. The field rules are on [Start a developer-key login](https://docs.gethevn.com/api-reference/auth/start-a-developer-key-login-and-receive-the-payload-to-sign.md).

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

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

Base64-decode `payload`, sign the bytes as in [Signing](https://docs.gethevn.com/developers/signing.md#signature-format), and call `POST /dapi/v1/auth/token`:

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

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

`userId` is the `cl_…` id of your integrator account. Use it wherever a client id is accepted. The challenge refusals and the one-call login form are on [Exchange a signed login challenge](https://docs.gethevn.com/api-reference/auth/exchange-a-signed-login-challenge-for-a-session.md).

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

### When a key is refused

At login, `401 invalid_credentials` covers any problem with the email, the key, the allowlist, the signer or the signature. It does not name the check that failed.

Refusals after login are in [Errors](https://docs.gethevn.com/developers/reference/errors.md):

* `403 forbidden` (key deleted, address outside the allowlist, or `details.reason = "developerSessionRequired"`) is under [Developer keys](https://docs.gethevn.com/developers/reference/errors.md#developer-keys).
* `403 key_not_registered`, `409 signer_not_attached` and `400 signature_invalid` are under [Authentication and signing](https://docs.gethevn.com/developers/reference/errors.md#authentication-and-signing).
* `403 account_not_controlled` is under [Acting as a client](https://docs.gethevn.com/developers/reference/errors.md#acting-as-a-client).

### Tokens

The access token audience is `platform`, and the refresh token audience is `platform-refresh`. Lifetimes and token fields are on [Exchange a signed login challenge](https://docs.gethevn.com/api-reference/auth/exchange-a-signed-login-challenge-for-a-session.md).

Both tokens have a `developer_key_id` claim that names the key behind the session. `/dapi/v1` refuses other bearers.

* No bearer returns `401 unauthenticated`.
* An app token, a refresh token on another route, or a token with no key behind it returns `403 forbidden`. `details.reason` is `developerSessionRequired`.

`/api/v1`, the app's API, refuses a platform token.

Refresh with the refresh token as the bearer and an empty body:

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

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

Log in once per process and store the refresh token as a secret. Refresh about a minute before the access token expires. `Session` in [HTTP client](https://docs.gethevn.com/developers/reference/client.md) does this.

### Session scope

Production refuses a sandbox token, and the reverse. Log in on each host with a key created in that environment's app. See [rate limits](https://docs.gethevn.com/developers/conventions.md#rate-limits).

## Acting as a client

Send `X-Hevn-Account: cl_…` with your access token to act for a client. One token covers all your clients: those linked through [HEVN Connect](https://docs.gethevn.com/connect/overview.md) and those created with `POST /dapi/v1/clients` for [external wallets](https://docs.gethevn.com/external-wallets/overview.md).

The header selects whose data a call reads and writes. It does not let the developer key sign for that client's wallet.

```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"})
```

No path has a `{clientId}` parameter.

Your own `cl_…` id, the `userId` from `POST /dapi/v1/auth/token`, selects your integrator account. Send it on the routes that require the header, for example to open a sub account with `POST /dapi/v1/client/accounts`.

| Routes | Header | Behaviour |
| - | - | - |
| `/banks*`, `/payins*`, `/payouts*`, `/swaps*`, `/contacts*`, `/transactions*`, `/documents*`, `/sandbox/*` | Optional | Without it the call acts on your integrator account. |
| `GET /client`, `PATCH /client`, `GET /client/balance`, `GET /client/kyb`, `PUT /client/kyb`, `POST /client/kyb/complete`, `/client/accounts*` | Required | For your integrator account, send your `cl_…`. |
| `POST /clients`, `POST /clients/link`, `GET /clients`, `POST /connect/sessions` | Refused | These act on your integrator account. |
| `/escrow*` | Refused | A deal names `senderClientId` and `receiverClientId` in the body. You are the operator. |
| `/auth/*`, `GET /chains`, `GET /rates` | Ignored | n/a |

| Status | `code` | Cause |
| - | - | - |
| 400 | `account_header_invalid` | The value is not a `cl_…` client id. |
| 400 | `account_scope_conflict` | The header, or a token scoped to another account, reached a route that refuses it. `details.reason` is `selfScopedRoute`. |
| 422 | `validation_failed` | The header is missing on a `/client*` route. `details.fields` names it. |
| 404 | `account_not_found` | The id is unknown or is not one of your clients. |
| 403 | `account_not_controlled` | A call that needs your developer key to sign for a client: a payout confirm, a swap, a sub account. `details.clientId` names the client. See [Who can spend](https://docs.gethevn.com/developers/overview.md#who-can-spend). |
| 403 | `account_forbidden` | The client is yours, but your integrator standing or the link to the client is not current. |
| 403 | `account_read_only` | The credential in `details.credential` may read this client and not write to it. |

For an access token that acts for one client, send its `userId` to [`POST /dapi/v1/auth/refresh`](https://docs.gethevn.com/api-reference/auth/exchange-a-refresh-token-for-a-session-optionally-acting-for-an-account).

## Rotate a key

Both keys work while both exist.

**1. Create the second key**

Create it in the app, with an allowlist that covers the hosts that will use it.

**2. Switch your servers**

Deploy the new key file. Payments now send the new `publicKey`. An approval is bound to your signer, so one prepared before the switch can be confirmed with either key.

**3. Delete the old key**

Delete it in the app. The next login or payment that uses it is refused.

Deleting a key ends its sessions. The next call with an access token from that key returns `403`, and so does `POST /dapi/v1/auth/refresh`. Delete the key when a token leaks, too. Test rotation in the sandbox.

> [card] [Next: Signing](https://docs.gethevn.com/developers/signing.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.