Signing requests
Requests are authenticated with an Ed25519 signature over the request itself — RFC 9421 HTTP Message Signatures. There is no API key.
There is no API key. Every request carries an Ed25519 signature over the request itself, made with a private key that never leaves your machine — so a leaked log, proxy or backup holds nothing that can be replayed as you. (A compromised machine still can: the key is on it.)
Getting a key
Keys → Call the API → Add, or let stridee login register one
for your machine. To bring your own:
openssl genpkey -algorithm ed25519 -out stridee-sign.pem
openssl pkey -in stridee-sign.pem -puboutIt has to be Ed25519 — not the X25519 key your deliveries are sealed
to. The console shows its id; that is the keyid every
request names, and it is how we know which account you are.
The headers
POST /v1/connect HTTP/1.1
Host: api.stridee.com
Content-Digest: sha-256=:zeuXewdQlhgzXOqle0t2/j7Jxy9QEC903PurkiKOxvs=:
Signature-Input: sig1=("@method" "@target-uri" "content-digest");created=1770124811;keyid="5a8f31d6-0c94-4b27-a3e5-71fd2809bc4e";nonce="g1UIt9b3k_FkSpsX2KVCGA";alg="ed25519"
Signature: sig1=:wqcAqbmYJ2ji2glfAMaRy4gruYYnx2nEFN2HN6jrnDnQ…:| Parameter | |
|---|---|
created | Unix seconds. Must be within 5 minutes of our clock. |
keyid | Your key's id from the console. This is what identifies the account. |
nonce | Fresh per request. We refuse a repeat within 5 minutes — created limits how long a captured request is useful, the nonce is what stops it being replayed. |
alg | ed25519. Optional, and never used to select anything — the key decides. |
What you must cover
@method and @target-uri always, plus content-digest whenever there is a body — and
nothing else. RFC 9421 lets the signer choose what a signature covers, so we insist on
exactly these: a request missing one, or covering anything more, is refused.
The signature base
"@method": POST
"@target-uri": https://api.stridee.com/v1/connect
"content-digest": sha-256=:zeuXewdQlhgzXOqle0t2/j7Jxy9QEC903PurkiKOxvs=:
"@signature-params": ("@method" "@target-uri" "content-digest");created=1770124811;keyid="5a8f31d6-0c94-4b27-a3e5-71fd2809bc4e";nonce="g1UIt9b3k_FkSpsX2KVCGA";alg="ed25519"One line per covered component, each newline-terminated, then @signature-params last
with no trailing newline. Sign those bytes with Ed25519, base64 the result (standard
alphabet), and wrap it in colons.
The three things that go wrong:
@target-uriis the absolute URL including the query string, and we rebuild it ashttps://api.stridee.complus your path and query — never from yourHostheader. Signing one URL and calling another is the most common first failure.- No trailing newline after
@signature-params. Content-Digestis over the exact bytes you send. Serialize once, digest that, send that.
Signing one
import { createPrivateKey, createHash, randomBytes, sign } from 'node:crypto';
const key = createPrivateKey(process.env.STRIDEE_SIGNING_KEY);
export function signRequest({ method, url, body }) {
const covered = ['@method', '@target-uri'];
const lines = [`"@method": ${method.toUpperCase()}`, `"@target-uri": ${url}`];
const headers = {};
if (body?.length) {
const digest = `sha-256=:${createHash('sha256').update(body).digest('base64')}:`;
headers['content-digest'] = digest;
covered.push('content-digest');
lines.push(`"content-digest": ${digest}`);
}
const params =
`;created=${Math.floor(Date.now() / 1000)}` +
`;keyid="${process.env.STRIDEE_SIGNING_KEY_ID}"` +
`;nonce="${randomBytes(16).toString('base64url')}"` +
`;alg="ed25519"`;
const inner = `(${covered.map((c) => `"${c}"`).join(' ')})`;
lines.push(`"@signature-params": ${inner}${params}`);
return {
...headers,
'signature-input': `sig1=${inner}${params}`,
signature: `sig1=:${sign(null, Buffer.from(lines.join('\n')), key).toString('base64')}:`,
};
}There's a runnable version in
node-example/sign-request.mjs.
Check it works
GET /v1/whoamiIt reads nothing and only tests the signature, so a 200 means your client is right:
{ "account_id": "9f1c7d20-5b84-4a6e-9c3f-1d0e8a25b743" }Point a new client at it first — anywhere else, every mistake looks like the same 401.
When it fails
Each is a 401 whose body says which check failed.
does not cover @target-uri | your covered list is missing a required component |
is Ns from our clock | the signing machine's time is off — the message says by how much |
nonce has already been used | reusing a nonce, or retrying without minting a new one |
Content-Digest does not match | the body changed between digesting and sending |
No live signing key <id> | wrong keyid, or the key was deleted |
is an encryption key, not a signing key | you used your webhook key's id |
was never confirmed | confirm it in the console — its private half may not exist |
does not verify | almost always @target-uri: it must be https://api.stridee.com exactly, with no trailing slash before your path |
Rotating
Register the next key, deploy it, delete the old one. A signature names its own keyid, so
both work in between. An account can hold up to 10 signing keys.
Something wrong or missing on this page? Tell us in Discord. Need something the API doesn’t do yet? Request it on the roadmap.