Event polling
The events API lists your account’s event history, page by page. Use polling instead of the webhook, or to recover events missed while your endpoint was unavailable.
When to use polling
The webhook is the main way to receive events in real time. Polling is recommended in two scenarios:
- No webhook configured: fetch events regularly to process received messages, send confirmations and other notifications.
- Recovery after a failure: after an outage of your webhook endpoint, use polling to fetch the missed events and reconcile the state of your system.
Endpoint
GET /v1/eventsRequired scope: events:read
Query parameters
| Parameter | Type | Description |
|---|---|---|
types[] | string[] | Filter by event types (OR), up to 20 values. E.g. types[]=message.received&types[]=message.sent |
type | string | Filter by one event type, with wildcard support. E.g. type=message.* |
request_id | string | Filter by the events generated by a single asynchronous request (req_…). Useful to correlate the result of a POST (send, reply, edit or delete) using the X-Request-ID returned in the response header. Reactions cannot be correlated this way: the header returned by POST .../react is not a valid req_…; use the message_id of the target message (see Send a reaction). E.g. request_id=req_01993c5a1b24d3e9f01a2b3c4d5e6f70 |
number_alias | string | Filter by the events of a single number ("main" or the alias of an extra number). Absent = all of the organization’s numbers. Only the events that do not belong to a single number (storage.* and media.unavailable) are excluded when this filter is set; message.quota.* is per number and can be filtered. |
number_id | string | Filter by the events of a single number, by id (num_…). Combines with number_alias. It keeps finding the number’s events after a rename, because the filter compares the id and not the alias. A value that starts with num_ but is not a valid id answers 400 invalid_number_id. |
starting_after | string | Cursor for forward pagination. Pass the id of the last item of the previous page. |
ending_before | string | Cursor for backward pagination. Pass the id of the first item of the current page. |
limit | integer | Number of events per page. Range: 1 to 100. Default: 25. |
created[gte] | ISO-8601 | Return events created at or after this date and time. |
created[lte] | ISO-8601 | Return events created at or before this date and time. |
Response format
Results are sorted by created_at DESC (most recent first).
{
"object": "list",
"data": [
{
"id": "evt_01993c5a1b24d3e9f01a2b3c4d5e6f70",
"type": "message.received",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"created_at": "2026-06-01T13:00:00Z",
"data": {
"id": "msg_01993c5a1b24d3e9f01a2b3c4d5e6f70",
"from": {
"name": "Carlos Lima",
"number": "5511977776666"
},
"to": {
"alias": "main",
"number": "5511973117056"
},
"preview": "Hi, I would like to know about my order",
"content_type": "text",
"direction": "inbound",
"channel": "whatsapp",
"origin": "native",
"at": "2026-06-01T13:00:00Z"
}
},
{
"id": "evt_c00a44c3fc774698b0f130d40bb5f104",
"type": "message.received",
"organization_id": "org_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"number_alias": "main",
"number_id": "num_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"created_at": "2026-06-02T01:55:14Z",
"data": {
"id": "msg_e95500cc03c1492e81e46a77c1f0d420",
"from": {
"contact_id": "ct_45f2ece8a66a4eeaa661aeb9b8e58c77",
"name": "Ana Souza",
"number": "5511988887777"
},
"to": {
"group_id": "grp_e442553a56b14e2695f6acfd7d651b1a",
"name": "TNT"
},
"preview": "Good morning, everyone",
"content_type": "text",
"direction": "inbound",
"channel": "whatsapp",
"origin": "native",
"at": "2026-06-02T01:55:14Z"
}
}
],
"has_more": true
} Group events use
to.group_id(formatgrp_{hex32}) instead ofto.number; your number that takes part in the group is identified by the envelope’snumber_alias. Individual (1:1) messages omitto.group_id. See Webhooks for details.
List envelope
| Field | Type | Description |
|---|---|---|
object | string | Always "list". |
data | array | List of events (at most limit items). |
has_more | boolean | true when there are more events beyond the current page. |
Envelope of each event
| Field | Type | Description |
|---|---|---|
id | string | Unique identifier (evt_…). Use it as the idempotency key. |
type | string | Event type (e.g. message.received). |
organization_id | string | Public identifier of your organization (org_{hex32}). |
number_alias | string | Identifier of the Turbo Notify number the event belongs to: "main" for the main number or the alias of an extra number. It is the alias string, never a UUID. Present in events tied to a specific number: message.*, number.*, group.* and every contact.* (including contact.quota.*) and message.quota.* (the message quota is per number). Omitted in storage.* and in media.unavailable: the events that do not belong to a single number. |
number_id | string | Identifier of the number (num_…), right after number_alias. Present exactly when number_alias is present. It never changes: it is the key to store. number_alias is the alias the number had when the event happened. |
created_at | string | When Turbo Notify issued the event (when the record was created/published). ISO 8601 in UTC with the Z suffix and second precision (e.g. 2026-06-01T13:00:05Z). Different from data.at (when the action happened on WhatsApp), when present. |
data | object | Event-specific content, identical to what the webhook delivers. When there is a real action on WhatsApp, data.at carries that moment (a value different from created_at), including for events of sent messages (message.sent/replied/edited/deleted) and the number.* events of real actions, whose at is the real moment on WhatsApp, usually earlier than created_at. In failure events, data.at is when the failure was detected. Events that do not match an action on WhatsApp have no at. |
In number.* events, data carries the number’s id as its first field (data.id), equal to the envelope’s number_id.
Cursor pagination
Pagination uses a cursor, so pages do not change when new events arrive. The cursor is the id of an event.
Events come from newest to oldest, and that decides which cursor to use:
| You want to | Use | Direction |
|---|---|---|
| Follow what just happened | ending_before | Events newer than the cursor |
| Walk through the history | starting_after | Events older than the cursor |
Follow new events: ask for what arrived after the last processed event and, if the page comes back full, page down until you reach it.
# First request: no cursor, returns the most recent eventscurl "https://api.turbonotify.com/v1/events?limit=100" \ -H "Authorization: Bearer YOUR_KEY"
# Following requests: only what arrived after the last one you processedcurl "https://api.turbonotify.com/v1/events?limit=100&ending_before=evt_01993c5a1b24d3e9f01a2b3c4d5e6f70" \ -H "Authorization: Bearer YOUR_KEY"
# If that response came back with 100 events, there is more: continue from# its LAST event until you reach the cursor you already had.curl "https://api.turbonotify.com/v1/events?limit=100&starting_after=evt_01993c5a1b24d3e9f01a2b3c4d5e6f99" \ -H "Authorization: Bearer YOUR_KEY"const LIMIT = 100;let lastSeen = null;
async function fetchPage(params) { const response = await fetch( `https://api.turbonotify.com/v1/events?${params}`, { headers: { 'Authorization': 'Bearer YOUR_KEY' } } ); return (await response.json()).data;}
async function fetchNewEvents() { const params = new URLSearchParams({ limit: String(LIMIT) }); if (lastSeen) params.set('ending_before', lastSeen);
let current = await fetchPage(params); // Empty: you are up to date. if (current.length === 0) return [];
const batch = [...current];
// Full page with a known cursor: more arrived than fit, and what is // missing is older than the last event of this page. Page down until // you reach the previous cursor. while (lastSeen && current.length === LIMIT) { current = await fetchPage(new URLSearchParams({ limit: String(LIMIT), starting_after: batch[batch.length - 1].id, })); for (const event of current) { if (event.id === lastSeen) { current = []; break; } batch.push(event); } }
// The first event of the batch is the most recent: it becomes the next cursor. lastSeen = batch[0].id; return batch;}import requests
LIMIT = 100last_seen = None
def fetch_page(**params): response = requests.get( "https://api.turbonotify.com/v1/events", headers={"Authorization": "Bearer YOUR_KEY"}, params={"limit": LIMIT, **params}, ) return response.json()["data"]
def fetch_new_events(): global last_seen current = fetch_page(ending_before=last_seen) if last_seen else fetch_page()
# Empty: you are up to date. if not current: return []
batch = list(current)
# Full page with a known cursor: more arrived than fit, and what is # missing is older than the last event of this page. Page down until # you reach the previous cursor. while last_seen and len(current) == LIMIT: current = fetch_page(starting_after=batch[-1]["id"]) for event in current: if event["id"] == last_seen: current = [] break batch.append(event)
# The first event of the batch is the most recent: it becomes the next cursor. last_seen = batch[0]["id"] return batchWalk through the history: pass the id of the last returned event in starting_after:
# First pagecurl "https://api.turbonotify.com/v1/events?limit=20" \ -H "Authorization: Bearer YOUR_KEY"
# Next page (starting_after = id of the last returned event)curl "https://api.turbonotify.com/v1/events?limit=20&starting_after=evt_01993c5a1b24d3e9f01a2b3c4d5e6f70" \ -H "Authorization: Bearer YOUR_KEY"async function fetchAllEvents() { let startingAfter = null; const all = [];
while (true) { const params = new URLSearchParams({ limit: '20' }); if (startingAfter) params.set('starting_after', startingAfter);
const response = await fetch( `https://api.turbonotify.com/v1/events?${params}`, { headers: { 'Authorization': 'Bearer YOUR_KEY' } } ); const page = await response.json(); all.push(...page.data);
if (!page.has_more) break; startingAfter = page.data[page.data.length - 1].id; }
return all;}import requests
def fetch_all_events(): url = "https://api.turbonotify.com/v1/events" headers = {"Authorization": "Bearer YOUR_KEY"} all_events = [] starting_after = None
while True: params = {"limit": 20} if starting_after: params["starting_after"] = starting_after
response = requests.get(url, headers=headers, params=params) page = response.json() all_events.extend(page["data"])
if not page["has_more"]: break starting_after = page["data"][-1]["id"]
return all_eventsFilter by event type
Filter the events your system needs to process with the types[] parameter:
curl "https://api.turbonotify.com/v1/events?types[]=message.received&types[]=message.sent" \ -H "Authorization: Bearer YOUR_KEY"const params = new URLSearchParams();params.append('types[]', 'message.received');params.append('types[]', 'message.sent');params.set('limit', '20');
const response = await fetch( `https://api.turbonotify.com/v1/events?${params}`, { headers: { 'Authorization': 'Bearer YOUR_KEY' } });const page = await response.json();console.log(page);import requests
url = "https://api.turbonotify.com/v1/events"headers = {"Authorization": "Bearer YOUR_KEY"}params = { "types[]": ["message.received", "message.sent"], "limit": 20}
response = requests.get(url, headers=headers, params=params)page = response.json()print(page)Filter by number
Since messages, contacts and groups always belong to a number, you can
restrict polling to the events of a single number with number_alias. If the
parameter is absent, the response includes the events of all of the
organization’s numbers.
# Only the events of the main numbercurl "https://api.turbonotify.com/v1/events?number_alias=main&limit=20" \ -H "Authorization: Bearer YOUR_KEY"
# Only the events of an extra numbercurl "https://api.turbonotify.com/v1/events?number_alias=sales-sp&limit=20" \ -H "Authorization: Bearer YOUR_KEY"const params = new URLSearchParams({ number_alias: 'main', limit: '20' });
const response = await fetch( `https://api.turbonotify.com/v1/events?${params}`, { headers: { 'Authorization': 'Bearer YOUR_KEY' } });const page = await response.json();console.log(page);import requests
url = "https://api.turbonotify.com/v1/events"headers = {"Authorization": "Bearer YOUR_KEY"}params = {"number_alias": "main", "limit": 20}
response = requests.get(url, headers=headers, params=params)page = response.json()print(page)Recommended interval
Polling uses the same general API limit: 1000 requests per second per organization. Above that the response is 429 Too Many Requests (rate_limit_exceeded). Polling every few seconds stays far below that ceiling.
With high volume, prefer webhooks to receive events in real time and keep polling for reconciliation.
Parity with webhooks
The data object of each event returned by the API is identical, byte for byte, to the data delivered via webhook for the same event. The envelope is the same on both channels:
| Field | Webhook | Polling |
|---|---|---|
id | present | present |
type | present | present |
organization_id | present | present |
number_alias | present (in events tied to a number) | present (in events tied to a number) |
number_id | present (in events tied to a number) | present (in events tied to a number) |
created_at | present | present (same value) |
data | identical | identical |
In practice:
- Systems that use polling only process the same data as webhook-based systems.
- Hybrid systems can use the API to reconcile missed events while reusing the same processing code.
- Full catalog of
typeanddata: see the event reference in the webhook page. - Guest answers to a calendar event arrive as
message.calendar_responded, also matched bytype=message.*: see Calendar events. - Payload examples per
content_type(text, media, location, calendar): Examples by content_type.
Parity includes the data.mentions field:
when a message mentions someone, the
message.received/message.replied/message.sent/message.edited events returned
by polling carry exactly the same mentions list and the same preview
(each mention token counts as 1 character) delivered via webhook,
byte for byte.
Recovery after a webhook failure
To recover events missed while your endpoint was unavailable:
- Find the approximate date and time the failure started.
- Request
GET /v1/events?created[gte]=<failure_timestamp>&limit=100and, whilehas_moreistrue, continue withstarting_afterset to theidof the last event of the page (see Cursor pagination). A single page carries only the most recent events of the period. - Process only the events you have not seen, using each event’s
idas the idempotency key.
You can also recover a specific message by reading it: the list and the single-message read carry the same fields as the message.* event for that message (id, number_alias, number_id, from, to, direction, content_type, channel, origin, at). If you missed a webhook, read the message with Get a message or filter List messages by group, contact or direction and get the same information.
curl "https://api.turbonotify.com/v1/events?created[gte]=2026-06-01T08:00:00-03:00" \ -H "Authorization: Bearer YOUR_KEY"const params = new URLSearchParams({ 'created[gte]': '2026-06-01T08:00:00-03:00', limit: '100'});
const response = await fetch( `https://api.turbonotify.com/v1/events?${params}`, { headers: { 'Authorization': 'Bearer YOUR_KEY' } });const page = await response.json();console.log(page);import requests
url = "https://api.turbonotify.com/v1/events"headers = {"Authorization": "Bearer YOUR_KEY"}params = { "created[gte]": "2026-06-01T08:00:00-03:00", "limit": 100}
response = requests.get(url, headers=headers, params=params)page = response.json()print(page)Event retention
GET /v1/events lists events for the event store’s own horizon, 90 days, fixed by the platform: your plan’s content retention period does not hide events. What expires at that period is the content of message.* events: data.preview then holds a content-removed marker and data.transcription becomes {"kind": "unavailable", "reason": "content_retention_expired"}, within 15 minutes after the deadline (see Message retention).
Possible errors
| Status | Error code | Description |
|---|---|---|
400 | invalid_event_id | The cursor given in starting_after or ending_before is not a valid event id (evt_ + 32 hex). A well-formed but nonexistent cursor does not cause an error. The filter is ignored. |
400 | invalid_request_id | The request_id given is not a valid request id (req_ + 32 hex). The X-Request-ID returned in GET responses is a correlation UUID, not a req_…. Only the header returned by asynchronous POST requests works as a filter here. |
401 | not_authenticated | The API key is invalid or missing. |
403 | insufficient_scope | The API key does not have the events:read scope (the response includes required_scope). |
422 | validation_error | A query parameter failed validation (for example limit outside the 1 to 100 range, or created[gte]/created[lte] not in ISO 8601 format). The response carries details.errors with the rejected fields. |
429 | rate_limit_exceeded | Request limit exceeded. Wait before trying again. |
500 | internal_error | Unexpected internal server error. |