Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/error-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ This section is generated from `docs/error-codes.yaml`. Run `npm run error-codes
| `WEBHOOK_TIMESTAMP_OUT_OF_WINDOW` | Webhooks |
| `MALFORMED_WEBHOOK_SIGNATURE` | Webhooks |
| `INVALID_WEBHOOK_SIGNATURE` | Webhooks |
| `MALFORMED_WEBHOOK_NONCE` | Webhooks |
| `WEBHOOK_NONCE_REPLAYED` | Webhooks |
| `INVALID_DELIVERY_ID` | Webhooks |
| `INVALID_RETRY_POLICY` | Webhooks |
| `DLQ_ENTRY_NOT_FOUND` | Webhooks |
Expand Down
8 changes: 8 additions & 0 deletions docs/error-codes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,14 @@ error_codes:
section: Webhooks
description: Webhook signature verification failed

- code: MALFORMED_WEBHOOK_NONCE
section: Webhooks
description: Webhook nonce header is malformed

- code: WEBHOOK_NONCE_REPLAYED
section: Webhooks
description: Webhook nonce has already been used

- code: INVALID_DELIVERY_ID
section: Webhooks
description: The delivery ID provided for webhook replay is missing or invalid
Expand Down
2 changes: 2 additions & 0 deletions docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -5815,6 +5815,8 @@
"WEBHOOK_TIMESTAMP_OUT_OF_WINDOW",
"MALFORMED_WEBHOOK_SIGNATURE",
"INVALID_WEBHOOK_SIGNATURE",
"MALFORMED_WEBHOOK_NONCE",
"WEBHOOK_NONCE_REPLAYED",
"INVALID_DELIVERY_ID",
"INVALID_RETRY_POLICY",
"DLQ_ENTRY_NOT_FOUND",
Expand Down
25 changes: 14 additions & 11 deletions docs/webhooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,34 +138,37 @@ If you provide a `secret` during registration, each webhook delivery includes th
| Header | Format | Description |
|-----------------------------|---------------------|---------------------------------------|
| `X-Request-Id` | string | Correlation ID from the triggering request |
| `X-Callora-Signature-256` | `sha256=<hex>` | HMAC-SHA256 of signed payload |
| `X-Callora-Timestamp` | ISO-8601 timestamp | Delivery timestamp for replay defense |
| `X-Callora-Signature-256` | `sha256=<hex>` | HMAC-SHA256 of `<timestamp>.<nonce>.<rawBody>` |
| `X-Callora-Timestamp` | ISO-8601 timestamp | Delivery timestamp for skew/replay defense |
| `X-Callora-Nonce` | 16–128 URL-safe chars | Unique request nonce; persisted and rejected on reuse |
| `X-Callora-Event` | string | Event type being delivered |
| `X-Callora-Delivery` | UUID | Unique delivery identifier for idempotency |
| `User-Agent` | `Callora-Webhook/1.0` | Identifies Callora as the sender |
| `Content-Type` | `application/json` | Payload content type |

#### Signed Payload Format

The signed payload combines the timestamp and raw request body:
The signed payload combines the timestamp, nonce, and raw request body:

```
<timestamp>.<rawBody>
<timestamp>.<nonce>.<rawBody>
```

For example, if the timestamp is `2026-05-31T10:00:00.000Z` and body is `{"event":"new_api_call"}`:
For example, if the timestamp is `2026-05-31T10:00:00.000Z`, the nonce is
`nonce-7c9e6679-7425-40de`, and the body is `{"event":"new_api_call"}`:

```
2026-05-31T10:00:00.000Z.{"event":"new_api_call"}
2026-05-31T10:00:00.000Z.nonce-7c9e6679-7425-40de.{"event":"new_api_call"}
```

#### Verification Steps

1. **Extract headers** — Get `X-Callora-Signature-256` and `X-Callora-Timestamp`
2. **Reconstruct payload** — Combine `<timestamp>.<rawBody>`
3. **Compute expected signature** — HMAC-SHA256 with your secret
4. **Timing-safe comparison** — Compare using constant-time method
5. **Check timestamp** — Reject if outside 5-minute tolerance window (replay protection)
1. **Extract headers** — Get `X-Callora-Signature-256`, `X-Callora-Timestamp`, and `X-Callora-Nonce`
2. **Reconstruct payload** — Combine `<timestamp>.<nonce>.<rawBody>`
3. **Compute expected signature** — HMAC-SHA256 with the current secret and, during rotation, the previous secret still inside the grace window
4. **Timing-safe comparison** — Compare every active secret using constant-time equality. Failures never identify which key matched.
5. **Check timestamp** — Reject if outside 5-minute tolerance window (clock skew / replay)
6. **Persist nonce** — Reject reused nonces within the same window

### Signing Secret Rotation

Expand Down
6 changes: 6 additions & 0 deletions src/errors/codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,12 @@ export const ErrorCode = {
/** Webhook signature verification failed */
INVALID_WEBHOOK_SIGNATURE: "INVALID_WEBHOOK_SIGNATURE",

/** Webhook nonce header is malformed */
MALFORMED_WEBHOOK_NONCE: "MALFORMED_WEBHOOK_NONCE",

/** Webhook nonce has already been used */
WEBHOOK_NONCE_REPLAYED: "WEBHOOK_NONCE_REPLAYED",

/** The delivery ID provided for webhook replay is missing or invalid */
INVALID_DELIVERY_ID: "INVALID_DELIVERY_ID",

Expand Down
2 changes: 2 additions & 0 deletions src/errors/errorCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ export const ErrorCode = {
WEBHOOK_TIMESTAMP_OUT_OF_WINDOW: "WEBHOOK_TIMESTAMP_OUT_OF_WINDOW",
MALFORMED_WEBHOOK_SIGNATURE: "MALFORMED_WEBHOOK_SIGNATURE",
INVALID_WEBHOOK_SIGNATURE: "INVALID_WEBHOOK_SIGNATURE",
MALFORMED_WEBHOOK_NONCE: "MALFORMED_WEBHOOK_NONCE",
WEBHOOK_NONCE_REPLAYED: "WEBHOOK_NONCE_REPLAYED",
INVALID_RETRY_POLICY: "INVALID_RETRY_POLICY",

// IP allowlist
Expand Down
47 changes: 38 additions & 9 deletions src/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1540,10 +1540,13 @@ paths:
description: >
Receives a signed webhook event payload from an external system. The
request must include an HMAC-SHA256 signature in the
`X-Callora-Signature-256` header and a Unix timestamp in the
`X-Callora-Timestamp` header. The signature is verified against all
active secrets (current and grace-period previous) before the payload
is processed.
`X-Callora-Signature-256` header, an ISO-8601 timestamp in
`X-Callora-Timestamp`, and a unique nonce in `X-Callora-Nonce`.
The signed payload is `<timestamp>.<nonce>.<rawBody>`. The signature
is verified against all active secrets (current and grace-period
previous) using constant-time comparison. Reused nonces and timestamps
outside the tolerance window are rejected. Failure responses never
identify which key matched.
parameters:
- name: developerId
in: path
Expand All @@ -1560,8 +1563,10 @@ paths:
in: header
required: true
description: >
HMAC-SHA256 signature of the raw request body, prefixed with
`sha256=`. Computed as `sha256=<hex(hmac-sha256(secret, body))>`.
HMAC-SHA256 of `<timestamp>.<nonce>.<rawBody>`, prefixed with
`sha256=`. Compared in constant time against the current secret
and any previous secret still inside the rotation grace window.
Failure responses never identify which key matched.
schema:
type: string
examples:
Expand All @@ -1571,13 +1576,28 @@ paths:
- name: X-Callora-Timestamp
in: header
required: true
description: Unix timestamp (seconds) of when the event was sent.
description: ISO-8601 timestamp of when the event was sent. Rejected when outside the configured skew window.
schema:
type: string
examples:
ts:
summary: Example timestamp header
value: "1722074400"
value: "2026-07-27T09:30:00.000Z"
- name: X-Callora-Nonce
in: header
required: true
description: >
Unique request nonce (16–128 URL-safe characters). Bound into the
HMAC and persisted for the timestamp window so replays are rejected.
schema:
type: string
minLength: 16
maxLength: 128
pattern: "^[A-Za-z0-9._-]{16,128}$"
examples:
nonce:
summary: Example nonce header
value: "nonce-7c9e6679-7425-40de"
requestBody:
required: true
content:
Expand Down Expand Up @@ -1657,7 +1677,7 @@ paths:
requestId: req-webhook-deliver-400-sig
timestamp: "2026-07-27T09:31:00.000Z"
"401":
description: HMAC signature verification failed.
description: HMAC signature verification failed, timestamp outside the skew window, or nonce replayed.
content:
application/json:
schema:
Expand All @@ -1672,6 +1692,15 @@ paths:
message: Webhook signature verification failed
requestId: req-webhook-deliver-401-invalid
timestamp: "2026-07-27T09:32:00.000Z"
nonceReplayed:
summary: Nonce was already consumed (replay)
value:
success: false
error:
code: UNAUTHORIZED
message: Webhook signature verification failed
requestId: req-webhook-deliver-401-replay
timestamp: "2026-07-27T09:32:30.000Z"
"404":
description: No webhook registered for this developer.
content:
Expand Down
3 changes: 2 additions & 1 deletion src/routes/webhooks.openapi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,11 +228,12 @@ describe('src/openapi.yaml — POST deliver examples', () => {
expect(content).toContain('$ref: "#/components/schemas/WebhookDeliveryResponse"');
});

test('documents signature and timestamp header parameters', () => {
test('documents signature, timestamp, and nonce header parameters', () => {
const content = readOpenApiYaml();

expect(content).toContain('X-Callora-Signature-256');
expect(content).toContain('X-Callora-Timestamp');
expect(content).toContain('X-Callora-Nonce');
});

test('documents three delivery request examples covering all supported event types', () => {
Expand Down
3 changes: 2 additions & 1 deletion src/routes/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { WebhookEventType, type RetryPolicy } from '../webhooks/webhook.types.js
import {
captureRawBody,
verifyWebhookSignature,
parseCapturedJson,
} from '../webhooks/webhook.signature.js';
import { AppError, BadRequestError, NotFoundError } from '../errors/index.js';
import { createRestRateLimitMiddleware } from '../middleware/restRateLimit.js';
Expand Down Expand Up @@ -288,7 +289,7 @@ router.post(
next();
},
verifyWebhookSignature,
express.json(),
parseCapturedJson,
(req: Request, res: Response) => {
return res.status(200).json({ message: 'Webhook delivery accepted.', body: req.body });
}
Expand Down
Loading
Loading