Skip to main content
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.

Signed message format

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"
    }
  ]
}
FieldDescription
kidKey identifier. Match this against the X-Turnkey-Signature-Key-Id delivery header.
ktyKey type. OKP (Octet Key Pair) for Ed25519 keys.
crvCurve. Ed25519.
algAlgorithm. EdDSA.
useKey usage. sig (signature).
xBase64url-encoded 32-byte Ed25519 public key.
turnkey_signature_algorithmTurnkey-specific. Matches the X-Turnkey-Signature-Algorithm header value.
turnkey_signature_versionTurnkey-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:
  1. Fetch the JWKS from https://api.turnkey.com/public/v1/discovery/webhooks/jwks.
  2. 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.
  3. 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>.
  4. Hex-decode the X-Turnkey-Signature header to obtain the 64-byte Ed25519 signature.
  5. Verify the Ed25519 signature over the signed input bytes using the public key from step 2.
  6. Check that X-Turnkey-Timestamp is within an acceptable freshness window (e.g. 5 minutes) to guard against replay attacks.