---
title: "Update a webhook"
method: PATCH
path: "/webhooks/{id}"
tags: ["Webhooks"]
---

# Update a webhook

`PATCH /webhooks/{id}`

## Request

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

Change the endpoint, the description, the filters, or `enabled`. Fields you omit are left as they are; a body that names none of them is a `400`.

A new `url` is checked exactly as on create. `enabled: true` clears a `failing` disable and starts fresh: the 24 hours before it can be disabled for failing again count from now, not from its last 2xx. Sent to a subscription that is already enabled, it does not restart them. `enabled: false` stops deliveries and records that you chose to. It is not a pause that holds events for later: most events while it is disabled are never queued, and a queued delivery whose next attempt falls due while it is disabled is `dropped`. Nor is it a clean cut: a retry that was not yet due, or an event that reaches the platform late, can still be delivered after you enable it again. Read the computer’s `events_url` with `since=` to reconcile, and dedupe on `webhook-id`.

While the account’s plan includes no webhooks, a body with a `url` or with `enabled: true` is a `402` and nothing changes; the description, the filters and `enabled: false` can still be set.

Requires the `member` role or higher.

## Path parameters

- `id` string, required

## Request body

- object
  - `url` string: 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: Whether deliveries are made.

## Response `200`

The subscription as stored.

- Webhook
  - `id` string, required: `whk-` and sixteen hex characters.
  - `url` string, required: Where deliveries are POSTed. `https://` only. The URL as the platform parsed it, which can differ in spelling from what you sent: the host lower-cased, a default port dropped, a bare origin ending in `/`.
  - `description` string, required: Free text, for your listing. Empty when you gave none.
  - `events` string[], required: The event types this subscription receives. **Empty means every type.** The vocabulary is the event socket’s (the computer’s `events_url`) less `file.changed`: `window.opened`, `window.closed`, `window.focused`, `window.blurred`, `clipboard.changed`, `process.exited`, `computer.idle`, `computer.ready`, `computer.started`, `computer.stopped`, `computer.suspended`.
  - `computers` string[], required: The computer ids this subscription receives events for. **Empty means every computer in scope.** Not checked against your computers when you set it — a subscription may name a computer you are about to create.
  - `enabled` boolean, required: Whether deliveries are made. Set `false` by the platform when an endpoint has failed for a day, or when the account moves to a plan without webhooks — see `disabled_reason`. Back to `true` by you with `PATCH`, which starts fresh, or, for `plan`, by the platform when you choose a plan again, which starts fresh too.
  - `disabled_reason` 'customer' | 'failing' | 'plan' | 'null', nullable, required: Why `enabled` is false: `customer` when you disabled it, `failing` when the platform did — a delivery ran out of attempts and nothing had been accepted for 24 hours, counted from the later of the last 2xx and when the subscription was last enabled — and `plan` when the account moved to a plan without webhooks. A `plan` subscription is enabled again, oldest first, as many as the plan allows, when the account chooses a plan that includes webhooks. `null` while enabled.
  - `disabled_at` string, nullable, required: RFC 3339 timestamp, or `null` while enabled.
  - `last_success_at` string, nullable, required: When the endpoint last answered 2xx to any delivery. `null` until it has.
  - `last_failure_at` string, nullable, required: When a delivery attempt last failed. `null` until one has.
  - `last_status` integer, nullable, required: The HTTP status of the newest attempt, whatever it was. `null` before any attempt, or when the newest got no answer.
  - `workspace_id` string: The workspace this subscription is confined to. Present only when it was created with a workspace-scoped API key; absent on an account-wide subscription.
  - `created_at` string, required: RFC 3339 timestamp.
  - `updated_at` string, required: RFC 3339 timestamp.

## 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.
- `404`: No such resource in your scope — a computer, snapshot, secret, webhook, SSH key, API key, workspace, operation and so on — or no such endpoint; a confirmed missing guest file is no such file in the guest.
- `405`: This public path exists but does not accept that method. Allow lists only its public methods, with HEAD for GET and metadata OPTIONS.
- `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)
