Skip to content
Closed
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
68 changes: 68 additions & 0 deletions docs/webhooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Webhooks

AgentPay can deliver audit events to registered HTTP subscribers.

## Registration

Create a webhook with a target URL and one or more event types:

```http
POST /api/v1/webhooks
Content-Type: application/json

{
"url": "https://example.com/agentpay-webhook",
"events": ["usage.recorded", "usage.settled"]
}
```

Use `"*"` to subscribe to every event type.

The creation response includes a `secret` once. Store it securely; list and
update responses never echo it.

## Payload

AgentPay sends JSON:

```json
{
"id": "event-id",
"type": "usage.recorded",
"ts": 1782310000000,
"payload": {
"agent": "agent-a",
"serviceId": "service-a",
"requests": 3,
"total": 10
}
}
```

## Headers

Every delivery includes:

- `X-AgentPay-Delivery`: unique delivery id.
- `X-AgentPay-Event`: event type.
- `X-AgentPay-Signature`: `sha256=<hex hmac>`.

The HMAC uses SHA-256 over the exact request body with the webhook secret.
Receivers should compare the expected and supplied signature with a constant-time
comparison.

## Retry and Dead Letters

AgentPay retries 5xx and network failures up to three attempts. A 2xx response
marks the delivery successful. A 4xx response is treated as permanent and is not
retried.

When delivery fails permanently, AgentPay increments the webhook `deadLetters`
count. The count is visible in webhook list, patch, and test responses.

## SSRF Protection

Webhook targets must be `http` or `https`. Private, loopback, and link-local
targets are rejected by default, including hostnames that resolve to private
addresses. Set `ALLOW_PRIVATE_WEBHOOKS=true` only in controlled local/test
environments that intentionally need private targets.
5 changes: 4 additions & 1 deletion src/events.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
import { deliverWebhookEvent } from "./webhooks/deliver.js";

export type AppEvent = {
id: string;
Expand All @@ -14,6 +15,8 @@ export const eventLog: AppEvent[] = [];
* Appends an audit event to the bounded in-memory event log.
*/
export function recordEvent(type: string, payload: Record<string, unknown>): void {
eventLog.push({ id: randomUUID(), ts: Date.now(), type, payload });
const event = { id: randomUUID(), ts: Date.now(), type, payload };
eventLog.push(event);
if (eventLog.length > EVENT_LOG_CAP) eventLog.shift();
void deliverWebhookEvent(event);
}
3 changes: 2 additions & 1 deletion src/routes/operational.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,8 @@ void describe("operational routes", () => {

const tested = await request(app).post(`/api/v1/webhooks/${webhookId}/test`);
assert.strictEqual(tested.status, 200);
assert.strictEqual(tested.body.simulated, true);
assert.strictEqual(tested.body.delivered, false);
assert.ok(tested.body.error);

const events = await request(app).get("/api/v1/events");
assert.strictEqual(events.status, 200);
Expand Down
81 changes: 60 additions & 21 deletions src/routes/webhooks.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,53 @@
import { randomUUID } from "node:crypto";
import { Router, type Request, type Response } from "express";
import { recordEvent } from "../events.js";
import { type AppEvent, recordEvent } from "../events.js";
import { webhookStore } from "../store/state.js";
import { getRequestId } from "../types.js";
import { createWebhookSecret, deliverSingleWebhook } from "../webhooks/deliver.js";

const publicWebhook = (
id: string,
meta: {
url: string;
events: string[];
createdAt: number;
deadLetters: number;
}
) => ({
id,
url: meta.url,
events: meta.events,
createdAt: meta.createdAt,
deadLetters: meta.deadLetters,
});

const testWebhook = async (req: Request, res: Response) => {
const { id } = req.params;
const requestId = getRequestId(req);
const hook = webhookStore.get(id);
if (!hook) {
res.status(404).json({
error: "not_found",
message: `webhook ${id} not registered`,
requestId,
});
return;
}
const event: AppEvent = {
id: randomUUID(),
ts: Date.now(),
type: "webhook.test",
payload: { id, url: hook.url },
};
const delivery = await deliverSingleWebhook(id, hook, event);
recordEvent("webhook.test", {
id,
url: hook.url,
delivered: delivery.delivered,
attempts: delivery.attempts,
});
res.json({ id, deliveredAt: Date.now(), ...delivery });
};

/**
* Builds webhook registration, update, deletion, and synthetic test routes.
Expand All @@ -25,27 +70,14 @@ export function createWebhooksRouter(): Router {
});

router.get("/api/v1/webhooks", (_req, res: Response) => {
const items = Array.from(webhookStore.entries()).map(([id, meta]) => ({
id,
...meta,
}));
const items = Array.from(webhookStore.entries()).map(([id, meta]) =>
publicWebhook(id, meta)
);
res.json({ items });
});

router.post("/api/v1/webhooks/:id/test", (req: Request, res: Response) => {
const { id } = req.params;
const requestId = getRequestId(req);
const hook = webhookStore.get(id);
if (!hook) {
res.status(404).json({
error: "not_found",
message: `webhook ${id} not registered`,
requestId,
});
return;
}
recordEvent("webhook.test", { id, url: hook.url });
res.json({ id, deliveredAt: Date.now(), simulated: true });
void testWebhook(req, res);
});

router.patch("/api/v1/webhooks/:id", (req: Request, res: Response) => {
Expand Down Expand Up @@ -88,7 +120,7 @@ export function createWebhooksRouter(): Router {
existing.events = events;
}
webhookStore.set(id, existing);
res.json({ id, ...existing });
res.json(publicWebhook(id, existing));
});

router.post("/api/v1/webhooks", (req: Request, res: Response) => {
Expand All @@ -115,8 +147,15 @@ export function createWebhooksRouter(): Router {
return;
}
const id = `wh_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
webhookStore.set(id, { url, events, createdAt: Date.now() });
res.status(201).json({ id, url, events });
const secret = createWebhookSecret();
webhookStore.set(id, {
url,
events,
createdAt: Date.now(),
secret,
deadLetters: 0,
});
res.status(201).json({ id, url, events, secret });
});

return router;
Expand Down
8 changes: 7 additions & 1 deletion src/store/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@

export type ApiKeyRecord = { label: string; createdAt: number };
export type ServiceMetadataDto = { description: string; owner: string };
export type WebhookRecord = { url: string; events: string[]; createdAt: number };
export type WebhookRecord = {
url: string;
events: string[];
createdAt: number;
secret: string;
deadLetters: number;
};

/** Mirrors the on-chain pause flag for write-gated endpoints. */
export const pauseState = { paused: false };
Expand Down
Loading
Loading