Webhook Signature Verification

Verify the authenticity of webhook calls

All webhook calls include a JWT signature that you can use to verify the authenticity and integrity of the event.

📘

Recipe

Webhook Signature Verification packages these steps into a reusable verify_webhook_signature() helper with issuer and timestamp checks.

Signature Header

Lirium's webhook POST requests include an HTTP header:

X-JWT-SIGNATURE: <jwt-token>

The JWT is signed using the RS512 algorithm.

JWT Structure

{
  "iss": "[SIGNING_KEY_ID]",
  "iat": 1646758802,
  "exp": 1646759102,
  "digest": "[REQUEST_BODY_DIGEST]"
}
ClaimDescription
issSigning key ID: lirium-sandbox or lirium-production
iatUnix timestamp when the JWT was generated
expUnix timestamp when the signature expires, 5 minutes after iat. Reject the call if it has passed.
digestHex-encoded SHA-256 hash of the request body

The signature is short-lived so a captured delivery cannot be replayed indefinitely. A fresh token is generated on every delivery attempt, so a retry of the same event arrives with a new iat/exp but the same digest.

📘

Deduplicate on the Event ID

Because retries are re-signed, exp is not a way to detect duplicates. Deduplicate on payload["id"], which is stable across retries and authenticated by the digest claim.

Verification Steps

  1. Extract the JWT from the X-JWT-SIGNATURE header
  2. Decode and verify the JWT using Lirium's public key
  3. Check the exp claim has not passed (most JWT libraries do this for you)
  4. Compute the SHA-256 hash of the request body
  5. Compare the computed hash with the digest claim

Python Example

from jose import jwt
from hashlib import sha256
import flask

# Get the JWT from the request header
token = flask.request.headers.get("X-JWT-SIGNATURE")

# Verify and decode the JWT. python-jose rejects an expired `exp` for you.
decoded = jwt.decode(token, signing_public_key, algorithms=["RS512"])

# Compute the digest of the request body
digest = sha256(flask.request.data).hexdigest()

# Verify the digest matches
assert decoded.get("digest") == digest

# Verify the issuer matches your environment
assert decoded.get("iss") == "lirium-sandbox"  # or "lirium-production"

Public Keys

Get the public keys for signature verification from Webhooks Public Keys.

Example JWT

Decoded payload:

{
  "iss": "lirium-sandbox",
  "iat": 1646758802,
  "exp": 1646759102,
  "digest": "075b83598e4e147f240f5b15694b42063741b677548c89c9a4f100b5a36263f5"
}

Encoded JWT (captured before the exp claim was added, so it carries only iss, iat and digest):

eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJsaXJpdW0tc2FuZGJveCIsImlhdCI6MTY0Njc1ODgwMiwiZGlnZXN0IjoiMDc1YjgzNTk4ZTRlMTQ3ZjI0MGY1YjE1Njk0YjQyMDYzNzQxYjY3NzU0OGM4OWM5YTRmMTAwYjVhMzYyNjNmNSJ9.ONcsg4ZWPKVuXC_nGWk7zfKuoqWgfV4edxDeweL7S4cSkj9SdnxZnxSBdPFMzeIaPAETnFNc-6Pxcd919pYPIx0M155yEBP4SHwmsflh93vrjdoUZYB2sD3XL6xeiCQ0KuwRp_yHpICO9JzCto4XsbvZ_wmhI1wIl-hDnlPDfdbL--rV-usbx1EPXoS67v3oTV2YtdvtoO5QZAy7relxyA9hLEpsKv_Duf_RYDiNkGEl_aZrKu8fQEFf0CUZkbqsbv1Xt-VBavk1A3muGXJfOHjBxbT5er0_Mkn7pj5T1o4XiRLlz-WhYjwsKaewpvsdoFVt_ggFPOwcBYaOLKPx5Xfapt7WtsxSH3gds9PzUGj9mi4lIm-wCYjIn7tb9iimwfhkNm_6himIflLGCQ1eu3n1UzirnX5OvlmBOutXkHddWxzp1xBn5PuWWRMMHGWg-tZcUl5x4cV9kkkse9FmheHkstjf0Ham3ePCydlMp5r0vjrgsXbHaC3iLSf_3al7wpkLXogWiVVbqAkoLYpy3nCTZ_i0btnn5GwcRW2Gx5-ILNoxLe49lMD_le-iSEb9VaC7n8lyZYw3WTXdN1a0RAAO3RbkT8UahqTxfH_iqWbbRcr1Uep92OKTt3xbaCqvyGMOSLSO-nlnXrLgyZHllG0K59wUwQjp2GcMVOljoNU

Did this page help you?