Event Delivery
logi delivers state changes related to user identity to the RP via three paths. The design ensures that even if one path fails, the RP eventually converges to the correct state through another path.
Why three tiers
If you rely on webhooks alone, an RP falls permanently into a stale state in modes like these:
- The RP server is briefly down at webhook delivery time and exhausts its retry budget.
- During HMAC signing key rotation, the RP knows only the old key.
- A network partition blocks only the webhook (token rotation still gets through).
- A bug in the RP-side webhook receiver returns 200 OK without actually processing the event.
The 3-tier design guarantees reliable convergence against such failures.
Tier 1 — Transactional Outbox + Webhook
A row is inserted into the webhook_outbox table inside the transaction where the event occurs (for example, MergeService's merge transaction). This is the outbox pattern adopted as a standard by Stripe / Shopify / Linear and others:
- INSERT inside the transaction — it is fundamentally impossible for an event to occur but fail to be sent. The identity change in the logi DB and the outbox row are atomic.
- Signing time = INSERT time — the
raw_payloadcolumn stores the string serialized as canonical JSON (an RFC 8785 subset) as-is, with an HMAC-SHA256 signature baked in alongside it. The dispatcher then sends it byte-for-byte, which blocks the failure mode where the signature breaks due to key-order differences on re-serialization. - Unique idempotency key —
webhook_outbox(oauth_application_id, event_id)is unique. Even if the dispatcher sends the same row twice, the RP can dedupe.
The dispatcher (a background job) polls the outbox with FOR UPDATE SKIP LOCKED and re-sends with exponential backoff. After a certain number of failures, the row is marked with dlq_at and can be replayed manually from the admin UI (/api/v1/admin/webhook_outbox).
Webhook payload example — user.merged
{
"event_id": "evt_01HE3...",
"event_type": "user.merged",
"occurred_at": "2026-05-11T12:34:56Z",
"data": {
"survivor_canonical_sub": "9182",
"merged_sub": "7341",
"merged_canonical_sub_before": "7341",
"merged_via": "t3_otp",
"triggered_at": "2026-05-11T12:34:55Z",
"source_event_id": "trg_..."
}
}Signature headers:
X-Logi-Signature: sha256=<hex-hmac>
X-Logi-Key-Id: whk_01HE3...
X-Logi-Timestamp: 1715432095
X-Logi-Event-Id: evt_01HE3...The RP must check that the value is within the (timestamp ± 5min) range, dedupe by X-Logi-Event-Id, and then verify the HMAC. For the verification procedure, see Webhook HMAC signature verification.
Tier 2 — Polling API
The RP periodically calls GET /api/v1/events?since=<cursor> to receive events after its own cursor. This is:
- A catch-up path for an RP that missed webhooks — events dropped due to a receiver bug or exhausted retries remain visible with the polling cursor not advancing.
- Drift recovery — when an RP suspects its own state is stale, it can intentionally rewind the cursor to reprocess.
For the call format and the meaning of the cursor, see Polling Events API.
⚠️ Migration-order invariant: the RP-side polling reconciler and webhook receiver must be active before logi's first merge trigger (T1) is turned on in production. Otherwise the webhook fires but the reconciler is a no-op → the polling cursor advances past that event → permanent loss. This is the first item on the RP integration checklist.
Tier 3 — Login-time Fallback
On the next logi token rotation (a refresh or a new SSO), the RP's BearerAuthentication (or equivalent concern) looks at userinfo's canonical_sub / linked_subs, compares them against its own DB's logi_identity_links, and corrects any difference immediately.
This is the last safety net:
- An RP that missed both the webhook and polling is corrected at that user's next sign-in anyway.
- The downside is that "the RP-side state of a user who never signs in is never corrected" — which is why Tier 1/2 take priority and Tier 3 is a fallback.
RP integration checklist
Items to confirm before the logi merge trigger is turned on in production:
- [ ]
webhook_urlis registered in the RP console and the HMAC signing key is stored securely (the registration surface is a single URL column per application) - [ ] The webhook receiver implements
event_iddedupe + timestamp verification + HMAC verification - [ ] The webhook receiver INSERTs a
logi_identity_linksrow into its own DB after processinguser.merged - [ ] The polling reconciler calls
GET /api/v1/events?since=<cursor>on a per-minute basis - [ ] The login-time canonical resolver (the BearerAuthentication concern) is active
- [ ] The
enforce_canonical_resolutionflag has been flipped to true
Following the deployment-order procedure in migration-public-clients sorts the items above out in order.