Webhooks
Send a project's alerts and instant-form leads to Zapier, Make, n8n, your CRM or your own service as signed JSON: the headers, the signature, the retry schedule, the exact event for every alert kind and lead event, and a Node verification snippet.
Marketing OS can send every alert it raises to an HTTPS endpoint you choose, as a signed JSON
POST. Anything that accepts a webhook can receive it: Zapier's Webhooks by Zapier ("Catch
Hook"), Make, n8n, Pipedream, or your own service.
Add an endpoint
- Pick the project in the sidebar switcher. Endpoints belong to a project, so each client's alerts can go to that client's own tooling.
- Open Settings → Notifications → Webhooks and paste the endpoint URL. It must be
https://and must resolve to a public address. Private, loopback and cloud-metadata addresses are refused, both when you save and on every send. - Choose which alert kinds it receives, and whether it receives leads (
lead.created,lead.status_changed— see Lead events). - Copy the signing secret (
whsec_…). It is shown once. To rotate it, delete the endpoint and add it again. - Press Send test event to receive a signed example with
"test": true.
Adding, changing and reading endpoints takes the same permission as connecting an integration: an organization owner or admin, or an admin grant on that project. A project can have up to five endpoints.
An alert that belongs to the whole organization rather than one project (the monthly AI spend
alert, for example) has businessId: null, and it goes to every endpoint in the organization
that subscribed to its kind.
The request
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: MarketingOS-Webhooks/1.0 (+https://marketing-os.app)
X-MarketingOS-Event: notification.spend_anomaly
X-MarketingOS-Delivery: 5b0e7c9a-1f2d-4e3c-8a7b-6c5d4e3f2a10
X-MarketingOS-Signature: t=1790085791,v1=5f2b8c…e41a| Header | Meaning |
|---|---|
X-MarketingOS-Event | The event type, so a router can switch without parsing the body. |
X-MarketingOS-Delivery | The delivery id. It is the same on every retry of one delivery, so you can dedupe on it. |
X-MarketingOS-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256>. See Verify the signature. |
Answer with any 2xx within 10 seconds. The response body is ignored.
Retries
A delivery that does not get a 2xx is retried three times, one, two and four minutes apart
(four attempts in all). After the last attempt the delivery is marked failed, and the
endpoint shows the error in settings. Each attempt is signed again with a fresh timestamp over
the same body.
- Redirects are not followed. A
3xxcounts as a failed attempt. Save the final URL. 410 Goneswitches the endpoint off and no further events are sent to it. Resume it in settings once the receiver is back.- If the URL starts resolving to a private address, the endpoint is switched off too.
The delivery log (status, attempts, the last response code and error) is under Deliveries on each endpoint and is kept for 30 days.
Verify the signature
The signature is an HMAC-SHA256 of "<t>.<raw body>", keyed with the endpoint's secret and
hex-encoded. Compute it over the raw request body, before any JSON parsing, and reject a
timestamp more than five minutes old so a captured request cannot be replayed.
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
export function verifyMarketingOsWebhook(secret, rawBody, signatureHeader) {
const parts = Object.fromEntries(
signatureHeader.split(',').map((part) => {
const [key, ...rest] = part.trim().split('=');
return [key, rest.join('=')];
}),
);
const timestamp = Number(parts.t);
const received = parts.v1 ?? '';
if (!Number.isInteger(timestamp) || !/^[0-9a-f]{64}$/.test(received)) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(received, 'hex'));
}With Express, keep the raw body for the check:
import express from 'express';
const app = express();
app.post('/marketing-os', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verifyMarketingOsWebhook(
process.env.MARKETING_OS_WEBHOOK_SECRET,
req.body.toString('utf8'),
req.get('X-MarketingOS-Signature') ?? '',
);
if (!ok) return res.sendStatus(400);
const event = JSON.parse(req.body.toString('utf8'));
// …route on event.type
res.sendStatus(204);
});In Zapier, a Catch Hook cannot check a signature. If that matters to you, put a Code step in front of the rest of the Zap, or send to your own service first.
The event
Every event has the same shape. Fields may be added over time. None will be renamed or removed.
| Field | Type | Notes |
|---|---|---|
id | string | The alert's id. Stable across retries. |
type | string | notification.<kind> |
kind | string | One of the kinds below. |
severity | "info" | "warning" | "critical" | Chosen by whatever raised the alert, not derived from the kind. |
title | string | At most 200 characters. |
body | string | At most 2,000 characters. |
url | string | null | A link back into Marketing OS. |
occurrences | number | How many times the condition had been seen when this was sent. |
organizationId | string | |
businessId | string | null | The project, or null for an organization-wide alert. |
businessName | string | null | |
createdAt | string | ISO 8601. When the condition was first raised. |
occurredAt | string | ISO 8601. When it was last seen. |
test | boolean | true only for Send test event. |
An alert that repeats while it is still unread is folded into the one already standing, so you get one event per condition rather than one per repeat.
Every kind
The titles and bodies below are representative. The wording comes from whatever raised the
alert, so route on kind and severity, never on the text. operator_run_failed is part of
the contract but nothing emits it yet.
Guardrail blocks (guardrail_blocked)
{
"id": "3f6c2a1e-8b1d-4f5e-9a0c-2d7e4b9c1f00",
"type": "notification.guardrail_blocked",
"kind": "guardrail_blocked",
"severity": "warning",
"title": "A guardrail blocked update_budget",
"body": "The new daily budget of $450.00 would take this month past the $10,000.00 spend cap.",
"url": "https://marketing.keyescode.com/approvals",
"occurrences": 1,
"organizationId": "0b8f5e2c-4d1a-4c3b-9e7f-6a5d4c3b2a10",
"businessId": "7d2e9f1a-3b4c-4d5e-8f6a-1b2c3d4e5f60",
"businessName": "Acme Coffee",
"createdAt": "2026-09-22T14:03:11.000Z",
"occurredAt": "2026-09-22T14:03:11.000Z",
"test": false
}Approvals waiting (approval_pending)
{
"id": "3f6c2a1e-8b1d-4f5e-9a0c-2d7e4b9c1f00",
"type": "notification.approval_pending",
"kind": "approval_pending",
"severity": "warning",
"title": "An action is waiting for your approval",
"body": "pause_campaign on \"Spring sale — prospecting\" was staged by the operator.",
"url": "https://marketing.keyescode.com/approvals",
"occurrences": 1,
"organizationId": "0b8f5e2c-4d1a-4c3b-9e7f-6a5d4c3b2a10",
"businessId": "7d2e9f1a-3b4c-4d5e-8f6a-1b2c3d4e5f60",
"businessName": "Acme Coffee",
"createdAt": "2026-09-22T14:03:11.000Z",
"occurredAt": "2026-09-22T14:03:11.000Z",
"test": false
}Actions taken automatically (action_auto_approved)
{
"id": "3f6c2a1e-8b1d-4f5e-9a0c-2d7e4b9c1f00",
"type": "notification.action_auto_approved",
"kind": "action_auto_approved",
"severity": "info",
"title": "An action ran under your standing approval",
"body": "update_budget raised \"Retargeting — 30d\" from $80.00 to $95.00 a day.",
"url": "https://marketing.keyescode.com/approvals",
"occurrences": 1,
"organizationId": "0b8f5e2c-4d1a-4c3b-9e7f-6a5d4c3b2a10",
"businessId": "7d2e9f1a-3b4c-4d5e-8f6a-1b2c3d4e5f60",
"businessName": "Acme Coffee",
"createdAt": "2026-09-22T14:03:11.000Z",
"occurredAt": "2026-09-22T14:03:11.000Z",
"test": false
}Measurement failures (measurement_failed)
{
"id": "3f6c2a1e-8b1d-4f5e-9a0c-2d7e4b9c1f00",
"type": "notification.measurement_failed",
"kind": "measurement_failed",
"severity": "critical",
"title": "Acme Coffee: measurement is broken",
"body": "The Meta pixel has not received a Purchase event in 48 hours.",
"url": "https://marketing.keyescode.com/",
"occurrences": 1,
"organizationId": "0b8f5e2c-4d1a-4c3b-9e7f-6a5d4c3b2a10",
"businessId": "7d2e9f1a-3b4c-4d5e-8f6a-1b2c3d4e5f60",
"businessName": "Acme Coffee",
"createdAt": "2026-09-22T14:03:11.000Z",
"occurredAt": "2026-09-22T14:03:11.000Z",
"test": false
}Expired connections (integration_expired)
{
"id": "3f6c2a1e-8b1d-4f5e-9a0c-2d7e4b9c1f00",
"type": "notification.integration_expired",
"kind": "integration_expired",
"severity": "critical",
"title": "Acme Coffee: the Meta connection needs reconnecting",
"body": "The access token expired. Syncs and changes stop until someone reconnects.",
"url": "https://marketing.keyescode.com/settings/integrations",
"occurrences": 1,
"organizationId": "0b8f5e2c-4d1a-4c3b-9e7f-6a5d4c3b2a10",
"businessId": "7d2e9f1a-3b4c-4d5e-8f6a-1b2c3d4e5f60",
"businessName": "Acme Coffee",
"createdAt": "2026-09-22T14:03:11.000Z",
"occurredAt": "2026-09-22T14:03:11.000Z",
"test": false
}Operator run failures (operator_run_failed)
{
"id": "3f6c2a1e-8b1d-4f5e-9a0c-2d7e4b9c1f00",
"type": "notification.operator_run_failed",
"kind": "operator_run_failed",
"severity": "warning",
"title": "Acme Coffee: the operator run failed",
"body": "The daily operator run ended with an error before it finished its checks.",
"url": "https://marketing.keyescode.com/operator",
"occurrences": 1,
"organizationId": "0b8f5e2c-4d1a-4c3b-9e7f-6a5d4c3b2a10",
"businessId": "7d2e9f1a-3b4c-4d5e-8f6a-1b2c3d4e5f60",
"businessName": "Acme Coffee",
"createdAt": "2026-09-22T14:03:11.000Z",
"occurredAt": "2026-09-22T14:03:11.000Z",
"test": false
}Spend anomalies (spend_anomaly)
{
"id": "3f6c2a1e-8b1d-4f5e-9a0c-2d7e4b9c1f00",
"type": "notification.spend_anomaly",
"kind": "spend_anomaly",
"severity": "critical",
"title": "Acme Coffee: spend is 3.2× its usual daily level",
"body": "\"Spring sale — prospecting\" spent $1,240.00 yesterday against a norm of $390.00.",
"url": "https://marketing.keyescode.com/ads-manager",
"occurrences": 1,
"organizationId": "0b8f5e2c-4d1a-4c3b-9e7f-6a5d4c3b2a10",
"businessId": "7d2e9f1a-3b4c-4d5e-8f6a-1b2c3d4e5f60",
"businessName": "Acme Coffee",
"createdAt": "2026-09-22T14:03:11.000Z",
"occurredAt": "2026-09-22T14:03:11.000Z",
"test": false
}Competitor angles (competitor_digest)
{
"id": "3f6c2a1e-8b1d-4f5e-9a0c-2d7e4b9c1f00",
"type": "notification.competitor_digest",
"kind": "competitor_digest",
"severity": "info",
"title": "2 competitors tried new angles this week",
"body": "Blue Bottle is leading with subscriptions; Stumptown is pushing gift bundles.",
"url": "https://marketing.keyescode.com/brain?tab=competitors",
"occurrences": 1,
"organizationId": "0b8f5e2c-4d1a-4c3b-9e7f-6a5d4c3b2a10",
"businessId": "7d2e9f1a-3b4c-4d5e-8f6a-1b2c3d4e5f60",
"businessName": "Acme Coffee",
"createdAt": "2026-09-22T14:03:11.000Z",
"occurredAt": "2026-09-22T14:03:11.000Z",
"test": false
}Research briefs (research_ready)
{
"id": "3f6c2a1e-8b1d-4f5e-9a0c-2d7e4b9c1f00",
"type": "notification.research_ready",
"kind": "research_ready",
"severity": "warning",
"title": "Your positioning brief for Acme Coffee is ready",
"body": "Review and approve it to apply the positioning and voice to this project.",
"url": "https://marketing.keyescode.com/settings/businesses",
"occurrences": 1,
"organizationId": "0b8f5e2c-4d1a-4c3b-9e7f-6a5d4c3b2a10",
"businessId": "7d2e9f1a-3b4c-4d5e-8f6a-1b2c3d4e5f60",
"businessName": "Acme Coffee",
"createdAt": "2026-09-22T14:03:11.000Z",
"occurredAt": "2026-09-22T14:03:11.000Z",
"test": false
}AI spend alerts (ai_spend_threshold)
{
"id": "3f6c2a1e-8b1d-4f5e-9a0c-2d7e4b9c1f00",
"type": "notification.ai_spend_threshold",
"kind": "ai_spend_threshold",
"severity": "warning",
"title": "AI spend passed $50.00 this month",
"body": "Your organization has spent $51.20 on AI this month. Change the alert line in Settings.",
"url": "https://marketing.keyescode.com/settings/ai-providers",
"occurrences": 1,
"organizationId": "0b8f5e2c-4d1a-4c3b-9e7f-6a5d4c3b2a10",
"businessId": null,
"businessName": null,
"createdAt": "2026-09-22T14:03:11.000Z",
"occurredAt": "2026-09-22T14:03:11.000Z",
"test": false
}New leads (lead_received)
Only raised when at least one member of the organization has switched on Show new leads in my
bell (Settings → Notifications), and folded into one standing alert per project for an hour.
It never carries the lead's answers. To receive every lead with its answers, subscribe to
lead.created instead.
{
"id": "6a1d9e4b-2c3f-4a5b-8c7d-9e0f1a2b3c4d",
"type": "notification.lead_received",
"kind": "lead_received",
"severity": "info",
"title": "Acme Plumbing: a new lead from “Free quote — spring”",
"body": "Someone filled in the instant form on “Spring quotes — instant form”.",
"url": "https://marketing.keyescode.com/leads",
"occurrences": 3,
"organizationId": "0b8f5e2c-4d1a-4c3b-9e7f-6a5d4c3b2a10",
"businessId": "7c2e9a4f-1b3d-4e5f-8a6b-9c0d1e2f3a4b",
"businessName": "Acme Plumbing",
"createdAt": "2026-09-23T15:04:05.000Z",
"occurredAt": "2026-09-23T15:41:12.000Z",
"test": false
}Launch reviews (launch_review)
Raised once, seven days after a campaign built by the campaign wizard is activated: what it spent and what it got since launch, against the cost per result the plan assumed. When the project runs the operator, the operator reviews the launch at the same time and the link opens its run.
{
"id": "5b8e2d7a-9c1f-4e3b-8a6d-2f4c1e9b7a30",
"type": "notification.launch_review",
"kind": "launch_review",
"severity": "warning",
"title": "Acme Coffee: “Spring launch” has run a week",
"body": "Spent $412.30 for 9 conversions ($45.81 each) since launch on 2026-09-17. The plan assumed $38.00 each. Its ads are in a creative test cycle. The operator is reviewing it now and will say keep or adjust.",
"url": "https://marketing.keyescode.com/operator",
"occurrences": 1,
"organizationId": "0b8f5e2c-4d1a-4c3b-9e7f-6a5d4c3b2a10",
"businessId": "7d2e9f1a-3b4c-4d5e-8f6a-1b2c3d4e5f60",
"businessName": "Acme Coffee",
"createdAt": "2026-09-24T09:15:02.000Z",
"occurredAt": "2026-09-24T09:15:02.000Z",
"test": false
}Lead events
When a project takes leads from Meta instant forms (the Leads page), an endpoint can subscribe to two events that are records rather than alerts. They are how leads reach a CRM: every lead, with its answers, within seconds of the form being submitted.
type | Sent when |
|---|---|
lead.created | A form was submitted and the lead was stored. Once per lead. |
lead.status_changed | Someone changed the lead's status in the inbox (or an agent did, by tool). |
Leads pulled in by the one-time 90-day backfill when a Page is first switched on are not
sent as lead.created. Download them with Export CSV on the Leads page instead.
The same headers, signature and retries as every other event apply. The body differs from an
alert's: no severity, title, body or occurrences, and a lead object instead.
| Field | Type | Notes |
|---|---|---|
id | string | The event id. Stable across retries. |
type, kind | "lead.created" | "lead.status_changed" | |
organizationId | string | |
businessId | string | The project. |
businessName | string | null | |
url | string | The lead in the inbox. |
occurredAt | string | ISO 8601. |
test | boolean | |
lead.id | string | Our lead id. |
lead.leadgenId | string | Meta's lead id. |
lead.status | string | new, contacted, qualified, won or lost. |
lead.previousStatus | string | null | Set on lead.status_changed only. |
lead.fields | { name, values[] }[] | The form's answers as Meta returns them: full_name, email, phone_number, and any custom question. |
lead.consent | { checkboxKey, isChecked }[] | The form's consent checkboxes. |
lead.createdTime | string | ISO 8601. When the person submitted the form. |
lead also carries note, pageId, formId, formName, campaignId, campaignName,
adSetId, adId and isOrganic.
{
"id": "lead.created.5b0c1c7e-0000-4000-8000-000000000000",
"type": "lead.created",
"kind": "lead.created",
"organizationId": "0b8f5e2c-4d1a-4c3b-9e7f-6a5d4c3b2a10",
"businessId": "7c2e9a4f-1b3d-4e5f-8a6b-9c0d1e2f3a4b",
"businessName": "Acme Plumbing",
"url": "https://marketing.keyescode.com/leads?lead=5b0c1c7e-0000-4000-8000-000000000000",
"occurredAt": "2026-09-23T15:04:07.000Z",
"test": false,
"lead": {
"id": "5b0c1c7e-0000-4000-8000-000000000000",
"leadgenId": "1234567890123456",
"status": "new",
"previousStatus": null,
"note": null,
"pageId": "102030405060708",
"formId": "908070605040302",
"formName": "Free quote — spring",
"campaignId": "120200000000000001",
"campaignName": "Spring quotes — instant form",
"adSetId": "120200000000000002",
"adId": "120200000000000003",
"isOrganic": false,
"fields": [
{ "name": "full_name", "values": ["Jamie Example"] },
{ "name": "email", "values": ["jamie@example.com"] },
{ "name": "phone_number", "values": ["+15555550123"] }
],
"consent": [],
"createdTime": "2026-09-23T15:04:05.000Z"
}
}A lead's answers are personal data. Your endpoint receives them because you subscribed it; treat the receiving system the way you treat your CRM.
Something wrong or missing on this page? Tell us and we will fix it.