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

# Events feed

> Lists the events of the last 72 hours that matched a subscription of your account, and shows a scheduled poll that catches up after an outage.

The events feed, [`GET /v1/webhooks/events`](/api-reference/webhooks/list-webhook-events), returns
the events of the last 72 hours that matched a subscription of your account. Use it to catch up
after your endpoint was down, and as a scheduled safety net beside your subscriptions. This page says
what the feed holds and what it does not, how to walk it, and where its limits are.

## What the feed holds

The feed returns one item per `event_id`, whatever the outcome of the deliveries of that event. Each
item is the event exactly as delivered: the same `event_id`, `event_type`, `created_at`, `trace_id`
and `data`, with `data.metadata` and `data.line_metadata` always present. See the
[envelope](/webhooks/event-catalog#envelope).

The feed does not hold every change of your account:

* **Only the events that matched an active subscription when they occurred.** An event is in the
  feed only if, at that moment, it matched at least one subscription of your account that was active
  and not suspended. An account without a subscription has an empty feed.
* **Not what a suspended or inactive subscription missed.** Those events were not recorded: the feed
  cannot recover them, unless another active subscription of your account matched them.
* **Not the events of a deleted subscription.** The events that only a deleted subscription received
  leave the feed.
* **Not the probes.** A `webhook.test` request never appears in the feed.
* **Not beyond 72 hours.** An event leaves the feed 72 hours after its last delivery.

The feed is therefore not a replacement for a subscription, and not a complete history. For the
changes that produce no event, see
[What events do not announce](/webhooks/event-catalog#what-events-do-not-announce).

The operation accepts an account API key. A [line-scoped key](/authentication#line-scoped-keys) is
not restricted here: it reads the events of every line of the account, message content included.
Items can carry personal data: message text, handles, contact names and attachment file names. Do
not copy them into logs, tickets or other tools.

## Parameters

| Parameter | Required | Value |
| - | - | - |
| `since` | Yes | The start of the window, inclusive: the earliest recording time to return. A value older than 72 hours is raised to 72 hours ago, without an error. |
| `until` | No | The end of the window, inclusive. Default: now. |
| `event` | No | An exact event type, a family wildcard such as `line.*`, or `*`, which is the same as no filter. An exact name that is not an event type returns `400 validation_failed`. |
| `line_id` | No | A line id. The feed keeps the events tied to that line and excludes the events tied to no line. An id that matches no line returns an empty page. |
| `limit` | No | 1 to 100. Default 100. |
| `cursor` | No | The `next_cursor` of the previous page. |

`since` and `until` are timestamps in ISO 8601, in UTC, ending with `Z`, such as
`2026-10-08T12:00:00Z`. An offset such as `+02:00`, a missing `Z` or a date without a time returns
`400 validation_failed`. A `since` later than `until` returns an empty page.

## Walking the feed

Items are returned newest first. There is no oldest-first order.

```json theme={null}
{
  "success": true,
  "data": {
    "items": [
      {
        "api_version": "v1",
        "event_id": "evt_Fh50GcqYY37ay4VjUxQ2rR6jN",
        "event_type": "message.received",
        "created_at": "2026-10-08T14:22:57.143Z",
        "trace_id": "174b4a27e4e4938fa67ea87b23a18579",
        "data": {
          "message": {
            "id": "msg_7Hq3Zd9Kp2Vx6Wn4Tb8Lc5Ym1",
            "line_id": "line_8Xk2mQ4vTn7Bw9Rz3Lc6Yp1Hd",
            "direction": "inbound",
            "kind": "message",
            "to": "+14155550124",
            "from": "+14155550123",
            "body": "Is the table still free at 8?",
            "channel": "iMessage",
            "service": "iMessage",
            "status": "delivered",
            "effect": null,
            "reply_to_message_id": null,
            "use_typing_indicator": true,
            "metadata": null,
            "attachments": [],
            "reactions": [],
            "scheduled_for": null,
            "schedule_reason": null,
            "reply_lane": false,
            "ignore_quiet_hours": false,
            "queue_info": null,
            "created_at": "2026-10-08T14:22:57.101Z",
            "queued_at": null,
            "sent_at": null,
            "delivered_at": "2026-10-08T14:22:56.000Z",
            "read_at": null,
            "failed_at": null,
            "error_code": null,
            "error_message": null,
            "cancelled_at": null
          },
          "chat_id": "chat_6Rm1Yb5Tq9Hd3Kx7Wc2Ln8Vz4",
          "contact_name": "Dana Whitfield",
          "received_at": "2026-10-08T14:22:56.000Z",
          "metadata": null,
          "line_metadata": { "crm_account_id": "acct_4821" }
        }
      }
    ],
    "has_more": true,
    "next_cursor": "b3BhcXVlLWN1cnNvci12YWx1ZQ"
  },
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "request_id": "req_5Hn2Wq8Kd3Lx7Vb1Zt9Rm4Yc6"
}
```

| Rule | Detail |
| - | - |
| Next page | `has_more` says whether another page exists. `next_cursor` is then a string: pass it as `cursor`. It is `null` on the last page. |
| Filters | The cursor does not carry the filters. Send the same `since`, `until`, `event` and `line_id` on every page. |
| Malformed cursor | `400 invalid_cursor`. Start the walk again without a cursor. |
| Cursor older than the window | A cursor that points past the 72-hour window returns an empty page with `has_more: false`. It is not an error. |
| Replays | A replay records a new delivery of the event at the time of the replay. The feed lists an event at the time of its earliest delivery inside your window: while the first delivery is inside the window, a replay does not move the event; otherwise the event is listed at the time of the replay, with its original `created_at`. Deduplicate on `event_id`. |
| Rate limit | Each page is one request and counts against the [request-rate limit](/errors-and-rate-limits#rate-limits). |

See [Cursors](/pagination#cursors) for the rules shared by the list operations.

## Unknown event types

Ignore response fields, enum values and event types you do not know. When the feed returns an event
type your code does not handle, count the event as processed and skip it: do not stop the walk.

## A scheduled catch-up poll

Your subscriptions are the main path. A poll on a schedule is a safety net for the deliveries your
endpoint did not process: an outage of your endpoint, a deployment, a request your code refused.

**1. Note the time**

Note the time the poll starts, from a clock synchronized with UTC.

**2. Walk the window**

Read the feed with `since` set to the start time of the previous poll minus an overlap, such as
10 minutes. Walk every page until `has_more` is `false`. The overlap absorbs a difference between
your clock and the clock of iSnap. It costs nothing, because you deduplicate.

**3. Process what you have not seen**

Deduplicate on `event_id`, against the same store as your receiver. The feed is newest first:
process the oldest event first. Order events by the timestamps inside `data`, not by arrival.

**4. Store the start time**

Store the time you noted, once the walk is complete. It is the `since` of the next poll.

The limits of this safety net:

* It recovers only what the feed holds. It does not recover the events of a period in which your
  subscription was suspended or inactive, nor an event that was lost before it was recorded, nor a
  change that produces no event. After such a period, reconcile from the resource operations.
* It needs an active subscription that matches the events you want. With no subscription, the feed
  is empty.
* It reaches back 72 hours. If the poll stops for longer, the older events are gone.

```bash curl theme={null}
# First page. Keep the response to read next_cursor from it.
PAGE=$(curl -sS -G https://api.isnap.ai/v1/webhooks/events \
  -H "Authorization: Bearer $ISNAP_API_KEY" \
  --data-urlencode "since=2026-10-08T12:00:00Z" \
  --data-urlencode "limit=100")
echo "$PAGE"
NEXT_CURSOR=$(echo "$PAGE" | jq -r '.data.next_cursor // empty')

# Next page, only when there is one: the same filters, and the cursor of the previous page.
if [ -n "$NEXT_CURSOR" ]; then
  curl -sS -G https://api.isnap.ai/v1/webhooks/events \
    -H "Authorization: Bearer $ISNAP_API_KEY" \
    --data-urlencode "since=2026-10-08T12:00:00Z" \
    --data-urlencode "limit=100" \
    --data-urlencode "cursor=$NEXT_CURSOR"
fi
```

```typescript TypeScript theme={null}
import { ISnapClient, type WebhookEventItem } from '@isnap/sdk'

const client = new ISnapClient({ apiKey: process.env.ISNAP_API_KEY ?? '', maxRetries: 2 })
const OVERLAP_MS = 10 * 60 * 1000

// Replace these two with your own storage: the same store of processed event ids as your
// receiver, and the start time of the previous poll.
const processed = new Set<string>()
let previousPollStartedAt = new Date(Date.now() - 60 * 60 * 1000)

async function catchUp (): Promise<void> {
  const startedAt = new Date()
  const since = new Date(previousPollStartedAt.getTime() - OVERLAP_MS).toISOString()

  // The iterator fetches every page of the window, newest event first.
  const missed: WebhookEventItem[] = []
  for await (const event of client.webhooks.events({ since, limit: 100 })) {
    if (!processed.has(event.event_id)) missed.push(event)
  }

  // Process the oldest event first.
  for (const event of missed.reverse()) {
    handle(event)
    processed.add(event.event_id)
  }
  // Move the window only after a complete walk.
  previousPollStartedAt = startedAt
}

function handle (event: WebhookEventItem): void {
  switch (event.event_type) {
    case 'message.received':
      console.log('inbound message', event.event_id)
      break
    default:
      // An event type you do not handle, or do not know: count it as processed and skip it.
      break
  }
}

await catchUp()
```

```python Python theme={null}
import os
import time
from datetime import datetime, timedelta, timezone

import requests

API = "https://api.isnap.ai"
HEADERS = {"Authorization": f"Bearer {os.environ['ISNAP_API_KEY']}"}
OVERLAP = timedelta(minutes=10)

# Replace these two with your own storage: the same store of processed event ids as your
# receiver, and the start time of the previous poll.
processed = set()
previous_poll_started_at = datetime.now(timezone.utc) - timedelta(hours=1)


def get(path, params):
    """A read. Retries a 429 after Retry-After seconds, and a 500 or a 503 up to 2 times."""
    failures = 0
    while True:
        response = requests.get(f"{API}{path}", headers=HEADERS, params=params, timeout=30)
        if response.status_code == 429:
            time.sleep(int(response.headers.get("Retry-After", "60")))
            continue
        if response.status_code in (500, 503) and failures < 2:
            failures += 1
            time.sleep(2**failures)
            continue
        response.raise_for_status()
        return response.json()["data"]


def catch_up():
    global previous_poll_started_at
    started_at = datetime.now(timezone.utc)
    since = (previous_poll_started_at - OVERLAP).strftime("%Y-%m-%dT%H:%M:%SZ")

    # Walk every page of the window, newest event first. Send the same filters on every page.
    params = {"since": since, "limit": 100}
    missed = []
    while True:
        page = get("/v1/webhooks/events", params)
        missed.extend(event for event in page["items"] if event["event_id"] not in processed)
        if not page["has_more"]:
            break
        params["cursor"] = page["next_cursor"]

    # Process the oldest event first.
    for event in reversed(missed):
        handle(event)
        processed.add(event["event_id"])
    # Move the window only after a complete walk.
    previous_poll_started_at = started_at


def handle(event):
    if event["event_type"] == "message.received":
        print("inbound message", event["event_id"])
    # An event type you do not handle, or do not know: count it as processed and skip it.


catch_up()
```

The samples run one poll. Run them on your scheduler, and keep the processed event ids and the start
time of the previous poll in your own storage. The iterator of the SDK does not expose the cursor:
an interrupted walk starts again from the first page.

The feed is a read. Retry a `429` after `Retry-After` seconds. Retry a `500` or a `503` up to 2
times, with exponential backoff. Do not retry any other `4xx`: fix the request. The SDK applies its
own retries: see [SDK errors and retries](/sdk/errors).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.