Integrations

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:

Envelope
{
  "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 */ }
}
Settings → Webhooks listing a registered endpoint with its subscribed events and an active status badge
Settings → Webhooks — one endpoint, its subscribed events, and a Send test button.
Webhooks

Event catalogue

An endpoint subscribes to one or more of these. Payloads mirror the matching API response shape and never carry design JSON.

ParamTypeNotes
render.completedRendersA 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.failedRendersA render failed for good. data: renderId (null if it failed before a request existed), sourceTemplateId, sourceRenderId, kind, format, error { status, code, message }.
render.editedRendersA 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.createdTemplatesA template was created, duplicated, or imported. data: id, name, description, tags, folderId, pageCount, width, height, thumbnailUrl, createdAt, updatedAt.
template.updatedTemplatesA 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.deletedTemplatesdata: { id, name }. Never fires for a whole-workspace delete (its endpoints go with it).
post.scheduledSocial postsA post was scheduled or rescheduled to a new time. data is the same shape as GET social/posts/:id.
post.publishedSocial postsA post went out, on any path (cron, retry, post-now). data is the same shape as GET social/posts/:id.
post.failedSocial postsA 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.lowCreditsUsage 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.exhaustedCreditsUsage 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.
Webhooks

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 to your endpoint
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))
  );
}
Webhooks

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.

An endpoint that fails 20 deliveries in a row over 3 or more days is turned off automatically: its waiting deliveries are marked dead and the workspace's owners and admins are notified. Turn it back on from Settings → Webhooks once the receiver is fixed — that also resets its failure count.
Webhooks

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-Id can 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.failed for a post that later sends post.published after a retry.

Was this page helpful?