Docs
BlogHomeStart building

Webhooks (totalum-sdk/webhooks)


import … from 'totalum-sdk/webhooks' — or the same namespace on totalum from 'totalum-sdk'. Install, configuration and errors: Totalum SDK.

Receive Totalum's signed events (payments, e-mail delivery, browser programs, connected-account events, credits exhausted) and check that they really come from Totalum. verifyWebhook() checks the Totalum-Signature header with TOTALUM_WEBHOOK_SECRET, which Totalum sets for you; totalum.webhooks reads and changes the optional extra URL events are also sent to, and rotates the secret. Free.

ts
import { verifyWebhook } from 'totalum-sdk/webhooks';
const event = await verifyWebhook(await request.text(), request.headers);
Method or typeWhat it does
verifyWebhookChecks that a webhook delivery really comes from Totalum and returns its event.
totalumWebhooksCreates the webhooks namespace (the same object as totalum.webhooks; a key with scope *): where the project receives its signed events (payments, e-mail, browser programs, project.credits.exhausted) and the secret they are signed with.
webhooks.verifyThe same function as verifyWebhook: checks Totalum-Signature and returns the parsed event.
webhooks.getThe project's extra webhook URL and the first characters of its signing secret (the secret itself is only shown by rotateSecret).
webhooks.setUrlSets the extra https:// URL the project's events are also delivered to (for example a separate backend or an automation tool).
webhooks.rotateSecretReplaces the signing secret, for example after it leaked.
TotalumWebhookErrorWhat verifyWebhook (and totalum.webhooks.verify, connections.verifyEvent) throws when a delivery cannot be trusted: answer it with 401 and do nothing else.
WebhookConfigThe project's extra webhook address, from webhooks.get() and webhooks.setUrl().
WebhookEventA signed event Totalum delivers to the app, returned by verifyWebhook().
WebhookSecretOutputThe answer of webhooks.rotateSecret().

#verifyWebhook

ts
verifyWebhook(rawBody: string, headers: Headers | Record<string, string>, secret?: string, now?: number): Promise<WebhookEvent>

Checks that a webhook delivery really comes from Totalum and returns its event. It verifies the Totalum-Signature: t=…,v1=… header — HMAC-SHA256 keyed with the secret over ${t}.${rawBody} — and refuses a timestamp more than 300 s away. Pass the body exactly as received (await request.text(), never re-serialised JSON). secret defaults to TOTALUM_WEBHOOK_SECRET, which Totalum sets on the preview, the published site and the sandbox (a rotation reaches them at once): never ask the owner to set it. Totalum retries a delivery that does not get a 2xx, so handle each event id once.

Parameters

NameTypeRequiredDescription
rawBodystringyesThe request body as text, untouched.
headersHeaders | Record<string, string>yesThe request's headers (a Headers or a plain object).
secretstringnoThe signing secret. Default: TOTALUM_WEBHOOK_SECRET.
nownumbernoThe current time in milliseconds, for tests. Default: Date.now().

Returns Promise<WebhookEvent> — The event: its type, id and data.

FieldTypeAlways presentDescription
idstringyesThe event id; the same event can be delivered more than once (retries), so handle each id once.
typestringyesWhat happened: payments.<Stripe event type> (payments.checkout.session.completed, payments.invoice.paid, …), email.delivered, email.bounced, email.complained, email.failed, email.delivery_delayed, browser.program.completed, connections.event, project.credits.exhausted, project.payments.disconnected.
createdAtstringyesWhen it happened.
projectIdstringyesYour project id.
env'dev' | 'live'yesdev for the preview (Stripe test mode), live for the published site.
dataRecord<string, unknown>yesThe event's content: for a payment event { object } (the Stripe object), for an e-mail event { emailId, to, providerId, at }.
livemodebooleannoFor payment events: whether it is a real-money (live) Stripe event.

Errors — besides the errors every call can throw:

CodeHTTPWhen
WEBHOOK_SECRET_MISSING—No secret: TOTALUM_WEBHOOK_SECRET is unset and none was passed.
WEBHOOK_SIGNATURE_INVALID401The header is missing or no signature matches: not from Totalum, or the body was changed.
WEBHOOK_TIMESTAMP_EXPIRED401The signature is older (or newer) than 300 s: a replayed delivery.

Example

ts
// src/app/api/webhooks/stripe/route.ts
import { isTotalumError } from 'totalum-sdk';
import { verifyWebhook } from 'totalum-sdk/webhooks';

