Webhooks
A workspace registers one or more endpoint URLs, picks which events to send each one, and buildonce.app POSTs a signed message as soon as that event happens — a template saved, a render finished, a post published, credits running low. Manage endpoints under Settings → Webhooks in the dashboard, or through the /v1/webhooks API with a workspace API key.
Every delivery shares the same envelope, whatever the event — only type and data change:
{
"id": "evt_8m2kq4x7tb1c",
"type": "render.completed",
"created_at": "2026-09-28T12:05:00.000Z",
"workspace_id": "ws_123",
"data": { /* shape depends on the event, see below */ }
}
Event catalogue
An endpoint subscribes to one or more of these. Payloads mirror the matching API response shape and never carry design JSON.
| Param | Type | Notes |
|---|---|---|
| render.completed | Renders | A render finished — any surface (dashboard, /v1, MCP, embed), including a rerender and an edit save. data is the same shape as GET /v1/renders/:renderId, plus kind. Never fires for an idempotency replay. |
| render.failed | Renders | A render failed for good. data: renderId (null if it failed before a request existed), sourceTemplateId, sourceRenderId, kind, format, error { status, code, message }. |
| render.edited | Renders | A save in the embedded editor’s render mode that changed the design. data: sessionId, renderId, sourceRenderId, sourceTemplateId, embedId, files, credits. Never fires for an unchanged save. |
| template.created | Templates | A template was created, duplicated, or imported. data: id, name, description, tags, folderId, pageCount, width, height, thumbnailUrl, createdAt, updatedAt. |
| template.updated | Templates | A real change — details, folder, a parameter, or a saved design revision. data adds change ("details" | "folder" | "parameter" | "design") and parameter or revision. One event per studio editing session, never per autosave. |
| template.deleted | Templates | data: { id, name }. Never fires for a whole-workspace delete (its endpoints go with it). |
| post.scheduled | Social posts | A post was scheduled or rescheduled to a new time. data is the same shape as GET social/posts/:id. |
| post.published | Social posts | A post went out, on any path (cron, retry, post-now). data is the same shape as GET social/posts/:id. |
| post.failed | Social posts | A post failed to publish. The same post can later send post.published after a manual retry. data is the same shape as GET social/posts/:id. |
| credits.low | Credits | Usage crossed 80% of the plan’s included credits. Sent to every workspace on the subscription, since the credit pool is shared. data: threshold, creditsUsed, creditsIncluded, period. |
| credits.exhausted | Credits | Usage crossed 100%. Same data shape as credits.low. Sent to every workspace on the subscription. |
webhook.test is not subscribable — it is sent once, to one endpoint, by its "Send test event" button, whatever that endpoint is actually subscribed to.Verifying a delivery
Every delivery carries three headers: BuildOnce-Event (the event type), BuildOnce-Delivery-Id (whd_…, stable across retries and useful for deduplication), and BuildOnce-Signature: t=<unix>,v1=<hex>.
POST /your/endpoint HTTP/1.1
BuildOnce-Event: render.completed
BuildOnce-Delivery-Id: whd_6tq2k9m4xa1b
BuildOnce-Signature: t=1790606243,v1=5f0c…e91a
Content-Type: application/json
{
"id": "evt_8m2kq4x7tb1c",
"type": "render.completed",
"created_at": "2026-09-28T12:05:00.000Z",
"workspace_id": "ws_123",
"data": { /* shape depends on the event, see below */ }
}v1 must equal HMAC-SHA256 of t + "." + rawBody, keyed with the endpoint's signing secret (whsec_…, shown once when you create or rotate it under Settings → Webhooks). Use the raw request body — not a re-serialized one — and reject old timestamps to stop replays.
import crypto from "node:crypto";
function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= toleranceSeconds;
return (
fresh &&
typeof parts.v1 === "string" &&
parts.v1.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected))
);
}Retry policy & auto-disable
A delivery that doesn't get back a 2xx is retried with backoff: 30s → 2m → 10m → 1h → 6h, up to six attempts total before it's marked dead. A 4xx other than 408/429, a 3xx, or a blocked/invalid URL fails the delivery immediately with no retry — your endpoint must answer 2xx to count as delivered.
Idempotency & redelivery
- One event is delivered at most once per endpoint it's subscribed to — retrying the same delivery never creates a duplicate row, and a receiver that keys on
BuildOnce-Delivery-Idcan safely ignore a message it has already processed. - Redelivering a delivery (from the dashboard or
POST …/deliveries/:deliveryId/redeliver) reuses the same delivery id and sends the exact bytes originally signed — it is not a new event. - A failed post can later succeed: you may see
post.failedfor a post that later sendspost.publishedafter a retry.
Was this page helpful?