> ## Documentation Index
> Fetch the complete documentation index at: /docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> How Flatpeak delivers events to your endpoint, retries failed deliveries and signs every request.

Flatpeak sends each [event](/docs/fleet-api/guides/platform/events.md) to your endpoint as a `POST` request.

Register an endpoint and choose its events in [Console](https://console.flatpeak.com) > **Webhooks**.

## Delivery

Return `200` within 10 seconds; any other response is a failure. Respond first, then do slow work.

Flatpeak retries a failed delivery after 30 seconds, 1 hour, 6 hours, 12 hours and 24 hours, each counted from the previous attempt. After the last retry, resend it from **Console** > **Webhooks** > **Logs**.

| Log status | Fix |
| :- | :- |
| Unable to connect | Make the host reachable from the internet |
| `3xx` | Redirects fail, so register the final URL |
| `4xx` | Accept `POST` on the URL without access restrictions |
| `5xx` | Your handler failed. Check your logs |
| TLS error | Fix the certificate chain. Run an [SSL server test](https://www.ssllabs.com/ssltest/) |
| Timed out | Respond within 10 seconds |

## Verify signatures

Flatpeak signs every delivery, so verify the signature before you act on the event.

The signed message is `{Flatpeak-Timestamp}.{raw request body}`. The algorithm is RSA-PSS with SHA-256 (PS256): RSA-2048 key, MGF1-SHA256, salt length 32 bytes. Live and test mode use separate keys.

| Header | Value |
| :- | :- |
| `Flatpeak-Signature` | `v1=` followed by the signature, base64url without padding. `none` when signing is unavailable: reject it |
| `Flatpeak-Signature-Scheme` | `v1` |
| `Flatpeak-Timestamp` | Unix time in seconds when the payload was signed |
| `Flatpeak-Key-ID` | `kid` of the signing key |
| `Flatpeak-Version` | API version of the event |

1. Read the raw request body bytes before any JSON parsing.
2. Build the message: the timestamp, a literal `.`, then the raw body.
3. Fetch the JWKS and pick the key whose `kid` equals `Flatpeak-Key-ID`. Cache it, and refetch on an unknown `kid`, because keys rotate.
4. Strip `v1=`, base64url-decode the signature, and verify it with RSA-PSS SHA-256.
5. Reject a timestamp more than 5 minutes from your clock.

Fetch the JWKS:

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

Raw body: `express.raw({ type: 'application/json' })` (Express), a raw `contentTypeParser` (Fastify), `request.get_data()` (Flask), `await request.body()` (FastAPI), `request.body` (Django), `io.ReadAll(r.Body)` (Go).

```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,
 })
}
```

| Symptom | Fix |
| :- | :- |
| Always fails | Use the raw body bytes, decode the signature as base64url and strip `v1=` |
| Fails after working | The key rotated. Refetch the JWKS |
| Fails sometimes | Clock skew. Allow 5 minutes and sync with NTP |
| Signature is `none` | The key is created on the first delivery. Retry the event |

> [accordion] Debug a failing signature
>
> Reproduce the check with OpenSSL. Save the raw body as `raw_body.json` and the JWKS as `jwks.json`. Copy the header values from **Console** > **Webhooks** > **Logs**.
>
> ```bash theme={"system"}
> TIMESTAMP="<Flatpeak-Timestamp>"
> SIG="<Flatpeak-Signature without v1=>"
> KID="<Flatpeak-Key-ID>"
>
> # 1. Public key as PEM. Needs python3 and: pip install cryptography
> python3 -c "
> import json, sys, base64
> from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers
> from cryptography.hazmat.primitives import serialization
>
> jwks = json.load(sys.stdin)
> kid = '$KID'
> key = next(k for k in jwks['keys'] if k['kid'] == kid)
> def b64(s): return int.from_bytes(base64.urlsafe_b64decode(s + '=='), 'big')
> pub = RSAPublicNumbers(b64(key['e']), b64(key['n'])).public_key()
> sys.stdout.buffer.write(pub.public_bytes(
>     serialization.Encoding.PEM,
>     serialization.PublicFormat.SubjectPublicKeyInfo,
> ))
> " < jwks.json > public.pem
>
> # 2. Signed message: timestamp, dot, raw body. No trailing newline.
> printf '%s.' "$TIMESTAMP" > payload.bin
> cat raw_body.json >> payload.bin
> xxd payload.bin | tail -1      # last byte must be 7d ('}'), not 0a
> # truncate -s -1 payload.bin   # run only if the last byte is 0a
>
> # 3. Signature: base64url to bytes. Must be exactly 256 bytes.
> SIG_STD=$(printf '%s' "$SIG" | tr -- '-_' '+/')
> PAD=$(( (4 - ${#SIG_STD} % 4) % 4 ))
> [ "$PAD" -gt 0 ] && SIG_STD="${SIG_STD}$(printf '=%.0s' $(seq 1 $PAD))"
> printf '%s' "$SIG_STD" | base64 -d > sig.bin
> wc -c < sig.bin
>
> # 4. Verify. Salt length -1 is the digest length, 32 bytes. The default fails.
> openssl dgst -sha256 \
>   -sigopt rsa_padding_mode:pss \
>   -sigopt rsa_pss_saltlen:-1 \
>   -verify public.pem \
>   -signature sig.bin \
>   payload.bin
> ```
>
> `Verified OK` means success. On `Verification failure`, check the body bytes, the key ID and the signature decode.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.