Skip to content
Open
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: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ available through one entrypoint and absent from the other. Rows marked
| POST | /api/webhooks/:developerId/rotate-secret | src/routes/webhooks.ts | route-specific | webhook management limiter |
| DELETE | /api/webhooks/:developerId | src/routes/webhooks.ts | route-specific | webhook management limiter |
| PATCH | /api/webhooks/:developerId/retry-policy | src/routes/webhooks.ts | route-specific | webhook management limiter |
| POST | /api/webhooks/deliver/:developerId | src/routes/webhooks.ts | route-specific | webhook management limiter |
| POST | /api/webhooks/deliver/:developerId | src/routes/webhooks.ts (registered only when `NODE_ENV === 'test'`; 404 in production) | route-specific | webhook management limiter |
| GET | /api/health/db/ | src/routes/health.ts | route-specific | none |
| GET | /api/health/health/ | src/routes/health.ts | route-specific | none |
| GET | /api/plans/ | src/routes/plans.ts | route-specific | none |
Expand Down
18 changes: 9 additions & 9 deletions docs/webhooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,12 +288,12 @@ idempotency for replay defense, and treat X-Callora-Timestamp as advisory
metadata only. A future change will be versioned and documented here before
receivers are required to adopt it.

Inbound deliver route (platform-internal)
POST /api/webhooks/deliver/:developerId uses a separate scheme
(X-Callora-Signature-256 over <timestamp>.<nonce>.<rawBody> with nonce
replay checks in src/webhooks/webhook.signature.ts). That path authenticates
traffic into Callora. Third-party endpoints that receive Callora events
must implement the outbound contract above, not the inbound one.
Inbound deliver example (test-only)
POST /api/webhooks/deliver/:developerId is a test fixture and is only
registered when NODE_ENV === 'test'; it returns 404 in production builds.
For a runnable receiver that verifies Callora deliveries, see
examples/webhook-receiver.ts. Third-party endpoints that receive Callora
events must implement the outbound contract above, not the inbound scheme.

Retry Policy
Failed deliveries (non-2xx, timeout, DNS failure) are retried with exponential backoff:
Expand Down Expand Up @@ -333,9 +333,9 @@ PATCH /api/webhooks/:developerId/retry-policy Update retry policy
DELETE /api/webhooks/:developerId Remove webhook
Rate Limiting
The webhook management endpoints (POST /, GET /:developerId, DELETE /:developerId) are
protected by an IP-based rate limiter. The signed inbound delivery route
(POST /deliver/:developerId) is not rate-limited here because it is
protected independently by HMAC signature verification.
protected by an IP-based rate limiter. The signed inbound delivery example
(POST /deliver/:developerId) is test-only and not registered in production,
so it is not rate-limited here.

Env variable Default (fallback) Description
WEBHOOK_RATE_LIMIT_WINDOW_MS REST_RATE_LIMIT_WINDOW_MS (60 000) Window length in milliseconds
Expand Down
42 changes: 42 additions & 0 deletions examples/webhook-receiver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Example: a receiver endpoint that verifies inbound Callora webhook
* deliveries using the same HMAC-SHA256 scheme as src/webhooks/webhook.signature.ts.
*
* The platform no longer ships a production route for this — see
* docs/webhooks.md. Run locally with:
*
* npx tsx examples/webhook-receiver.ts
*/
import express, { Request, Response, NextFunction } from 'express';
import {
captureRawBody,
verifyWebhookSignature,
parseCapturedJson,
} from '../src/webhooks/webhook.signature.js';

const app = express();
const PORT = Number(process.env.PORT ?? 4100);

// In a real deployment, look the secret up from your subscription record
// instead of a hard-coded constant.
const WEBHOOK_SECRETS = (process.env.WEBHOOK_SECRETS ?? 'example-secret').split(',');

app.post(
'/webhooks',
captureRawBody,
(req: Request & { webhookSecrets?: string[] }, _res: Response, next: NextFunction) => {
req.webhookSecrets = WEBHOOK_SECRETS;
next();
},
verifyWebhookSignature,
parseCapturedJson,
(req: Request, res: Response) => {
const event = (req.body as { event?: string })?.event ?? 'unknown';
console.log('[webhook-receiver] verified delivery:', event);
res.status(200).json({ message: 'Webhook delivery accepted.', event });
},
);

app.listen(PORT, () => {
console.log(`webhook-receiver listening on http://localhost:${PORT}/webhooks`);
});
11 changes: 8 additions & 3 deletions src/routes/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,8 +417,12 @@ router.patch('/:developerId/retry-policy', webhookMgmtRateLimit, express.json(),
}
});

router.post(
'/deliver/:developerId',
// POST /api/webhooks/deliver/:developerId is a test fixture only.
// It must never be reachable in production: it acts as a signature oracle
// and reflects request bodies. Register it only when NODE_ENV === 'test'.
if (process.env.NODE_ENV === 'test') {
router.post(
'/deliver/:developerId',
captureRawBody,
(req: Request & { webhookSecrets?: string[] }, res: Response, next) => {
const config = WebhookStore.get(req.params.developerId);
Expand All @@ -437,7 +441,8 @@ router.post(
(req: Request, res: Response) => {
return res.status(200).json({ message: 'Webhook delivery accepted.', body: req.body });
}
);
);
}

export function createWebhooksRouter(): Router {
return router;
Expand Down
11 changes: 8 additions & 3 deletions src/webhooks/webhook.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,12 @@ router.patch('/:developerId/retry-policy', webhookMgmtRateLimit, express.json(),
* 3. verifyWebhookSignature — enforces HMAC + replay-window check
* 4. express.json() — parses the verified body for the handler
*/
router.post(
'/deliver/:developerId',
// Test fixture only: never mounted in production. In non-test environments
// this route is not registered, so requests fall through to a 404 and no
// request body is ever echoed back.
if (process.env.NODE_ENV === 'test') {
router.post(
'/deliver/:developerId',
validate({ params: webhookDeveloperParamsSchema }),
captureRawBody,
// Attach the stored secret so verifyWebhookSignature can read it
Expand All @@ -258,6 +262,7 @@ router.post(
// Payload has been verified — safe to process
return res.status(200).json({ message: 'Webhook delivery accepted.', body: req.body });
}
);
);
}

export default router;