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

# Debug webhook signature verificaiton with OpenSSL

This is a companion to [Signature verification](/guides/system/webhooks/verify-signatures) for integrators debugging verification failures. When your library-based verification keeps returning `false` and you can't tell why, reproducing the check on the command line with OpenSSL isolates the problem to a single step — bad bytes, wrong key, or wrong algorithm settings.

## What you need before starting

| Item                                                     | Where to get it                                                                                                       |
| :------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------- |
| The exact `Flatpeak-Timestamp` value                     | Logged from the incoming request, or **Dashboard** → **Webhooks** → **Logs**.                                         |
| The exact `Flatpeak-Signature` value (with `v1=` prefix) | Same — copy verbatim, do not let your shell trim or re-encode it.                                                     |
| The **raw** request body bytes                           | Captured before any JSON parsing. Saving from a log or framework debugger is risky — see the warning below.           |
| The signing public key (PEM)                             | From the JWKS at `https://api.flatpeak.com/jwks.json`. Match the key whose `kid` equals the `Flatpeak-Key-ID` header. |

<Note>
  If your application logs the body via a JSON pretty-printer or a framework
  that re-serialised the payload, the bytes you captured are **not** what was
  signed. Always work from the raw request bytes.
</Note>

## How webhook signing works

Flatpeak signs the byte string `{Flatpeak-Timestamp}.{raw_request_body}` using **RSA-PSS with SHA-256** (PS256), MGF1-SHA256, salt length = 32 (digest length), with an RSA-2048 key. The signature is base64url-encoded (no padding) and prefixed with the scheme: `v1=<base64url>`.

The full header set:

| Header                      | Description                                                  |
| :-------------------------- | :----------------------------------------------------------- |
| `Flatpeak-Signature`        | The signature, prefixed with `v1=` (e.g. `v1=WbyqgN...`).    |
| `Flatpeak-Timestamp`        | Unix timestamp (seconds) when the payload was signed.        |
| `Flatpeak-Key-ID`           | The `kid` of the signing key — must match a key in the JWKS. |
| `Flatpeak-Signature-Scheme` | The signature scheme version (currently `v1`).               |

## Verifying a signature with OpenSSL

<Steps>
  <Step title="Fetch the public key as PEM">
    Pull the JWKS, pick the key whose `kid` matches `Flatpeak-Key-ID`, and convert it to PEM. The cleanest path is via a one-shot Python script (no extra tooling needed beyond `python3`):

    ```bash theme={"system"}
    KID="<value of Flatpeak-Key-ID>"

    curl -s https://api.flatpeak.com/jwks.json \
      -H "Authorization: Bearer <your_secret_key>" \
    | 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,
    ))
    " > public.pem
    ```

    If your JWKS is unavailable but you have the raw DER bytes, wrap them as PEM directly:

    ```bash theme={"system"}
    echo "-----BEGIN PUBLIC KEY-----" > public.pem
    echo "<base64_der>" | fold -w 64 >> public.pem
    echo "-----END PUBLIC KEY-----" >> public.pem
    ```
  </Step>

  <Step title="Prepare the payload file">
    The signed message is `{timestamp}.{raw_body}` with **no trailing newline**.

    ```bash theme={"system"}
    TIMESTAMP="<value of Flatpeak-Timestamp>"

    # Write timestamp + dot, then append the raw body bytes you captured.
    printf '%s.' "$TIMESTAMP" > payload.bin
    cat raw_body.json >> payload.bin
    ```

    If `raw_body.json` was saved with a trailing newline by an editor, strip it:

    ```bash theme={"system"}
    xxd payload.bin | tail -1   # last byte must be '}' (0x7d), not '\n' (0x0a)
    truncate -s -1 payload.bin  # only run this if there is a trailing 0x0a
    ```

    If your only copy of the body is pretty-printed, you cannot reliably reconstruct the exact bytes that were signed — capture the raw body next time. As a last resort, if the body is plain JSON without significant whitespace decisions, compact it:

    ```bash theme={"system"}
    jq -cj . pretty.json > raw_body.json
    ```
  </Step>

  <Step title="Decode the signature">
    Strip the `v1=` prefix and base64url-decode. Convert URL-safe chars and add `=` padding so the length is a multiple of 4:

    ```bash theme={"system"}
    SIG="<value after v1=>"
    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

    # Sanity check — RSA-2048 PSS signatures are exactly 256 bytes.
    wc -c < sig.bin
    ```

    If `wc -c` reports anything other than `256`, the decode is wrong — re-check that you stripped `v1=` and didn't include any whitespace.
  </Step>

  <Step title="Verify">
    ```bash theme={"system"}
    openssl dgst -sha256 \
      -sigopt rsa_padding_mode:pss \
      -sigopt rsa_pss_saltlen:-1 \
      -verify public.pem \
      -signature sig.bin \
      payload.bin
    ```

    A successful result prints:

    ```
    Verified OK
    ```

    `-sigopt rsa_pss_saltlen:-1` tells OpenSSL to use a salt length equal to the digest length (32 bytes), matching how Flatpeak signs.
  </Step>
</Steps>

## Common pitfalls

| Problem                              | Symptom                                | Fix                                                                                                                    |
| :----------------------------------- | :------------------------------------- | :--------------------------------------------------------------------------------------------------------------------- |
| Pretty-printed or re-serialized JSON | `Verification failure`                 | Use the **raw** bytes from the original request. Reconstruction is unreliable.                                         |
| Trailing newline in payload file     | `Verification failure`                 | Check with `xxd`, remove with `truncate -s -1`.                                                                        |
| Base64 padding missing               | `Verification failure` or decode error | The signature is base64url with no padding — add `=` so the length is a multiple of 4 before standard base64 decoding. |
| Base64 URL-safe chars not converted  | `Verification failure`                 | Replace `-` with `+` and `_` with `/` before standard base64 decoding.                                                 |
| Wrong public key                     | `Verification failure`                 | Confirm `Flatpeak-Key-ID` matches the `kid` of the JWK you converted to PEM.                                           |
| `v1=` prefix included in decode      | Decode error or wrong-length bytes     | Strip the `v1=` scheme identifier before decoding.                                                                     |
| Wrong PSS salt length                | `Verification failure`                 | Use `-sigopt rsa_pss_saltlen:-1` (digest length, 32 bytes). The default `0` does not match Flatpeak's signing.         |
| Decoded signature isn't 256 bytes    | `Verification failure`                 | The decode is malformed — re-check padding, URL-safe chars, and the `v1=` strip.                                       |
