API keys
A Varnir API key is not a secret string. It is a secp256k1 keypair that you generate, of which Varnir only ever stores the public half. Requests are authenticated by signature, so there is no bearer token to leak in a log, a proxy, or a request header.
This is the same signing shape every chain write in the system already uses — there is no separate auth system layered on top.
Generate a keypair
Use a fresh keypair, not your identity key. Scoping and revocation are then independent: revoking an API key does not touch your identity, and a compromised API key cannot sign ledger transactions.
import {secp256k1} from '@noble/curves/secp256k1';
const privateKey = secp256k1.utils.randomPrivateKey();
const publicKey = secp256k1.getPublicKey(privateKey, true); // compressed
const toHex = (b: Uint8Array) => '0x' + Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
const apiKeyPair = {privateKeyHex: toHex(privateKey), publicKeyHex: toHex(publicKey)};
Keep privateKeyHex. It is never transmitted and cannot be recovered — if you
lose it, revoke the key and register a new one.
Register it
POST /api/api-keys
Content-Type: application/json
{
"owner": "<identity stream id>",
"label": "Billing service",
"publicKey": "0x02…",
"permissions": ["invoices:create"],
"ttlDays": 90
}
| Field | Type | Required | Notes |
|---|---|---|---|
owner | string | yes | The identity this key acts as |
label | string | yes | Trimmed, truncated to 60 characters |
publicKey | string | yes | The compressed public key, hex |
permissions | string[] | yes | At least one valid value. Unrecognised entries are silently dropped |
ttlDays | number | no | Positive number of days. Omit for a key that never expires |
Returns the stored record:
{
"id": "3f9a2c1d…",
"owner": "…",
"label": "Billing service",
"public_key": "0x02…",
"permissions": "invoices:create",
"expires_at": 1737776000000,
"revoked_at": null,
"created_at": 1730000000000
}
permissions comes back as a comma-joined string, not an array. id is 16
hex characters, and is what you pass to revoke.
POST /api/api-keys uses the same informal same-origin trust model as the web
wallet's other endpoints: it trusts owner from the body. The signature
requirement applies to using a key, not to registering one. Do not treat the
existence of a registered key as proof of anything about who registered it.
Permissions
| Permission | Enforced today |
|---|---|
invoices:create | Yes — required by POST /api/invoices |
payments:send | No — accepted and stored, but no route checks it yet |
treasury:approve | No — accepted and stored, but no route checks it yet |
The last two are placeholders for the treasury-approval model described in the
introduction, which is not built. Granting them does not currently confer
anything. Grant invoices:create only, unless you specifically want the record
to exist ahead of time.
Unrecognised permission strings are filtered out silently rather than rejected —
if you typo one and it was your only entry, the request fails with the generic
400 for having no valid permissions.
Signing a request
import {buildApiKeyAuthHeaders} from '@varnir/chain-client';
const bodyText = JSON.stringify({walletIds: [walletId], name: 'Invoice', amount: '10', currency: 'native'});
const res = await fetch('https://scanner.varnir.site/api/invoices', {
method: 'POST',
headers: {
'content-type': 'application/json',
...buildApiKeyAuthHeaders('POST /api/invoices', bodyText, apiKeyPair.publicKeyHex, apiKeyPair.privateKeyHex),
},
body: bodyText,
});
Three headers go out:
| Header | Value |
|---|---|
X-Varnir-Api-Key | Your compressed public key, hex. There is no separate key id in the request |
X-Varnir-Timestamp | Date.now() as a string |
X-Varnir-Signature | Base64 DER ECDSA signature over `${routeTag}\n${timestamp}\n${bodyText}` |
Rules that bite if ignored:
- The route tag is a fixed literal per endpoint (
POST /api/invoices), matching the server's own constant — not derived from the URL you called. This is what prevents replaying a signature against a different route. - The body string must be identical to what you signed. Serialise once, sign that string, send that string.
- Sending any one of the three headers commits you to the signed path. The
server switches to API-key auth as soon as all three are present, and a bad
signature is a
401— it does not fall back to trustingownerfrom the body.
Auth failures
All return 401 with an error field:
| Message | Cause |
|---|---|
Missing API key auth headers. | Not all three headers present |
Request timestamp is too old or invalid. | More than 5 minutes of clock skew in either direction |
Invalid signature. | Signature does not verify against the supplied public key |
Unknown API key. | That public key is not registered |
This API key has been revoked. | |
This API key has expired. | Past expires_at |
This API key does not have the '…' permission. |
If you see Request timestamp is too old or invalid. intermittently, check the
clock on the machine making the request — the window is generous, so persistent
failures mean real drift.
List and revoke
GET /api/users/:id/api-keys
POST /api/api-keys/:id/revoke {"owner": "<identity stream id>"}
Listing returns the same records as registration, newest first, and never includes any private material — Varnir does not have it.
Revocation is immediate and permanent: it stamps revoked_at, and every
subsequent signed request with that key is rejected. There is no un-revoke.
Register a new key instead.