export async function POST(request: Request) {
  let event;
  try {
    event = await verifyWebhook(await request.text(), request.headers);
  } catch (e) {
    if (isTotalumError(e)) return new Response('invalid signature', { status: 401 });
    throw e;
  }
  if (event.type === 'payments.checkout.session.completed') {
    const session = event.data.object as { metadata: { orderId: string } };
    await markOrderPaid(session.metadata.orderId);
  }
  return new Response('ok');
}

Result:

json
{
  "id": "evt_1QZ7cDPQm4n8Xa2LoP5qR6sT",
  "type": "payments.checkout.session.completed",
  "createdAt": "2026-10-11T18:01:12.000Z",
  "projectId": "acme-crm",
  "env": "live",
  "livemode": true,
  "data": { "object": { "id": "cs_live_a1B2c3D4e5F6g7H8", "payment_status": "paid", "metadata": { "orderId": "ord_42" } } }
}

#totalumWebhooks

ts
totalumWebhooks(options?: TotalumClientOptions): TotalumWebhooks

Creates the webhooks namespace (the same object as totalum.webhooks; a key with scope *): where the project receives its signed events (payments, e-mail, browser programs, project.credits.exhausted) and the secret they are signed with. Payment events also reach the app's own /api/webhooks/stripe by themselves; this URL is an optional extra address.

Parameters

NameTypeRequiredDescription
optionsTotalumClientOptionsnoThe project key, API URL and environment; each falls back to its environment variable.
options.keystringnoProject key tlm_pk_…; default TOTALUM_PROJECT_KEY. Server-side only.
options.apiUrlstringnoThe Totalum API's URL: default TOTALUM_SDK_API_URL, else https://sdk-api.totalum.app.
options.env'dev' | 'live'noSent as x-totalum-env on every call (the database it targets, and dev vs live usage); default TOTALUM_ENV, else live.

Returns TotalumWebhooks — The webhooks namespace: the methods below.

Example

ts
import { totalumWebhooks } from 'totalum-sdk/webhooks';

const webhooks = totalumWebhooks();
const config = await webhooks.get();

The namespace's type is exported as TotalumWebhooks (import type { TotalumWebhooks } from 'totalum-sdk/webhooks').

#webhooks.verify

ts
webhooks.verify(rawBody: string, headers: Headers | Record<string, string>, secret?: string, now?: number): Promise<WebhookEvent>

The same function as verifyWebhook: checks Totalum-Signature and returns the parsed event.

Parameters

NameTypeRequiredDescription
rawBodystringyesThe request body as text, untouched.
headersHeaders | Record<string, string>yesThe request's headers.
secretstringnoThe signing secret. Default: TOTALUM_WEBHOOK_SECRET.
nownumbernoThe current time in milliseconds, for tests. Default: Date.now().

Returns Promise<WebhookEvent> — The verified event.

FieldTypeAlways presentDescription
idstringyesThe event id; the same event can be delivered more than once (retries), so handle each id once.
typestringyesWhat happened: payments.<Stripe event type> (payments.checkout.session.completed, payments.invoice.paid, …), email.delivered, email.bounced, email.complained, email.failed, email.delivery_delayed, browser.program.completed, connections.event, project.credits.exhausted, project.payments.disconnected.
createdAtstringyesWhen it happened.
projectIdstringyesYour project id.
env'dev' | 'live'yesdev for the preview (Stripe test mode), live for the published site.
dataRecord<string, unknown>yesThe event's content: for a payment event { object } (the Stripe object), for an e-mail event { emailId, to, providerId, at }.
livemodebooleannoFor payment events: whether it is a real-money (live) Stripe event.

Errors — besides the errors every call can throw:

CodeHTTPWhen
WEBHOOK_SECRET_MISSING—No secret is set or passed.
WEBHOOK_SIGNATURE_INVALID401No signature matches.
WEBHOOK_TIMESTAMP_EXPIRED401The signature is more than 300 s old.

Example

ts
const event = await totalum.webhooks.verify(await request.text(), request.headers);

Result:

json
{
  "id": "msg_2nQx8kZf0aYc3Vb1",
  "type": "email.delivered",
  "createdAt": "2026-10-11T18:01:12.000Z",
  "projectId": "acme-crm",
  "env": "live",
  "data": { "emailId": "em_01M4P0S77M6XCM69SYZWP1XME9", "to": ["ana@example.com"], "providerId": "01a12c0c-9e5f-74ea-a128-a72d3fdd47ae", "at": "2026-10-11T18:01:12.000Z" }
}

#webhooks.get

ts
webhooks.get(): Promise<WebhookConfig>

The project's extra webhook URL and the first characters of its signing secret (the secret itself is only shown by rotateSecret). Free.

Returns Promise<WebhookConfig> — The configured URL (or null), whether delivery is on, and the secret's prefix.

