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
| Function | What 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
| Category | Events |
|---|---|
| User | user.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 |
| Auth | auth.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 |
| Organization | organization.member_invited, organization.member_accepted, organization.member_role_changed, organization.member_removed, organization.public_write_limit_reached, organization.end_user_ceiling_reached |
| Workspace | workspace.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 |
| Subscription | subscription.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 |
| Payment | payment.succeeded, payment.failed, payment.action_required, payment.refunded |
| Quota | quota.limit_exceeded |
| Credit | credit.purchased, credit.consumed, credit.expired, credit.granted, credit.revoked, credit.low_balance |
| Workflow | workflow.created, workflow.deleted, workflow.published, workflow.paused, workflow.resumed, workflow.instance_completed, workflow.instance_failed |
| Audience | audience.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 |
email.domain_added, email.domain_verified, email.sent, email.opened, email.clicked, email.bounced, email.complained, email.unsubscribed, email.campaign_sent | |
| Push | push.campaign_sent |
| Token | token.created, token.updated, token.revoked |
| Group version | group_version.published |
| Form | form.submitted |
| Blog | blog.published, blog.updated, blog.unpublished, blog.deleted |
| Doc | doc.published, doc.updated, doc.unpublished, doc.deleted |
| FAQ | faq.updated |
| FAQ collection | faq_collection.updated |
| Testimonial | testimonial.updated, testimonial.deleted |
| Rich content | rich_content.updated, rich_content.deleted |
| Collection | collection.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 nameisWebhookEvent 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:
| Header | Contents |
|---|---|
x-buildbase-signature | sha256=<hex> - HMAC-SHA256 of {timestamp}.{body} with your signing secret |
x-buildbase-timestamp | Same Unix timestamp as the body |
x-buildbase-event | The event name (e.g. subscription.upgraded) |
x-buildbase-event-id | The same id as the body |
The data object varies by event type. Some common patterns:
| Event | Key data fields |
|---|---|
user.registered | actorId, email, name, and fromGuest for a guest |
user.imported | actorId, userId, email, name, externalId, source |
user.account_linked, user.account_unlinked | actorId, userId, provider, accountId |
user.phone_added | actorId, userId, phone, verified |
user.phone_verified, user.phone_removed | actorId, userId, phone |
user.deletion_scheduled | actorId, userId, deleteAfter |
user.deletion_cancelled | actorId, userId |
user.deleted | actorId, userId, by |
auth.sms_budget_exceeded | level, sent, dailyCap, alertAt |
subscription.created | workspaceId, planId, billingInterval, trialEnd |
subscription.trial_started | workspaceId, subscriptionId, planVersionId, trialEnd |
payment.failed | see below |
credit.consumed | workspaceId, amount, description, remaining |
workspace.member_added | workspaceId, 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:
| Field | Contents |
|---|---|
workspaceId | The workspace whose subscription failed to bill |
subscriptionId | The subscription |
invoiceId | The Stripe invoice |
dunningState | notified, warning or final, by how many attempts have failed so far |
amount | Amount due, in the currency's minor unit (cents) |
currency | Lowercase ISO currency code, as Stripe sends it |
failedAt | ISO 8601 timestamp |
nextRetryAt | ISO 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: itsdatais resolved again on each attempt and can differ. - No ordering guarantee - events may arrive out of order. Use
event.timestampif 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.
| Limit | Value | What happens at the limit |
|---|---|---|
| Endpoints per organization | 10 (excluding archived) | Creating an eleventh is refused. Delete one first |
| Deliveries per minute | 240 per organization | Further deliveries that minute are not sent, and appear in the delivery log as throttled |
| Deliveries in flight at once | 3 per organization | The next delivery waits a few seconds and is then sent. Nothing is dropped and no retry is spent |
| Failed deliveries in a row | 50 per endpoint | The 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.