---
title: "Create a webhook"
method: POST
path: "/webhooks"
tags: ["Webhooks"]
---

# Create a webhook

`POST /webhooks`

## Request

- Base URL: `https://app.mandala.computer/api/v1`
- URL: `https://app.mandala.computer/api/v1/webhooks`
- Auth: HTTP bearer

Subscribe an HTTPS endpoint to this account’s events. The answer carries the signing `secret` **once**; it is not readable again, and `POST /webhooks/{id}/rotate` is how you get a new one.

WHAT ARRIVES. A `POST` per event to `url`, whose body is the event object exactly as the computer’s event socket (its `events_url`) frames it — `type`, `at`, `computer`, `seq`, `cursor`, `source`, `data` — byte for byte, with nothing added and nothing wrapped around it. `cursor` is the bridge back to the stream: a job woken by `process.exited` that wants everything since can open the computer’s `events_url` with `since=` that cursor added. `computer` says which machine, and every subscription may receive events from many. Two bodies are not events and lack fields: a `gap` (below) has no `seq`, and a test delivery (`type: "webhook.test"`, from `POST /webhooks/{id}/test`) has neither `seq` nor `cursor`. `data` is left out of an event that has none. `WebhookEvent` in the OpenAPI document (its top-level `webhooks`) is all three as a schema.

