> ## Documentation Index
> Fetch the complete documentation index at: https://docs.signa.so/llms.txt
> Use this file to discover all available pages before exploring further.

# Handling alerts

> Pull, push, deduplication, and reconciliation

You have three ways to receive alerts, in increasing operational complexity:

1. **Polling.** A cron job calls [`GET /v1/alerts`](/api-reference/monitoring/alerts/list) and walks pages until it sees an `id` you've already stored.
2. **Webhook.** Register an endpoint via [`POST /v1/webhooks`](/api-reference/monitoring/webhooks/create) and let Signa push alerts to you.
3. **Webhook + reconciliation.** Push for low latency, then periodically call [`POST /v1/alerts/lookup`](/api-reference/monitoring/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.

```ts theme={null}
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

```ts theme={null}
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:

| Parameter    | Type                             | Notes                                                                                                                                                  |
| ------------ | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `limit`      | number                           | 1..100, default 20.                                                                                                                                    |
| `cursor`     | string                           | Opaque cursor from the previous response.                                                                                                              |
| `severity`   | `normal` \| `high` \| `critical` | Filter by severity.                                                                                                                                    |
| `event_type` | string                           | One of the three event types.                                                                                                                          |
| `epoch`      | `all` \| `current`               | `all` (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.

```ts theme={null}
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:

```ts theme={null}
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:

| Cadence | Filter              | What it does                                            |
| ------- | ------------------- | ------------------------------------------------------- |
| 1/min   | `severity=critical` | Pages on-call.                                          |
| 1/hour  | (no filter)         | Catches `normal` and `high` for the daily review queue. |

```ts theme={null}
const critical = await signa.alerts.list({ severity: 'critical' });
for await (const a of critical) routeToOnCallAttorney(a);
```
