Skip to main content
You have three ways to receive alerts, in increasing operational complexity:
  1. Polling. A cron job calls GET /v1/alerts and walks pages until it sees an id you’ve already stored.
  2. Webhook. Register an endpoint via POST /v1/webhooks and let Signa push alerts to you.
  3. Webhook + reconciliation. Push for low latency, then periodically call POST /v1/alerts/lookup with the IDs you’ve persisted to confirm nothing was lost during a receiver outage.
Pure polling is fine for low-volume internal tools. For production external workflows, prefer webhook + reconciliation.

Deduplicate by alert ID

Alerts are immutable, but the same alert.created event can arrive at your endpoint multiple times across retries and redeliveries. Use the webhook-id header (which equals the alert’s prefixed ID, alt_*) as your idempotency key.
const alertId = req.header('webhook-id')!;
if (await alreadyProcessed(alertId)) {
  return res.sendStatus(200); // ack but skip
}
// Record processing and execute the side effect in the same transaction
// so a crash between them leaves the alert unprocessed (and retriable),
// not silently "done".
await db.transaction(async (tx) => {
  await markProcessed(tx, alertId);
  await handle(tx, JSON.parse(req.body));
});
return res.sendStatus(200);
If your side effect cannot share a transaction with your dedup store (for example, a third-party API call), use a two-stage processing -> done state and only flip to done after the side effect returns success.

Polling pattern

let cursor: string | undefined;
while (true) {
  const page = await signa.alerts.list({ limit: 100, cursor });
  for (const alert of page.data) {
    if (await alreadyProcessed(alert.id)) {
      // Caught up -- older alerts have already been processed.
      return;
    }
    await markProcessed(alert.id);
    await handle(alert);
  }
  if (!page.has_more) return;
  cursor = page.pagination.cursor!;
}
The GET /v1/alerts endpoint accepts:
ParameterTypeNotes
limitnumber1..100, default 20.
cursorstringOpaque cursor from the previous response.
severitynormal | high | criticalFilter by severity.
event_typestringOne of the three event types.
epochall | currentall (default) includes alerts from prior query revisions/replays. Set to current to keep only alerts from each watch’s current evaluation_epoch.

Reconciliation pattern

For defense-in-depth on top of webhooks, run a daily job that asks Signa “did you ever fire these IDs?” — useful when you suspect a webhook outage.
const recentIds = await myStore.alertsLast48h();
const confirmed = await signa.alerts.lookup(recentIds);
const seen = new Set(confirmed.map((a) => a.id));
for (const id of recentIds) {
  if (!seen.has(id)) reportPossibleLoss(id);
}
alerts.lookup() takes an array of 1-100 prefixed alert IDs (alt_*) per call:
const alerts = await signa.alerts.lookup([
  'alt_01HK7M...',
  'alt_01HK7N...',
  'alt_01HK7P...',
]);
// alerts is an array of Alert objects -- unknown or cross-org IDs are
// silently omitted, so `alerts.length <= ids.length`.
The endpoint is organization-scoped. Malformed IDs — anything that is not a well-formed alt_* ID — fail the whole request with 400 validation_error, listing each bad entry by index. Well-formed but unknown IDs, and IDs that belong to another organization, are silently dropped from the result. Any gap between the IDs you sent and the alerts you got back is real.

Severity-based routing

A typical production setup has two cron jobs:
CadenceFilterWhat it does
1/minseverity=criticalPages on-call.
1/hour(no filter)Catches normal and high for the daily review queue.
const critical = await signa.alerts.list({ severity: 'critical' });
for await (const a of critical) routeToOnCallAttorney(a);