Turnkey signs webhook deliveries with Ed25519. Verify the signature before parsing or trusting the JSON payload.
Verification must use the exact raw request body bytes that Turnkey sent. Re-serializing parsed JSON, changing whitespace, or changing key order will cause verification to fail.
The signature covers the signature contract fields and the raw body:
v1.ed25519.<signing_key_id>.<timestamp_ms>.<event_id>.<raw_body>
The event_id segment is the value of the X-Turnkey-Event-Id header. Other delivery headers such as organization ID and event type are not part of the signed message.
Key discovery
Turnkey publishes webhook signing keys at a public JWKS endpoint. Use this endpoint to fetch the Ed25519 public key needed to verify webhook signatures. Match the kid field in the response to the X-Turnkey-Signature-Key-Id header on each delivery:
GET https://api.turnkey.com/public/v1/discovery/webhooks/jwks
This endpoint requires no authentication. The response is a standard JSON Web Key Set (RFC 7517) containing Ed25519 public keys:
{
"keys": [
{
"kid": "<signing-key-id>",
"kty": "OKP",
"crv": "Ed25519",
"alg": "EdDSA",
"use": "sig",
"x": "<base64url-encoded-public-key>",
"turnkey_signature_algorithm": "ed25519",
"turnkey_signature_version": "v1"
}
]
}
| Field | Description |
|---|
kid | Key identifier. Match this against the X-Turnkey-Signature-Key-Id delivery header. |
kty | Key type. OKP (Octet Key Pair) for Ed25519 keys. |
crv | Curve. Ed25519. |
alg | Algorithm. EdDSA. |
use | Key usage. sig (signature). |
x | Base64url-encoded 32-byte Ed25519 public key. |
turnkey_signature_algorithm | Turnkey-specific. Matches the X-Turnkey-Signature-Algorithm header value. |
turnkey_signature_version | Turnkey-specific. Matches the X-Turnkey-Signature-Version header value. |
The JWKS endpoint returns standard Cache-Control headers. Cache the response according to those headers and refresh on expiry. If a delivery arrives with a kid that does not match any cached key, refetch the JWKS before rejecting the delivery. This avoids stale-cache failures during key rotation while still allowing efficient caching.
SDK verification helper
The @turnkey/crypto package (v2.10.0+) exports verifyTurnkeyWebhookSignature, which handles signed-input reconstruction, timestamp freshness, and Ed25519 verification. The helper accepts caller-provided verification keys and returns a typed result instead of throwing.
Fetch the JWKS endpoint, convert each key’s base64url-encoded x field to hex, and pass the result as verificationKeys:
import { verifyTurnkeyWebhookSignature } from "@turnkey/crypto";
const JWKS_URL =
"https://api.turnkey.com/public/v1/discovery/webhooks/jwks";
// Fetch and cache the JWKS. Convert base64url public keys to hex.
async function fetchVerificationKeys() {
const res = await fetch(JWKS_URL);
const jwks = await res.json();
return jwks.keys.map((key: any) => ({
keyId: key.kid,
publicKey: Buffer.from(key.x, "base64url").toString("hex"),
algorithm: "ed25519" as const,
}));
}
const verificationKeys = await fetchVerificationKeys();
// In your webhook handler:
const rawBody = await request.text();
const result = verifyTurnkeyWebhookSignature({
headers: request.headers,
body: rawBody,
verificationKeys,
maxTimestampAgeMs: 5 * 60 * 1000, // 5-minute replay window
});
if (!result.ok) {
// result.reason describes the failure (e.g. "stale_timestamp", "missing_key")
return new Response("Invalid signature", { status: 401 });
}
// Signature verified. Safe to parse.
const payload = JSON.parse(rawBody);
Always read the raw request body before any middleware parses it. Frameworks like Express require express.raw({ type: "application/json" }) to preserve the original bytes. If your framework has already parsed the body, the re-serialized output may differ from what Turnkey signed.
Manual verification
If you are not using the TypeScript SDK, verify signatures manually:
- Fetch the JWKS from
https://api.turnkey.com/public/v1/discovery/webhooks/jwks.
- Find the key whose
kid matches the X-Turnkey-Signature-Key-Id header. Base64url-decode the x field to obtain the 32-byte Ed25519 public key.
- Reconstruct the signed input by concatenating the header values and raw body in the format:
v1.ed25519.<key_id>.<timestamp_ms>.<event_id>.<raw_body>.
- Hex-decode the
X-Turnkey-Signature header to obtain the 64-byte Ed25519 signature.
- Verify the Ed25519 signature over the signed input bytes using the public key from step 2.
- Check that
X-Turnkey-Timestamp is within an acceptable freshness window (e.g. 5 minutes) to guard against replay attacks.