BuildBaseBuildBase

Overview

Receive real-time event notifications when users sign up, subscribe, pay, or trigger actions.

BuildBase fires webhook events for every significant action - user signups, subscription changes, payments, credit usage, workspace updates, and more. Verify the signature and handle the event in your backend.

import { parseWebhookEvent } from '@buildbase/sdk';

export async function POST(req) {
  const body = await req.text();

  const event = parseWebhookEvent({
    body,
    signature: req.headers.get('x-buildbase-signature'),
    timestamp: req.headers.get('x-buildbase-timestamp'),
    secret: process.env.WEBHOOK_SECRET,
  });

  if (!event) {
    return Response.json({ error: 'Invalid signature' }, { status: 401 });
  }

  switch (event.event) {
    case 'subscription.upgraded':
      await enablePremiumFeatures(event.data.workspaceId);
      break;
    case 'credit.low_balance':
      await notifyTeam(event.data.workspaceId);
      break;
    case 'workspace.member_added':
      await syncToExternalCRM(event.data);
      break;
  }

  return Response.json({ received: true });
}

Before you start

Create a webhook endpoint in the BuildBase dashboard and copy the signing secret.

Two functions

FunctionWhat it does
verifyWebhookSignature({ body, signature, timestamp, secret })Returns true if the signature is valid
parseWebhookEvent({ body, signature, timestamp, secret })Verifies and parses - returns the event object or null

Use parseWebhookEvent for most cases. Use verifyWebhookSignature if you want to parse the body yourself.

Express / Hono example

import { parseWebhookEvent } from '@buildbase/sdk';

app.post('/webhooks', (req, res) => {
  const event = parseWebhookEvent({
    body: req.body,
    signature: req.headers['x-buildbase-signature'],
    timestamp: req.headers['x-buildbase-timestamp'],
    secret: process.env.WEBHOOK_SECRET,
  });

  if (!event) return res.status(401).json({ error: 'Invalid' });

  // Handle event.event and event.data
  res.json({ received: true });
});

Event categories

CategoryEvents
Useruser.registered, user.logged_in, user.new_device_login, user.email_verified, user.password_changed, user.password_reset_requested, user.profile_updated, user.tag_added, user.tag_removed, user.blocked, user.unblocked, user.mfa_enrolled, user.mfa_disabled, user.mfa_reset_by_admin, user.email_changed, user.imported, user.account_linked, user.account_unlinked, user.phone_added, user.phone_verified, user.phone_removed, user.deletion_scheduled, user.deletion_cancelled, user.deleted
Authauth.login_failed, auth.signup_challenged, auth.sms_budget_exceeded, auth.method_deleted, auth.domain_added, auth.domain_verified, auth.domain_deleted, auth.client_self_registered
Organizationorganization.member_invited, organization.member_accepted, organization.member_role_changed, organization.member_removed, organization.public_write_limit_reached, organization.end_user_ceiling_reached
Workspaceworkspace.created, workspace.updated, workspace.deleted, workspace.settings_updated, workspace.features_updated, workspace.member_added, workspace.member_removed, workspace.member_role_changed, workspace.member_invited, workspace.invitation_accepted, workspace.invitation_declined, workspace.invitation_revoked, workspace.invitation_expired
Subscriptionsubscription.created, subscription.updated, subscription.upgraded, subscription.canceled, subscription.cancel_scheduled, subscription.resumed, subscription.suspended, subscription.trial_started, subscription.trial_will_end, subscription.trial_expired, subscription.downgraded
Paymentpayment.succeeded, payment.failed, payment.action_required, payment.refunded
Quotaquota.limit_exceeded
Creditcredit.purchased, credit.consumed, credit.expired, credit.granted, credit.revoked, credit.low_balance
Workflowworkflow.created, workflow.deleted, workflow.published, workflow.paused, workflow.resumed, workflow.instance_completed, workflow.instance_failed
Audienceaudience.member_created, audience.member_updated, audience.added_to_list, audience.removed_from_list, audience.attribute_changed, audience.tag_added, audience.tag_removed, audience.unsubscribed, audience.resubscribed, audience.email_invalidated, audience.blocked, audience.unblocked, audience.import_completed, audience.list_created, audience.list_updated, audience.list_deleted
Emailemail.domain_added, email.domain_verified, email.sent, email.opened, email.clicked, email.bounced, email.complained, email.unsubscribed, email.campaign_sent
Pushpush.campaign_sent
Tokentoken.created, token.updated, token.revoked
Group versiongroup_version.published
Formform.submitted
Blogblog.published, blog.updated, blog.unpublished, blog.deleted
Docdoc.published, doc.updated, doc.unpublished, doc.deleted
FAQfaq.updated
FAQ collectionfaq_collection.updated
Testimonialtestimonial.updated, testimonial.deleted
Rich contentrich_content.updated, rich_content.deleted
Collectioncollection.version_published, collection.record_created, collection.record_updated, collection.record_deleted

