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