diff --git a/docs/webhooks.md b/docs/webhooks.md index 9df9945..190ea80 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -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=` | 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 diff --git a/src/webhooks/webhook.dispatcher.test.ts b/src/webhooks/webhook.dispatcher.test.ts index d8882fa..fbdeabd 100644 --- a/src/webhooks/webhook.dispatcher.test.ts +++ b/src/webhooks/webhook.dispatcher.test.ts @@ -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; + 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; + 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({