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:read permission 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:

seekStarts atUse it when
Beginning (default)The oldest event still kept, up to 30 days back.You want the history.
HeadNow. 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:

  1. Process each event the same way you process a push: skip any id you've already handled, then fetch the resource by data.id.
  2. Save the new resumeCursor after the page is processed. If you save it first and then crash, you'll skip events.
  3. If pagination.nextPageToken is not "", more events are waiting. Call again right away with the new resumeCursor.
  4. If pagination.nextPageToken is "", you've caught up. Wait, then poll again with the saved resumeCursor.

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:

FilterExample
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:

  1. Call with seek=Head and save the new resumeCursor. Do this first.
  2. Resynchronize from the resource APIs. For example, list payables or invoices changed in the period you missed, and update your records.
  3. 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 codeWhyWhat to do
409 EventCursorExpiredThe cursor is past 30 days, or the scope or filters changed.See When a cursor expires.
403Your 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.
422seek and page[token] were sent together, or a parameter is malformed.Send one or the other.
404 ResourceNotFoundOn 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


Did this page help you?