Skip to content

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/events

Required scope: events:read

Query parameters

ParameterTypeDescription
types[]string[]Filter by event types (OR), up to 20 values. E.g. types[]=message.received&types[]=message.sent
typestringFilter by one event type, with wildcard support. E.g. type=message.*
request_idstringFilter 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_aliasstringFilter 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_idstringFilter 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_afterstringCursor for forward pagination. Pass the id of the last item of the previous page.
ending_beforestringCursor for backward pagination. Pass the id of the first item of the current page.
limitintegerNumber of events per page. Range: 1 to 100. Default: 25.
created[gte]ISO-8601Return events created at or after this date and time.
created[lte]ISO-8601Return events created at or before this date and time.

Response format

Results are sorted by created_at DESC (most recent first).

HTTP status: 200
{
  "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 (format grp_{hex32}) instead of to.number; your number that takes part in the group is identified by the envelope’s number_alias. Individual (1:1) messages omit to.group_id. See Webhooks for details.

List envelope

FieldTypeDescription
objectstringAlways "list".
dataarrayList of events (at most limit items).
has_morebooleantrue when there are more events beyond the current page.

Envelope of each event

FieldTypeDescription
idstringUnique identifier (evt_…). Use it as the idempotency key.
typestringEvent type (e.g. message.received).
organization_idstringPublic identifier of your organization (org_{hex32}).
number_aliasstringIdentifier 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_idstringIdentifier 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_atstringWhen 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.
dataobjectEvent-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 toUseDirection
Follow what just happenedending_beforeEvents newer than the cursor
Walk through the historystarting_afterEvents 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.

follow.sh
# First request: no cursor, returns the most recent events
curl "https://api.turbonotify.com/v1/events?limit=100" \
-H "Authorization: Bearer YOUR_KEY"
# Following requests: only what arrived after the last one you processed
curl "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"

Walk through the history: pass the id of the last returned event in starting_after:

polling.sh
# First page
curl "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"

Filter by event type

Filter the events your system needs to process with the types[] parameter:

polling-filtered.sh
curl "https://api.turbonotify.com/v1/events?types[]=message.received&types[]=message.sent" \
-H "Authorization: Bearer YOUR_KEY"

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.

polling-by-number.sh
# Only the events of the main number
curl "https://api.turbonotify.com/v1/events?number_alias=main&limit=20" \
-H "Authorization: Bearer YOUR_KEY"
# Only the events of an extra number
curl "https://api.turbonotify.com/v1/events?number_alias=sales-sp&limit=20" \
-H "Authorization: Bearer YOUR_KEY"

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:

FieldWebhookPolling
idpresentpresent
typepresentpresent
organization_idpresentpresent
number_aliaspresent (in events tied to a number)present (in events tied to a number)
number_idpresent (in events tied to a number)present (in events tied to a number)
created_atpresentpresent (same value)
dataidenticalidentical

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 type and data: see the event reference in the webhook page.
  • Guest answers to a calendar event arrive as message.calendar_responded, also matched by type=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:

  1. Find the approximate date and time the failure started.
  2. Request GET /v1/events?created[gte]=<failure_timestamp>&limit=100 and, while has_more is true, continue with starting_after set to the id of the last event of the page (see Cursor pagination). A single page carries only the most recent events of the period.
  3. Process only the events you have not seen, using each event’s id as 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.

recovery.sh
curl "https://api.turbonotify.com/v1/events?created[gte]=2026-06-01T08:00:00-03:00" \
-H "Authorization: Bearer YOUR_KEY"

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

StatusError codeDescription
400invalid_event_idThe 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.
400invalid_request_idThe 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.
401not_authenticatedThe API key is invalid or missing.
403insufficient_scopeThe API key does not have the events:read scope (the response includes required_scope).
422validation_errorA 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.
429rate_limit_exceededRequest limit exceeded. Wait before trying again.
500internal_errorUnexpected internal server error.