Skip to navigation

Webhooks

Subscribe to Agent Handler events from your own pipeline

Webhooks let your systems react to events happening inside Agent Handler. Today Agent Handler emits five events: a Registered User is created, a Registered User is deleted, a third party sends an inbound webhook that Agent Handler forwards to you, an employee submits a tool access request, and a tool access request is approved or denied. 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.
  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).

For local development, 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 five event types. This table is the complete set.

EventFires when
REGISTERED_USER_CREATEDA Registered User is created through the API or dashboard
REGISTERED_USER_DELETEDA Registered User is deleted
INBOUND_WEBHOOK_RECEIVEDA subscribed third-party Connector (Slack, Jira, GitHub) sent an inbound webhook that Agent Handler forwards to you (see Inbound webhooks)
TOOL_ACCESS_REQUEST_CREATEDAn employee submits a tool access request (see Tool access request events)
TOOL_ACCESS_REQUEST_REVIEWEDA tool access request is approved or denied, from the dashboard or through the API

The two tool access request events are set up in Workforce organization settings rather than with the steps above; Tool access request events covers where.

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 and Violations and alerts dashboards, and can be pulled via the logs API.

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.

{
"hook": {
"id": "a1c4f0d2-9b3e-4a77-8f21-6d5e2b9c0a14",
"event_id": "e2d7a91c-4f06-4b3a-8c5e-17b9f0a3d264",
"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
}
FieldDescription
hook.idThe ID of the subscription this delivery came from. Not unique per delivery.
hook.event_idA UUID for the event, the key to deduplicate on. Every subscription that receives the same event gets the same event_id, and each new event gets a new one.
hook.eventOne of the event types in the catalog.
hook.targetThe callback URL the event was sent to.
hook.event_timestampWhen the event occurred, ISO 8601.
hook.data_typeregistered_user for the Registered User events, tool_access_request for the tool access request events, third_party_data for inbound events.
hook.third_party_data_event_typeFor inbound events, the third party’s own event name (channel_created, for example). null otherwise.
dataThe event payload. For Registered User events this is the Registered User, with credentials and groups omitted. For tool access request events it’s the request (see Tool access request events). For inbound events it’s the forwarded third-party body.
originThe inbound request that triggered the forward. Populated for inbound events only; null for every other event.

Tool access request events

TOOL_ACCESS_REQUEST_CREATED and TOOL_ACCESS_REQUEST_REVIEWED let you route employee tool access requests into the approval system you already run, such as a ticketing workflow or an on-call bot, instead of watching the Requests page. They are set up somewhere different from the other events: in Workforce, open Settings → Organization and add your endpoint under Request webhooks, in the Tool access requests section. That section appears once tool access requests are turned on. Deliveries are signed with the same verification key and follow the same delivery rules as every other outbound webhook.

TOOL_ACCESS_REQUEST_CREATED fires when an employee submits a request. Submitting an identical request while the first is still pending does not fire a second event. TOOL_ACCESS_REQUEST_REVIEWED fires when a request is approved or denied, and on nothing else.

Revoking an approval, an employee canceling their own request, and an approval expiring fire no event. A revocation or a lapsed grant has to be picked up from the API.

Delivery is best effort. A delivery that errors or takes longer than the 2-second timeout is dropped and not replayed (see Delivery and failures). If you cannot afford to miss a request, reconcile on a schedule against GET /api/v1/tool-access-requests/?status=pending, described in Reviewing tool requests with the API.

A TOOL_ACCESS_REQUEST_REVIEWED event for a request approved from the dashboard:

{
"hook": {
"id": "6b1f3c2e-0d7a-4e4b-9a51-2f0c8d9e7a10",
"event_id": "5a9c0e3f-7b21-4d8e-a6f4-c3e1b2d09f87",
"event": "TOOL_ACCESS_REQUEST_REVIEWED",
"target": "https://customer.example.com/hooks",
"event_timestamp": "2026-10-30T20:02:00.220830Z",
"data_type": "tool_access_request",
"third_party_data_event_type": null
},
"data": {
"id": "0c5e8f7a-3b2d-4f61-8e9a-7d4c1b2a3f50",
"status": "approved",
"created_at": "2026-10-30T19:58:12.104512Z",
"registered_user": {
"id": "9a7b6c5d-4e3f-4a2b-8c1d-0e9f8a7b6c5d",
"origin_id": "00u1abcd2EFGH3ijk4l5",
"name": "Jane Doe",
"email": "jane.doe@example.com"
},
"groups": ["Contractors", "Engineering"],
"tool_pack": null,
"connectors": ["asana", "jira"],
"requested_tool_slugs": ["asana__create_task", "jira__create_issue"],
"reason": "Triage bug ticket 123",
"requested_expires_at": null,
"approved_tool_slugs": ["asana__create_task", "jira__create_issue"],
"approved_expires_at": null,
"decision_note": "ok",
"reviewed_at": "2026-10-30T20:02:00.220830Z",
"reviewed_by": {
"type": "user",
"id": "3d2c1b0a-9f8e-4d7c-6b5a-4f3e2d1c0b9a",
"email": "admin@example.com",
"name": "Alex Admin"
}
},
"origin": null
}

A TOOL_ACCESS_REQUEST_CREATED event has the same shape, with status set to pending, approved_tool_slugs empty, decision_note empty, and approved_expires_at, reviewed_at, and reviewed_by all null. hook.event_timestamp is when the request was submitted for TOOL_ACCESS_REQUEST_CREATED, and when it was approved or denied for TOOL_ACCESS_REQUEST_REVIEWED.

FieldDescription
data.idThe request ID, the one to pass to the API when you decide the request.
data.statuspending for TOOL_ACCESS_REQUEST_CREATED. approved or denied for TOOL_ACCESS_REQUEST_REVIEWED.
data.registered_userThe employee who asked: id, origin_id (their ID in your identity provider), name, and email. email is null when the employee has no linked dashboard user.
data.groupsThe names of every directory group the employee belongs to, sorted.
data.tool_pack{id, name} of the Tool Pack the request was made from, or null when it was not made from a Tool Pack.
data.connectorsThe Connectors the requested tools belong to, sorted and deduplicated.
data.requested_tool_slugsThe tools asked for, in connector__tool form, sorted and deduplicated.
data.reasonThe reason the employee gave.
data.requested_expires_atWhen the employee asked for access to end, ISO 8601, or null for permanent access.
data.approved_tool_slugsThe tools granted. Empty until the request is approved.
data.approved_expires_atWhen the granted access ends, ISO 8601, or null for permanent access.
data.decision_noteThe reviewer’s note. Empty string when none was given.
data.reviewed_atWhen the request was approved or denied.
data.reviewed_byWho decided. {"type": "user"} with the reviewer’s id, email, and name for a decision made in the dashboard, or {"type": "api"} for a decision made through the API.

The two events close a round trip. A TOOL_ACCESS_REQUEST_CREATED event carries who asked, for which tools, and why, which is enough for your system to decide. Send the decision back with PATCH /api/v1/tool-access-requests/{data.id}/, described in Reviewing tool requests with the API, and the result arrives as a TOOL_ACCESS_REQUEST_REVIEWED event with reviewed_by.type set to api.

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. Rotate it from the same page; rotation invalidates all signatures generated with the old key.

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
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 on a schedule rather than relying on the webhook alone. Treat webhooks as a low-latency signal, not the system of record.

Idempotency

Deduplicate on hook.event_id. hook.id identifies the subscription, not the event, so it repeats on every delivery. Because deliveries aren’t retried, duplicates are unlikely, but one event does reach every subscription that includes it with the same event_id, so if two subscriptions point at the same endpoint, event_id is how you tell a second copy from a new event.

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.
  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.

{
"hook": {
"id": "a1c4f0d2-9b3e-4a77-8f21-6d5e2b9c0a14",
"event_id": "c81f4b27-0e9d-4a63-b5f2-9d7e3a1c6b08",
"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.