Ask Anvil

Answers to questions about automating PDFs, e-signatures, Webforms, and other paperwork problems.
E-signatures
Categories

Why do my e-signature webhook events arrive out of order, and how do I fix it?

Your integration marks a signature request as Completed, and a minute later the record flips back to Viewed. Or the countersigned copy never gets generated because the "signed" handler ran after the "completed" handler and reset the state. Nothing is wrong with the provider or your parser. Your handler is trusting arrival order.

Why it happens

Webhook notifications are separate HTTP requests, and nothing forces them to reach you in the order the events happened. A delivery that failed or timed out gets retried later, so an old "viewed" event can land after a fresh "completed" one. Deliveries can also go out in parallel, and network latency or your own load balancer can reorder them further. Some providers say this outright in their webhook docs: notifications may not arrive in the order they occur, because of retries, network conditions, or internal processing.

If your handler does UPDATE status = payload.status, the last request to arrive wins, whether or not it describes the latest event.

The fix: make status move forward only

Model the lifecycle as ordered steps and refuse any event that would move a record backward. Use the timestamp for when the event occurred, not when the notification was sent or received, since retries change the latter.

// Higher rank = later in the lifecycle. Terminal states share the top rank.
const RANK = { sent: 1, viewed: 2, signed: 3, completed: 4, declined: 4, voided: 4 };
const TERMINAL = 4;

function applyEvent(record, event) {
  const next = RANK[event.status];
  const current = RANK[record.status] ?? 0;
  const isOlder = Date.parse(event.occurredAt) <= Date.parse(record.lastEventAt);

  if (next === undefined) return record;             // unknown status: ignore it
  if (current === TERMINAL) return record;           // terminal: never overwrite
  if (next < current) return record;                 // stale: would move backward
  if (next === current && isOlder) return record;    // same step, older copy

  return { ...record, status: event.status, lastEventAt: event.occurredAt };
}

An in-memory check is not enough when two deliveries hit two app servers at once. Put the same rule in the database write so the check and the update happen atomically:

UPDATE documents
SET status = $1, status_rank = $2, last_event_at = $3
WHERE id = $4
  AND status_rank < 4
  AND (status_rank < $2 OR (status_rank = $2 AND last_event_at < $3));

If the statement updates zero rows, the event was stale or the document already reached a terminal state. Acknowledge it with a 2xx anyway, so the provider stops retrying.

Caveats

Multi-signer requests need per-signer state. "Signer 2 viewed" arriving after "Signer 1 signed" is not stale, so rank events per recipient, and treat only the whole-document completion as terminal.

Before a high-stakes step like releasing funds or filing the signed PDF, fetch the current status from the provider's API instead of acting on the payload alone. The webhook tells you something changed; the API tells you what is true now. Combine this with deduplication by event ID, since the same retries that reorder events also deliver duplicates.

Back to All Questions

The fastest way to build software for documents

Anvil Document SDK is a comprehensive toolbox for product teams launching document flows where PDF filling, signing, and complex conditional scenarios are necessary.
Explore Anvil
Anvil Webforms
Marketing Mode