HEADERS. `content-type: application/json`, `user-agent: Mandala-Webhooks/1`, and the three [Standard Webhooks](https://www.standardwebhooks.com) headers: `webhook-id` (the delivery id, unchanged across retries), `webhook-timestamp` (Unix seconds, the time of THIS attempt) and `webhook-signature` (`v1,` and base64 of HMAC-SHA256 over `<id>.<timestamp>.<raw body>`, keyed by the secret’s bytes after `whsec_`, base64-decoded). Any Standard Webhooks verifier library checks it; verify the RAW request bytes, never a re-serialised body. Refuse a `webhook-timestamp` more than 300 seconds from your clock, and remember each `webhook-id` you accept for at least that long — together those two close every replay, and a retry of a delivery you already accepted is then recognised rather than processed twice. `mandala-subscription` names the subscription and is NOT signed: route on it, never authorise on it.

ACKNOWLEDGE WITH A 2xx BEFORE DOING THE WORK. An attempt is cut at 10 seconds and counted as a failure; anything else — a non-2xx, a timeout, a refused connection, a TLS error, a redirect (never followed) — is retried: eight attempts over about fourteen hours (30 s, 2 min, 10 min, 30 min, 1 h, 4 h, 8 h), then the delivery is `exhausted` and visible in `GET /webhooks/{id}/deliveries`. Retries carry the same `webhook-id` and a fresh timestamp and signature. No ordering is promised: deliveries for one subscription run four at a time and retries interleave with new events, so order by `seq` per computer if you care. At least once, never silently dropped.

AN ENDPOINT THAT KEEPS FAILING IS DISABLED: when a delivery runs out of attempts and nothing has been accepted for 24 hours, `enabled` becomes `false` with `disabled_reason: "failing"`, pending deliveries are dropped, and `PATCH {"enabled": true}` starts it again. The 24 hours count from the later of the last 2xx and when the subscription was last enabled: created enabled, enabled again with `PATCH`, or turned back on by a plan. So an endpoint enabled again gets a fresh 24 hours, not the remainder of a day that began at its last 2xx.

WHEN THE PLAN ENDS. An account that moves to a plan without webhooks has every enabled subscription disabled with `disabled_reason: "plan"`. Deliveries already queued are still made, and so is one more kind for the next 24 hours: `computer.stopped`, so you hear about the computers stopped because the plan ended (subject to the subscription’s own `events` and `computers`). Nothing else is queued. Until the account has a plan again, a new `url`, `enabled: true` and a test are `402`; reading, filters, rotating and deleting still work. Choosing a plan that includes webhooks enables them again, oldest first, as many as it allows, each starting fresh as a `PATCH` would; the rest stay disabled with `disabled_reason: "plan"`.

GAPS. If the hypervisor holding a computer was unreachable for longer than its event journal holds, you get a `{"type": "gap", "computer": …}` delivery whose `data.detail` says events were lost and whose `cursor` is the computer’s own — the same frame the socket sends, and it arrives whatever `events` filters on. Reconcile with a listing rather than assume nothing happened.

Every paid plan allows ten subscriptions per account; the eleventh is a `409` naming the cap. The count is the whole account’s, so an API key confined to a workspace can meet the cap while `GET /webhooks` shows it fewer than ten.

Requires the `member` role or higher.

## Request body

- object
  - `url` string, required: Where to POST. `https://` only, no username or password in it, at most 2048 characters — counted on the string you send, before it is parsed, so a non-ASCII character may count as two — and it must resolve to a public address — a hostname whose answers include a private, loopback, link-local or otherwise reserved address is refused, and so is a literal one. A port other than 443 is fine. The name is looked up when you send it: one that does not resolve then is a `400` (`<host> did not resolve`) even when the cause is a passing DNS failure, so a name you have not published yet cannot be registered, and the same request works once it resolves. What is stored is the URL as parsed: the host lower-cased, a default port dropped, and a `/` added to a bare origin, so compare against the stored `url` rather than what you sent.
  - `description` string: Free text for your listing, up to 200 characters.
  - `events` string[]: Event types to deliver, from the vocabulary on `Webhook`. Omit or send `[]` for every type. An unknown type is a `400` that lists them.
  - `computers` string[]: Computer ids to deliver for, up to 64, each a non-empty string of at most 64 characters. Omit or send `[]` for every computer in scope.
  - `enabled` boolean: Start it disabled with `false`, to enable later. Defaults to `true`.

## Response `201`

The subscription, with its secret — shown once.

- WebhookCreated
  - `id` string, required: `whk-` and sixteen hex characters.
  - `url` string, required: Where deliveries are POSTed.
  - `description` string, required
  - `events` string[], required: As on `Webhook`.
  - `computers` string[], required: As on `Webhook`.
  - `enabled` boolean, required
  - `disabled_reason` string, nullable, required
  - `disabled_at` string, nullable, required
  - `last_success_at` string, nullable, required
  - `last_failure_at` string, nullable, required
  - `last_status` integer, nullable, required
  - `workspace_id` string: As on `Webhook`: present only on a workspace-confined subscription.
  - `created_at` string, required: RFC 3339 timestamp.
  - `updated_at` string, required: RFC 3339 timestamp.
  - `secret` string, required: The signing secret: `whsec_` and 44 characters of base64. **Shown here and never again** — store it now. Every delivery is signed with it; see the resource description for how to verify.

## Other responses

- `400`: The request was malformed — a bad resolution string, a missing `command`, a `{pid}` or `{window}` that is not one. It is also what a well-formed request gets when the thing it names cannot be acted on and never will be — a restore onto a computer that is gone, for instance. Either way it is final: the message says what is wrong, and sending it again unchanged answers the same. That is the whole difference from a `503`, which means ask again shortly.
- `401`: Platform authentication required or refused: reason is missing, invalid, or revoked, with WWW-Authenticate: Bearer (invalid_token for invalid/revoked).
- `402`: Your plan refused this: a quota, or a payment that has not gone through. On an agent run it can instead be `Insufficient AI credits` with `top_up_url` (a run funded by the account’s AI credits), or the model provider’s own billing refusal of your `X-Model-Key`, relayed.
- `403`: The credential is recognized but its holder lacks the required role or membership, or the holder/account is suspended, or the holder has not verified their email address. An ordinary denial for too low a role may carry no `reason`. The email one carries none either, and its message begins `Verify your email before`: it answers a create, a clone of a computer or a snapshot, a new API key and a new SSH key, and is cleared by verifying the address in the dashboard. `reason: "revoked"` marks authority that is gone: a suspended account or a lost membership (refused as the request arrives, or if it happens while the request is in flight), or a role that was lowered while the request was in flight. The message identifies the refusal; replacing a valid key alone does not restore authority, and sending the same call again does not help.
- `405`: This public path exists but does not accept that method. Allow lists only its public methods, with HEAD for GET and metadata OPTIONS.
- `409`: Refused for the state something is in — resizing a computer that is running, running a command on one that is stopped, taking a snapshot while another capture of it is running. WHETHER RETRYING HELPS IS IN THE BODY, NOT THE STATUS, and this is the one thing to take from this entry. Some of these describe a state that is passing: a guest agent still inside its boot window, a guest agent busy with another call, a move already running on the account. Those clear on their own and the same request works a moment later. The rest describe a DECISION about the request you sent — the size does not fit, the computer is the wrong one for this, the saved session cannot travel — and no amount of retrying turns one of those into a yes. A client that treats 409 as uniformly transient loops forever on half of them. Where the platform can say which it is, it sends `reason` beside `error` — one word, meant to be switched on rather than read: `contention` and `starting` clear on their own, `unavailable` means the computer is not running and only starting it helps, `running` means the computer is running and only stopping it helps (a resize), `unsupported` means waiting will not change the answer — this computer cannot do it at all, or it needs another step first, which the message names (a stop, or removing its secret bindings, for example) — `exists` means a create-only upload found its path taken, `name_taken` means a secret of that name is already in that scope, `stale_revision` means a secret changed since the `revision_id` you sent was read (or a computer’s bound list since the `version` you sent), and `stale_capture` means the screenshot capture you named is no longer held: a newer one replaced it, or its host’s daemon restarted or the computer moved. `starting` covers the two minutes after a start or a restart, and after a reboot from inside the guest on a computer last cold-started recently enough to report one (a stop and a start gives an older one that); a guest agent that is still silent after that is a `502`. Match on that and never on the sentence, which is prose and is rewritten. **Absent means no classification was given** — treat it, and any word you do not recognise, as no answer, because not every refusal here has one yet. It is what the guest routes answer: the clipboard on a stopped computer used to be a 409 indistinguishable from a clipboard whose selection was claimed for an instant, and a blanket retry loop spun against the first until its deadline. Every 409 that clears on its own carries `contention` or `starting`: a host busy with another build or with no room for one, an operation already running on the same computer or snapshot (a capture, a copy, a delete, a restore or clone reading it, another agent run), a lock another request holds, a move or a delivery settling. So a 409 with no word, or with any other word, is not one that waiting fixes: read the sentence for the step it names. The ones worth knowing by name, because each has a next step that is not "wait": A resize past what the computer’s host can run carries `move`, an object rather than a message: `{"required":true,"possible":true}` means another host in this region could run it and the computer has to be moved there first — `POST /computers/{id}/move` with the same sizing fields is how you agree to that, and it is a separate call because relocating a computer is not something a resize should do to you quietly. `possible:false` means nowhere in the region can, and the size is the thing to change. The move itself then refuses for the things it re-decides at the moment it runs, and only one of those is worth waiting on: a computer that is running or suspended has to be stopped or resumed-and-stopped by you, a size that fits where it already is wants an ordinary resize instead, and a region with nowhere to put it wants a smaller size. `Another computer on this account is being moved right now` is the one that clears by itself, and it carries `reason: "contention"` — one move runs per account, and `GET /moves` says which. And the terminal socket refuses twice over: `reason: "unavailable"` with `resume_required: true` on a suspended computer, which clears when you start it and not before, and `reason: "unsupported"` on a running computer whose hardware carries no terminal channel — one last started before interactive terminals existed — which needs a stop and a start. Not a restart, which resets the same machine, and not a retry, which never comes good.
- `413`: What you sent, or asked for, is past a size limit. A JSON request body is capped at 1 MiB unless the route states its own limit: `POST /computers/{id}/exec` takes up to 4 MiB, and some routes take less; guest file transfers, template documents and the clipboard have limits of their own. The message names the limit that applied. This route reads at most 16 KiB (16384 bytes) of request body, less than the general cap.
- `429`: You are asking faster than your plan allows. `Retry-After` says how many seconds to wait, and every metered response — anything past authentication and the re-check that your credential’s authority still holds — carries `RateLimit-Remaining`, so you do not have to be refused to find out. A `401`, a `403` from that re-check, an `OPTIONS` answer and an unexpected `500` carry none. The budget is spent in proportion to what a call costs rather than per request. What a call costs, in budget units, by kind (each operation’s exact figure is its `x-rate-cost` in the OpenAPI document): 1 to read one thing; 5 for a listing that spans the fleet, and 5 for a call into a running guest such as a screenshot or an exec; 10 for a power change and 10 to move file bytes; 25 for `GET /usage`, `GET /credits`, creating a public URL, and creating, rotating or testing a webhook; 50 to capture a snapshot; 100 to create, clone or restore a computer, and 100 to start an agent run, whose actions are then charged as they happen. The account-wide budget, as what an idle caller may spend at once and what refills each minute: No plan 1200 at once and 1200 a minute; Solo 4800 at once and 4800 a minute; Studio 12000 at once and 12000 a minute; Swarm 21600 at once and 21600 a minute. A single API key gets half of either. A `401` for a missing or unusable credential costs nothing: it is refused before anything is metered. A few refusals at the door cost one unit: a `403` because the role is too low or the route refuses an API key confined to a workspace, a `404` for a path that matches nothing, a `405`, and an `Idempotency-Key` answered from its stored result or still in progress. Everything else is charged in full when it is admitted, before it is carried out, so a refusal after that has spent the whole cost: a `400`, `402` or `409` from the route, a `403` its handler sends (on most routes, a suspended account included), and a model provider’s `401` on an agent run. The budget belongs to the account and is shared across everything on it, and any single API key may spend at most half of it. `RateLimit-Limit` is whichever of those two is binding on the request that carried it — usually the per-key half, and the account-wide figure when other keys have already spent more of it than this one has. All three `RateLimit-*` headers describe the same one, so `limit` minus `remaining` is what has been spent against it. A few `429`s are not about this budget, say so in their message, and can carry no `Retry-After`: a model provider’s rate limit relayed on an agent run. The cap on forks of one snapshot waiting for their secrets sends its own bounded expiry-based `Retry-After` with `reason: "contention"` (see `POST /snapshots/{id}/clone`).
- `500`: The application could not finish this: an unexpected exception, a build that broke, or a storage fault. Application refusals carry the request correlation ID; unexpected internal details are not returned.

---

[API](https://skmtc.dev/mandala/apis/mandala-computer-api.md) · [All operations](https://skmtc.dev/mandala/apis/mandala-computer-api/llms.txt) · [OpenAPI document](https://skmtc.dev/mandala/apis/mandala-computer-api/revisions/2afbb87fa703?raw)
