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 currently in preview. Headers, signature scheme, and verification steps may change before general availability.
This is a companion to Signature verification 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

ItemWhere to get it
The exact Flatpeak-Timestamp valueLogged from the incoming request, or DashboardWebhooksLogs.
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 bytesCaptured 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.
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.

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:
HeaderDescription
Flatpeak-SignatureThe signature, prefixed with v1= (e.g. v1=WbyqgN...).
Flatpeak-TimestampUnix timestamp (seconds) when the payload was signed.
Flatpeak-Key-IDThe kid of the signing key — must match a key in the JWKS.
Flatpeak-Signature-SchemeThe signature scheme version (currently v1).

Verifying a signature with OpenSSL

1

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):
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:
echo "-----BEGIN PUBLIC KEY-----" > public.pem
echo "<base64_der>" | fold -w 64 >> public.pem
echo "-----END PUBLIC KEY-----" >> public.pem
2

Prepare the payload file

The signed message is {timestamp}.{raw_body} with no trailing newline.
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:
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:
jq -cj . pretty.json > raw_body.json
3

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:
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.
4

Verify

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.

Common pitfalls

ProblemSymptomFix
Pretty-printed or re-serialized JSONVerification failureUse the raw bytes from the original request. Reconstruction is unreliable.
Trailing newline in payload fileVerification failureCheck with xxd, remove with truncate -s -1.
Base64 padding missingVerification failure or decode errorThe signature is base64url with no padding — add = so the length is a multiple of 4 before standard base64 decoding.
Base64 URL-safe chars not convertedVerification failureReplace - with + and _ with / before standard base64 decoding.
Wrong public keyVerification failureConfirm Flatpeak-Key-ID matches the kid of the JWK you converted to PEM.
v1= prefix included in decodeDecode error or wrong-length bytesStrip the v1= scheme identifier before decoding.
Wrong PSS salt lengthVerification failureUse -sigopt rsa_pss_saltlen:-1 (digest length, 32 bytes). The default 0 does not match Flatpeak’s signing.
Decoded signature isn’t 256 bytesVerification failureThe decode is malformed — re-check padding, URL-safe chars, and the v1= strip.