> This page is for Agent Handler.

> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.merge.dev/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.merge.dev/_mcp/server.

# Webhooks

> How to receive outbound webhooks from Agent Handler, verify signatures, handle failed deliveries, and the full event catalog.

Webhooks let your systems react to events happening inside Agent Handler. Today Agent Handler emits three events: a Registered User is created, a Registered User is deleted, and a third party sends an inbound webhook that Agent Handler forwards to you. Every outbound webhook is signed, so your endpoint can confirm it came from Agent Handler before trusting the payload.

**Outbound webhooks** are events from Agent Handler to you, the common case. **Inbound webhooks** are events from a third party (Slack, Jira, GitHub) to Agent Handler, then forwarded to your outbound webhook so you get one unified event stream.

## Outbound webhooks

### Setting up a subscription

1. Open [Connectors → Webhooks](https://ah.merge.dev/connectors/webhooks).
2. Click **Add webhook**.
3. Enter your HTTPS callback URL.
4. Select the events to subscribe to (see catalog below).
5. Save.

Your endpoint should accept `POST` requests, parse JSON, and return `2xx` on success. A non-`2xx` response, or a response slower than the timeout, is recorded as a failed delivery (see [Delivery and failures](#delivery-and-failures)).

For local development, [webhook.site](https://webhook.site) gives you a temporary URL that captures requests in a browser. Once you've confirmed payloads look right, point at your real endpoint.

### Event catalog

Agent Handler emits three event types. This table is the complete set.

| Event                      | Fires when                                                                                                                                                      |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `REGISTERED_USER_CREATED`  | A Registered User is created through the API or dashboard                                                                                                       |
| `REGISTERED_USER_DELETED`  | A Registered User is deleted                                                                                                                                    |
| `INBOUND_WEBHOOK_RECEIVED` | A subscribed third-party Connector (Slack, Jira, GitHub) sent an inbound webhook that Agent Handler forwards to you (see [Inbound webhooks](#inbound-webhooks)) |

There are no tool-call, credential, or security-rule webhook events today. Tool-call activity and security violations are visible in the [Tool Call Logs](/merge-agent-handler/observe/tool-call-logs) and [Violations and alerts](/merge-agent-handler/secure/violations-and-alerts) dashboards, and can be pulled via the [logs API](/merge-agent-handler/observe/tool-call-logs).

### Payload shape

Every payload shares the same envelope: a `hook` object with delivery metadata, a `data` object whose shape depends on the event, and an `origin` object that is populated only for inbound events.

```json
{
  "hook": {
    "id": "a1c4f0d2-9b3e-4a77-8f21-6d5e2b9c0a14",
    "event": "REGISTERED_USER_CREATED",
    "target": "https://your-app.example.com/webhooks/merge",
    "event_timestamp": "2026-05-04T16:42:11Z",
    "data_type": "registered_user",
    "third_party_data_event_type": null
  },
  "data": {
    "id": "f9813dd5-e70b-484c-91d8-00acd6065b07",
    "origin_id": "user_a3f9b2",
    "name": "Alice Chen",
    "registered_company": "Acme Inc.",
    "is_test_user": false,
    "user_type": "external"
  },
  "origin": null
}
```

| Field                              | Description                                                                                                                                                             |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `hook.id`                          | The ID of the subscription this delivery came from. Not unique per delivery.                                                                                            |
| `hook.event`                       | One of the three event types in the catalog.                                                                                                                            |
| `hook.target`                      | The callback URL the event was sent to.                                                                                                                                 |
| `hook.event_timestamp`             | When the event occurred, ISO 8601.                                                                                                                                      |
| `hook.data_type`                   | `registered_user` for the Registered User events, `third_party_data` for inbound events.                                                                                |
| `hook.third_party_data_event_type` | For inbound events, the third party's own event name (`channel_created`, for example). `null` otherwise.                                                                |
| `data`                             | The event payload. For Registered User events this is the Registered User, with credentials and groups omitted. For inbound events it's the forwarded third-party body. |
| `origin`                           | The inbound request that triggered the forward. Populated for inbound events only; `null` for the Registered User events.                                               |

### Verifying the signature

Every outbound webhook is signed with HMAC-SHA256 using a secret you control. The signature is in the `X-Webhook-Signature` header, hex-encoded. Verify it before trusting the payload, since anyone can `POST` to a public URL.

Get your signing secret at [Webhooks → Verification key](https://ah.merge.dev/connectors/webhooks). Rotate it from the same page; rotation invalidates all signatures generated with the old key.

**`webhook_handler.py`**

```python webhook_handler.py
import hmac
import hashlib
import os

SIGNING_SECRET = os.environ["MERGE_WEBHOOK_SIGNING_SECRET"]

def verify(raw_body: bytes, signature_header: str) -> bool:
    expected = hmac.new(
        SIGNING_SECRET.encode(),
        raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, signature_header)
```

**`webhook-handler.ts`**

```typescript webhook-handler.ts
import { createHmac, timingSafeEqual } from "node:crypto";

const SIGNING_SECRET = process.env.MERGE_WEBHOOK_SIGNING_SECRET!;

export function verify(rawBody: Buffer, signatureHeader: string): boolean {
  const expected = createHmac("sha256", SIGNING_SECRET).update(rawBody).digest("hex");
  return timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
}
```

The signature is computed over the **raw request body** exactly as sent. If your framework parses JSON before you see the bytes, configure it to give you the raw body; re-stringifying parsed JSON will produce a different signature.

### Delivery and failures

Agent Handler sends each event with a **2-second** timeout. A response slower than 2 seconds, a non-`2xx` status, or a connection error is recorded as a failed delivery.

Failed deliveries are **not** retried, and there is no replay from the dashboard today. A delivery that fails is not sent again. Build for this:

* Return `2xx` as fast as you can and do the real work asynchronously. Holding the connection open to finish processing will blow past the 2-second window and the event is lost.
* If you need a guarantee that you've seen every Registered User, reconcile against the [Registered Users API](/merge-agent-handler/build/users/registered-users) on a schedule rather than relying on the webhook alone. Treat webhooks as a low-latency signal, not the system of record.

### Idempotency

The envelope has no per-delivery unique ID today. `hook.id` identifies the subscription, not the individual event, so you can't dedupe on the envelope alone. Because deliveries aren't retried, duplicates are unlikely, but if you need strict idempotency, derive a key from the event contents, for example `hook.event` plus `data.id` plus `hook.event_timestamp`.

## Inbound webhooks

Some Connectors (Slack, Jira, GitHub) emit webhooks to subscribers. Agent Handler can be that subscriber, then forward the event to your outbound webhook as an `INBOUND_WEBHOOK_RECEIVED` event. You get a single, unified event stream regardless of how many third parties you're reacting to.

Inbound webhooks are passthrough only. Every inbound subscription must be paired with an outbound subscription that includes `INBOUND_WEBHOOK_RECEIVED`.

### Setup

1. **Subscribe an outbound webhook to `INBOUND_WEBHOOK_RECEIVED`.** Add the event to an existing outbound subscription (or create a new one) at [Connectors → Webhooks](https://ah.merge.dev/connectors/webhooks).
2. **Enable inbound webhooks for the Connector.** Open the Connector's detail page; if it supports inbound webhooks, you'll see a Webhook section. Click **Add webhook**.
3. **Connector-specific configuration.** Each third party requires different credentials. Slack wants the app's signing secret; GitHub wants a webhook secret you generate; Jira wants admin access to register the webhook with the third party.

Each Connector's webhook setup is documented inline in the dashboard. The Slack flow, as a representative example:

1. In the Slack app dashboard, copy the signing secret from **Basic Information → App Credentials**.
2. Paste it into the Slack Connector's webhook field in Agent Handler.
3. Agent Handler returns a webhook URL, copy it.
4. In Slack, go to **Event Subscriptions → Request URL**, paste the URL, and select the events you want forwarded.
5. Slack verifies the URL automatically. Once verified, events flow Slack → Agent Handler → your outbound endpoint as `INBOUND_WEBHOOK_RECEIVED` payloads.

### Inbound payload shape

For an inbound event, `data` holds the forwarded third-party body and `origin` holds the original request Agent Handler received from the third party (method, URL, headers, and body), along with the Connector and Registered User it belongs to. `hook.third_party_data_event_type` carries the third party's own event name when the Connector can determine it.

```json
{
  "hook": {
    "id": "a1c4f0d2-9b3e-4a77-8f21-6d5e2b9c0a14",
    "event": "INBOUND_WEBHOOK_RECEIVED",
    "target": "https://your-app.example.com/webhooks/merge",
    "event_timestamp": "2026-05-04T16:42:11Z",
    "data_type": "third_party_data",
    "third_party_data_event_type": "channel_created"
  },
  "data": {
    "channel": { "id": "C01ABC", "name": "new-channel" }
  },
  "origin": {
    "connector_slug": "slack",
    "organization_id": "org_8f21a0",
    "registered_user_id": "f9813dd5-e70b-484c-91d8-00acd6065b07",
    "method": "POST",
    "url": "https://ah-api.merge.dev/api/webhooks/inbound/listener/...",
    "headers": { "x-slack-signature": "v0=..." },
    "body": { "type": "event_callback", "event": { "type": "channel_created" } }
  }
}
```

## Next

Tag tool calls with session or workflow metadata using [Custom headers for MCP](/merge-agent-handler/build/connecting-agents/custom-headers-for-mcp).