---
title: Webhooks
description: 'Webhooks give one of an app’s ops a delivery URL and secret so an external service can call it.'
sidebar:
  label: Webhooks
  order: 14
---

## What you can do

**Let another service call your app.** A Webhook gives one of an app's declared ops its own delivery URL and secret. A form tool, a payment provider, an automation platform, or a partner backend POSTs JSON to that URL and the op runs with that JSON as its input. Nothing else on the account is reachable with that secret.

**Hand the sender the contract.** Creating a Webhook returns the URL, the secret (shown once), and the op's input and output schemas, so the sender knows exactly what to send and what comes back.

**Rotate or switch off at any time.** Anyone who can edit the app can rename a Webhook, disable it, re-enable it, rotate its secret, or delete it. Webhooks never switch themselves off: a sender that fails on purpose cannot take an integration down.

**See every delivery.** **App settings → Webhooks** lists the app's Webhooks to anyone who can edit the app, and each Webhook's page shows the newest deliveries with the request each sender made and the response it got.

## Technical

A Webhook is the externally triggered sibling of a [Routine](routines): one `(app, op)` target with run state, invoked in the same way. The delivery URL is `POST https://charm.ing/api/v1/hooks/webhook_<uuid>`. The sender presents the secret as `Authorization: Bearer chrm_hook_…` or, when it cannot set headers, as `?key=chrm_hook_…`. The JSON body is the op's input and is validated against the op's declared `inputSchema`; the response is the op's own result envelope, validated against its `outputSchema`. The handler sees `x-charming-trigger: webhook` on the request, so it can tell a delivery apart from a live call.

A delivery runs as the app itself and is recorded like any other server-side call. It does not count as the owner opening the app.

### Copy this prompt for your agent

```text
Let an external service call my Charming app. Read
https://charm.ing/docs/capabilities/webhooks.md first. Work out which
op the sender should invoke, create the Webhook, and give me the URL,
the secret, and the JSON body the sender needs to POST.
```

### How an agent performs this job

Use [`create_webhook`](/docs/technical-reference/mcp/tools/create_webhook) to declare a Webhook on a declared op, [`list_webhooks`](/docs/technical-reference/mcp/tools/list_webhooks) to see the Webhooks on every app the caller can edit, with their delivery state, [`update_webhook`](/docs/technical-reference/mcp/tools/update_webhook) to rename, enable, disable, or rotate one, and [`delete_webhook`](/docs/technical-reference/mcp/tools/delete_webhook) to remove one. The HTTP twins are `POST /api/v1/apps/{appId}/webhooks`, `GET /api/v1/webhooks`, `PATCH /api/v1/webhooks/{webhookId}`, and `DELETE /api/v1/webhooks/{webhookId}`.

A worked example:

```text
create_webhook({ app_id: "<uuid>", op: "intake", name: "Lead intake" })
→ Created Webhook webhook_<uuid> “Lead intake” for `intake` on <app>.
  Deliver to: POST https://charm.ing/api/v1/hooks/webhook_<uuid>
  Secret (shown once): chrm_hook_…
  Input schema: {"type":"object","properties":{"email":{"type":"string"}},"required":["email"]}

curl -X POST https://charm.ing/api/v1/hooks/webhook_<uuid> \
  -H "Authorization: Bearer chrm_hook_…" \
  -H "Content-Type: application/json" \
  -d '{"email":"lead@example.com"}'
→ { "ok": true, "value": { "accepted": true } }
```

### The contract

The target op must be declared on the app's API surface; unlike a Routine, it may declare required input, since the sender supplies the body. `create_webhook` and its HTTP twin reject a second Webhook on the same `(app, op)` pair (`duplicate_webhook`) and enforce per-app and per-owner caps (`webhook_limit_exceeded`). The `limits` object on `GET /api/v1/webhooks` holds the caps on the caller's own plan. A team's apps follow the team's plan. `update_webhook({ rotate_secret: true })` replaces the secret immediately and returns the new one once; the old secret stops working on the same request. Re-enable is `update_webhook({ enabled: true })` and resets the failure counter.

Every Webhooks response returns a `webhook` object with `id`, `app_id`, `op`, `name`, `url`, `secret_prefix`, and six run-state fields:

| Field                  | Meaning                                                                |
| ---------------------- | ---------------------------------------------------------------------- |
| `enabled`              | `false` once the owner disables the Webhook                            |
| `disabled_reason`      | `"owner"`, or `null` when enabled                                      |
| `last_run_at`          | Last delivery's timestamp, or `null` before the first                  |
| `last_outcome`         | `"success"`, `"failure"`, or `"deferred"`, or `null` before the first  |
| `last_error`           | `{ kind, message }` for the last failure, or `null`                    |
| `consecutive_failures` | Diagnostic only; resets to 0 on any success or on re-enable            |

A delivery to a disabled Webhook returns 403 `webhook_disabled`, and a missing or wrong secret returns 401 `unauthorized`. A body that is not a JSON object or fails the op's input schema returns 400. Two rate limits guard the delivery URL: 300 requests a minute from one address, and 120 requests a minute to one Webhook that carry its valid secret, counted before the body is checked. Either returns 429 `rate_limited` with `Retry-After`. An app at its run ceiling returns 429 `app_busy`. The sender should retry both.

### Limits, access, and deletion

- **Caps.** On Free, 3 Webhooks per app and 25 per owner, across your own apps or a team's; paid [plans](limits#plans) raise both.
- **Access.** Creating, updating, and deleting a Webhook requires the same `app:write` access as editing the app itself. Listing returns the Webhooks on every app where the caller has that access: their own apps, their teams' apps, and apps shared with them as a collaborator. Revoking someone's share grant or team membership does not rotate or disable the Webhooks they created or the secrets they hold. To cut a sender off, rotate the secret with `update_webhook({ rotate_secret: true })` or delete the Webhook. See [Privacy and sharing](privacy-and-sharing).
- **Deliveries.** Every request to a Webhook's URL is recorded, accepted or refused, except one the per-address rate limit turns away. The record keeps up to 16 KB of the request body and 16 KB of the response body on Free, and 64 KB of each on Pro and Business ([plans](limits#plans)). The op gets the whole request and the sender the whole response; only the record drops the rest. Request and response bodies are kept for 30 days and the rest of the record for 90. Deleting a Webhook leaves its delivery records to age out on that schedule; deleting the app removes them.

### Not yet

The handler receives parsed JSON, not the raw request bytes, so provider signatures computed over the exact body (Stripe-style HMAC) cannot be verified inside the op today. A Webhook has one secret at a time, so rotation has no grace window.

## Related

- [Routines](routines): run an op on a timer instead of when a sender calls
- [Privacy and sharing](privacy-and-sharing): a Webhook is managed with the same `app:write` gate as editing the app
- [Limits](limits): each plan's Webhooks caps and the delivery rate limits
- [How Charming works](../concepts/how-charming-works)
- [Docs home](..)
- [llms-full.txt](https://charm.ing/docs/llms-full.txt)

Found a bug or need a feature? [Tell us](/docs/capabilities/feedback) with `submit_feedback` or `POST /app/{id}/feedback`. Your feedback shapes what we build next.
