> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mesa.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Programmatically subscribe to events on your organization's repositories.

Webhooks allow you to receive notifications of changes to your organization's repositories in real time. A **webhook target** represents an `https://` URL to which Mesa will send these notifications, in the form of HTTP POST requests. Each target is scoped to your organization, can subscribe to multiple event types, and can optionally be restricted to a subset of repositories.

<Frame>
  <video autoPlay muted loop playsInline>
    <source src="https://mintcdn.com/mesa/0wzVcHndtgCit0C-/assets/webhook-docs-create.webm?fit=max&auto=format&n=0wzVcHndtgCit0C-&q=85&s=f3b842ac63a5c14edd5ba57e0e7f5f17" type="video/webm" data-path="assets/webhook-docs-create.webm" />
  </video>
</Frame>

## Create a webhook target

<CodeGroup>
  ```typescript TypeScript theme={null}
  import { Mesa } from "@mesadev/sdk";

  const mesa = new Mesa({ apiKey: process.env.MESA_API_KEY });

  const target = await mesa.webhookTargets.create({
    org: "acme",
    name: "My App - Prod", // Optional human-readable label.
    url: "https://acme.dev/webhooks/mesa",
    events: ["push", "change.created", "change.evolved"],
    // Omit `repo_ids` for an org-wide target.
    repo_ids: ["repo_abc123"],
  });

  // The secret is returned once on create. Save it to verify signatures later.
  console.log(target.secret);
  ```

  ```python Python theme={null}
  from mesa_sdk import Mesa

  mesa = Mesa()

  target = await mesa.webhook_targets.create(
      org="acme",
      name="My App - Prod",  # Optional human-readable label.
      url="https://acme.dev/webhooks/mesa",
      events=["push", "change.created", "change.evolved"],
      # Omit `repo_ids` for an org-wide target.
      repo_ids=["repo_abc123"],
  )

  # The secret is returned once on create. Save it to verify signatures later.
  print(target.secret)
  ```
</CodeGroup>

Store `target.secret` in your secret manager. The SDK and REST API only return it once, here on create. The dashboard always shows it on the target's detail page, and you can also [rotate](#rotate-the-signing-secret) to mint a new one.

## Supported events

Subscribe to any combination of the following on a target:

| Event              | Fires when                                                                              | `data` shape                              |
| ------------------ | --------------------------------------------------------------------------------------- | ----------------------------------------- |
| `repo.created`     | A repository is created.                                                                | `{ repo }`                                |
| `repo.updated`     | A repository's name, default bookmark, or tags change.                                  | `{ repo, before, after }`                 |
| `repo.deleted`     | A repository is deleted.                                                                | `{ repo }`                                |
| `bookmark.created` | A bookmark is created (including the default bookmark on `repo.created`).               | `{ bookmark }`                            |
| `bookmark.deleted` | A bookmark is deleted.                                                                  | `{ bookmark }`                            |
| `bookmark.moved`   | A bookmark is moved to a different change.                                              | `{ bookmark, from }`                      |
| `bookmark.merged`  | A bookmark is advanced via a merge.                                                     | `{ bookmark }`                            |
| `change.created`   | A new change is created.                                                                | `{ change }`                              |
| `change.evolved`   | An existing change advances to a new commit (rebase, content edit, message edit, etc.). | `{ change, previous_current_commit_oid }` |
| `push`             | A git push lands one or more bookmark updates via `git-receive-pack`.                   | `{ source, updates, reconciliation }`     |
| `sync.queued`      | A `syncUpstream` call enqueues a new upstream sync.                                     | `{ sync }`                                |
| `sync.in_progress` | The sync worker begins running.                                                         | `{ sync }`                                |
| `sync.completed`   | A sync finishes with terminal status `completed`.                                       | `{ sync }`                                |
| `sync.failed`      | A sync finishes with terminal status `failed`.                                          | `{ sync }`                                |

If `events` is omitted on create, it defaults to `["push"]`.

<Note>
  Managing webhook targets requires the `admin` scope. Repo-scoped tokens
  can only manage targets whose `repo_ids` is a subset of the repos the
  token has access to. They cannot create org-wide targets.
</Note>

## List webhook targets

Paginated. `limit` is at most 100 and defaults to 100. Pass the previous response's `next_cursor` to fetch the next page; `has_more` indicates whether another page is available.

<CodeGroup>
  ```typescript TypeScript theme={null}
  const { webhook_targets, next_cursor, has_more } = await mesa.webhookTargets.list({
    org: "acme",
    limit: 50,
    // cursor: previousResponse.next_cursor,
  });
  ```

  ```python Python theme={null}
  targets = await mesa.webhook_targets.list(
      org="acme",
      limit=50,
      # cursor=previous_response.next_cursor,
  )

  webhook_targets = targets.webhook_targets
  next_cursor = targets.next_cursor
  has_more = targets.has_more
  ```
