# Rotate keys and the signing secret

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

Source: https://www.patterna.nl/docs/rotate-keys-and-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.

```curl
# 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"
```

```ts
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;
}
```

```go
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)
}
```

```python
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_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.

## 1. 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
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"
  }'
```

```ts
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);
```

```go
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)
```

```python
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.

## 2. 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.

## 3. 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
curl -sS "https://patterna-api.orven.am/v1/keys" \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY"
```

```ts
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);
```

```go
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)
}
```

```python
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"])
```

## 4. Revoke the old key

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

```ts
await rest("PATTERNA_ADMIN_KEY", "DELETE", `/keys/${process.env.OLD_KEY_ID}`); // 204
```

```go
if err := rest(ctx, "PATTERNA_ADMIN_KEY", "DELETE", "/keys/"+os.Getenv("OLD_KEY_ID"), nil, nil); err != nil {
	return err
}
```

```python
r = admin.delete(f"/keys/{os.environ['OLD_KEY_ID']}")
r.raise_for_status()  # 204
```

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"}`.

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

```curl
# 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}'
```

```ts
import { signEvent } from "patterna-sdk";

// Keys the HMAC with the pss_ secret string.
const signed = await signEvent(event, process.env.PATTERNA_SIGNING_SECRET!);
```

```go
// 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)
```

```python
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-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://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`.
