Patterna Docs

Rotate keys and the signing secret

Swap an API key without downtime, sign events correctly, and rotate the signing secret.

Developers 4 min read View as Markdown

Rotate a key in four steps: create the new one, deploy it, check it is used, revoke the old one. Two keys of the same scope can be active at the same time, so there is no gap.

Setup for the code examples#

Every example below comes in four tabs. The TypeScript and Go tabs use the SDKs for serve and ingest calls. The SDKs (0.2) have admin methods too (applyFlow, setSurfaceStatus, ledger and so on), but these tabs call the admin REST API through the small rest helper defined here so every request is visible. The Python tab calls the REST API with httpx; the Python SDK in packages/sdk-py has the same shape as the other two. The Go snippets run inside a function that has ctx context.Context and pc *patterna.Client from newClient() and returns an error; add time to the imports where a snippet uses it.

# Keys come from Connectors or Governance. Each is shown once and starts with pk_s_, pk_i_ or pk_a_.
export PATTERNA_SERVE_KEY="paste-the-serve-key"
export PATTERNA_INGEST_KEY="paste-the-ingest-key"
export PATTERNA_ADMIN_KEY="paste-the-admin-key"

How keys work#

  • Every key has one scope, visible in its prefix: pk_s_ (serve), pk_i_ (ingest), pk_a_ (admin). A serve or ingest key only works on the endpoints of its scope (401 wrong_scope elsewhere); an admin key works everywhere, so keep it off request paths.
  • The tenant comes from the key, never from the request body.
  • Patterna stores a SHA-256 hash of the key, not the key. The full secret is shown once, when you create it.
  • Lists show the key's first ten characters only, such as pk_s_Xb3kQ, with its name, scope, environment and last use.
  • environment is a label for your own bookkeeping. It does not isolate data: a test key reads and writes the same tenant. Use a separate staging tenant for tests.

Create the new key#

In Governance → API keys choose Create key, pick the scope and environment, and copy the secret. Or through the API:

curl -sS -X POST "https://api.patterna.nl/v1/keys" \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "backend serve 2026-10",
    "scope": "serve",
    "environment": "production"
  }'

The answer holds the key's id, prefix and, this one time, secret. Put the secret straight into your secret store. If you lose it, create another key.

Deploy it#

Replace the old value in your backend's environment, for example PATTERNA_SERVE_KEY, and deploy. Keys belong on a backend only. An app talks to your backend proxy, never to Patterna with a key.

Check it is used#

In Governance → API keys the new key shows a recent "last used" time. The old key's last use stops moving once every instance runs the new value. You can also list keys:

curl -sS "https://api.patterna.nl/v1/keys" \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY"

Revoke the old key#

curl -sS -X DELETE "https://api.patterna.nl/v1/keys/$OLD_KEY_ID" \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY"

Set OLD_KEY_ID to the id from the list first. The answer is 204 No Content. From then on the old key gets 401. Revoking is audited.

If a key leaked, skip the waiting: revoke it first, then create and deploy the new one. In the meantime your backend serves its own lists, as it does on any failed serve call.

The signing secret#

You do not need the signing secret to send events. POST /events takes an ingest key (or admin), and every event sent with such a key is trusted. There is no separate SDK key scope. The secret is for one case: you want a second check that an event really came from your backend, for example because it passes through a queue or a relay.

A signature on an event is optional. When it is present, the engine always verifies it, and a wrong signature rejects that event (the rest of the batch is stored). A signed event for a tenant that has no secret yet is rejected with no_signing_secret.

Rules for the secret:

  • It lives on your backend, next to your keys. Never in an app bundle: a secret in an app is a public secret.
  • It is pss_ followed by 64 hex characters (since engine 0.2). A secret from before that has no prefix and no longer verifies: rotate it once.

Sign an event#

  1. Take the event as you will send it and remove signature.
  2. Serialise it as canonical JSON: keys sorted at every level, no whitespace, <, > and & not escaped, U+2028 and U+2029 escaped, no trailing newline. Keep numbers integers where you can; the engine re-encodes numbers the way Go does.
  3. Compute HMAC-SHA256 over those bytes with the key below.
  4. Put the lowercase hex digest in signature.

The key is the pss_… secret string itself, its UTF-8 bytes, nothing decoded. This is what all three SDKs do.

Test vector. Check your code before you sign anything real; the canonical body is {"event_id":"evt_1","occurred_at":"2026-10-13T09:00:00Z","props":{"item_id":"doc_42","kind":"document","position":0},"request_id":"req_1","session_id":"s_1","type":"rec_click"}.

SecretKeySignature
pss_000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fthe string's UTF-8 bytes8fefcd355112cecfce0cde75bdc7566d7d6620918e6b6109bfbd7878e317d815

If you get 3e3a9cbc22f2101a…, you hex-decoded the part after pss_; that was the key for secrets before engine 0.2.

# Shell check only. jq + openssl match the canonical form ONLY when every number is an
# integer and no string contains U+2028 or U+2029. event.json is the event as you will send it.
# The key is the whole pss_ secret string.
jq -jcS 'del(.signature)' event.json \
  | openssl dgst -sha256 -hmac "$PATTERNA_SIGNING_SECRET" | awk '{print $NF}'

Get or rotate the secret#

  • Rotate: POST /settings/signing-secret/rotate (an owner in the panel, staff, or an admin key). The answer holds the new secret once. The old secret stops verifying at that moment, and events signed with it are rejected. So: stop signing (send unsigned for a minute), rotate, deploy the new secret, sign again. Rotate also creates a first secret when there is none.
  • First secret: POST /settings/signing-secret creates the first secret when the tenant has none and answers 200 with signing_secret, issued_at and a note, once. When one exists it answers 409 signing_secret_exists; rotate instead.
curl
curl -sS -X POST https://api.patterna.nl/v1/settings/signing-secret/rotate \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY"
# {"signing_secret":"pss_<64 hex characters, shown once>","issued_at":"…","note":"…"}

The rotation is in the audit log as signing_secret.rotate.

esc
  • Type to search every page, heading and code sample.