Guides

Alerts and webhooks

Get told by email, or by a signed webhook to your own URL, when an agent goes offline, your allowance runs low, or a key changes.

There are two ways to be told that something happened without watching the dashboard. Email alerts go to the address on your account when an agent has been offline for a while, when you are using up your monthly calls, and when an API key is about to expire. Webhooks post a signed JSON message to a URL you own, so you can page yourself, post to chat, or feed your own audit log. This page covers both, and shows how to receive and verify a webhook with code in Node.js, Go and Python.

Email alerts

Alerts go to the email address on your account. There are three kinds, each switchable on its own under Settings, Email notifications. All three are on by default.

Alert (Settings label)Sent whenHow often
Agent offlineA project that had an agent connected has had none for 10 minutesOnce per outage
Monthly allowanceThe month's calls reach 80% of your plan's allowance, and again at 100%Once each, per month
Key expiryAn API key that has an expiry date is 14 days from expiring, and again at 3 daysOnce each, per key

Agent offline. The subject is Your agent for "<project>" is offline. The message names the project, says how many minutes it has been without an agent, and links to the project page. The 10 minutes is a grace period so that an ordinary restart or deploy does not email you. You get one email per outage: when the agent comes back and goes away again, that is a new outage. An agent you have disabled on the Agents page does not count as offline, because it is still connected.

Monthly allowance. The subject is You have used 80% of your monthly calls at 80%, and You have used your <plan> plan's monthly calls at 100%. The message gives the used and limit counts. The check runs every 10 minutes, so an alert can trail the real crossing by that long, and if you jump straight past 80% to 100% you get the 100% email only. The wording says calls will be refused at the limit. That is true on the Free plan only; paid plans keep working and are counted. See Plans and limits.

Key expiry. The subject is API key "<label>" expires in <n> days. It names the key and the project and links to the project page, so you can create a replacement and update whatever uses the old key before it stops. A key created with fewer than 3 days left gets only the 3-day email. Keys with no expiry never trigger it.

Alerts are sent once and are not retried: if your mail provider rejects one, it is lost. Sign-up, password-reset and email-change messages are a different thing and cannot be switched off. If emails never arrive, see Troubleshooting.

Webhooks

Manage webhooks on the Webhooks page (under Build in the sidebar). To add one:

  1. Enter an endpoint URL.
  2. Tick the events you want. Tick none to receive all of them.
  3. Press Add webhook.

The signing secret is shown once, in a banner. Copy it now. A lost secret cannot be shown again: delete the webhook and make a new one.

You can have 5 webhooks per account. Adding a sixth fails with At most 5 webhooks per account.. Webhooks belong to the account, not to a project: an event from any project goes to every webhook that wants it, and the payload says which project.

Events

EventSent whendata
agent.connectedA project's first agent comes online (none to one){ "project": { "id", "name" } }
agent.disconnectedA project's last agent goes away (one to none){ "project": { "id", "name" } }
key.createdAn API key is created{ "project": { "id", "name" }, "key": { "id", "label", "kind" } }
key.revokedAn API key is revoked{ "project": { "id", "name" }, "keyId" }
allowance.warningThe month reaches 80% of your calls{ "used", "limit", "plan", "percent": 80 }
allowance.exceededThe month reaches 100% of your calls{ "used", "limit", "plan", "percent": 100 }
pingYou press Send test{ "message": "This is a test event." }

You cannot subscribe to ping. It is only the test. Rotating a project's keys does not send key.created or key.revoked. Creating and revoking single keys does.

The webhook has no grace period, unlike the email. agent.disconnected fires the moment the last agent goes, so an agent that restarts sends a disconnected followed by a connected. If you page someone from it, add a short delay on your side.

What arrives

Every delivery is an HTTP POST with a JSON body:

json
{
  "id": "0b6c3f0e-5f0a-4c0e-9d0c-3d0c9a1e8c11",
  "type": "agent.disconnected",
  "at": "2026-10-19T10:00:00.000Z",
  "data": { "project": { "id": "b3f6d9d7-3b0a-4d13-930e-fe2aa66ee586", "name": "orders-api" } }
}

and these headers:

HeaderValue
Content-Typeapplication/json
User-Agentspacefix-webhooks/1
X-Webhook-EventThe event type, for example agent.disconnected
X-Webhook-DeliveryThe same value as the body's id. Use it to skip repeats
X-Webhook-Signaturet=<unix seconds>,v1=<hex>

The signature

v1 is the lowercase hex HMAC-SHA256 of the string <t>.<raw request body>, keyed with your webhook's secret (the whole whsec_... string, prefix included). t is the time of that attempt in Unix seconds. To verify a delivery:

  1. Parse t and v1 from the header.
  2. Reject the request if t is too far from your clock. We do not state a tolerance. Five minutes is a sensible choice and is what the code below uses.
  3. Compute the HMAC over t, a dot, and the exact bytes of the body, as received. Do not parse and re-serialise the JSON first, because that changes the bytes.
  4. Compare it with v1 in constant time.

