Rotate keys and the signing secret
Swap an API key without downtime, sign events correctly, and rotate the signing secret.
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"import { PatternaClient } from "patterna-sdk";
export const API_BASE = "https://patterna-api.orven.am/v1";
// Serve and ingest calls, plus user mode, profile and deletion, go through the SDK.
export const patterna = new PatternaClient({
baseUrl: API_BASE,
keys: {
serve: process.env.PATTERNA_SERVE_KEY,
ingest: process.env.PATTERNA_INGEST_KEY,
admin: process.env.PATTERNA_ADMIN_KEY,
},
});
// Endpoints the SDK leaves out (admin, proof, catalog reads) use plain fetch.
export async function rest<T = unknown>(
keyEnv: string,
method: string,
path: string,
body?: unknown,
): Promise<T | undefined> {
const res = await fetch(`${API_BASE}${path}`, {
method,
headers: {
authorization: `Bearer ${process.env[keyEnv]}`,
...(body === undefined ? {} : { "content-type": "application/json" }),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
if (!res.ok) {
throw new Error(`Patterna ${method} ${path}: ${res.status} ${text} (request id ${res.headers.get("x-request-id")})`);
}
return text ? (JSON.parse(text) as T) : undefined;
}package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
patterna "github.com/payam-h/patterna-sdk/go"
)
const apiBase = "https://patterna-api.orven.am/v1"
// newClient builds the SDK client: serve and ingest calls, plus user mode, profile and deletion.
func newClient() (*patterna.Client, error) {
return patterna.New(patterna.Config{
BaseURL: apiBase,
ServeKey: os.Getenv("PATTERNA_SERVE_KEY"),
IngestKey: os.Getenv("PATTERNA_INGEST_KEY"),
AdminKey: os.Getenv("PATTERNA_ADMIN_KEY"),
Logger: slog.Default(),
})
}
// rest calls the endpoints the SDK leaves out (admin, proof, catalog reads).
func rest(ctx context.Context, keyEnv, method, path string, body, out any) error {
var rd io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
rd = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, apiBase+path, rd)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv(keyEnv))
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
msg, _ := io.ReadAll(resp.Body)
return fmt.Errorf("patterna %s %s: %d %s (request id %s)", method, path, resp.StatusCode, msg, resp.Header.Get("X-Request-Id"))
}
if out == nil || resp.StatusCode == http.StatusNoContent {
return nil
}
return json.NewDecoder(resp.Body).Decode(out)
}import os
import httpx
API_BASE = "https://patterna-api.orven.am/v1"
def client(key_env: str) -> httpx.Client:
return httpx.Client(
base_url=API_BASE,
headers={"Authorization": f"Bearer {os.environ[key_env]}"},
timeout=5.0, # ingest and admin budget; recommend passes its own 150 ms
)
serve = client("PATTERNA_SERVE_KEY")
ingest = client("PATTERNA_INGEST_KEY")
admin = client("PATTERNA_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_scopeelsewhere); 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. environmentis 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://patterna-api.orven.am/v1/keys" \
-H "Authorization: Bearer $PATTERNA_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "backend serve 2026-10",
"scope": "serve",
"environment": "production"
}'const key = await rest<{ id: string; prefix: string; secret: string }>("PATTERNA_ADMIN_KEY", "POST", "/keys", {
name: "backend serve 2026-10",
scope: "serve",
environment: "production",
});
// key.secret is returned once: put it in your secret store now and never log it.
console.log(key?.id, key?.prefix);var key struct {
ID string `json:"id"`
Prefix string `json:"prefix"`
Secret string `json:"secret"` // returned once: store it now, never log it
}
err := rest(ctx, "PATTERNA_ADMIN_KEY", "POST", "/keys", map[string]string{
"name": "backend serve 2026-10",
"scope": "serve",
"environment": "production",
}, &key)
if err != nil {
return err
}
fmt.Println(key.ID, key.Prefix)r = admin.post(
"/keys",
json={"name": "backend serve 2026-10", "scope": "serve", "environment": "production"},
)
r.raise_for_status() # 201
key = r.json()
# key["secret"] is returned once: store it now and never log it.
print(key["id"], key["prefix"])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://patterna-api.orven.am/v1/keys" \
-H "Authorization: Bearer $PATTERNA_ADMIN_KEY"const keys = await rest<Array<{ id: string; name: string; scope: string; prefix: string; revoked_at: string | null }>>(
"PATTERNA_ADMIN_KEY",
"GET",
"/keys",
);
console.table(keys);var keys []struct {
ID string `json:"id"`
Name string `json:"name"`
Scope string `json:"scope"`
Prefix string `json:"prefix"`
RevokedAt *string `json:"revoked_at"`
}
if err := rest(ctx, "PATTERNA_ADMIN_KEY", "GET", "/keys", nil, &keys); err != nil {
return err
}
for _, k := range keys {
fmt.Println(k.ID, k.Name, k.Scope, k.Prefix, k.RevokedAt)
}r = admin.get("/keys")
r.raise_for_status()
for k in r.json():
print(k["id"], k["name"], k["scope"], k["prefix"], k["revoked_at"])Revoke the old key#
curl -sS -X DELETE "https://patterna-api.orven.am/v1/keys/$OLD_KEY_ID" \
-H "Authorization: Bearer $PATTERNA_ADMIN_KEY"await rest("PATTERNA_ADMIN_KEY", "DELETE", `/keys/${process.env.OLD_KEY_ID}`); // 204if err := rest(ctx, "PATTERNA_ADMIN_KEY", "DELETE", "/keys/"+os.Getenv("OLD_KEY_ID"), nil, nil); err != nil {
return err
}r = admin.delete(f"/keys/{os.environ['OLD_KEY_ID']}")
r.raise_for_status() # 204Set 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#
- Take the event as you will send it and remove
signature. - Serialise it as canonical JSON: keys sorted at every level, no whitespace,
<,>and¬ 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. - Compute HMAC-SHA256 over those bytes with the key below.
- 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"}.
| Secret | Key | Signature |
|---|---|---|
pss_000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f | the string's UTF-8 bytes | 8fefcd355112cecfce0cde75bdc7566d7d6620918e6b6109bfbd7878e317d815 |
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}'import { signEvent } from "patterna-sdk";
// Keys the HMAC with the pss_ secret string.
const signed = await signEvent(event, process.env.PATTERNA_SIGNING_SECRET!);// The secret is the pss_ string, used byte for byte.
signed, err := patterna.SignEvent(event, os.Getenv("PATTERNA_SIGNING_SECRET"))
if err != nil {
return err
}
fmt.Println(signed.Signature)import hashlib
import hmac
import json
def canonical_json(event: dict) -> bytes:
# The event minus "signature"; leave absent fields out instead of sending None.
body = {k: v for k, v in event.items() if k != "signature"}
text = json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
# The canonical form escapes U+2028 and U+2029; json.dumps leaves them raw.
text = text.replace("\u2028", "\\u2028").replace("\u2029", "\\u2029")
return text.encode("utf-8")
def sign_event(event: dict, secret: str) -> dict:
key = secret.encode("utf-8") # the pss_ string, byte for byte
sig = hmac.new(key, canonical_json(event), hashlib.sha256).hexdigest()
return {**event, "signature": sig}
event = {
"event_id": "evt_1",
"type": "rec_click",
"occurred_at": "2026-10-13T09:00:00Z",
"session_id": "s_1",
"request_id": "req_1",
"props": {"item_id": "doc_42", "kind": "document", "position": 0},
}
secret = "pss_000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
assert sign_event(event, secret)["signature"] == "8fefcd355112cecfce0cde75bdc7566d7d6620918e6b6109bfbd7878e317d815"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-secretcreates the first secret when the tenant has none and answers200withsigning_secret,issued_atand anote, once. When one exists it answers409 signing_secret_exists; rotate instead.
curl -sS -X POST https://patterna-api.orven.am/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.