Skip to content

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

ParameterRequiredMeaning
sincerecommendedThe 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).
limitoptionalThe maximum number of rows per page. Default 100, max 1000.
event_typeoptionalFilter by user.merged, user.grants_revoked, etc. Comma-separate multiple values. If omitted, all are returned.

Response

json
{
  "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
}
  • events is sorted in ascending occurred_at order, with event_id as the tiebreak within the same millisecond.
  • next_cursor is the since value for the next call. has_more=false means there is nothing to send for now.
  • The data in 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_X

Therefore the RP can safely store the id of the most recently processed event as the cursor.

Rate Limits

Client typeRPSBurst
General RP5/s10
polling_intensive=true flag20/s40

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:

ruby
# 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)

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
end

In 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.

Identity가 제품의 신뢰를 만듭니다.