# Totalum App Builder API — Webhooks

This file is the complete reference for this area. The short guide for agents is https://www.totalum.app/totalum-api.md.

- Base URL: `https://builder-api.totalum.app/api/v1/vcaas`
- Authentication: header `api-key: tlm_sk_…` on every request, from a server only.
- Response envelope: `{ "errors": null | { "errorCode", "errorMessage", "errorDetails?" }, "data": … }`.


---

# 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](https://www.totalum.app/docs/sdk/webhooks)). Payment events need no address: they reach the app's own `/api/webhooks/stripe` by 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:

```json
{ "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](#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).

```js
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

```endpoint
method: PUT
path: /api/v1/vcaas/webhooks
cost: Free
```

Subscribe 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**

```bash
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/webhooks
```

:::success Success · 201 Created
```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

```endpoint
method: GET
path: /api/v1/vcaas/webhooks
cost: Free
```

Your 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**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/webhooks
```

:::success Success · 200 OK
```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

```endpoint
method: DELETE
path: /api/v1/vcaas/webhooks/:webhookId
cost: Free
```

Remove a subscription.

**Path parameters**

| Parameter | Type | Description |
|---|---|---|
| `webhookId` | string | Subscription id |

**Response fields**

| Field | Type | Description |
|---|---|---|
| `data.success` | boolean | `true` |

**Example request**

```bash
curl -X DELETE -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/webhooks/6701f2a3c4d5e6f7a8b9c0d1
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "success": true } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `WEBHOOK_NOT_FOUND` | 404 | No such subscription |

## Get Webhook Secret

```endpoint
method: GET
path: /api/v1/vcaas/webhooks/:webhookId/secret
cost: Free
```

The 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**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/webhooks/6701f2a3c4d5e6f7a8b9c0d1/secret
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "webhookId": "6701f2a3c4d5e6f7a8b9c0d1", "secret": "whsec_3f9a…" } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `WEBHOOK_NOT_FOUND` | 404 | No such subscription |

## List Webhook Deliveries

```endpoint
method: GET
path: /api/v1/vcaas/webhooks/:webhookId/deliveries
cost: Free
```

The 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**

```bash
curl -H "api-key: tlm_sk_your_key" \
  "https://builder-api.totalum.app/api/v1/vcaas/webhooks/6701f2a3c4d5e6f7a8b9c0d1/deliveries?limit=20"
```

:::success Success · 200 OK
```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

```endpoint
method: GET
path: /api/v1/vcaas/projects/:projectId/webhook
cost: Free
```

The 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**

```bash
curl -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/webhook
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "url": "https://hooks.zapier.com/hooks/catch/123456/abcdef/", "enabled": true, "secretPrefix": "whsec_3f9a" } }
```
:::

## Set Project Webhook

```endpoint
method: PUT
path: /api/v1/vcaas/projects/:projectId/webhook
cost: Free
```

Set 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**

```bash
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/webhook
```

:::success Success · 200 OK
```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

```endpoint
method: POST
path: /api/v1/vcaas/projects/:projectId/webhook/rotate-secret
cost: Free
```

Create 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**

```bash
curl -X POST -H "api-key: tlm_sk_your_key" \
  https://builder-api.totalum.app/api/v1/vcaas/projects/barber-bookings/webhook/rotate-secret
```

:::success Success · 200 OK
```json
{ "errors": null, "data": { "secret": "whsec_0b1c…" } }
```
:::

**Error codes**

| Code | HTTP | Meaning |
|---|---|---|
| `OWNER_ONLY` | 403 | Account owner only |