</CodeGroup>

## Update a webhook target

`PATCH` uses **full-replace** semantics on each field you include. Omitted fields are left unchanged.

* `events`: providing an array replaces the current list of subscribed events in full. There is no incremental add/remove.
* `repo_ids`: providing an array replaces the current repo filter. Pass `null` to clear the filter and make the target org-wide. Empty arrays are rejected.

<CodeGroup>
  ```typescript TypeScript theme={null}
  await mesa.webhookTargets.update({
    org: "acme",
    webhookTargetId: "wh_123",
    url: "https://acme.dev/webhooks/mesa-v2",
    events: ["push"], // replaces all previously subscribed events
    repo_ids: null,    // clear the filter -> org-wide
  });
  ```

  ```python Python theme={null}
  await mesa.webhook_targets.update(
      org="acme",
      webhook_target_id="wh_123",
      url="https://acme.dev/webhooks/mesa-v2",
      events=["push"],  # replaces all previously subscribed events
  )

  # clear the filter -> org-wide
  await mesa.webhook_targets.clear_repo_filter(
      org="acme",
      webhook_target_id="wh_123",
  )
  ```
</CodeGroup>

The secret is **not** returned by update. See [Rotate the signing secret](#rotate-the-signing-secret) below.

## Delete a webhook target

<CodeGroup>
  ```typescript TypeScript theme={null}
  await mesa.webhookTargets.delete({
    org: "acme",
    webhookTargetId: "wh_123",
  });
  ```

  ```python Python theme={null}
  await mesa.webhook_targets.delete(
      org="acme",
      webhook_target_id="wh_123",
  )
  ```
</CodeGroup>

## Payload shape

Every delivery shares the same envelope:

```json theme={null}
{
  "id": "01HXXXXXXXXXXXXXXXXXXXXXX",
  "type": "push",
  "occurred_at": "2026-01-28T19:01:05.000Z",
  "organization": {
    "id": "org_abc123",
    "slug": "acme",
    "name": "Acme"
  },
  "repository": {
    "id": "repo_abc123",
    "name": "vibecode-dashboards",
    "url": "https://api.mesa.dev/acme/vibecode-dashboards.git"
  },
  "data": { ... }
}
```

### `push` event `data`

```json theme={null}
{
  "source": "git_receive_pack",
  "updates": [
    {
      "ref": "refs/heads/main",
      "bookmark": "main",
      "before": "abc123...",
      "after": "def456...",
      "action": "updated"
    }
  ],
  "reconciliation": {
    "reconciled_commit_count": 1,
    "touched_change_count": 1,
    "changes_created_count": 0,
    "evolog_inserted_count": 0,
    "invalid_change_id_header_count": 0,
    "dirty_change_skip_count": 0
  }
}
```

`updates[].action` is `created`, `updated`, or `deleted`. `before` is `null` when the action is `created`; `after` is `null` when the action is `deleted`.

### `repo.*` event `data`

```json theme={null}
{
  "repo": {
    "id": "repo_abc123",
    "name": "vibecode-dashboards",
    "default_bookmark": "main",
    "head_change_id": "...",
    "created_at": "2026-01-28T19:01:05.000Z",
    "tags": { "team": "ui" }
  },
  "before": { "name": "...", "default_bookmark": "...", "tags": { ... } },
  "after":  { "name": "...", "default_bookmark": "...", "tags": { ... } }
}
```

`before` and `after` are only included on `repo.updated`. `repo.created` and `repo.deleted` carry just `repo`.

### `bookmark.*` event `data`

```json theme={null}
{
  "bookmark": {
    "name": "main",
    "is_default": true,
    "change_id": "...",
    "commit_oid": "..."
  },
  "from": { "change_id": "...", "commit_oid": "..." }
}
```

`from` is only included on `bookmark.moved`. `bookmark.created`, `bookmark.deleted`, and `bookmark.merged` carry just `bookmark`.

### `change.*` event `data`

```json theme={null}
{
  "change": {
    "id": "...",
    "current_commit_oid": "def456...",
    "is_conflicted": false,
    "message": "feat: add foo",
    "author":    { "name": "...", "email": "...", "date": "..." },
    "committer": { "name": "...", "email": "...", "date": "..." },
    "parents": ["abc123..."],
    "created_at": "2026-01-28T19:01:05.000Z",
    "updated_at": "2026-01-28T19:01:05.000Z"
  },
  "previous_current_commit_oid": "abc123..."
}
```

`previous_current_commit_oid` is only included on `change.evolved`. `change.created` carries just `change`.

### `sync.*` event `data`

```json theme={null}
{
  "sync": {
    "id": "sync_abc123",
    "repo_id": "repo_abc123",
    "direction": "pull",
    "status": "completed",
    "attempt": 1,
    "refs": { "mode": "all" },
    "stats": { ... },
    "error": null,
    "created_at": "2026-05-13T19:01:05.000Z",
    "started_at": "2026-05-13T19:01:06.100Z",
    "finished_at": "2026-05-13T19:01:09.420Z"
  }
}
```

`status` reflects the run's state at the moment the event fires: `queued` on `sync.queued`, `in_progress` on `sync.in_progress`, and `completed` or `failed` on the corresponding terminal event. `refs` is the ref-selection policy for the run. `error` is populated on `sync.failed`. `stats` is populated once the worker finishes ingestion or reconciliation.

## Webhook headers

* `Content-Type: application/json`
* `User-Agent: Mesa-Webhook/1.0`
* `X-Mesa-Event: <event_type>`
* `X-Mesa-Delivery: <delivery_id>`
* `X-Mesa-Signature: t=<unix>,sha256=<hex>`

## Verify signatures

The signature is computed as:

```
HMAC_SHA256(secret, `${timestamp}.${rawBody}`)
```

<CodeGroup>
  ```typescript TypeScript theme={null}
  import { createHmac, timingSafeEqual } from "crypto";

  const signatureHeader = request.headers.get("x-mesa-signature");
  if (!signatureHeader) throw new Error("Missing signature header");

  const parts = Object.fromEntries(
    signatureHeader.split(",").map((part) => part.trim().split("="))
  );

  const timestamp = Number(parts.t);
  const signature = parts.sha256;
  const body = await request.text();

  const expected = createHmac("sha256", process.env.WEBHOOK_SECRET)
    .update(`${timestamp}.${body}`)
    .digest("hex");

  const valid = timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
  if (!valid) throw new Error("Invalid signature");
  ```

  ```python Python theme={null}
  import hashlib
  import hmac
  import os

  signature_header = request.headers.get("x-mesa-signature")
  if signature_header is None:
      raise ValueError("Missing signature header")

  parts = dict(part.strip().split("=", 1) for part in signature_header.split(","))
  timestamp = parts["t"]
  signature = parts["sha256"]
  body = await request.body()

  expected = hmac.new(
      os.environ["WEBHOOK_SECRET"].encode(),
      f"{timestamp}.".encode() + body,
      hashlib.sha256,
  ).hexdigest()

  if not hmac.compare_digest(signature, expected):
      raise ValueError("Invalid signature")
  ```
</CodeGroup>

<Tip>
  In your receiver, also reject deliveries whose `t` is more than a few
  minutes old (5 minutes is a common choice). This stops an attacker from
  replaying a captured request later. The signature stays valid until you
  rotate the secret. Time alone doesn't invalidate it.
</Tip>

## Rotate the signing secret

To rotate, open the webhook target's detail page in the dashboard
(**Webhooks** → click the target) and click **Regenerate** in the
**Signing secret** section. Confirm the dialog.

<Frame>
  <video autoPlay muted loop playsInline>
    <source src="https://mintcdn.com/mesa/9etuU9rBSYFcRtn5/assets/webhook-docs-regenerate-secret.webm?fit=max&auto=format&n=9etuU9rBSYFcRtn5&q=85&s=fd91ff125fb47897fb6bd7b96db1bb15" type="video/webm" data-path="assets/webhook-docs-regenerate-secret.webm" />
  </video>
</Frame>

Rotation takes effect immediately:

* Mesa starts signing every subsequent delivery with the new secret.
* Deliveries will fail signature verification at your receiver until
  you update it with the new value.

To minimize the failure window, copy the new secret and update your
receiver as soon as you rotate. Rotation requires the `admin` scope, same as create, update, and delete. See the note at the top of this page.

## Delivery behavior

Mesa delivers webhooks with a **10-second timeout** per request. Receivers should respond fast and defer long-running work to a background queue. Anything longer than 10s is treated as a failure.

Mesa automatically retries timeouts, network failures, HTTP `408`, `425`, `429`, and `5xx` responses. Other `4xx` responses and redirects are permanent failures and are not retried. Delivery is at least once after Mesa accepts an event into its queue, so duplicate requests are possible.

The `X-Mesa-Delivery` header contains the stable event ID and remains the same across retries. Use it as an idempotency key on the receiver.

## Local development

Expose your local webhook listener using a tunnel like Tailscale or Cloudflare
Tunnel, then use the public URL when creating the webhook.