FieldTypeAlways presentDescription
urlstring | nullyesThe https:// URL events are also sent to, or null.
enabledbooleanyesWhether events are being delivered there (a URL and a secret are both set).
secretPrefixstring | nullyesThe first 10 characters of the signing secret (whsec_…), to tell secrets apart; null before the first one.

Errors — besides the errors every call can throw:

CodeHTTPWhen
FORBIDDEN_SCOPE403The key does not have scope *.

Example

ts
const config = await totalum.webhooks.get();

Result:

json
{ "url": "https://hooks.acme.com/totalum", "enabled": true, "secretPrefix": "whsec_4f9a" }

#webhooks.setUrl

ts
webhooks.setUrl(url: string): Promise<WebhookConfig>

Sets the extra https:// URL the project's events are also delivered to (for example a separate backend or an automation tool). Free.

Parameters

NameTypeRequiredDescription
urlstringyesThe https:// URL that receives the events.

Returns Promise<WebhookConfig> — The new configuration.

FieldTypeAlways presentDescription
urlstring | nullyesThe https:// URL events are also sent to, or null.
enabledbooleanyesWhether events are being delivered there (a URL and a secret are both set).
secretPrefixstring | nullyesThe first 10 characters of the signing secret (whsec_…), to tell secrets apart; null before the first one.

Errors — besides the errors every call can throw:

CodeHTTPWhen
VALIDATION_ERROR400Not an https:// URL.
FORBIDDEN_SCOPE403The key does not have scope *.

Example

ts
const config = await totalum.webhooks.setUrl('https://hooks.acme.com/totalum');

Result:

json
{ "url": "https://hooks.acme.com/totalum", "enabled": true, "secretPrefix": "whsec_4f9a" }

#webhooks.rotateSecret

ts
webhooks.rotateSecret(): Promise<WebhookSecretOutput>

Replaces the signing secret, for example after it leaked. The new secret is shown once; for 24 hours every delivery is signed with both, so receivers keep working while they update. Totalum sets the new one as TOTALUM_WEBHOOK_SECRET on the preview, the published site and the sandbox by itself.

Returns Promise<WebhookSecretOutput> — The new secret (store it only if another system verifies the events).

FieldTypeAlways presentDescription
secretstringyesThe new signing secret (whsec_…), shown only this once.

Errors — besides the errors every call can throw:

CodeHTTPWhen
FORBIDDEN_SCOPE403The key does not have scope *.

Example

ts
const { secret } = await totalum.webhooks.rotateSecret();

Result:

json
{ "secret": "whsec_7b1e0c9f…" }

#TotalumWebhookError

What verifyWebhook (and totalum.webhooks.verify, connections.verifyEvent) throws when a delivery cannot be trusted: answer it with 401 and do nothing else. errorCode says why.

MemberTypeDescription
namestringAlways 'TotalumWebhookError'.
errorCode'WEBHOOK_SECRET_MISSING' | 'WEBHOOK_SIGNATURE_INVALID' | 'WEBHOOK_TIMESTAMP_EXPIRED'Why the delivery was refused: no secret, a signature that does not match, or a timestamp outside ±300 s.

#WebhookConfig

The project's extra webhook address, from webhooks.get() and webhooks.setUrl().

FieldTypeAlways presentDescription
urlstring | nullyesThe https:// URL events are also sent to, or null.
enabledbooleanyesWhether events are being delivered there (a URL and a secret are both set).
secretPrefixstring | nullyesThe first 10 characters of the signing secret (whsec_…), to tell secrets apart; null before the first one.

#WebhookEvent

A signed event Totalum delivers to the app, returned by verifyWebhook().

FieldTypeAlways presentDescription
idstringyesThe event id; the same event can be delivered more than once (retries), so handle each id once.
typestringyesWhat happened: payments.<Stripe event type> (payments.checkout.session.completed, payments.invoice.paid, …), email.delivered, email.bounced, email.complained, email.failed, email.delivery_delayed, browser.program.completed, connections.event, project.credits.exhausted, project.payments.disconnected.
createdAtstringyesWhen it happened.
projectIdstringyesYour project id.
env'dev' | 'live'yesdev for the preview (Stripe test mode), live for the published site.
dataRecord<string, unknown>yesThe event's content: for a payment event { object } (the Stripe object), for an e-mail event { emailId, to, providerId, at }.
livemodebooleannoFor payment events: whether it is a real-money (live) Stripe event.

#WebhookSecretOutput

The answer of webhooks.rotateSecret().

FieldTypeAlways presentDescription
secretstringyesThe new signing secret (whsec_…), shown only this once.