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

  1. 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.
  2. 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.
  3. Choose which alert kinds it receives, and whether it receives leads (lead.created, lead.status_changed — see Lead events).
  4. Copy the signing secret (whsec_…). It is shown once. To rotate it, delete the endpoint and add it again.
  5. 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

http
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
HeaderMeaning
X-MarketingOS-EventThe event type, so a router can switch without parsing the body.
X-MarketingOS-DeliveryThe delivery id. It is the same on every retry of one delivery, so you can dedupe on it.
X-MarketingOS-Signaturet=<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 3xx counts as a failed attempt. Save the final URL.
  • 410 Gone switches 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.

js
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:

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

FieldTypeNotes
idstringThe alert's id. Stable across retries.
typestringnotification.<kind>
kindstringOne of the kinds below.
severity"info" | "warning" | "critical"Chosen by whatever raised the alert, not derived from the kind.
titlestringAt most 200 characters.
bodystringAt most 2,000 characters.
urlstring | nullA link back into Marketing OS.
occurrencesnumberHow many times the condition had been seen when this was sent.
organizationIdstring
businessIdstring | nullThe project, or null for an organization-wide alert.
businessNamestring | null
createdAtstringISO 8601. When the condition was first raised.
occurredAtstringISO 8601. When it was last seen.
testbooleantrue 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)

json
{
  "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)

json
{
  "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)

json
{
  "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)

json
{
  "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)

json
{
  "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)

json
{
  "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)

json
{
  "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)

json
{
  "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)

json
{
  "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)

json
{
  "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.

json
{
  "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.

json
{
  "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.

typeSent when
lead.createdA form was submitted and the lead was stored. Once per lead.
lead.status_changedSomeone 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.

FieldTypeNotes
idstringThe event id. Stable across retries.
type, kind"lead.created" | "lead.status_changed"
organizationIdstring
businessIdstringThe project.
businessNamestring | null
urlstringThe lead in the inbox.
occurredAtstringISO 8601.
testboolean
lead.idstringOur lead id.
lead.leadgenIdstringMeta's lead id.
lead.statusstringnew, contacted, qualified, won or lost.
lead.previousStatusstring | nullSet 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.createdTimestringISO 8601. When the person submitted the form.

lead also carries note, pageId, formId, formName, campaignId, campaignName, adSetId, adId and isOrganic.

json
{
  "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.