> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.telekesher.dev/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.telekesher.dev/_mcp/server.

# Webhook signatures

Every webhook the Gateway delivers is cryptographically signed with an
[Ed25519](https://en.wikipedia.org/wiki/EdDSA#Ed25519) cluster key so you can
verify it really came from us and was not tampered with in transit. Signing is
cluster-wide and is always active — there is no per-app shared secret.

## Signature Header

Every delivery carries all three headers below. The application UUID and idempotency key are part of the signed pre-image, so verify the signature before trusting either value:

| Header                | Value                              | Description                                                                                         |
| --------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------- |
| `X-Webhook-Signature` | `t=<unix>,akid=<kid>,v1a=<base64>` | A comma-separated list of `key=value` fields (see below).                                           |
| `X-Webhook-App-UUID`  | `{app_uuid}`                       | Application receiving the event. Require it and compare it with your configured application UUID.   |
| `X-Idempotency-Key`   | `{unique_key}`                     | Stable logical-event identity. Use it for deduplication only after signature verification succeeds. |

### Signature Fields

| Field  | Meaning                                                                                                                                                                       |
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `t`    | Unix epoch seconds when the delivery was signed.                                                                                                                              |
| `akid` | Opaque positive integer identifying the cluster Ed25519 public key used for `v1a`. Match this against the `akid` of a key returned by `GET /.well-known/webhook-public-keys`. |
| `v1a`  | Base64 Ed25519 signature over the signing input. Verified with the cluster **public** key — no secret needed.                                                                 |

Fields may appear in any order. Require exactly one each of `t`, `akid`, and `v1a`, reject duplicate known fields, and ignore unknown extension fields for forward compatibility. Webhook endpoints should enforce an application-appropriate request-body limit before buffering the raw bytes.

```http
X-Webhook-Signature: t=1750000000,akid=2,v1a=iQ8s...base64...Cg==
X-Webhook-App-UUID: app_3f9c0b2a-7d41-4e8b-9f12-2a6c5d0e7b34
X-Idempotency-Key: 01JY0M6M2C1Z8ZQ0CNA1K8A6JM
```

## The Signing Input

`v1a` signs this exact byte sequence, with one LF byte (`\n`, byte `0x0a`) after each of the first four fields and the raw request bytes appended without alteration:

```text
v1a\n<unix_seconds>\n<app_uuid>\n<idempotency_key>\n<raw_body>
```

Use the exact textual `t` field from `X-Webhook-Signature` and the exact `X-Webhook-App-UUID` and `X-Idempotency-Key` header values. Do not add a trailing newline after the body.

**Use the raw bytes.** Compute the signature over the request body exactly as received, before any JSON parse/re-serialize. Re-encoding (whitespace, key order, unicode escaping) changes the bytes and breaks verification. Read the raw body first, verify, *then* parse.

## Fetching the Public Key

The cluster's Ed25519 public key(s) are published at an unauthenticated endpoint:

```http
GET /.well-known/webhook-public-keys HTTP/1.1
Host: api.telekesher.dev
```

The endpoint is unauthenticated and returns key IDs, algorithm names, and base64-encoded public keys.

```json
{
  "keys": [
    {
      "akid": 2,
      "alg": "ed25519",
      "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
    },
    {
      "akid": 1,
      "alg": "ed25519",
      "public_key": "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="
    }
  ]
}
```

| Field        | Description                                                                                                  |
| ------------ | ------------------------------------------------------------------------------------------------------------ |
| `keys`       | The complete active key set. Array order has no signing meaning.                                             |
| `akid`       | Opaque positive integer that identifies a key. Match it exactly against the `akid` in `X-Webhook-Signature`. |
| `alg`        | Signature algorithm. Currently `ed25519`.                                                                    |
| `public_key` | Standard-base64 encoding of the raw 32-byte Ed25519 public key.                                              |

The `keys` array always contains at least one active key and may contain two keys during rotation.

Match the delivery's `akid=` field against a key's `akid`, then verify `v1a`
with that key only. Periodically refresh the complete key set and atomically
replace the cached keyring so retired keys are evicted. Also refresh immediately
when you encounter an `akid` you do not recognise, and reject the delivery if
no exact match exists after refresh.

## Verifying the Ed25519 Signature (`v1a`)

1. Read the raw request body plus `X-Webhook-Signature`, `X-Webhook-App-UUID`, and `X-Idempotency-Key`. Reject the request if any are missing or empty.
2. Compare `X-Webhook-App-UUID` with your configured application UUID and reject a mismatch.
3. Parse the signature header. Require exactly one each of `t`, `akid`, and `v1a`; reject duplicate known fields and ignore unknown extension fields.
4. Reject the delivery if `t` is too far from your current time (for example, more than 5 minutes) to limit replay.
5. Fetch the public key for this `akid` from `GET /.well-known/webhook-public-keys`. Refresh periodically by atomically replacing the full keyring, and immediately on an unknown `akid`.
6. Strictly decode the `public_key` from canonical standard base64 and require exactly 32 bytes.
7. Reconstruct the exact LF-delimited pre-image, strictly decode `v1a` from canonical standard base64, require exactly 64 signature bytes, and verify it with the Ed25519 public key.
8. Only after verification succeeds, parse and validate the JSON body. Then atomically claim the signed application UUID plus `X-Idempotency-Key` in a durable deduplication store. Use a short processing lease and an opaque ownership token that fences every renew, complete, and release operation. While processing, renew the lease with margin before it expires and pass a cancellation signal into external operations; if renewal fails, cancel or stop side effects so a stale owner cannot keep mutating state after takeover. Mark the claim complete only when side effects commit, and release it on failure. If another handler still owns the lease, return `425 Too Early` with `Retry-After` equal to the 30-second lease duration so the delivery is deferred without spending its failure budget. A completed claim should suppress later deliveries for at least 24 hours.

Redirects are not followed. Configure the final HTTPS webhook URL directly: a `3xx` response is a retryable redirect-policy failure under the normal delivery budget, and the signed body and headers are never forwarded to its target.

**Reject present-but-invalid signatures.** If `v1a` is present but fails verification, treat the delivery as unauthenticated and reject it. Do not fall through to any other check.

#### Node.js

```javascript
const crypto = require('crypto');

function decodeCanonicalBase64(value) {
  if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) return null;
  const decoded = Buffer.from(value, 'base64');
  return decoded.toString('base64') === value ? decoded : null;
}

// publicKeys: Map of akid (string) -> Buffer (raw 32-byte Ed25519 public key)
function verifyWebhook(rawBody, headers, publicKeys, expectedAppUuid) {
  const header = headers['x-webhook-signature'];
  const appUuid = headers['x-webhook-app-uuid'];
  const idempotencyKey = headers['x-idempotency-key'];
  if (!header || !appUuid || !idempotencyKey || !expectedAppUuid ||
      appUuid !== expectedAppUuid) return false;

  const fields = {};
  const allowed = new Set(['t', 'akid', 'v1a']);
  for (const part of header.split(',')) {
    const i = part.indexOf('=');
    if (i <= 0) return false;
    const key = part.slice(0, i).trim();
    if (!allowed.has(key)) continue;
    if (Object.hasOwn(fields, key)) return false;
    fields[key] = part.slice(i + 1).trim();
  }
  const { t, akid, v1a } = fields;
  if (!t || !akid || !v1a || !/^[1-9]\d*$/.test(akid)) return false;

  // Reject stale deliveries (replay protection).
  if (!/^\d+$/.test(t)) return false;
  const timestamp = Number(t);
  if (!Number.isSafeInteger(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;

  const rawPublicKey = publicKeys.get(akid);
  if (!rawPublicKey || rawPublicKey.length !== 32) return false;

  const prefix = `v1a\n${t}\n${appUuid}\n${idempotencyKey}\n`;
  const signingInput = Buffer.concat([Buffer.from(prefix, 'utf8'), rawBody]);
  const sig = decodeCanonicalBase64(v1a);
  if (!sig || sig.length !== 64) return false;

  try {
    // Node's crypto API accepts an SPKI key, so wrap the published raw key.
    const spkiPrefix = Buffer.from('302a300506032b6570032100', 'hex');
    const publicKey = Buffer.concat([spkiPrefix, rawPublicKey]);
    return crypto.verify(null, signingInput, { key: publicKey, format: 'der', type: 'spki' }, sig);
  } catch {
    return false;
  }
}

// Call verifyWebhook before JSON.parse(rawBody) and before consulting or
// updating your idempotency-key store.
```

#### Python

```python
import time
import base64
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.exceptions import InvalidSignature

def decode_canonical_base64(value: str, expected_length: int) -> bytes:
    decoded = base64.b64decode(value, validate=True)
    if len(decoded) != expected_length or base64.b64encode(decoded).decode("ascii") != value:
        raise ValueError("non-canonical base64 or unexpected decoded length")
    return decoded

# public_keys: dict mapping akid (str) -> Ed25519PublicKey. Build each value
# with Ed25519PublicKey.from_public_bytes(
#     decode_canonical_base64(item["public_key"], 32)
# ).
def verify_webhook(
    raw_body: bytes, headers: dict, public_keys: dict, expected_app_uuid: str
) -> bool:
    header = headers.get("X-Webhook-Signature")
    app_uuid = headers.get("X-Webhook-App-UUID")
    idempotency_key = headers.get("X-Idempotency-Key")
    if not (header and app_uuid and idempotency_key and expected_app_uuid):
        return False
    if app_uuid != expected_app_uuid:
        return False

    fields = {}
    allowed = {"t", "akid", "v1a"}
    for part in header.split(","):
        k, separator, v = part.partition("=")
        k = k.strip()
        if not separator:
            return False
        if k not in allowed:
            continue
        if not v or k in fields:
            return False
        fields[k] = v.strip()

    t, akid, v1a = fields.get("t"), fields.get("akid"), fields.get("v1a")
    if not (t and akid and v1a):
        return False

    try:
        # Reject malformed or stale deliveries (replay protection).
        if not t.isascii() or not t.isdigit() or int(t) < 0:
            return False
        if not akid.isascii() or not akid.isdigit() or int(akid) <= 0:
            return False
        if abs(time.time() - int(t)) > 300:
            return False

        public_key = public_keys.get(akid)
        if not public_key:
            return False

        prefix = f"v1a\n{t}\n{app_uuid}\n{idempotency_key}\n".encode("utf-8")
        signing_input = prefix + raw_body
        sig = decode_canonical_base64(v1a, 64)
        public_key.verify(sig, signing_input)
        return True
    except (InvalidSignature, ValueError):
        return False

# Call verify_webhook before parsing raw_body and before consulting or updating
# your idempotency-key store.
```

#### Go

```go
import (
    "crypto/ed25519"
    "encoding/base64"
    "strconv"
    "strings"
    "time"
)

func DecodePublicKey(encoded string) (ed25519.PublicKey, bool) {
    raw, err := base64.StdEncoding.Strict().DecodeString(encoded)
    if err != nil || len(raw) != ed25519.PublicKeySize ||
        base64.StdEncoding.EncodeToString(raw) != encoded {
        return nil, false
    }
    return ed25519.PublicKey(raw), true
}

// publicKeys maps akid -> ed25519.PublicKey. Populate it with DecodePublicKey.
func VerifyWebhook(
    rawBody []byte,
    sigHeader, appUUIDHeader, idempotencyKeyHeader, expectedAppUUID string,
    publicKeys map[string]ed25519.PublicKey,
) bool {
    if sigHeader == "" || appUUIDHeader == "" || idempotencyKeyHeader == "" ||
        expectedAppUUID == "" || appUUIDHeader != expectedAppUUID {
        return false
    }

    fields := map[string]string{}
    for _, part := range strings.Split(sigHeader, ",") {
        k, v, ok := strings.Cut(part, "=")
        k = strings.TrimSpace(k)
        if !ok {
            return false
        }
        if k != "t" && k != "akid" && k != "v1a" {
            continue
        }
        if _, duplicate := fields[k]; duplicate {
            return false
        }
        fields[k] = strings.TrimSpace(v)
    }
    t, akid, v1a := fields["t"], fields["akid"], fields["v1a"]
    if t == "" || akid == "" || v1a == "" {
        return false
    }

    // Reject stale deliveries (replay protection).
    ts, err := strconv.ParseInt(t, 10, 64)
    if err != nil {
        return false
    }
    parsedAKID, err := strconv.ParseInt(akid, 10, 64)
    if err != nil || parsedAKID <= 0 {
        return false
    }
    skew := time.Now().Unix() - ts
    if skew > 300 || skew < -300 {
        return false
    }

    pubKey, ok := publicKeys[akid]
    if !ok || len(pubKey) != ed25519.PublicKeySize {
        return false
    }

    sig, err := base64.StdEncoding.Strict().DecodeString(v1a)
    if err != nil || len(sig) != ed25519.SignatureSize ||
        base64.StdEncoding.EncodeToString(sig) != v1a {
        return false
    }

    prefix := "v1a\n" + t + "\n" + appUUIDHeader + "\n" + idempotencyKeyHeader + "\n"
    signingInput := append([]byte(prefix), rawBody...)
    return ed25519.Verify(pubKey, signingInput, sig)
}

// Call VerifyWebhook before JSON decoding and before consulting or updating
// your idempotency-key store.
```

## Key Rotation

During a cluster key rotation, two public keys are published at `GET /.well-known/webhook-public-keys` simultaneously (current and previous). Deliveries signed under the previous `akid` remain verifiable for an overlap window. Periodically fetch the full list, build a new keyring, and atomically replace the old one so a retired key cannot remain trusted indefinitely. Also refresh immediately when a delivery uses an unknown `akid`.