Skip to content
Esc
↑↓navigate↵open⌘Jpreview
On this page

Webhooks

Send automation trigger events to your own HTTPS endpoint

Webhooks send an automation’s trigger events to a URL you choose instead of running the Modem Agent. Set the automation’s Then to Send a webhook, and each time its trigger fires, Modem POSTs the event to your endpoint as signed JSON.

Use one to feed Modem’s events into your own systems: a release bot, a data warehouse, an on-call tool, or an agent you run yourself.

An automation form. When: a pull request is merged, narrowed by the filter token 'Repository is merchant-rail/storefront', and the analysis 'the pull request changes the checkout or payment flow', with the status 'Can be answered from the pull request alone'. Then: Send a webhook, with the URL https://hooks.merchantrail.com/modem/releases, a masked signing secret, and a 15 minute delay.

How it works

An automation is a trigger plus an outcome. The trigger says when it runs: an event in a connected tool, or a schedule. The outcome says what a run does. The default outcome runs the agent with your prompt. A webhook outcome delivers the event to your URL instead, so the run has no prompt, uses no tools, and calls no model.

Each delivery is an HTTPS POST with a JSON body and Standard Webhooks signature headers. Your endpoint verifies the signature with the automation’s signing secret, answers with a 2xx status, and handles the event.

Choose which events you receive

An activity trigger, such as a customer sends a message or a pull request has new activity, fires for every matching conversation, pull request, or issue in your organization. Modem won’t save one without something that narrows it. The form says so under When: “Add a filter or an analysis: without one, this automation would run on activity from every pull request in the organization.”

There are two ways to narrow a trigger, and you can combine them.

Filter. Click + Filter under When and pick a field, such as Repository is merchant-rail/storefront, Source is Intercom, Author is a customer, or Base branch matches ^release/. Modem matches a filter in code before anything else, so an event it rules out costs nothing and is never delivered. The fields each event offers are listed under Filters.

Analyze. Click + Analyze and describe the events you want in one sentence, such as “the pull request changes the checkout or payment flow”. A fast evaluation model reads each event that passes the filter and delivers only the ones that match. If the model is unavailable, or the event is too large for it to read, Modem skips the event rather than deliver it unchecked. See Trigger conditions for how to write one.

The When and 'and' rows of a trigger. When reads 'a pull request is merged' with the token 'Repository is merchant-rail/storefront' and a '+ Filter' button. The 'and' row holds 'the pull request changes the checkout or payment flow' with the status line 'Can be answered from the pull request alone · Checked on every event before the webhook is sent'.

When the automation has an analysis, its page lists Trigger evaluations: each recent event, whether it Ran or was Skipped, and counts for the last seven days.

Set up a webhook

Create or open an automation

Go to Automations in your dashboard, then click Create Automation and choose Create manually, or open an existing automation.

Choose the trigger

Pick a preset or event under When and narrow it with a filter or an analysis. A scheduled automation can send a webhook too; its deliveries carry no event.

Send a webhook

Under Then, choose Send a webhook and enter the URL. It must start with https://, carry no username, password, or fragment, and point at a public host. Modem refuses localhost, private, and link-local addresses when you save and again before each delivery.

Set a delay, if you want one

Delay waits up to 7 days after the event before delivering. The analysis is still checked when the event fires, against the event as it was then.

Save and copy the signing secret

Once saved, the Signing secret appears under the URL, masked. Click Reveal to see it or the copy button to copy it. Only people who can edit the automation can see it; the API and MCP tools never return it.

The Then, URL, and Delay rows of a saved webhook automation. Then reads 'Send a webhook'. URL holds https://hooks.merchantrail.com/modem/releases, with the note 'Modem POSTs the trigger event as JSON, signed with Standard Webhooks' and a masked Signing secret field with Reveal and copy buttons. Delay reads '15 minutes'.

Changing the URL keeps the secret. Switching Then back to Run the Modem agent deletes the webhook and its secret; switching to a webhook again creates a new one.

You can also create a webhook automation over MCP with create_automation and outcome: { "type": "webhook", "url": "…" }; see Automation tools. The public /v1 API creates automations that run the agent only.

What Modem sends

Every delivery is a POST with content-type: application/json and this body:

{
    "id": "msg_3f9c2a71-8d4e-5b16-a0c3-7e52d9b4f018",
    "type": "automation.triggered",
    "created_at": "2026-10-16T09:42:11.000Z",
    "organization_id": "1842",
    "automation": {
        "id": "018f6b2a-0000-7000-8000-000000000902",
        "name": "Tell the release bot about checkout changes"
    },
    "event": {
        "name": "pull_request.activity",
        "data": {
            "change": {
                "kind": "merged",
                "occurred_at": "2026-10-16T09:42:10.000Z",
                "actor": { "name": "Jordan Mercer", "handle": "jmercer", "email": null, "role": "staff" }
            },
            "source": { "name": "github", "container_kind": "pull_request" },
            "pull_request": {
                "key": "github:2319077512",
                "url": "https://github.com/merchant-rail/storefront/pull/1287",
                "number": 1287,
                "title": "Retry a declined card once before failing checkout",
                "state": "merged",
                "repository": { "full_name": "merchant-rail/storefront" },
                "base_ref": "main",
                "labels": ["checkout", "payments"]
            },
            "topic": { "id": "t-checkout-declined-retry", "title": "Checkout fails after one declined card" },
            "people": [],
            "customer": null,
            "products": [{ "id": "prod-checkout", "name": "Checkout" }]
        }
    }
}

