> ## 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.

# Verify webhook signatures

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](#code-examples) below do all of this end-to-end.

<Note>
  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](#capturing-the-raw-body) below.
</Note>

## 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.                                  |

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:

```http theme={"system"}
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"}}}
```

<Note>
  Header values above are illustrative. Use actual request values when verifying.
</Note>

## 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](#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](https://datatracker.ietf.org/doc/html/rfc4648#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](https://datatracker.ietf.org/doc/html/rfc7517)):

```bash theme={"system"}
curl https://api.flatpeak.com/jwks.json \
  -H "Authorization: Bearer <your_secret_key>"
```

The response contains one or more RSA keys:

```json theme={"system"}
{
  "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:

| 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

<CodeGroup>
  ```javascript Node.js theme={"system"}
  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,
    );
  }
  ```

  ```python Python theme={"system"}
  import base64
  import time
  from cryptography.exceptions import InvalidSignature
  from cryptography.hazmat.primitives import hashes, serialization
  from cryptography.hazmat.primitives.asymmetric import padding

  TOLERANCE_SECONDS = 5 * 60

  def verify_webhook_signature(body: bytes, headers: dict, public_key_pem: bytes) -> bool:
      """`body` must be the raw request bytes — e.g. Flask's `request.get_data()`."""
      signature_header = headers.get("Flatpeak-Signature", "")
      timestamp = headers.get("Flatpeak-Timestamp", "")

      if not signature_header or signature_header == "none" or not timestamp:
          return False

      # Replay protection.
      try:
          if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS:
              return False
      except ValueError:
          return False

      # Strip v1= prefix; pad so length is a multiple of 4 for urlsafe_b64decode.
      sig_b64 = signature_header.removeprefix("v1=")
      sig_b64 += "=" * (-len(sig_b64) % 4)
      sig_bytes = base64.urlsafe_b64decode(sig_b64)

      message = f"{timestamp}.".encode() + body

      public_key = serialization.load_pem_public_key(public_key_pem)
      try:
          public_key.verify(
              sig_bytes,
              message,
              padding.PSS(
                  mgf=padding.MGF1(hashes.SHA256()),
                  salt_length=padding.PSS.DIGEST_LENGTH,
              ),
              hashes.SHA256(),
          )
          return True
      except InvalidSignature:
          return False
  ```

  ```go Go theme={"system"}
  package webhook

  import (
   "crypto"
   "crypto/rsa"
   "crypto/sha256"
   "crypto/x509"
   "encoding/base64"
   "encoding/pem"
   "fmt"
   "math"
   "strconv"
   "strings"
   "time"
  )

  const toleranceSeconds = 5 * 60

  // VerifyWebhookSignature returns nil if rawBody is authentic, an error otherwise.
  // rawBody must be the unmodified request body bytes (read once from r.Body).
  func VerifyWebhookSignature(rawBody []byte, timestamp, signatureHeader string, publicKeyPEM []byte) error {
   if signatureHeader == "" || signatureHeader == "none" || timestamp == "" {
    return fmt.Errorf("missing or unsigned webhook")
   }

   // Replay protection.
   ts, err := strconv.ParseInt(timestamp, 10, 64)
   if err != nil {
    return fmt.Errorf("invalid timestamp: %w", err)
   }
   if math.Abs(float64(time.Now().Unix()-ts)) > toleranceSeconds {
    return fmt.Errorf("timestamp outside tolerance window")
   }

   sigB64 := strings.TrimPrefix(signatureHeader, "v1=")
   sigBytes, err := base64.RawURLEncoding.DecodeString(sigB64)
   if err != nil {
    return fmt.Errorf("failed to decode signature: %w", err)
   }

   // Signed message: "{timestamp}.{raw_body}".
   h := sha256.New()
   h.Write([]byte(timestamp))
   h.Write([]byte("."))
   h.Write(rawBody)
   digest := h.Sum(nil)

   block, _ := pem.Decode(publicKeyPEM)
   if block == nil {
    return fmt.Errorf("failed to parse PEM block")
   }
   pub, err := x509.ParsePKIXPublicKey(block.Bytes)
   if err != nil {
    return fmt.Errorf("failed to parse public key: %w", err)
   }
   rsaPub, ok := pub.(*rsa.PublicKey)
   if !ok {
    return fmt.Errorf("public key is not RSA")
   }

   return rsa.VerifyPSS(rsaPub, crypto.SHA256, digest, sigBytes, &rsa.PSSOptions{
    SaltLength: rsa.PSSSaltLengthEqualsHash,
   })
  }
  ```
</CodeGroup>

## Troubleshooting

| Problem                       | Cause                                             | Solution                                                                                                               |
| :---------------------------- | :------------------------------------------------ | :--------------------------------------------------------------------------------------------------------------------- |
| Signature always fails        | Re-serialized JSON body                           | Use the **raw** request body bytes — see [Capturing the raw body](#capturing-the-raw-body).                            |
| Signature always fails        | Wrong base64 variant                              | Use base64**url** 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](/guides/system/webhooks/verify-signatures). |

<Card title="Debug with OpenSSL" icon="terminal" href="/guides/system/webhooks/debug-signatures">
  Step-by-step walkthrough for reproducing signature verification from the
  command line — useful when your integration keeps failing and you need to
  pinpoint why.
</Card>
