Webhooks and deploy hooks
Signed outbound webhooks with retries and replay, inbound webhooks from Stripe, GitHub, Clerk and Slack forwarded to your app, and hook URLs that trigger a deploy.
"Webhooks" covers two different things here, and they face opposite directions:
- Project webhooks — Camplax calls your HTTPS endpoint when something happens in the project.
- Inbound webhooks — Stripe, GitHub, Clerk or Slack call Camplax, which verifies and forwards to your app.
- Deploy hooks — you call a secret Camplax URL to trigger a deploy.
Project webhooks (outbound)
Create an endpoint in the console, or with POST /v1/projects/<slug>/webhooks:
{ "url": "https://your-app.example.com/hooks/camplax", "events": ["deploy.*", "user.deleted"], "maxAttempts": 6 }
The URL must be https, and private or loopback hosts are refused. There is nothing to store: Camplax signs every delivery with its own key, and your receiver checks it against Camplax's public key (below). An empty events list means every event; a trailing * subscribes to a family.
The events a project can emit:
| Event | Fires when |
|---|---|
user.deleted | Someone deletes one of your app's users in the console (Users). The data is { user: { id, email }, deletedFrom: "console" }. Sign-ups and deletions inside your app happen in your own code, which Camplax does not see — for those, add a database webhook on the user table |
order.paid, order.refunded | Reserved names. Camplax has no payments feature, so nothing sends them |
deploy.succeeded, deploy.failed | A deploy finishes either way |
email.received | Mail lands in one of the project's mailbox inboxes — registered now, fires when inbound mail goes live (not live yet) |
form.submitted | A post to one of the project's form endpoints passes its spam rails and is stored. The data is { formId, formName, submissionId, fields, submittedAt }, with no IP, browser or captcha token |
webhook.test | The test button or POST /test |
email.received and form.submitted are opt-in only: catch-all endpoints (events: []) never receive them — a project endpoint must name the event or its family (email.*, form.*) explicitly, since both carry what a stranger typed. Its payload is { inbox, thread, message } — the message carries from/fromName, to, subject, capped text/html (plus a truncated flag), attachments metadata, senderAuthenticated (aligned-DKIM verdict), rfcMessageId, and rawKey pointing at the stored .eml. An inbox's own webhookUrl rides this same pipeline and is signed the same way — but that endpoint is managed through the mailbox routes only, and never appears in your project webhook list.
Verifying a delivery
There is no secret on your side. Camplax signs every delivery with its own key — the same one as cron calls and log drains — and your receiver checks it against Camplax's public key, which anyone can fetch. Nothing to create, copy, store or rotate.
Deliveries are POSTs with a JSON body { "id", "event", "data", "sentAt" } and these headers:
| Header | Value |
|---|---|
Camplax-Timestamp | Unix seconds when it was signed |
Camplax-Key | Which public key signed it |
Camplax-Signature | v1= + base64url Ed25519 signature |
Camplax-Purpose | webhook |
Camplax-Project | The project id it was signed for |
In a Plax app, check the raw body before you parse it:
import { readRawBody, sendFail, sendOk, verifyCamplaxWebhook } from "@camplax/plax";
"POST /hooks/camplax": async (req, res) => {
const raw = await readRawBody(req);
const verified = await verifyCamplaxWebhook(req, raw, { purpose: "webhook" });
if (!verified.ok) return sendFail(res, verified); // 401
const { id, event, data } = JSON.parse(raw.toString());
// skip it if you already handled this id
sendOk(res, { ok: true });
},
verifyCamplaxWebhook accepts a delivery only when the signature checks out, Camplax-Timestamp is within 5 minutes of your clock, and it was signed for your app's own project (CAMPLAX_PROJECT_ID, which Camplax injects) — so another project's webhook pointed at your URL fails, and so does a cron call, drain batch or alert replayed at it. Dedupe on id: it is inside the signed body, the same across retries and a replay, so a captured request re-sent inside the 5 minutes is recognised.
Outside Plax, fetch https://camplax.dev/v1/signing/keys (public, no credential), take the key whose kid matches Camplax-Key, and verify the Ed25519 signature over ` webhook.${timestamp}.${projectId}.${rawBody} , where projectId is your project's id. Cache the keys, and refetch when an unknown Camplax-Key` shows up — that is a key rotation.
A receiver outside Camplax that would rather keep a shared secret can ask for an HMAC as well. It is off until someone asks: Add an HMAC secret in the endpoint's ⋯ menu (or POST /:id/rotate-secret) shows one once, and from then on every delivery also carries Camplax-Hmac-Signature: t=<unix>,v1=<hex> — Stripe's scheme: v1 is the hex HMAC-SHA256 of <t>.<raw body>. Creating an endpoint never hands out a secret, and nothing on Camplax needs one.
Retries, dead-letters, replay
Your endpoint gets 10 seconds per attempt; only the status code matters — the response body is never read. Failures back off roughly 10s, 40s, 3m, 11m, 43m with jitter (up to maxAttempts, 1–10, default 6), and a minute-by-minute sweep picks up whatever fell due. After the last attempt the delivery is dead-lettered, not lost — it sits in the delivery log with its error.
GET /v1/projects/<slug>/webhooks/:id/deliveries shows the last 100 attempts per endpoint. Delivered and dead-lettered deliveries are kept for your plan's history window — 7 days on Indie, 14 on Pro, 30 on Scale — and then removed; a delivery still retrying is never removed. POST /deliveries/:id/replay resets a delivery to pending with a fresh attempt budget. POST /test fires a webhook.test event — the one endpoint call a webhooks:send API key may make; everything else here is console-session only. POST /:id/rotate-secret mints an HMAC secret (whsec_…), shown once — the first call switches the HMAC on. On a later call the old one keeps signing beside the new one for 24 hours — Camplax-Hmac-Signature carries two v1 digests — so a receiver that checks the HMAC can switch without dropping a delivery; previousSecretValidUntil in the answer says when the old one stops. An inbox's webhook works the same way through POST /email/inboxes/:id/rotate-secret.
Inbound webhooks (for your app)
Stripe, GitHub, Clerk, Slack and any Svix-style sender can post to your app through Camplax. Camplax checks the sender's signature, stores the event, answers the sender straight away, and forwards the event to a path on your app — retrying if your app is down or mid-deploy.
- On Keys → Inbound, add an endpoint: the sender, a name, and the path to forward to (
/webhooks/stripe). You get a URL:
``text https://camplax.dev/v1/webhooks/in/<slug>/ibh_… ``
- Paste that URL into the sender (Stripe → Developers → Webhooks, a GitHub repo's Webhooks, Clerk → Webhooks, your Slack app's Event Subscriptions).
- Paste the sender's signing secret back into the endpoint. This is the one secret in the whole path, and it comes from them, not from Camplax. It is stored encrypted and is write-only — after saving you only ever see its last four characters. Until it is set, the endpoint refuses every request.
| Sender | Checks | Dedupes on |
|---|---|---|
| Stripe | Stripe-Signature (HMAC of t.body, 5-minute window) | event id |
| GitHub | X-Hub-Signature-256 | a hash of the signed body (GitHub signs no timestamp or delivery id) |
| Clerk | Svix headers (svix-id / -timestamp / -signature) | svix-id |
| Slack | X-Slack-Signature (v0:ts:body, 5-minute window); answers url_verification itself | event_id, or the signed bytes |
| Svix / Standard Webhooks | webhook-id / -timestamp / -signature (or the svix-* names) | webhook-id |
A sender's retry of an event already received is acknowledged and not forwarded twice. Bodies over 256 KB get 413; a bad or missing signature gets 401; an unknown, paused or switched-off endpoint gets 404. Each project can take 10,000 deliveries and 64 MB of payloads per rolling 24 hours; past that the receiver answers 429 and the sender retries later.
What your app receives. A POST to your path with Camplax's own signature (Camplax-Purpose: inbound-webhook) — your app holds no secret:
import { readRawBody, sendFail, sendOk, verifyCamplaxWebhook } from "@camplax/plax";
"POST /webhooks/stripe": async (req, res) => {
const raw = await readRawBody(req);
const verified = await verifyCamplaxWebhook(req, raw, { purpose: "inbound-webhook" });
if (!verified.ok) return sendFail(res, verified);
const delivery = JSON.parse(raw.toString()); // { id, attempt, endpoint, event: { id, type }, headers, body }
const event = JSON.parse(delivery.body); // the sender's payload, byte for byte
sendOk(res, { ok: true });
},
Dedupe on delivery.id (the same across Camplax's retries) or delivery.event.id. A 2xx in 15 seconds is delivered; anything else retries after 30s, 2m, 8m, 32m, then every 2h — eight attempts, about eight hours — and is then kept as dead. Ten dead deliveries in a row pause forwarding and email the owners. The endpoint keeps receiving (a sender will not resend what it was told arrived); everything it takes is held, and Resume forwarding (PATCH … { "enabled": true }) resets the count and sends what was held. Switching an endpoint off yourself is different: it stops receiving, and senders get 404. Replay in the console (POST /v1/projects/<slug>/inbound-webhooks/deliveries/:id/replay) re-sends a delivery as a new one with replayOf set. Delivered events are kept (and replayable) for 3 days, dead and held ones for 14; the record that an event already arrived is kept 30 days, so a late redelivery is still recognised as a duplicate.
The endpoints API (GET/POST /v1/projects/<slug>/inbound-webhooks, PATCH/DELETE /:id, GET /:id/deliveries) takes a console session, a CLI token or a manage key. Adding an endpoint turns on the Webhooks inbox card in Integrations; switching that card off makes every endpoint answer 404.
Deploy hooks (inbound)
A deploy hook is a secret URL that queues a deploy — for CI systems, other hosts' build pipelines, or a button anywhere:
POST https://camplax.dev/v1/hooks/cxhook_…
Create one in the console or with POST /v1/projects/<slug>/deploys/hooks (GET lists, DELETE removes). The cxhook_… token is shown once and only its hash is stored. Each hook is bound to a branch: matching the project's default branch queues a production deploy, any other branch a preview. The answer is 202 with the deployId, or 409 while a production deploy is already running — the URL is the secret, so anyone holding it can deploy.
If the caller can sign, send X-Hub-Signature-256: sha256=<hex> — an HMAC-SHA256 of the raw body keyed with the hook token itself (X-Camplax-Signature works too). With no header the token alone is the check.
The platform's own inbound hooks
For completeness — these exist so external providers can reach Camplax, and are not yours to call: POST /v1/webhooks/github (GitHub events, signature-checked), POST /v1/webhooks/stripe and /stripe/connect (billing and connected-account events), and POST /v1/webhooks/ses (Amazon SES email delivery events, signed by SNS — see Email). Each verifies its provider's signature; the signature is the auth.