Webhooks
Get told when things happen.
Two kinds of webhooks:
- Account webhooks — Totalum calls your server when something happens to your projects (a prompt finished, a publish ended, a limit was reached…). One subscription per event, optionally filtered to some projects.
- The project webhook — an optional address where the app's own integration events (for example payments) are also sent, signed with the project's webhook secret, verified with
totalum.webhooks.verify(SDK). Payment events need no address: they reach the app's own/api/webhooks/stripeby themselves (the preview for test payments, the published site for real ones).
Events
| Event | Sent when | data |
|---|---|---|
agent.prompt.finished | A prompt finished | { projectId, status, prompt, … } |
deployment.finished | A publish ended | { projectId, deploymentId, status: "success" | "error", fixAttempts, creditRefunded?, errorDetails?, at } |
project.credit_limit.reached | A project reached a monthly credit limit | { projectId, category, spent, limit } |
project.feature_limit.warning | A feature's monthly budget reached 80 % (once per feature and month) | { projectId, feature, scope, refusing, cap, capKind, used, period, at } |
project.feature_limit.reached | A feature's monthly budget is spent | same as above |
project.feature.disabled / project.feature.enabled | A feature was switched off / on | { projectId, feature, scope, refusing, at } |
project.paused / project.resumed | The project was paused / resumed | { projectId, state, pausedAt, resumable, at } |
project.lifecycle.state_changed | The project went to sleep after inactivity, or woke up | { projectId, to, at, reason, nextStep } |
project.kill_switch.fired | The app's usage spiked far above normal and it was paused to protect your credits | { projectId, … } |
project.credits.exhausted | The account ran out of credits; the app's paid features stop | { projectId, … } |
project.mobile_build.finished | A mobile build ended | { projectId, buildId, status, … } |
project.preview.url_rotated | The preview address changed | { projectId, … } |
project.payments.disconnected | The app's Stripe account was disconnected | { projectId, … } |
cron.job.paused | A scheduled job failed too many runs in a row and was paused | { projectId, jobId, name, env, failures, lastStatus, at } |
cron.job.failing | A job set never to pause failed 10 runs in a row (once per streak) | same as above |
Payload — every delivery is a POST with this JSON body:
{ "event": "deployment.finished", "timestamp": "2026-10-08T11:23:10.000Z", "data": { "projectId": "barber-bookings", "deploymentId": "dep_01k6xf3a9b", "status": "success", "fixAttempts": 0, "at": "2026-10-08T11:23:10.000Z" } }Verify a delivery. Each delivery carries X-Totalum-Signature: t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 of "<t>.<raw body>" keyed with the subscription's secret (Get Webhook Secret): drop the whsec_ prefix and hex-decode the rest. X-Totalum-Event-Id identifies the event; a retry carries the same id. A delivery that does not answer 2xx within 10 s is retried after 30 s and after 2 minutes, then marked failed (the owner gets at most one e-mail a day about it).
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyTotalumWebhook(rawBody, header, secret) {
const { t, v1 } = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'hex');
const expected = createHmac('sha256', key).update(`${t}.${rawBody}`).digest();
return Math.abs(Date.now() / 1000 - Number(t)) < 300 && timingSafeEqual(expected, Buffer.from(v1, 'hex'));
}#Register Webhook
/api/v1/vcaas/webhooksapi-keyFreeSubscribe an HTTPS URL to one event. One unfiltered subscription per event; filtered ones (by project or feature) can sit beside it.
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
url | string | Yes | https:// URL that receives the POST |
event | string | Yes | One event from the table above |
headers | object | No | Extra headers sent with every delivery (stored encrypted) |
projectIds | string[] | No | Only these projects (up to 50) |
features | string[] | No | Only these features (ai, email…) — for the project.feature* events |
Response fields
| Field | Type | Description |
|---|---|---|
data.id | string | Subscription id |
data.url | string | URL |
data.headers | object | undefined | Extra headers |
data.event | string | Event |
data.createdAt | string | ISO 8601 |
data.projectIds | string[] | undefined | Project filter |
data.features | string[] | undefined | Feature filter |
Example request
curl -X PUT \
-H "api-key: tlm_sk_your_key" \
-H "Content-Type: application/json" \
-d '{"url":"https://yourserver.com/totalum-webhook","event":"deployment.finished"}' \
https://builder-api.totalum.app/api/v1/vcaas/webhooksconst res = await fetch("https://builder-api.totalum.app/api/v1/vcaas/webhooks", {
method: "PUT",
headers: {
"api-key": process.env.TOTALUM_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
"url": "https://yourserver.com/totalum-webhook",
"event": "deployment.finished"
}),
});
const { errors, data } = await res.json();{ "errors": null, "data": { "id": "6701f2a3c4d5e6f7a8b9c0d1", "url": "https://yourserver.com/totalum-webhook", "event": "deployment.finished", "createdAt": "2026-10-08T10:00:00.000Z" } }Error codes
| Code | HTTP | Meaning |
|---|---|---|
MISSING_WEBHOOK_FIELDS | 400 | url and event are required |
INVALID_WEBHOOK_URL | 400 | The URL is not HTTPS |
INVALID_WEBHOOK_EVENT | 400 | Unknown event; the message lists every event you can register |
VALIDATION_ERROR | 400 | projectIds/features is not a list of strings, or more than 50 projects |
WEBHOOK_FILTER_NOT_APPLICABLE | 400 | features on an event that is not a feature event |
WEBHOOK_PROJECT_NOT_OWNED | 403 | A projectIds entry is not your project |
WEBHOOK_EVENT_ALREADY_EXISTS | 409 | A subscription for this event (and filter) already exists — delete it first |
#List Webhooks
/api/v1/vcaas/webhooksapi-keyFreeYour subscriptions, newest first.
Response fields
| Field | Type | Description |
|---|---|---|
data.webhooks[].id | string | Subscription id |
data.webhooks[].url | string | URL |
data.webhooks[].headers | object | undefined | Extra headers |
data.webhooks[].event | string | Event |
data.webhooks[].createdAt | string | ISO 8601 |
data.webhooks[].updatedAt | string | ISO 8601 |
data.webhooks[].projectIds | string[] | undefined | Project filter |
data.webhooks[].features | string[] | undefined | Feature filter |
Example request
curl -H "api-key: tlm_sk_your_key" \
https://builder-api.totalum.app/api/v1/vcaas/webhooksconst res = await fetch("https://builder-api.totalum.app/api/v1/vcaas/webhooks", {
headers: {
"api-key": process.env.TOTALUM_API_KEY,
},
});
const { errors, data } = await res.json();{ "errors": null, "data": { "webhooks": [{ "id": "6701f2a3c4d5e6f7a8b9c0d1", "url": "https://yourserver.com/totalum-webhook", "event": "deployment.finished", "createdAt": "2026-10-08T10:00:00.000Z", "updatedAt": "2026-10-08T10:00:00.000Z" }] } }#Delete Webhook
/api/v1/vcaas/webhooks/:webhookIdapi-keyFreeRemove a subscription.
Path parameters
| Parameter | Type | Description |
|---|---|---|
webhookId | string | Subscription id |
Response fields
| Field | Type | Description |
|---|---|---|
data.success | boolean | true |
Example request
curl -X DELETE -H "api-key: tlm_sk_your_key" \
https://builder-api.totalum.app/api/v1/vcaas/webhooks/6701f2a3c4d5e6f7a8b9c0d1const res = await fetch("https://builder-api.totalum.app/api/v1/vcaas/webhooks/6701f2a3c4d5e6f7a8b9c0d1", {
method: "DELETE",
headers: {
"api-key": process.env.TOTALUM_API_KEY,
},
});
const { errors, data } = await res.json();{ "errors": null, "data": { "success": true } }Error codes
| Code | HTTP | Meaning |
|---|---|---|
WEBHOOK_NOT_FOUND | 404 | No such subscription |
#Get Webhook Secret
/api/v1/vcaas/webhooks/:webhookId/secretapi-keyFreeThe subscription's signing secret, to verify X-Totalum-Signature.
Path parameters
| Parameter | Type | Description |
|---|---|---|
webhookId | string | Subscription id |
Response fields
| Field | Type | Description |
|---|---|---|
data.webhookId | string | Subscription id |
data.secret | string | whsec_… |
Example request
curl -H "api-key: tlm_sk_your_key" \
https://builder-api.totalum.app/api/v1/vcaas/webhooks/6701f2a3c4d5e6f7a8b9c0d1/secretconst res = await fetch("https://builder-api.totalum.app/api/v1/vcaas/webhooks/6701f2a3c4d5e6f7a8b9c0d1/secret", {
headers: {
"api-key": process.env.TOTALUM_API_KEY,
},
});
const { errors, data } = await res.json();{ "errors": null, "data": { "webhookId": "6701f2a3c4d5e6f7a8b9c0d1", "secret": "whsec_3f9a…" } }Error codes
| Code | HTTP | Meaning |
|---|---|---|
WEBHOOK_NOT_FOUND | 404 | No such subscription |
#List Webhook Deliveries
/api/v1/vcaas/webhooks/:webhookId/deliveriesapi-keyFreeThe subscription's deliveries of the last 30 days, newest first.
Path parameters
| Parameter | Type | Description |
|---|---|---|
webhookId | string | Subscription id |
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
limit | number | No | 1–200 (default 50) |
cursor | number | No | nextCursor of the previous page |
Response fields
| Field | Type | Description |
|---|---|---|
data.deliveries[].id | number | Delivery id |
data.deliveries[].webhookId | string | Subscription |
data.deliveries[].event | string | Event |
data.deliveries[].eventId | string | The event's id (X-Totalum-Event-Id) |
data.deliveries[].projectId | string | null | Project |
data.deliveries[].feature | string | null | Feature, for feature events |
data.deliveries[].status | string | pending, delivered or failed |
data.deliveries[].attempts | number | Attempts made |
data.deliveries[].httpStatus | number | null | Your server's last answer |
data.deliveries[].durationMs | number | null | Its duration |
data.deliveries[].error | string | null | Why it failed |
data.deliveries[].at | string | When the event happened |
data.deliveries[].updatedAt | string | Last attempt |
data.nextCursor | number | null | Next page |
Example request
curl -H "api-key: tlm_sk_your_key" \
"https://builder-api.totalum.app/api/v1/vcaas/webhooks/6701f2a3c4d5e6f7a8b9c0d1/deliveries?limit=20"const res = await fetch("https://builder-api.totalum.app/api/v1/vcaas/webhooks/6701f2a3c4d5e6f7a8b9c0d1/deliveries?limit=20", {
headers: {
"api-key": process.env.TOTALUM_API_KEY,
},
});
const { errors, data } = await res.json();{ "errors": null, "data": { "deliveries": [{ "id": 1042, "webhookId": "6701f2a3c4d5e6f7a8b9c0d1", "event": "deployment.finished", "eventId": "deploy:dep_01k6xf3a9b", "projectId": "barber-bookings", "feature": null, "status": "delivered", "attempts": 1, "httpStatus": 200, "durationMs": 182, "error": null, "at": "2026-10-08T11:23:10.000Z", "updatedAt": "2026-10-08T11:23:10.000Z" }], "nextCursor": null } }#Get Project Webhook
/api/v1/vcaas/projects/:projectId/webhookapi-keyFreeThe project's optional extra webhook address (for the app's integration events; payment events reach the app's /api/webhooks/stripe without it).
Path parameters
| Parameter | Type | Description |
|---|---|---|
projectId | string | The project id |
Response fields
| Field | Type | Description |
|---|---|---|
data.url | string | null | The address |
data.enabled | boolean | A URL and a secret are set |
data.secretPrefix | string | null | The start of the current secret |
Example request
curl -H "api-key: tlm_sk_your_key" \
https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/webhookconst res = await fetch("https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/webhook", {
headers: {
"api-key": process.env.TOTALUM_API_KEY,
},
});
const { errors, data } = await res.json();{ "errors": null, "data": { "url": "https://hooks.zapier.com/hooks/catch/123456/abcdef/", "enabled": true, "secretPrefix": "whsec_3f9a" } }#Set Project Webhook
/api/v1/vcaas/projects/:projectId/webhookapi-keyFreeSet the project's optional extra webhook address (your own server, Zapier…). Account owner only.
Path parameters
| Parameter | Type | Description |
|---|---|---|
projectId | string | The project id |
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
url | string | Yes | https:// address |
Response fields
| Field | Type | Description |
|---|---|---|
data.url | string | The address |
data.enabled | boolean | A URL and a secret are set |
data.secretPrefix | string | null | The start of the secret |
Example request
curl -X PUT \
-H "api-key: tlm_sk_your_key" \
-H "Content-Type: application/json" \
-d '{"url":"https://hooks.zapier.com/hooks/catch/123456/abcdef/"}' \
https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/webhookconst res = await fetch("https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/webhook", {
method: "PUT",
headers: {
"api-key": process.env.TOTALUM_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
"url": "https://hooks.zapier.com/hooks/catch/123456/abcdef/"
}),
});
const { errors, data } = await res.json();{ "errors": null, "data": { "url": "https://hooks.zapier.com/hooks/catch/123456/abcdef/", "enabled": true, "secretPrefix": "whsec_3f9a" } }Error codes
| Code | HTTP | Meaning |
|---|---|---|
VALIDATION_ERROR | 400 | Not an https:// URL |
OWNER_ONLY | 403 | Account owner only |
#Rotate Project Webhook Secret
/api/v1/vcaas/projects/:projectId/webhook/rotate-secretapi-keyFreeCreate a new signing secret (shown once). The app receives it at once; the old secret keeps signing for 24 hours. Account owner only.
Path parameters
| Parameter | Type | Description |
|---|---|---|
projectId | string | The project id |
Response fields
| Field | Type | Description |
|---|---|---|
data.secret | string | The new whsec_… secret |
Example request
curl -X POST -H "api-key: tlm_sk_your_key" \
https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/webhook/rotate-secretconst res = await fetch("https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/webhook/rotate-secret", {
method: "POST",
headers: {
"api-key": process.env.TOTALUM_API_KEY,
},
});
const { errors, data } = await res.json();{ "errors": null, "data": { "secret": "whsec_0b1c…" } }Error codes
| Code | HTTP | Meaning |
|---|---|---|
OWNER_ONLY | 403 | Account owner only |