That delivery is what the automation in the screenshots sends: a pull request merged into merchant-rail/storefront that the analysis judged to change the checkout flow. Its data is shortened here. A real delivery carries every field of the Pull Request Activity example, including pull_request.body, recent_comments, the people involved, and the full topic.

Field Value
id The delivery’s id, the same value as the webhook-id header. It stays the same across retries of one delivery, so deduplicate on it.
type Always automation.triggered. Branch on event.name, not on type.
created_at When the run started, as an ISO 8601 timestamp. The same on every retry.
organization_id Your Modem organization’s id.
automation The automation’s id and name, so one endpoint can serve several automations.
event The trigger event: its name and its data. null for a scheduled automation and for Run Once.

Event data

event.data is the same context the agent would have been given, unchanged.

The activity events (chat.activity, call.activity, support_ticket.activity, feedback.activity, social_post.activity, pull_request.activity, and issue.activity) send the nested payloads shown under Example payloads, field for field. Each conversation event carries the recent messages, the people and customer company, the topic, and the products; a pull request or issue event carries the change, the pull request or issue with its newest comments, and the same people, company, topic, and products.

The other events send a flat object. Modem’s own topic events:

Event data
topic.created topic_id, topic_title, and topic_summary (may be null)
topic.priority.high topic_id, previous_priority, new_priority, and topic_priority_context, a text summary of why the priority rose, when one exists

A topic.created delivery:

{
    "id": "msg_b81e64d0-2c95-5f7a-9d38-41a6e0c7b2f5",
    "type": "automation.triggered",
    "created_at": "2026-10-16T10:03:27.000Z",
    "organization_id": "1842",
    "automation": {
        "id": "018f6b2a-0000-7000-8000-000000000903",
        "name": "New topics to the data warehouse"
    },
    "event": {
        "name": "topic.created",
        "data": {
            "topic_id": "t-revenue-chart-spinner",
            "topic_title": "Revenue chart never loads",
            "topic_summary": "The dashboard revenue chart stays on its loading spinner for some accounts."
        }
    }
}

The Stripe events carry the ids and a few fields of the Stripe object: stripe.customer.created sends customer_id, customer_email, and customer_name; the subscription events send subscription_id, customer_id, customer_email, plan_name, and the status, amount, or cancellation details; stripe.invoice.payment_failed sends invoice_id, customer_id, subscription_id, amount_due, currency, and attempt_count. Fields Stripe had no value for are null.

A scheduled automation’s delivery has the same envelope with "event": null.

Receive deliveries

Answer every delivery with a 2xx status within 10 seconds. Modem ignores the response body, so acknowledge first and do the work afterwards. The 10 seconds cover the DNS lookup, the request, and reading your response.

Modem retries a delivery that fails in a way another attempt could fix: a 408, 429, or 5xx response, a timeout, or a dropped connection. It makes up to 6 attempts with exponential backoff starting at 10 seconds, about 5 minutes in all. Every attempt carries the same webhook-id and body, with a fresh webhook-timestamp and signature.

Modem does not retry any other 4xx response, a 413, a redirect (it never follows one), a TLS failure, or a body over 256 KiB. A delivery that fails for good marks the automation’s run as failed, and the run’s history on the automation’s page records why, as a category and status such as http_4xx (404).

Deliveries are not guaranteed to arrive in order. Two events 55 ms apart can reach you reversed, and a retried delivery arrives after newer ones. Deduplicate on id, and order by the event’s own time, event.data.change.occurred_at for a pull request or issue, or by created_at.

Verify the signature

Each delivery carries the three Standard Webhooks headers:

Header Value
webhook-id The delivery’s id, equal to id in the body.
webhook-timestamp When this attempt was signed, in Unix seconds.
webhook-signature v1, followed by the base64 HMAC-SHA256 of <webhook-id>.<webhook-timestamp>.<raw body>, keyed with the secret. Several signatures can appear separated by spaces.

The signing secret is whsec_ followed by base64. The key is the decoded base64 part. Sign over the raw request body, byte for byte, not over re-serialized JSON.

The official standardwebhooks library verifies a delivery in one call and rejects a timestamp more than five minutes from your clock:

import { Webhook } from 'standardwebhooks';

const webhook = new Webhook(MODEM_WEBHOOK_SECRET);

type ModemDelivery = { id: string; event: { name: string; data: unknown } | null };

// Express, with the raw body kept as a string or Buffer.
app.post('/webhooks/modem', express.raw({ type: 'application/json', limit: '256kb' }), (req, res) => {
    let delivery: ModemDelivery;
    try {
        delivery = webhook.verify(req.body, req.headers as Record<string, string>) as ModemDelivery;
    } catch {
        res.sendStatus(401);
        return;
    }
    res.sendStatus(200);

    if (delivery.event?.name === 'pull_request.activity') {
        // delivery.event.data is the Pull Request Activity payload
    }
});

Without a library, in Node:

import { createHmac, timingSafeEqual } from 'node:crypto';

function isModemDelivery(secret: string, headers: Record<string, string>, rawBody: string): boolean {
    const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
    const id = headers['webhook-id'];
    const timestamp = headers['webhook-timestamp'];
    if (!id || !timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

    const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest();
    return (headers['webhook-signature'] ?? '').split(' ').some((candidate) => {
        const [version, signature] = candidate.split(',');
        if (version !== 'v1' || !signature) return false;
        const actual = Buffer.from(signature, 'base64');
        return actual.length === expected.length && timingSafeEqual(actual, expected);
    });
}

Last updated on

Was this page helpful?