Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.flatpeak.com/llms.txt

Use this file to discover all available pages before exploring further.

Webhook signing is in preview. Headers, signature scheme, and verification may change before general availability.
Flatpeak signs webhook payloads so you can verify that requests are authentic and haven’t been tampered with. To verify a webhook, your handler needs to:
  1. Capture the raw request body bytes — before any JSON parsing or framework re-serialisation.
  2. Build the signed message: {Flatpeak-Timestamp}.{raw_body}.
  3. Fetch the public key matching Flatpeak-Key-ID from the JWKS endpoint (cache it).
  4. Decode the signature (base64url, no padding) and verify with RSA-PSS SHA-256.
  5. Reject signatures older than your replay tolerance (e.g. 5 minutes).
The code examples below do all of this end-to-end.
You must use the raw, unmodified request body bytes as received over the wire. If your framework parses the JSON and your handler re-serialises it, even a single whitespace or key-order change will break verification. See Capturing the raw body below.

Headers

Every signed webhook request includes these headers:
HeaderExampleDescription
Flatpeak-Signaturev1=WbyqgN...The base64url-encoded RSA-PSS signature, prefixed with the scheme version.
Flatpeak-Signature-Schemev1The signature scheme version (currently v1).
Flatpeak-Timestamp1776847880Unix timestamp (seconds) when the payload was signed.
Flatpeak-Key-IDwsk_live_3f69b1...Identifies which signing key was used.
Flatpeak-Version2025-09-14.anodeThe API version that generated the event.
If signing is unavailable, the Flatpeak-Signature header is set to none, and no timestamp or key ID headers are sent. You should reject requests with a signature of none in production.

Example request

A signed webhook delivery looks like this on the wire — the headers carry the signature material, and the body is the exact bytes that were signed:
POST /your-webhook-endpoint HTTP/1.1
Host: example.com
Content-Type: application/json
Flatpeak-Signature: v1=WbyqgN8s2k0kQH3Yx4f0pYx9Q3l1m2n4oP6rT7sV8w9XaYbZc0d1e2f3g4h5i6j7k8l9m0n1o2p3q4r5s6t7u8v9w0x1y2z3A4B5C6D7E8F9G0H1I2J3K4L5M6N7O8P9Q0R1S2T3U4V5W6X7Y8Z9
Flatpeak-Signature-Scheme: v1
Flatpeak-Timestamp: 1776847880
Flatpeak-Key-ID: wsk_live_3f69b1c2d4e5f6a7b8c9d0e1f2a3b4c5
Flatpeak-Version: 2025-09-14.anode

{
    "id": "evt_01HZX9K7M3N4P5Q6R7S8T9",
    "object": "event",
    "type": "location.created",
    "created": 1776847880,
    "data":
    {
        "object":
        {
            "id": "loc_01HZX9K7M3N4P5Q6R7S8T9",
            "object": "location"
        }
    }
}
The string that was signed is the Flatpeak-Timestamp value, a literal ., then the raw request body — e.g.:
1776847880.{"id":"evt_01HZX9K7M3N4P5Q6R7S8T9","object":"event","type":"location.created","created":1776847880,"data":{"object":{"id":"loc_01HZX9K7M3N4P5Q6R7S8T9","object":"location"}}}
Header values above are illustrative. Use actual request values when verifying.

Algorithm

Signatures use RSA-PSS with SHA-256 (PS256) with RSA-2048 keys, MGF1-SHA256, and a salt length equal to the digest length (32 bytes). Each account has separate signing keys for live and test mode.

Verification steps

  1. Extract the headers. Read Flatpeak-Signature, Flatpeak-Timestamp, and Flatpeak-Key-ID from the incoming request. Reject the request immediately if Flatpeak-Signature is missing or set to none.
  2. Construct the signed message. Concatenate the timestamp, a literal ., and the raw request body bytes: {timestamp}.{raw_request_body}. Do not parse and re-serialize the JSON — any difference in whitespace or key ordering will cause verification to fail.
  3. Get the public key. Fetch the JWKS from the Flatpeak endpoint (see Fetching the public key below) and select the key whose kid matches Flatpeak-Key-ID. Cache the JWKS — signing keys are long-lived but rotate occasionally, so refetch when you see an unknown kid.
  4. Decode the signature. Strip the v1= prefix from the header value. The remainder is base64url without padding (RFC 4648 Section 5). Most standard libraries decode base64url natively; the code samples below show how.
  5. Verify. Verify the decoded signature against the signed message from step 2 using RSA-PSS with SHA-256 (salt length = digest length).
  6. Check the timestamp. To prevent replay attacks, reject requests where |now - Flatpeak-Timestamp| exceeds your tolerance — 5 minutes is a sensible default. The code samples below include this check.