129 event types total. See the SDK README for the full list.

The event names, from the package

Since @buildbase/sdk 0.0.79 the catalog ships with the SDK, so a handler can be checked against it instead of against this page:

import {
  isWebhookEvent,
  WEBHOOK_EVENTS,
  type WebhookEventName,
} from '@buildbase/sdk';

WEBHOOK_EVENTS.length; // every name this SDK version knows
isWebhookEvent('payment.failed'); // true
isWebhookEvent('invoice.payment_failed'); // false, that is Stripe's name

isWebhookEvent narrows a string to WebhookEventName, so a switch over a parsed event's name is checked at compile time. WEBHOOK_EVENTS_BY_CATEGORY gives the same names grouped as the table above groups them.

The catalog is the list as it stood when that SDK version was released, so it can trail the platform. Versions 0.0.79 to 0.0.89 hold 112 names (113 in 0.0.89, which adds email.complained), without the sixteen sign-in events in the User and Auth rows above: two-step sign-in (user.mfa_enrolled, user.mfa_disabled, user.mfa_reset_by_admin, auth.login_failed), changing an email, bot protection, imports, connected accounts, phone numbers, the SMS budget and deleting accounts (user.deletion_scheduled, user.deletion_cancelled, user.deleted). The platform sends them to every version, but on one whose catalog lacks a name isWebhookEvent answers false, so a handler that drops unknown names drops it as well. @buildbase/sdk 0.0.90 holds all of them. Check WEBHOOK_EVENTS.length on the version you run. A catalog with all of them also types the deletion payload as UserDeletionWebhookData, and user.registered as UserRegisteredWebhookData.

Event payload structure

Every webhook POST body has this shape - four fields, nothing else:

{
  "id": "evt_3f1c9a0b7d2e4c6f8a1b2c3d4e5f6a7b",
  "event": "subscription.upgraded",
  "timestamp": 1719000000,
  "data": {
    "workspaceId": "ws_xyz789",
    "userId": "usr_def456",
    "planId": "plan_starter",
    "planVersionId": "pv_v2",
    "billingInterval": "monthly",
    "previousPlanId": "plan_free"
  }
}

id names the event: it is the same on every retry, and for every endpoint the event goes to. timestamp is Unix seconds, set when the event fires. Each delivery also carries four headers:

HeaderContents
x-buildbase-signaturesha256=<hex> - HMAC-SHA256 of {timestamp}.{body} with your signing secret
x-buildbase-timestampSame Unix timestamp as the body
x-buildbase-eventThe event name (e.g. subscription.upgraded)
x-buildbase-event-idThe same id as the body

The data object varies by event type. Some common patterns:

EventKey data fields
user.registeredactorId, email, name, and fromGuest for a guest
user.importedactorId, userId, email, name, externalId, source
user.account_linked, user.account_unlinkedactorId, userId, provider, accountId
user.phone_addedactorId, userId, phone, verified
user.phone_verified, user.phone_removedactorId, userId, phone
user.deletion_scheduledactorId, userId, deleteAfter
user.deletion_cancelledactorId, userId
user.deletedactorId, userId, by
auth.sms_budget_exceededlevel, sent, dailyCap, alertAt
subscription.createdworkspaceId, planId, billingInterval, trialEnd
subscription.trial_startedworkspaceId, subscriptionId, planVersionId, trialEnd
payment.failedsee below
credit.consumedworkspaceId, amount, description, remaining
workspace.member_addedworkspaceId, userId, role
Content events (blog.*, doc.*, faq.updated, rich_content.*, collection.*, ...)module, id, slug, path (null for now), tags

actorId on user.registered is the new user's id, and fromGuest: true marks a guest who created their account. by on user.deleted is self, admin or guest_expired; see Deleting accounts.

payment.failed fires on every failed Stripe payment attempt except the one that suspends the subscription, which fires subscription.suspended instead. Its data is:

FieldContents
workspaceIdThe workspace whose subscription failed to bill
subscriptionIdThe subscription
invoiceIdThe Stripe invoice
dunningStatenotified, warning or final, by how many attempts have failed so far
amountAmount due, in the currency's minor unit (cents)
currencyLowercase ISO currency code, as Stripe sends it
failedAtISO 8601 timestamp
nextRetryAtISO 8601 timestamp of Stripe's next attempt, or null

