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
7 changes: 6 additions & 1 deletion docs/webhooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,17 @@ Internal/private IP addresses are blocked. The following ranges are rejected:

### Signature Verification

If you provide a `secret` during registration, each webhook delivery includes two headers:
If you provide a `secret` during registration, each webhook delivery includes these headers:

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

Expand Down
42 changes: 42 additions & 0 deletions src/webhooks/webhook.dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,48 @@ describe('Webhook Dispatcher', () => {
expect(headers['X-Request-Id']).toBe('req-webhook-als');
});

it('omits X-Request-Id header when no request context is set', async () => {
const fetchMock = jest.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: 'OK',
} as Response);
global.fetch = fetchMock as any;

await dispatchWebhook(config, payload);

const headers = fetchMock.mock.calls[0][1].headers as Record<string, string>;
expect(headers['X-Request-Id']).toBeUndefined();
});

it('includes all expected webhook headers on dispatch', async () => {
const fetchMock = jest.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: 'OK',
} as Response);
global.fetch = fetchMock as any;
const { runWithRequestContext } = await import('../utils/asyncContext.js');

const configWithSecret: WebhookConfig = {
...config,
secret: 'test-secret',
};

await runWithRequestContext({ requestId: 'req-test-123' }, async () => {
await dispatchWebhook(configWithSecret, payload);
});

const headers = fetchMock.mock.calls[0][1].headers as Record<string, string>;
expect(headers['Content-Type']).toBe('application/json');
expect(headers['User-Agent']).toBe('Callora-Webhook/1.0');
expect(headers['X-Callora-Event']).toBe(payload.event);
expect(headers['X-Callora-Timestamp']).toBe(payload.timestamp);
expect(headers['X-Callora-Delivery']).toBeDefined();
expect(headers['X-Request-Id']).toBe('req-test-123');
expect(headers['X-Callora-Signature']).toMatch(/^sha256=/);
});

it('retries on non-2xx response and uses same idempotency key', async () => {
const fetchMock = jest.fn()
.mockResolvedValueOnce({
Expand Down