One way to receive webhooks, in every product.
Mail, Auth, Waitlist, Trawl and Social send events the same way: signed with Standard Webhooks, retried for about 32 hours, switched off if your endpoint keeps failing, and kept for 30 days so you can replay them.
This page covers what is the same everywhere. Each product's guide lists its events and anything only that product does. You'll need a workspace API key (pcft_live_…) whose policy allows the product's webhook actions, or use the console: every product has a Webhooks page with the same screens.
1
Where each product's endpoints live
The routes under each path are identical in every product; only the host and the scope differ.
| Product | Base URL and scope | Events (full list in the guide) |
|---|---|---|
| https://api.mail.productcraft.co /v1/workspaces/:workspace_id/webhooks | Emails sent, delivered, bounced, complained; domains; the suppression list | |
| Auth | https://api.auth.productcraft.co /v1/apps/:app_id/webhooks | Sign-ups, sign-ins, email verification, roles, deletions, organizations |
| Waitlist | https://api.waitlist.productcraft.co /v1/workspaces/:workspace_id/webhooks | Entries created, approved, rejected |
| Trawl | https://api.trawl.productcraft.co /v1/workspaces/:workspace_id/webhooks | Jobs succeeded or failed; credits running low |
| Social | https://social.productcraft.co /v1/workspaces/:workspace_id/webhooks or /v1/communities/:community_id/webhooks | Posts, comments, reactions, follows, flags, moderation, messages, uploads |
Under each scope path:
GET …/webhooks/event-types: the product's events, each with a description and an example payload.GET/POST …/webhooks/endpoints,GET/PATCH/DELETE …/endpoints/:id: your endpoints (up to 10 per scope).POST …/endpoints/:id/testandPOST …/endpoints/:id/rotate-secret.GET …/webhooks/events,GET …/events/:idandPOST …/events/:id/replay: the event log.
2
Add an endpoint
The URL must be public https://. Pick the events you handle; an empty event_types list means every event, including ones added later.
curl -X POST https://api.waitlist.productcraft.co/v1/workspaces/<workspace_id>/webhooks/endpoints \
-H 'authorization: Bearer pcft_live_...' \
-H 'idempotency-key: add-signups-endpoint-1' \
-H 'content-type: application/json' \
-d '{
"url": "https://hooks.acme.com/webhooks/productcraft",
"description": "Sign-ups to the CRM",
"event_types": ["entry.created"]
}'
201 Created
{
"id": "9c1e5b7d-2f4a-4c8e-b3d6-0f1a2b3c4d5e",
"url": "https://hooks.acme.com/webhooks/productcraft",
"event_types": ["entry.created"],
"enabled": true,
"status": "enabled",
"signing_secret": "whsec_BDGVRE...", # shown exactly ONCE
"signing_secret_hint": "flao",
...
}Store signing_secret in your secret manager now: later reads show only its last 4 characters. Send an Idempotency-Key, so a retried request cannot create a second endpoint with a secret you never saw. PATCH changes the URL, description, events or enabled; DELETE removes the endpoint and drops its queued retries.
3
Verify the signature
Every delivery is a JSON POST with three Standard Webhooks headers. Check them before you trust the body.
POST https://hooks.acme.com/webhooks/productcraft
content-type: application/json
webhook-id: evt_83cfc794-78e9-4102-96de-3a9a6f5bc31e
webhook-timestamp: 1792231074
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{
"id": "evt_83cfc794-78e9-4102-96de-3a9a6f5bc31e",
"type": "entry.created",
"created_at": "2026-10-17T10:00:00.000Z",
"data": { ... }
}The signature is an HMAC-SHA256 of "<webhook-id>.<webhook-timestamp>.<raw body>", keyed with the bytes of your secret after whsec_ (base64-decoded), and sent base64-encoded after v1,. While a rotated secret is still in its grace period the header carries one signature per secret, separated by a space; accept the delivery if any of them matches.
Use the open-source standardwebhooks package (the same verifier works for every product), or the dependency-free version below. A verifyWebhook() helper in the ProductCraft Node SDK is on the way; until it is published, either of these is the way to verify.
import express from 'express';
import { Webhook } from 'standardwebhooks';
const app = express();
const wh = new Webhook(process.env.WEBHOOK_SECRET); // "whsec_..."
// express.raw: verify the exact bytes that were signed, not re-serialised JSON.
app.post('/webhooks/productcraft', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = wh.verify(req.body.toString('utf8'), {
'webhook-id': req.header('webhook-id'),
'webhook-timestamp': req.header('webhook-timestamp'),
'webhook-signature': req.header('webhook-signature'),
});
} catch {
return res.status(400).send('bad signature');
}
// event.id (= webhook-id) is the same on every retry: skip ones you have handled.
handle(event); // your code; keep it fast and do slow work in a queue
res.status(204).end();
});- Verify the raw body. Body-parsing middleware that re-serialises the JSON changes the bytes, and the signature no longer matches.
- Reject old timestamps. Both versions refuse a
webhook-timestampmore than five minutes from your clock, which stops someone replaying a captured request. - Dedupe on
webhook-id. It is the event id and stays the same on every retry and replay.
4
Send a test
Prove your receiver works before a real event arrives: the product sends the example of an event, signed like a real delivery, and tells you what your server answered.
curl -X POST https://api.waitlist.productcraft.co/v1/workspaces/<workspace_id>/webhooks/endpoints/<endpoint_id>/test \
-H 'authorization: Bearer pcft_live_...' \
-H 'idempotency-key: test-1' \
-H 'content-type: application/json' \
-d '{ "event_type": "entry.created" }'
{ "succeeded": true, "status_code": 204, "latency_ms": 112, "trigger": "test", "response_body": "", ... }The test body carries "test": true, shows up in the event log, and is never retried. In the console, Send test on an endpoint does the same and shows the status code and the start of your response.
5
Retries and auto-disable
Answer with any 2xx within 15 seconds. Anything else, including a timeout or a redirect, is a failed attempt.
- Automatic retries. A failed delivery is sent again 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours after each failure: 7 attempts over about 32 hours. Every retry has the same event id and body, freshly signed with a new timestamp.
- Auto-disable. After 20 failures in a row, or 24 hours of nothing but failures, the endpoint is switched off (
status: "auto_disabled",disabled_reason: "failing") and its queued retries are dropped. The console shows a banner on the Webhooks page. Fix the receiver, switch the endpoint back on (PATCHwith{ "enabled": true }), then replay what it missed. - A success resets the count. One delivered attempt clears
consecutive_failuresandfirst_failure_at.
6
The event log and replay
Every event of the last 30 days, with the latest attempt per endpoint and when the next retry is due.
curl 'https://api.waitlist.productcraft.co/v1/workspaces/<workspace_id>/webhooks/events?status=failed&limit=20' \
-H 'authorization: Bearer pcft_live_...'
# Send one event again, to one endpoint (omit endpoint_id for every endpoint that receives it)
curl -X POST 'https://api.waitlist.productcraft.co/v1/workspaces/<workspace_id>/webhooks/events/<event_id>/replay?endpoint_id=<endpoint_id>' \
-H 'authorization: Bearer pcft_live_...' \
-H 'idempotency-key: replay-1'Filter by endpoint_id, type and status (pending, delivered, failed, not_sent). GET …/events/:id adds every attempt: status code, latency and the first 1 KiB of your response. A replay sends the same body and webhook-id again and returns what your server answered.
7
Rotate a secret without dropping a delivery
The new secret is shown once; the old one keeps working for the grace period you choose.
curl -X POST https://api.waitlist.productcraft.co/v1/workspaces/<workspace_id>/webhooks/endpoints/<endpoint_id>/rotate-secret \
-H 'authorization: Bearer pcft_live_...' \
-H 'idempotency-key: rotate-1' \
-H 'content-type: application/json' \
-d '{ "grace_hours": 24 }'During the grace period (24 hours by default, up to 168; 0 ends the old secret now) every delivery is signed with both secrets, so a receiver holding either one verifies. Deploy the new secret within the grace period and nothing is rejected.
8
Older signature headers and their sunset
Endpoints made before a product moved to Standard Webhooks (17 October 2026 for all five) keep receiving that product's older header too, until the sunset date.
| Product | Older header | Sent until |
|---|---|---|
| X-Mail-Signature | 15 January 2027 | |
| Auth | X-Auth-Signature | 15 January 2027 |
| Waitlist | X-Waitlist-Signature | 15 January 2027 |
| Trawl | X-Trawl-Signature | 15 January 2027 |
| Social | x-pcft-signature | 15 January 2027 |
All five are t=<unix>,v1=<hex>, an HMAC-SHA256 of "<t>.<raw body>" keyed with the secret string as stored. Switch your verifier to the Standard Webhooks headers before the sunset. A secret from before the move works with any Standard Webhooks verifier as "whsec_" + base64(secret) (the dependency-free code above accepts it unchanged); secrets issued since already start with whsec_.
The older routes of each product (for example POST …/webhooks and …/webhooks/:id) keep working until the same date. Their responses carry Deprecation: true and Sunset: Fri, 15 Jan 2027 00:00:00 GMT headers, and endpoints created through them also get the older header. New endpoints get only the Standard Webhooks headers. Each product's guide notes anything else that changed for its older webhooks.