Deliveries are enriched with workspace and subscription objects where the server has them. There is no failureReason field; read it from the Stripe invoice if you need it. The lifecycle email for a failed payment is a console notification (billing:payment_failed) or a workflow on this trigger; your handler is for your own side effects.

Content events carry ready-made cache tags so a site can clear exactly what changed; see Delivering content.

Delivery guarantees

Delivery is best effort. BuildBase retries a failed delivery for a few minutes and then gives up; it does not keep events to send again later.

  • 5 attempts over about 2.5 minutes - a response other than 2xx, a network error, or no answer within 15 seconds is a failed attempt. It is retried 4 more times with exponential backoff, at roughly 10s, 20s, 40s and 80s, so the last attempt lands about 2.5 minutes after the first. A destination that is refused by our outbound safety checks is not retried at all; the delivery log says blocked. Deleting an endpoint or turning it off stops its pending retries: a delivery waiting for its next attempt is dropped, not sent.
  • One timestamp per event - the timestamp and signature are set once, when the event is dispatched, and every retry carries the same ones. That keeps every attempt inside the 5-minute window your receiver should enforce, and it is also why retries cannot run for longer.
  • No resend - a delivery that fails all 5 attempts is not sent again, and there is no resend or replay. Each attempt stays in the endpoint's delivery log.
  • Events can be lost without a trace - an event is lost, with no row in the delivery log, if it cannot be queued for delivery (for example while our queue store is unavailable), if looking up your endpoints fails at that moment, if the server stops between saving the change and dispatching its event, or if queued deliveries are lost along with the queue store. If missing a change would hurt, reconcile against your data from time to time (see the Server SDK) rather than relying on webhooks alone.
  • Duplicates are possible - a retry can repeat an event your receiver already processed, for example one it answered after our 15-second timeout. Every attempt carries the same id, so record the ids you have processed and skip one you have seen. Do not deduplicate on a hash of the body: its data is resolved again on each attempt and can differ.
  • No ordering guarantee - events may arrive out of order. Use event.timestamp if ordering matters.
  • Signature expiry - signatures are valid for 5 minutes by default. Reject older payloads to prevent replay attacks.

Limits

Webhooks fan out: one event reaching ten endpoints is ten calls to your servers, and some events fire per recipient rather than per action. These bounds keep one busy hour from becoming a queue nobody can drain.

LimitValueWhat happens at the limit
Endpoints per organization10 (excluding archived)Creating an eleventh is refused. Delete one first
Deliveries per minute240 per organizationFurther deliveries that minute are not sent, and appear in the delivery log as throttled
Deliveries in flight at once3 per organizationThe next delivery waits a few seconds and is then sent. Nothing is dropped and no retry is spent
Failed deliveries in a row50 per endpointThe endpoint is turned off, shows Auto-disabled in the console, and your owner and admins are emailed

A note on the in-flight limit and the signature window below: a delivery that is already close to its five-minute signature expiry is sent immediately rather than made to wait, because handing your receiver a payload it is bound to reject would be worse than letting one organization briefly exceed its cap.

A throttled delivery never left BuildBase, so it is not a failure of your receiver and is not retried. email.sent fires once per recipient, so a large campaign is the usual way to meet the per-minute limit; subscribe an endpoint to the specific events you need rather than to * if that matters to you.

The in-flight limit is different in kind: it is a queue, not a refusal. Three of your deliveries run at a time and the rest wait their turn, so a receiver of yours that answers slowly slows down your own events and nobody else's. Because it defers rather than drops, it costs you no retry attempts and shows up as nothing in the delivery log except later timestamps. A delivery nearing the five-minute signature window is never deferred again, so waiting for a slot cannot turn into a signature rejection. If your receiver answers quickly you will never notice it: three concurrent requests at, say, 200ms each is well above the per-minute limit above.

A delivery counts toward the failed-deliveries limit once, when all 5 of its attempts have failed; a failed attempt that is still going to be retried does not count. Any 2xx resets the count, so only 50 failed deliveries with no success between them turn an endpoint off. Because it is a count, a busy endpoint reaches it sooner than a quiet one: at 6 events a minute, an outage at your receiver has to last about 11 minutes.

While an endpoint is disabled, whether we turned it off or you did, nothing is sent to it. When we turned it off, each event it would have received appears in its delivery log as Skipped, so you can see what it missed; one you turned off yourself logs nothing. Turning the endpoint back on does not send those events. Re-enabling an auto-disabled endpoint from the console clears its failure count, and turning one off yourself makes it yours: it shows as Disabled and stops logging skipped events.

Next Steps

  • Server SDK - Backend setup and all server methods.
  • Billing - Subscription events and payment handling.