Receive and verify a webhook

Each example verifies the signature and then reads the event. Set WEBHOOK_SECRET to the whsec_... value, and answer with a 2xx quickly: do slow work after you respond. These were each run against signatures made with the algorithm above, and each accepted a valid signature and rejected a tampered body, a stale timestamp, a malformed header, a missing header and a re-serialised body.

hook.ts
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';

const SECRET = process.env.WEBHOOK_SECRET!;   // the whsec_... value shown once when you created the webhook
const TOLERANCE_SECONDS = 300;

/** True when `header` (X-Webhook-Signature) is a valid signature of `rawBody` made with SECRET. */
export function verify(rawBody: string, header: string | undefined): boolean {
  const fields = Object.fromEntries(
    (header ?? '').split(',').map(part => part.trim().split('=', 2))
  );
  const t = Number(fields.t);
  if (!Number.isInteger(t) || !fields.v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;   // stale or replayed

  const expected = createHmac('sha256', SECRET).update(`${t}.${rawBody}`).digest();
  const given = Buffer.from(fields.v1, 'hex');
  return given.length === expected.length && timingSafeEqual(given, expected);
}

const app = express();

// The signature covers the exact bytes we sent, so read the body as text, not as parsed JSON.
app.post('/hooks/mcp', express.text({ type: 'application/json' }), (req, res) => {
  if (!verify(req.body, req.get('x-webhook-signature'))) return res.sendStatus(401);

  const event = JSON.parse(req.body);          // { id, type, at, data }
  // A delivery can repeat (a retry reuses the id), so skip an id you have already handled.
  console.log(event.type, event.id, event.data);
  res.sendStatus(204);                         // any 2xx means delivered
});

app.listen(3300);

To test an endpoint before you depend on it, put a public HTTPS URL in front of it (a tunnel or a staging host: private and localhost addresses are refused) and press Send test. Check it with a bad signature too: your endpoint should answer 401, and the page will show the delivery as failed.

Delivery, retries and switch-off

  • A response with a 2xx status means delivered. We wait up to 5 seconds for an answer.
  • A failed delivery is retried twice more, after 10 seconds and then after 2 minutes. We retry after a timeout, a connection error, a 5xx, a 429, or a redirect (redirects are never followed, so they count as failures). Any other 4xx is not retried, because it would answer the same way again.
  • Retries repeat the same id and body with a fresh timestamp and signature. Make your handler idempotent by skipping an id you have seen.
  • Pending retries are held in memory. If the gateway restarts, retries that were waiting are lost.
  • Order is not guaranteed.
  • After 20 deliveries in a row fail (each delivery counts once, after its retries), the webhook is switched off. The page shows switched off after 20 failed deliveries and the button becomes Re-enable, which resets the counter. A single success resets the counter too. While a webhook is off, nothing is sent to it, and events that happen in that time are not replayed.
  • The page shows each webhook's last delivery: ok, or failed (<status>), or failed: <error>, with when. There is no per-delivery history.

Send test

Send test posts a ping event now, with a single attempt and no retries, and tells you the outcome: success with the HTTP status, or the error (for example getaddrinfo ENOTFOUND example.invalid) and how long it took. It is recorded as the webhook's last delivery and counts towards the 20-failure switch-off, so fix a failing endpoint rather than pressing it repeatedly.

What URLs are accepted

We make the request from inside our own network, so we refuse URLs that could be used to reach private systems. A URL is checked when you save it, and again, on the address it resolves to, every time we connect.

RuleMessage
Must be https://The URL must start with https://.
Must parse, at most 500 charactersNot a valid URL., The URL is too long.
No username or password in the URLPut credentials in the signature check, not the URL.
No localhost, *.localhost or *.internal hostThat host is not reachable from the internet.
No private, loopback, link-local or reserved address: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, 169.254.0.0/16, 100.64.0.0/10, 0.0.0.0/8, multicast and reserved ranges, and the IPv6 equivalentsThat address is not reachable from the internet.
A name that resolves to a private address is refused when we connectShown as the delivery error Resolved to a private address.
Redirects are not followedThe 3xx is a failed delivery

Ports are not restricted.

Things worth knowing

  • Email and webhooks are independent. Turning off the Monthly allowance email does not stop the allowance webhooks, and the reverse.
  • Allowance events are sent once per level per account per month, from the same 10-minute check as the email.
  • Not built yet: a per-delivery log, per-project webhook endpoints, and webhook events for plan changes.

Next steps

  • Troubleshooting: when a delivery fails or your signature check never passes.
  • Plans and limits: what the allowance is and what happens at 80% and 100%.
  • Security: the Activity log records webhook creation and deletion.