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.

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.