Webhook signing applies to every endpoint that accepts webhook_url. Each signed delivery includes an X-Webhook-Signature header containing a compact JWT. Your server uses this to confirm the delivery came from Waterfall and that the body was not modified in transit.If signing configuration is unavailable, Waterfall delivers the webhook without the X-Webhook-Signature header to preserve backward compatibility.
How it works
Waterfall signs each webhook delivery with an Ed25519 private key and puts the result in X-Webhook-Signature as a compact JWT. The body payload is not modified. The JWT contains a body_hash claim: the unpadded base64url SHA-256 digest of the exact raw request body bytes. Verification has three parts: valid signature, valid claims, matching body hash.
JWKS endpoint
Waterfall publishes its Ed25519 public keys at:
GET https://api.waterfall.io/.well-known/jwks.json
Cache the response for 5 minutes (Cache-Control: public, max-age=300). During key rotation, old and new keys overlap for at least the 5-minute cache window so in-flight deliveries continue to verify. See the Webhook JWKS reference for the full response schema.
Verification steps
Follow these steps in order. Reject the request on any failure.
- Header name:
X-Webhook-Signature
- Header value: one compact JWT in
header.payload.signature form
- Reject if absent or larger than 8192 bytes
2. Decode the JWT
Split on .. Base64url-decode each of the three segments (no padding). The protected header must contain:
| Field | Required value |
|---|
alg | EdDSA |
typ | JWT |
kid | non-empty string |
3. Fetch and cache public keys
Fetch the JWKS and select the key where the JWKS kid matches the JWT kid. JWK shape: kty: "OKP", crv: "Ed25519", public key bytes in x.
If the kid is not found in a cached response, refresh the JWKS once before rejecting. Throttle forced refreshes to one per 30 seconds.
4. Verify the signature
Verify the Ed25519 signature over the ASCII bytes of base64url(header) + "." + base64url(payload).
5. Validate claims
| Claim | Type | Description |
|---|
iat | integer | Issued-at timestamp (Unix seconds) |
exp | integer | Expiry. Always iat + 900 (15 minutes). |
jti | string | Unique delivery ID. Use for replay deduplication. |
job_id | string | The Waterfall job this delivery belongs to. |
body_hash | string | Unpadded base64url SHA-256 of the raw request body. |
body_hash_alg | string | Always sha-256. |
Validation rules:
body_hash_alg must be sha-256
exp must equal iat + 900
iat must not be more than 300 seconds ahead of your server clock
- Current time must be before
exp
6. Hash the body and compare
Hash the raw request body bytes before any JSON parsing. Do not reserialize. body_hash is the unpadded base64url SHA-256 digest of those exact bytes.
If the signature is valid but the body hash does not match, reject the request. A mismatch means the body was modified in transit.
Replay prevention
JWT verification does not prevent replay attacks. Store each accepted jti for at least 15 minutes (the signature lifetime) and reject any delivery whose jti you have already processed.
Error codes
| Code | Cause |
|---|
missing_signature_header | X-Webhook-Signature is absent |
signature_header_too_large | Header exceeds 8192 bytes |
malformed_compact_jwt | JWT does not have three dot-separated segments |
malformed_jwt_segment | A segment failed base64url decoding |
invalid_alg | alg is not EdDSA |
invalid_typ | typ is not JWT |
missing_kid | kid is empty or absent |
jwks_fetch_failed | JWKS endpoint returned an error |
unknown_kid | No JWKS key matches the JWT kid |
invalid_signature | Ed25519 signature verification failed |
invalid_time_claims | iat or exp is missing or malformed |
invalid_jti | jti is missing or malformed |
invalid_job_id | job_id is missing or malformed |
invalid_body_hash | body_hash is missing or malformed |
unsupported_body_hash_alg | body_hash_alg is not sha-256 |
invalid_expiry_window | exp does not equal iat + 900 |
issued_in_future | iat is more than 300 seconds ahead of server clock |
expired_signature | Current time is past exp |
body_hash_mismatch | Computed body hash does not match body_hash |
Verification kit
Waterfall provides a reference implementation with a single-file verifier for Python and TypeScript, designed to be copied directly into your app.
webhook-verification-kit on GitHub