Recover missed events from the event log
Read the Wingspan V3 event log with a saved cursor to catch events that webhook push missed, and resynchronize when a cursor expires.
This page shows you how to read the event log at GET /v3/platform/events so you can pick up every event that webhook push didn't deliver.
Every event Wingspan sends by push is also stored in the event log for 30 days. You read it with a cursor. Save the cursor after each read, and next time you continue from exactly where you stopped. Nothing is skipped, even while your endpoint or your subscription was down.
You don't need a webhook subscription to use the log. Some integrations poll the log only.
Before you start
- A token with the
platform.event:readpermission on the scope you want to read. - The scope, in the same shape a subscription uses (see Create a subscription). It's required on every call.
- Somewhere durable to store one string per scope: the
resumeCursor.
How the cursor works
Every response from GET /v3/platform/events includes a resumeCursor. It's never empty. It points just after the last event in the response, or at the newest point in the log if there were no more events. Send it back as page[token] to continue.
The cursor is tied to the exact scope and filters you used. Send the same scope and filter values every time you use it.
Events are ordered by when Wingspan recorded them (createdAt), not by when the change happened (eventCreatedAt). An event recorded late still appears after your cursor, so you won't miss it.
Step 1: Pick a starting point
The first time, you have no cursor. Choose with seek:
seek | Starts at | Use it when |
|---|---|---|
Beginning (default) | The oldest event still kept, up to 30 days back. | You want the history. |
Head | Now. Returns no events, only a resumeCursor. | You only want events from now on. |
curl -G https://api.wingspan.app/v3/platform/events \
-H "Authorization: Bearer $WINGSPAN_TOKEN" \
--data-urlencode "scope[type]=Account" \
--data-urlencode "scope[id]=Qm4xT8vLp2RzW6nYc0aBd3" \
--data-urlencode "scope[shouldIncludeDescendants]=false" \
--data-urlencode "seek=Head"{
"data": [],
"pagination": { "nextPageToken": "" },
"resumeCursor": "c2VlazpoZWFkOjE3OTAyNjI2MDI..."
}Save resumeCursor. Don't send seek and page[token] together; that returns 422.
Step 2: Read from your cursor
curl -G https://api.wingspan.app/v3/platform/events \
-H "Authorization: Bearer $WINGSPAN_TOKEN" \
--data-urlencode "scope[type]=Account" \
--data-urlencode "scope[id]=Qm4xT8vLp2RzW6nYc0aBd3" \
--data-urlencode "scope[shouldIncludeDescendants]=false" \
--data-urlencode "page[size]=100" \
--data-urlencode "page[token]=$RESUME_CURSOR"// (trimmed)
{
"data": [
{
"id": "5f0c1d7e9a2b4c6d8e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d",
"type": "Invoice.Paid",
"routingSubject": { "type": "Account", "id": "Qm4xT8vLp2RzW6nYc0aBd3" },
"createdAt": "2026-09-24T14:30:02Z",
"eventCreatedAt": "2026-09-24T14:30:00Z",
"occurrenceId": "9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c2b1a0f9e8d",
"data": { "id": "H7kP2wQx9LmN4vRt6yZa1b" }
}
],
"pagination": { "nextPageToken": "" },
"resumeCursor": "ZXZ0OjE3OTAyNjI2MDI6NWYwYzFk..."
}page[size] is 25 by default and at most 100. Events in the log are the same events push sends, with the same id, so your dedupe store handles both.
Step 3: Process, save, repeat
For each page:
- Process each event the same way you process a push: skip any
idyou've already handled, then fetch the resource bydata.id. - Save the new
resumeCursorafter the page is processed. If you save it first and then crash, you'll skip events. - If
pagination.nextPageTokenis not"", more events are waiting. Call again right away with the newresumeCursor. - If
pagination.nextPageTokenis"", you've caught up. Wait, then poll again with the savedresumeCursor.
Here's that loop in Node.js:
async function drainEventLog(scope, loadCursor, saveCursor, handleEvent) {
let cursor = await loadCursor();
for (;;) {
const params = new URLSearchParams({
'scope[type]': scope.type,
'scope[id]': scope.id,
'scope[shouldIncludeDescendants]': String(scope.shouldIncludeDescendants),
'page[size]': '100',
});
if (cursor) params.set('page[token]', cursor);
else params.set('seek', 'Beginning');
const response = await fetch(`https://api.wingspan.app/v3/platform/events?${params}`, {
headers: { Authorization: `Bearer ${process.env.WINGSPAN_TOKEN}` },
});
if (response.status === 409) throw new Error('EventCursorExpired: resynchronize (see below)');
if (!response.ok) throw new Error(`Event log read failed: ${response.status}`);
const page = await response.json();
for (const event of page.data) await handleEvent(event); // dedupes on event.id
cursor = page.resumeCursor;
await saveCursor(cursor);
if (page.pagination.nextPageToken === '') return; // caught up
}
}Run it on a schedule as a backstop to push, and always after an outage on your side.
Tip: Keep one cursor per scope and filter combination. A cursor from one scope can't be used with another.
Narrow what you read
You can filter by event type and by recording time:
| Filter | Example |
|---|---|
filter[type][eq] | filter[type][eq]=Invoice.DepositConfirmed |
filter[createdAt][gte], filter[createdAt][lte] | filter[createdAt][gte]=2026-09-20T00:00:00Z |
filter[createdAt] filters on when Wingspan recorded the event, not eventCreatedAt. Filters become part of the cursor, so keep them the same for every call with that cursor.
When you receive both parties' copies
Payments events such as Invoice.Paid are sent to the payer's Account and the payee's Account as two separate events. If your scope covers both Accounts (for example, an Organization scope with shouldIncludeAccounts: true), the log returns only one of them per change, grouped by occurrenceId. Push sends one delivery per subscription for each change. See Event types for which events go to both parties.
Get a single event
GET /v3/platform/events/{eventId} returns one event by its id, while it's still in the 30-day log. Use it when a delivery diagnostic gives you an eventId and you want the event itself.
A missing, expired, or not-visible event all return the same 404, so the ID can't be used to probe for data you can't see.
When a cursor expires
If your cursor is older than 30 days, or doesn't match the scope and filters you sent, you get 409 with code: EventCursorExpired:
{
"type": "https://api.wingspan.app/errors/event-cursor-expired",
"title": "Event Cursor Expired",
"status": 409,
"detail": "The event cursor can no longer be resumed; acquire a head checkpoint, resynchronize resources, then resume after it.",
"code": "EventCursorExpired",
"extensions": { "reason": "RetentionExpired" },
"requestId": "req_01HZ7W5N6QK2V9J4T3E8R1M0PA"
}extensions.reason is RetentionExpired (the cursor is too old) or BindingMismatch (the scope or filters changed). For BindingMismatch, first check that you're sending the same scope and filters as before. Otherwise, resynchronize in this order:
- Call with
seek=Headand save the newresumeCursor. Do this first. - Resynchronize from the resource APIs. For example, list payables or invoices changed in the period you missed, and update your records.
- Resume reading the log from the cursor you saved in step 1.
Taking the cursor before the resync means any change that happens during the resync shows up in the log afterward. You might see a few of those changes twice. Your dedupe and "fetch the resource" steps make that harmless.
What can go wrong
Status and code | Why | What to do |
|---|---|---|
409 EventCursorExpired | The cursor is past 30 days, or the scope or filters changed. | See When a cursor expires. |
403 | Your token can't read events for this scope. A Person scope must be your own Person ID. | Use a token with platform.event:read on the scope. |
422 | seek and page[token] were sent together, or a parameter is malformed. | Send one or the other. |
404 ResourceNotFound | On GET /v3/platform/events/{eventId}: the event doesn't exist, has expired, or isn't visible to you. | Check the ID and scope. |
Related pages
Updated 10 days ago