Stridee
Stridee Platform Docs

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:

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

Shell
openssl genpkey -algorithm ed25519 -out stridee-sign.pem
openssl pkey -in stridee-sign.pem -pubout

The headers

Parameter
createdUnix seconds. Must be within 5 minutes of our clock.
keyidYour key's id from the console. This is what identifies the account.
nonceFresh 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.
alged25519. Optional, and never used to select anything — the key decides.
A signed request
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…:

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

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-uri is the absolute URL including the query string, and we rebuild it as https://api.stridee.com plus your path and query — never from your Host header. Signing one URL and calling another is the most common first failure.
  • No trailing newline after @signature-params.
  • Content-Digest is over the exact bytes you send. Serialize once, digest that, send that.
Text
"@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"

Signing one

There's a runnable version in node-example/sign-request.mjs.

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')}:`,
  };
}

Check it works

Point a new client here first — anywhere else, every mistake looks like the same 401.

get /v1/whoami
Reference

The endpoint a client is pointed at while it is being written.

It exists because the first signed request anybody makes is always wrong, and the failures are hard to tell apart from outside: a clock, a stale key, a @target-uri built with the wrong scheme, a body digested after a framework re-encoded it. All four look like "401" against a real endpoint. Here the only thing being tested is the signature, so the error is about the signature — and a 200 means the client is correct, not that this particular call happened to be.

It reads nothing and changes nothing, so it is also the one endpoint safe to leave in a customer's health check.

GET /v1/whoami
GET /v1/whoami HTTP/1.1
Host: api.stridee.com
Signature-Input: sig1=(…);keyid="…";alg="ed25519"
Signature: sig1=:…:
Response · 200
{
  "account_id": "0b6f2d14-59c3-4e7a-8d05-1af38b62c9e7"
}

When it fails

Each is a 401 whose body says which check failed.

does not cover @target-uriyour covered list is missing a required component
is Ns from our clockthe signing machine's time is off — the message says by how much
nonce has already been usedreusing a nonce, or retrying without minting a new one
Content-Digest does not matchthe 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 keyyou used your webhook key's id
was never confirmedconfirm it in the console — its private half may not exist
does not verifyalmost 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.