Fetching the public key

Flatpeak publishes signing keys as a standard JWKS (RFC 7517):
curl https://api.flatpeak.com/jwks.json \
  -H "Authorization: Bearer <your_secret_key>"
The response contains one or more RSA keys:
{
  "keys": [
    {
      "kty": "RSA",
      "kid": "wsk_live_3f69b1c2d4e5f6a7b8c9d0e1f2a3b4c5",
      "use": "sig",
      "alg": "PS256",
      "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx...",
      "e": "AQAB"
    }
  ]
}
Cache the JWKS response. When a webhook arrives with a Flatpeak-Key-ID you don’t have, refetch — it indicates a key rotation.

Capturing the raw body

The signature is computed over the exact bytes Flatpeak sent. Most web frameworks parse JSON automatically, which throws those bytes away. You must capture them before parsing:
FrameworkHow to get the raw body
Express (Node.js)app.use(express.raw({ type: 'application/json' })) on the webhook route; req.body is then a Buffer.
FastifyAdd a contentTypeParser for application/json that returns the raw body, or read from the request stream.
Flask (Python)request.get_data() (returns bytes). Do not call request.get_json() first.
FastAPI / Starletteawait request.body() returns the raw bytes.
Djangorequest.body (raw bytes).
Go (net/http)io.ReadAll(r.Body) — read once, then re-attach via r.Body = io.NopCloser(bytes.NewReader(b)) if needed.

Code examples

const crypto = require("crypto");

const TOLERANCE_SECONDS = 5 * 60;

// rawBody must be a Buffer of the unmodified request body bytes
// (e.g. captured via express.raw({ type: 'application/json' })).
function verifyWebhookSignature(headers, rawBody, publicKeyPem) {
  const signature = headers["flatpeak-signature"];
  const timestamp = headers["flatpeak-timestamp"];

  if (!signature || signature === "none" || !timestamp) {
    return false;
  }

  // Replay protection.
  const skew = Math.abs(
    Math.floor(Date.now() / 1000) - parseInt(timestamp, 10),
  );
  if (Number.isNaN(skew) || skew > TOLERANCE_SECONDS) {
    return false;
  }

  // Strip v1= prefix; Buffer.from supports base64url natively.
  const sigBuffer = Buffer.from(signature.replace(/^v1=/, ""), "base64url");

  // Signed message: "{timestamp}.{raw_body}".
  const message = Buffer.concat([Buffer.from(`${timestamp}.`), rawBody]);

  const verifier = crypto.createVerify("SHA256");
  verifier.update(message);
  return verifier.verify(
    {
      key: publicKeyPem,
      padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
      saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST,
    },
    sigBuffer,
  );
}

Troubleshooting

ProblemCauseSolution
Signature always failsRe-serialized JSON bodyUse the raw request body bytes — see Capturing the raw body.
Signature always failsWrong base64 variantUse base64url decoding, not standard base64. Languages with native base64url support handle padding.
Signature always failsv1= prefix included in decodeStrip the v1= scheme prefix before decoding.
Signature fails after workingSigning key rotatedRefetch the JWKS and pick the key whose kid matches Flatpeak-Key-ID. Cache, but invalidate on miss.
Intermittent failuresTimestamp tolerance too strictAllow at least 5 minutes of clock skew, or sync your server clock with NTP.
Flatpeak-Signature: noneSigning key not yet provisioned or signing failedKeys are provisioned on first webhook delivery — retry the event. Reject none in production.
Need to inspect bytes by handReproduce verification on the command line with Debug webhook signatures.

Debug with OpenSSL

Step-by-step walkthrough for reproducing signature verification from the command line — useful when your integration keeps failing and you need to pinpoint why.