Skip to main content

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
}
FieldTypeRequiredNotes
ownerstringyesThe identity this key acts as
labelstringyesTrimmed, truncated to 60 characters
publicKeystringyesThe compressed public key, hex
permissionsstring[]yesAt least one valid value. Unrecognised entries are silently dropped
ttlDaysnumbernoPositive 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.

Registration itself is not signed

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

PermissionEnforced today
invoices:createYes — required by POST /api/invoices
payments:sendNo — accepted and stored, but no route checks it yet
treasury:approveNo — 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

Use buildApiKeyAuthHeaders:

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:

HeaderValue
X-Varnir-Api-KeyYour compressed public key, hex. There is no separate key id in the request
X-Varnir-TimestampDate.now() as a string
X-Varnir-SignatureBase64 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 trusting owner from the body.

Auth failures

All return 401 with an error field:

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