> ## Documentation Index
> Fetch the complete documentation index at: https://docs.waterfall.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook Verification

> Verify Waterfall webhook deliveries using Ed25519 signatures.

<Note>
  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.
</Note>

## 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](/v1/webhook-jwks) reference for the full response schema.

## Verification steps

Follow these steps in order. Reject the request on any failure.

### 1. Read the signature header

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

<Warning>
  If the signature is valid but the body hash does not match, reject the request. A mismatch means the body was modified in transit.
</Warning>

## 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](https://github.com/Waterfall-io/webhook-verification-kit)
