Webhooks API
Workspace-scoped webhook subscriptions deliver domain events
(pages → page.created, databases →
record.updated, comments → comment.created, …) to your
HTTPS endpoint. Delivery is handled by the webhook worker: event bus →
per-subscription delivery job → exponential-backoff retry → your
endpoint.
Shared conventions (auth, error model, pagination, rate limits): authentication.md.
- Auth: subscription management requires a workspace Admin/Owner JWT
or an API key with
ADMINscope. - Scope: webhooks only receive events from their own workspace
(never cross-tenant), filtered by the
events[]subscription list."*"subscribes to every catalog event. - Signing: every delivery is HMAC-SHA256-signed — you verify authenticity
with the
X-Factory-Signatureheader (§4.2). - Ordering & idempotency: see §5.
Webhook object:
{
"id": "webh_01J9ZKPF6H9J1L3M5N7P9Q1R3",
"workspaceId": "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1",
"url": "https://hooks.example.com/factory",
"events": ["page.created", "record.updated", "comment.created"],
"active": true,
"secretPrefix": "whsec_9f2c",
"createdById": "usr_01J9ZKQ1U3V5W7X9Y1Z3A5B7C9",
"createdAt": "2025-01-02T10:00:00.000Z",
"updatedAt": "2025-01-02T10:00:00.000Z"
}
The raw signing secret (
whsec_…, 32 random bytes, hex-encoded) is returned exactly once — at creation and again on rotation. It is stored AES-256-GCM encrypted (keyed byMASTER_ENCRYPTION_KEY) so the worker can re-sign retries, plus a SHA-256secretHashfor integrity and for legacy hash-only rows. It is never read back;secretPrefix(whsec_+ first 4 hash chars) is a bookkeeping-only identifier that leaks nothing about the secret.
1. Manage Subscriptions
1.1 POST /workspaces/:workspaceId/webhooks
- Auth: workspace Admin/Owner (JWT or API key
ADMIN).
Request:
{
"url": "https://hooks.example.com/factory",
"events": ["page.created", "page.updated", "comment.created"],
"secret": "whsec_my-own-64-hex-or-longer-string",
"active": true
}
| Field | Required | Notes |
|---|---|---|
url |
yes | Valid absolute http(s):// URL, ≤2048 chars, no query string. SSRF guard: the host is resolved and private/link-local/loopback IPs, reserved ranges, and *.local/localhost-style names are rejected → 400 VALIDATION_ERROR (details: [{ field: "url", issue: "private_address_not_allowed" }]). Dev override WEBHOOK_ALLOW_PRIVATE=true disables the guard (local testing only — never in production). |
events |
no | 1–32 names from the event catalog, or ["*"]. Unknown name → 400 VALIDATION_ERROR listing the valid names. Default ["*"]. |
secret |
no | Provide your own signing secret (≥16 chars) instead of a generated one. Omit to receive a generated whsec_…. |
active |
no | Default true. Inactive webhooks keep their row + delivery log but receive no deliveries. |
201 Created — the webhook object plus the raw signing secret, shown once
(the create/rotate responses are bare objects, not wrapped in data):
{
"id": "webh_01J9ZKPF6H9J1L3M5N7P9Q1R3",
"workspaceId": "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1",
"url": "https://hooks.example.com/factory",
"events": ["*"],
"active": true,
"secretPrefix": "whsec_9f2c",
"secret": "whsec_9f2c4a1e7b3d5f8a0c2e4b6d8f0a1c3e5f7a9b0c2d4e6f8a0b1c3d5e7f9a0b1c",
"createdById": "usr_01J9ZKQ1U3V5W7X9Y1Z3A5B7C9",
"createdAt": "2025-01-02T10:00:00.000Z",
"warning": "Store this secret now — it cannot be retrieved again."
}
Errors: 400 VALIDATION_ERROR (bad url/events), 401 UNAUTHORIZED,
403 FORBIDDEN, 404 NOT_FOUND (workspace), 409 CONFLICT (more than 20
webhooks per workspace).
Events emitted: webhook.created (audit; not delivered to other
webhooks).
1.2 GET /workspaces/:workspaceId/webhooks
- Auth: workspace Admin/Owner.
Query: cursor, limit (default 20, max 100).
200 OK — { data: [ <webhook objects, without secret> ], nextCursor }.
1.3 GET /workspaces/:workspaceId/webhooks/:webhookId
- Auth: workspace Admin/Owner.
200 OK — the webhook object (no secret, includes secretPrefix).
1.4 PATCH /workspaces/:workspaceId/webhooks/:webhookId
- Auth: workspace Admin/Owner.
Merge-patch: url (re-validated, incl. SSRF), events (re-validated),
active. To rotate the secret, send { "rotateSecret": true } — or call
the dedicated endpoint below:
{ "rotateSecret": true }
→ 200 OK — the webhook object with a new raw secret (shown once) and
a fresh warning. All in-flight and future deliveries are signed with the
new secret; your endpoint must accept the new secret before you rotate in
production.
200 OK (without rotation) — the updated webhook object (no secret).
Errors: 400 VALIDATION_ERROR, 401 UNAUTHORIZED, 403 FORBIDDEN,
404 NOT_FOUND.
Events emitted: webhook.updated (audit); secret rotation is
webhook.secret_rotated.
1.5 POST /workspaces/:workspaceId/webhooks/:webhookId/rotate-secret
Same contract as PATCH { "rotateSecret": true } — 200 OK with the
webhook object plus the new raw secret (shown once). A dedicated route so
the secret change is never conflated with field updates (and is independently
auditable as webhook.secret_rotated).
1.6 POST /workspaces/:workspaceId/webhooks/:webhookId/resubmit
- Auth: workspace Admin/Owner.
Re-queue failed deliveries as new rows (attempt counter reset; the
originals stay put in the log, linked via originDeliveryId). Exactly one of
the three selectors:
{ "deliveryId": "dlv_01J9ZKPG8J1L3M5N7P9Q1R3S5" }
{ "all_dead": true }
{ "window": { "since": "2025-01-01T00:00:00Z", "until": "2025-01-02T00:00:00Z" } }
| Selector | Effect |
|---|---|
deliveryId |
One specific row (must be FAILED, REJECTED or DEAD_LETTER). |
all_dead (or no body) |
Every DEAD_LETTER row of the webhook. |
window |
DEAD_LETTER/FAILED/REJECTED rows created in [since, until]. |
Bounded to the 200 most recent matching rows (resubmit is a manual operation, not a sweep). 200 OK:
{ "resubmitted": 3, "deliveryIds": ["dlv_…", "dlv_…", "dlv_…"], "originDeliveryIds": ["dlv_…", "dlv_…", "dlv_…"] }
Errors: 400 VALIDATION_ERROR (no selector / conflicting selectors),
404 NOT_FOUND (webhook or delivery), 409 CONFLICT (deliveryId already
DELIVERED/PENDING — details: [{ field: "deliveryId", issue: "not_retriable" }]).
1.7 DELETE /workspaces/:workspaceId/webhooks/:webhookId
- Auth: workspace Admin/Owner.
- Soft delete: sets
active=false. The row and its delivery log are kept (you can re-activate it withPATCH { "active": true }); pending deliveries are discarded, in-flight ones complete.
200 OK — { "deleted": true }.
Events emitted: webhook.deleted (audit).
2. Test Endpoint
POST /workspaces/:workspaceId/webhooks/:webhookId/test
Sends a synchronous webhook.test delivery straight to the URL (same
delivery core as production, so signature + envelope are real) to verify your
endpoint without waiting for a real event. It does not enter the retry
queue — one attempt, terminal.
- Auth: workspace Admin/Owner.
200 OK (bare object):
{
"deliveryId": "dlv_01J9ZKPG7I0J2L4M6N8P0Q2R4",
"status": "DELIVERED",
"statusCode": 200,
"attempts": 1,
"latencyMs": 213
}
status is DELIVERED, REJECTED (4xx) or FAILED (5xx/timeout/transport)
— see §4.3 for the split.
Errors: 401 UNAUTHORIZED, 403 FORBIDDEN, 404 NOT_FOUND.
3. Event Catalog
Every delivered payload shares the envelope (the worker forwards the
operation's event payload verbatim as data):
{
"id": "evt_01J9ZKPH8J1L3M5N7P9Q1R3S5",
"type": "record.updated",
"workspaceId": "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1",
"occurredAt": "2025-01-02T16:02:10.881Z",
"workspace": { "id": "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1", "name": "Acme", "slug": "acme" },
"data": { "…event-specific payload…" }
}
id— the stable event id; frozen across all retries of the same event (also sent as theX-Factory-Event-Idheader). Use it to deduplicate.occurredAt— when the event was minted, also frozen across retries.workspace—{ id, name, slug }of the owning workspace.data— the operation's payload (per-event, below).test: true— present only onwebhook.testtest deliveries (typewebhook.test,data: { message, sentAt }).
The catalog is the single source of truth in the codebase
(packages/shared/src/events/catalog.ts); a new event without an emitter
fails the completeness test. The 13 webhook-able events (platform-internal
events such as user.login/workspace.created are intentionally excluded):
| # | Event | When it fires | data payload (wire shape) |
|---|---|---|---|
| 1 | page.created |
Page created (incl. duplicated/templated) | { workspaceId, pageId, title, parentId?, databaseId?, fromTemplate? } |
| 2 | page.updated |
Page metadata/move/archive change; block edits also arrive here | { workspaceId, pageId, fields? } for metadata, or { workspaceId, pageId, blockIds: [ … ], changed: "created"|"updated"|"deleted" } for block ops |
| 3 | page.deleted |
Page trashed (or permanently purged) | { workspaceId, pageId, permanent: false, pageIds: [ …subtree… ] } (permanent: true on purge) |
| 4 | page.shared |
Share link created/changed or effective visibility changed | { workspaceId, shareId, targetType, targetId, scope } |
| 5 | database.created |
A page of type database was created |
{ workspaceId, databaseId, pageId, propertyCount } |
| 6 | database.updated |
Database metadata or schema (property/view) change | { workspaceId, databaseId, fields? } · …deleted: true · …propertyId · …viewId · …reordered: true |
| 7 | record.created |
Record created (incl. via form) | { workspaceId, databaseId, recordId, changedProperties: ["*"] } |
| 8 | record.updated |
Record property change (incl. bulk ops) | { workspaceId, databaseId, recordId, changedProperties: [ "Title", … ] } |
| 9 | comment.created |
New comment or reply | { workspaceId, commentId, replyId?, pageId?, blockId?, recordId? } |
| 10 | form.submitted |
Public or in-workspace form submission | { workspaceId, formId, slug, submissionId, recordId?, databaseId?, questionCount, anonymous, ip } (ip is /24-redacted, null when anonymous) |
| 11 | automation.executed |
An automation run finished (success or failure) | { workspaceId, automationId, runId, status } (+ run details) |
| 12 | file.uploaded |
A file reached READY in the upload pipeline |
{ workspaceId, fileId, name?, size?, mimeType? } |
| 13 | api_key.created |
An API key was created | { workspaceId, apiKeyId, name, scopes } — never the raw key |
Example — page.created (the data object exactly as the pages service
publishes it):
{
"id": "evt_01J9ZKPH1A2B3C4D5E6F7A8B9C",
"type": "page.created",
"workspaceId": "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1",
"occurredAt": "2025-01-02T15:00:00.112Z",
"workspace": { "id": "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1", "name": "Acme", "slug": "acme" },
"data": {
"workspaceId": "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1",
"pageId": "pg_01J9ZKQ8G2H4I6J8L0M2N4P6Q8",
"title": "Q2 Launch Checklist",
"parentId": "pg_01J9ZKQ4F8H5I7J9L1M3N5P7Q9",
"databaseId": null
}
}
Example — record.updated:
{
"id": "evt_01J9ZKPH2B3C4D5E6F7A8B9C0D",
"type": "record.updated",
"workspaceId": "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1",
"occurredAt": "2025-01-02T16:02:10.881Z",
"workspace": { "id": "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1", "name": "Acme", "slug": "acme" },
"data": {
"workspaceId": "wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1",
"databaseId": "db_01J9ZKQ7E4F6A8B0C2D4E6F8A0",
"recordId": "rpt_01J9ZKR3F4A6B8C0D2E4F6A8B0",
"changedProperties": ["Status"]
}
}
The payload is a delta + ids, not a full resource snapshot: for the
current state, re-GET the resource over REST (webhooks tell you what
changed; REST is the source of truth).
4. Delivery Spec
4.1 Request format
Each delivery is an HTTP POST to your URL:
POST /factory HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: factory-webhooks/1.0
X-Factory-Event: page.created
X-Factory-Event-Id: evt_01J9ZKPH8J1L3M5N7P9Q1R3S5
X-Factory-Delivery-Id: dlv_01J9ZKPG7I0J2L4M6N8P0Q2R4
X-Factory-Attempt: 1
X-Factory-Signature: t=1735824130,v1=52a7e9…c9
| Header | Meaning |
|---|---|
X-Factory-Event |
The event name (envelope type). |
X-Factory-Event-Id |
Stable event id (envelope id) — the dedupe key; identical across every retry of the event. |
X-Factory-Delivery-Id |
This delivery's id — identical across every retry (one logical delivery = one row). |
X-Factory-Attempt |
1-based attempt number of this delivery. |
X-Factory-Signature |
HMAC signature — §4.2. |
Timeouts: the whole delivery (connect + send) is bounded at 10 s.
Redirects are followed manually, ≤3, and every hop is re-checked by the
SSRF guard (a redirect to a private address aborts the delivery as
FAILED).
Response contract (how the outcome is classified):
| Your response | Status | Retried? |
|---|---|---|
2xx (within 10 s) |
DELIVERED |
no — terminal success |
4xx |
REJECTED |
no — terminal: it's your contract bug (bad signature, bad shape, 404…) |
5xx, timeout, connection/DNS error |
FAILED |
yes — per §4.3 |
4.2 Signature verification
X-Factory-Signature is t=<unix-seconds>,v1=<hex> — comma-separated, no
space after the comma:
X-Factory-Signature: t=1735824130,v1=52a7e9b1…(64 hex chars)
t— unix timestamp (seconds) when the delivery was sent.v1—HMAC-SHA256(key = your secret, message = "<t>.<raw-request-body>"), lowercase hex. Thetand the raw body are joined with a literal dot — parse the raw request bytes, do not re-serialize parsed JSON (whitespace/key order would change the digest).
Replay window: reject if |now − t| > 300 s (5 min). The platform never
replays stored deliveries — a stale signature must simply not verify.
Node.js verification (verify over the RAW body — use
express.raw({ type: "application/json" }) or an equivalent that hands you
the undecoded bytes):
import crypto from "node:crypto";
export function verifyFactorySignature(rawBody, headers, secret) {
const header = headers["x-factory-signature"];
const m = /^t=(\d+)[;,] ?v1=([0-9a-f]{64})$/.exec(header ?? "");
if (!m) return false;
const t = Number(m[1]);
if (Math.abs(Date.now() / 1000 - t) > 300) return false; // replay window
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return crypto.timingSafeEqual(Buffer.from(m[2], "hex"), Buffer.from(expected, "hex"));
}
Express handler:
app.post("/factory", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyFactorySignature(req.body, req.headers, process.env.FACTORY_WEBHOOK_SECRET)) {
return res.status(401).json({ error: "invalid signature" });
}
const event = JSON.parse(req.body);
queueMicrotask(() => handleEvent(event)); // ack fast, process async
res.status(200).json({ ok: true });
});
4.3 Retry & failure policy
The worker owns the retry schedule (the API only enqueues). A logical
delivery is one row: attempts increments in place (1 → 8) and
nextRetryAt carries the schedule — there are no intermediate per-attempt
rows. On every failure the delay is
delay(attempt n) = min(30s · 2^(n−1), 15min) · jitter(±20%)
| Attempt | Nominal delay after the previous failure |
|---|---|
| 1 (initial) | immediate |
| 2 | 30 s |
| 3 | 1 min |
| 4 | 2 min |
| 5 | 4 min |
| 6 | 8 min |
| 7 | 12 min |
| 8 (final) | 15 min |
Base/cap are deployment-configurable (WEBHOOK_BACKOFF_BASE_MS /
WEBHOOK_BACKOFF_MAX_MS; defaults 30 s / 15 min) — the schedule above is the
canonical production one. Jitter is ±20 % per delay.
After the 8th (final) failure the row is marked DEAD_LETTER (the DLQ
state), an alert is logged, and an in-app notification is sent to the
workspace admins (throttled per delivery to one per 24 h). Dead letters are
resubmittable via §1.6.
REJECTED (4xx) deliveries are not retried automatically — fix the
endpoint, then resubmit or retry manually.
5. Ordering & Idempotency
- At-least-once: retries redeliver the same event; you will receive
duplicates (same
X-Factory-Event-Id). Deduplicate on that header (a TTL ≥ the retry window, ≈30 min, is enough). - Ordering: events for one workspace are published in operation order and
attempted in that order, but a retrying delivery does not block later
events — a retried
page.createdmay arrive after the followingpage.updated. Order by the envelope'soccurredAt, never by arrival. - Stable identity:
id/occurredAt/X-Factory-Event-Idare frozen across every retry of the same event, so dedupe is exact. - Reconciliation: payloads are deltas + ids (no full snapshots). If a
retried/duplicated event conflicts with state you already applied, re-
GETthe resource over REST — it is the source of truth.
6. Delivery Log
GET /workspaces/:workspaceId/webhooks/:webhookId/deliveries
- Auth: workspace Admin/Owner.
Query: cursor, limit (default 20, max 100), status
(PENDING|DELIVERED|FAILED|REJECTED|DEAD_LETTER), event (event name),
since / until (ISO-8601). Newest first. The list omits the payload
(the single-delivery endpoint returns it).
200 OK
{
"data": [
{
"id": "dlv_01J9ZKPG7I0J2L4M6N8P0Q2R4",
"webhookId": "webh_01J9ZKPF6H9J1L3M5N7P9Q1R3",
"event": "record.updated",
"eventId": "evt_01J9ZKPH2B3C4D5E6F7A8B9C0D",
"status": "DELIVERED",
"attempts": 1,
"statusCode": 200,
"error": null,
"deliveredAt": "2025-01-02T16:02:11.094Z",
"nextRetryAt": null,
"createdAt": "2025-01-02T16:02:10.890Z",
"originDeliveryId": null,
"latencyMs": 213
},
{
"id": "dlv_01J9ZKPG8J1L3M5N7P9Q1R3S5",
"webhookId": "webh_01J9ZKPF6H9J1L3M5N7P9Q1R3",
"event": "page.created",
"eventId": "evt_01J9ZKPH1A2B3C4D5E6F7A8B9C",
"status": "FAILED",
"attempts": 3,
"statusCode": 502,
"error": "non-2xx: HTTP 502",
"deliveredAt": null,
"nextRetryAt": "2025-01-02T16:06:10.890Z",
"createdAt": "2025-01-02T15:00:00.120Z",
"originDeliveryId": null,
"latencyMs": 412
}
],
"nextCursor": null
}
One row per logical delivery — watch attempts climb (1 → 2 → 3 → …) as
retries happen; nextRetryAt shows when the next attempt is scheduled.
GET /workspaces/:workspaceId/webhooks/:webhookId/deliveries/:deliveryId
One delivery including the full envelope payload that was (or will be)
sent.
200 OK — the delivery object + payload: { …envelope… }.
Errors: 401 UNAUTHORIZED, 403 FORBIDDEN, 404 NOT_FOUND.
POST /workspaces/:workspaceId/webhooks/:webhookId/deliveries/:deliveryId/retry
Manually re-queue a FAILED/REJECTED/DEAD_LETTER delivery: the same
row goes back to PENDING with attempts reset; the original payload is
re-signed with the current secret (a rotation in between is respected).
- Auth: workspace Admin/Owner.
200 OK (bare object):
{ "deliveryId": "dlv_01J9ZKPG8J1L3M5N7P9Q1R3S5", "status": "PENDING", "attempts": 0 }
Errors: 401 UNAUTHORIZED, 403 FORBIDDEN, 404 NOT_FOUND,
409 CONFLICT (delivery already DELIVERED — nothing to retry).
For bulk resubmission (all dead letters, a window) use §1.6 resubmit.
7. Testing with curl
7.1 Receive a test delivery locally
# Terminal 1 — fake endpoint that dumps body + headers
cat > receive.sh <<'EOF'
#!/usr/bin/env bash
python3 - <<'PY'
import http.server
class H(http.server.BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
print("HEADERS:", {k: v for k, v in self.headers.items()
if k.startswith(("X-Factory", "Content", "User-Agent"))})
print("BODY:", body.decode())
self.send_response(200); self.end_headers()
self.wfile.write(b'{"ok":true}')
def log_message(self, *a): pass
http.server.HTTPServer(("127.0.0.1", 9000), H).serve_forever()
PY
EOF
bash receive.sh
# Terminal 2 — tunnel (e.g. cloudflared / ngrok)
cloudflared tunnel --url http://127.0.0.1:9000
# → https://abc123.trycloudflare.com
# Terminal 3 — create the webhook (secret printed ONCE!)
curl -s -X POST https://factory.example.com/api/v1/workspaces/wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1/webhooks \
-H "Authorization: Bearer $ACCESS_TOKEN" -H 'Content-Type: application/json' \
-d '{"url":"https://abc123.trycloudflare.com/factory","events":["*"]}'
# → { "id": "webh_01J9ZKPF…", "secret": "whsec_9f2c4a1e…", "warning": "Store this secret now — it cannot be retrieved again.", … }
# Fire the test delivery (synchronous)
curl -s -X POST https://factory.example.com/api/v1/workspaces/wks_01J9ZKP5C9H7J9K1L3M5N7P9Q1/webhooks/webh_01J9ZKPF6H9J1L3M5N7P9Q1R3/test \
-H "Authorization: Bearer $ACCESS_TOKEN"
# → { "deliveryId": "dlv_…", "status": "DELIVERED", "statusCode": 200, "attempts": 1, "latencyMs": 213 }
7.2 Verify a captured signature with curl + openssl
Save the raw body to body.json (byte-identical to what you received),
grab t and v1 from X-Factory-Signature:
SECRET="whsec_9f2c4a1e7b3d5f8a0c2e4b6d8f0a1c3e5f7a9b0c2d4e6f8a0b1c3d5e7f9a0b1c"
T="1735824130"
# HMAC-SHA256 over "<t>.<raw-body>"
EXPECTED=$(printf '%s' "$T." | cat - body.json | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
RECEIVED="52a7e9b1…"
[ "$EXPECTED" = "$RECEIVED" ] && echo "SIGNATURE OK" || echo "SIGNATURE MISMATCH"
7.3 Quick checklist
- Signature verifies with your secret (replay window ≤ 5 min).
- You return
2xxfast (< 10 s) — do heavy work async. - You dedupe on
X-Factory-Event-Id. - You handle
REJECTEDvsFAILEDdifferently (4xx = your bug; the server already decided not to retry — fix and resubmit). - For current state after any event, re-
GETthe resource over REST.
8. Incoming Webhooks
The reverse direction: an external system triggers one of the workspace's automations by calling your registered incoming webhook's URL. The trigger endpoint is:
POST /webhooks/incoming/:webhookId
- Auth: by signature, not credentials — the request must carry
X-Factory-Signature: t=<unix ts>,v1=<hmac>computed exactly like §4.2 over the raw body, with the incoming webhook's secret. (Public path; thewebhookIdis a bearer-capable identifier — the signature is the gate.)
Verification, in order:
- signature —
v1must match the HMAC →401WEBHOOK_SIGNATURE_INVALID. - timestamp —
|now − t|≤ 300 s →401WEBHOOK_TIMESTAMP_STALE. - replay — the
(t, body-hash)pair is a Redis nonce (TTL 10 min,SET NX EX); a second identical request →401WEBHOOK_REPLAY_DETECTED. - unknown
webhookId→404 NOT_FOUND(no existence leak beyond the signature failure).
202 Accepted — the linked automation is enqueued (it runs with its normal
automation permissions; the run is audited with the external integrator as
the INTEGRATION actor):
{ "data": { "runId": "run_01J9ZKPF5H7I9L1M3N5P7Q9R1" } }
400 VALIDATION_ERROR (non-JSON/empty body), 429 RATE_LIMITED, 500
round out the envelope. Incoming webhooks belong to automations with a
WEBHOOK trigger (Automations): the trigger's
webhookId is the path segment, the public URL is
https://<host>/api/v1/webhooks/incoming/<webhookId>, and the signing
secret is minted (shown once) when the trigger is first saved, with
POST /workspaces/:workspaceId/automations/:id/webhook/regenerate rotating
it (the old secret is invalidated immediately).
9. Developer Hub (in-app)
The same surface is managed in the web app under Settings → Developers (Overview · API keys · Webhooks · Events · Curl), including:
- Webhooks tab — create (URL + optional own secret + event chips), rotate secret, test send, enable/disable, delete.
- Delivery inspector — the live delivery log (this §6) with the
single-delivery drawer (full payload + the exact
X-Factory-Signaturehint to verify against), per-row retry, and resubmit dead — the log refreshes every 10 s so retries are visible while they happen. - Event explorer — the catalog above, rendered from the same
single source (
packages/sharedcatalog) with per-event schema + examples. - Curl tab — copy-paste create/verify examples for your workspace.
See docs/ui/developers.md for the UI specification.