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: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.
- Capture the raw request body bytes — before any JSON parsing or framework re-serialisation.
- Build the signed message:
{Flatpeak-Timestamp}.{raw_body}. - Fetch the public key matching
Flatpeak-Key-IDfrom the JWKS endpoint (cache it). - Decode the signature (base64url, no padding) and verify with RSA-PSS SHA-256.
- Reject signatures older than your replay tolerance (e.g. 5 minutes).
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:| Header | Example | Description |
|---|---|---|
Flatpeak-Signature | v1=WbyqgN... | The base64url-encoded RSA-PSS signature, prefixed with the scheme version. |
Flatpeak-Signature-Scheme | v1 | The signature scheme version (currently v1). |
Flatpeak-Timestamp | 1776847880 | Unix timestamp (seconds) when the payload was signed. |
Flatpeak-Key-ID | wsk_live_3f69b1... | Identifies which signing key was used. |
Flatpeak-Version | 2025-09-14.anode | The API version that generated the event. |
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:Flatpeak-Timestamp value, a literal ., then the raw request body — e.g.:
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
- Extract the headers. Read
Flatpeak-Signature,Flatpeak-Timestamp, andFlatpeak-Key-IDfrom the incoming request. Reject the request immediately ifFlatpeak-Signatureis missing or set tonone. - 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. - Get the public key. Fetch the JWKS from the Flatpeak endpoint (see Fetching the public key below) and select the key whose
kidmatchesFlatpeak-Key-ID. Cache the JWKS — signing keys are long-lived but rotate occasionally, so refetch when you see an unknownkid. - 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. - Verify. Verify the decoded signature against the signed message from step 2 using RSA-PSS with SHA-256 (salt length = digest length).
- 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):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:| Framework | How 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. |
| Fastify | Add 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 / Starlette | await request.body() returns the raw bytes. |
| Django | request.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
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Signature always fails | Re-serialized JSON body | Use the raw request body bytes — see Capturing the raw body. |
| Signature always fails | Wrong base64 variant | Use base64url decoding, not standard base64. Languages with native base64url support handle padding. |
| Signature always fails | v1= prefix included in decode | Strip the v1= scheme prefix before decoding. |
| Signature fails after working | Signing key rotated | Refetch the JWKS and pick the key whose kid matches Flatpeak-Key-ID. Cache, but invalidate on miss. |
| Intermittent failures | Timestamp tolerance too strict | Allow at least 5 minutes of clock skew, or sync your server clock with NTP. |
Flatpeak-Signature: none | Signing key not yet provisioned or signing failed | Keys are provisioned on first webhook delivery — retry the event. Reject none in production. |
| Need to inspect bytes by hand | — | Reproduce 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.

