Polling Events API
GET /api/v1/events?since=<cursor> is the catch-up path by which an RP fetches all events that occurred after its own cursor. If webhooks are first-class, then polling's primary purpose is filling in misses.
Confidential clients only
The polling events API is for confidential clients only — it requires HTTP Basic authentication with client_id:client_secret. A client that uses only public / PKCE (a mobile app, an SPA) cannot call it directly.
If a public-only RP needs to fill in misses:
- run a separate confidential BFF (server-side) and do the polling there, or
- use Webhook subscriptions for push-based delivery only.
For the detailed authentication policy, see Public Clients and Public vs Confidential.
Request
GET /api/v1/events?since=evt_01HDxxxxxxxxxxxxxxxxxxxxxx&limit=100
Authorization: Basic base64(client_id:client_secret)client_id is a confidential application id of the form logi_..., and client_secret is the value issued in the console. It is not a PAK (Personal API Key), so don't confuse the two.
Parameters
| Parameter | Required | Meaning |
|---|---|---|
since | recommended | The last event_id the RP processed. The next page starts immediately after this id. If omitted on the first call → only events from the last N minutes are returned (to avoid a history dump). |
limit | optional | The maximum number of rows per page. Default 100, max 1000. |
event_type | optional | Filter by user.merged, user.grants_revoked, etc. Comma-separate multiple values. If omitted, all are returned. |
Response
{
"events": [
{
"event_id": "evt_01HE3...",
"event_type": "user.merged",
"occurred_at": "2026-05-11T12:34:56Z",
"data": { /* same shape as webhook payload */ }
},
...
],
"next_cursor": "evt_01HE9...",
"has_more": false
}eventsis sorted in ascendingoccurred_atorder, withevent_idas the tiebreak within the same millisecond.next_cursoris thesincevalue for the next call.has_more=falsemeans there is nothing to send for now.- The
datain the response payload is not byte-for-byte identical — polling does not return an HMAC signature alongside it, and the trust path is different (TLS + client authentication). For events that require HMAC verification, we recommend receiving them via webhook.
Cursor semantics
The cursor is opaque. The RP just stores the next_cursor it received and passes it through on the next call. Even if logi changes the internal format, the RP is unaffected.
The event the cursor points to is not included (exclusive). That is:
since=evt_X → returns events strictly AFTER evt_XTherefore the RP can safely store the id of the most recently processed event as the cursor.
Rate Limits
| Client type | RPS | Burst |
|---|---|---|
| General RP | 5/s | 10 |
polling_intensive=true flag | 20/s | 40 |
On a burst overage, a 429 Too Many Requests + a Retry-After header is returned. A polling reconciler usually only needs a per-minute cadence, so the general RP limit handles it with plenty of headroom. The polling-intensive flag is for multi-region RPs or RPs that need fan-out, and is requested in the console.
Drift Recovery
When the RP suspects its own DB is stale (for example, reprocessing is needed after a bug fix), it intentionally rewinds the cursor to a past point:
# RP-side example — replay last 24h
since = LogiEvent.where("created_at > ?", 24.hours.ago).order(:created_at).first&.logi_event_id
events = LogiClient.events(since: since, limit: 1000)
events.each { |e| ApplyEventJob.perform_later(e) }In this case, ApplyEventJob must be idempotent — processing the same event_id twice must have the same effect. Since logi's webhooks can also be retried, idempotency is required anyway.
Retention
logi retains events for at least 90 days. A cursor that goes back further than that responds with 410 Gone, and the RP must fall back to a full reconciliation (re-fetching userinfo for all users). An RP in normal operation going beyond 90 days is treated as an incident.
Call example (Ruby)
class LogiPollingReconciler
def perform
cursor = LogiState.last_event_id
loop do
resp = http_get("/api/v1/events?since=#{cursor}&limit=200")
resp[:events].each { |e| ApplyEventJob.perform_later(e) }
cursor = resp[:next_cursor]
LogiState.update!(last_event_id: cursor) if cursor
break unless resp[:has_more]
end
end
endIn production, LogiState.update! and ApplyEventJob.perform_later must go inside the same transaction so that the cursor advance and the job enqueue are bound atomically. A DB-backed job adapter such as SolidQueue suits this pattern.