A payment screen returns to your app with a success label. That is an event your interface can observe. The service responsible for verifying the payment may still be processing its result.
Keep those two facts separate. A return can prompt your app to check status; it should not independently replace the authoritative result. Closing the provider window and reaching a client wait limit are also observations, rather than proof of payment failure or cancellation.
This distinction matters when confirmation arrives later, a status update is delivered twice, or an old response arrives after a newer one. Stripe’s Checkout fulfillment guide explains why fulfillment cannot rely only on a landing-page visit and why server processing must handle repeated calls for the same checkout.
Choose clear display states
The example uses four application states. These are a teaching model, not a claim that every payment API returns these exact values.
| State | What the interface can say |
|---|---|
| pending | Waiting for an authoritative result, or the server still reports processing. |
| succeeded | The authoritative source reports success. |
| failed | The authoritative source reports failure. |
| cancelled | The authoritative source reports cancellation. |
Notice that a closed browser window does not assign cancelled. A local timeout does not assign failed. Keep enough context to explain what happened without turning uncertainty into a financial result.
For the mock, an authoritative update contains a checkout identifier, a positive integer revision, and one of those states. The imaginary backend owns the revision counter and publishes newer snapshots with larger values. These fields and the ordering contract are invented for this example. Adapt ordering to your actual backend; do not assume a provider event identifier or timestamp is an equivalent revision.
Run the local simulation
Save the complete sample as payment-states.html and open it in a modern browser. No build, account, credential, payment provider, network call, or personal information is needed.
Start by pressing Provider returns “success.” The payment stays pending. Press Confirm after 1.2 seconds to see a separate mock server update arrive later. Use the four server buttons to inspect each state.
The controls deliberately let you supply authoritative-looking values locally. They demonstrate rendering and ordering, not a secure payment protocol. A production client must receive its result through the backend or SDK responsible for verification; this sample provides no authentication, webhook verification, or payment processing.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Payment confirmation state demo</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; color: #20342e; background: #f4f6f2; font: 16px/1.6 system-ui, sans-serif; }
main { max-width: 940px; margin: auto; padding: 40px 20px; }
h1, h2 { line-height: 1.2; }
h1 { font-size: clamp(28px, 5vw, 42px); margin: 8px 0 16px; }
h2 { font-size: 20px; margin-top: 0; }
p { max-width: 70ch; }
.eyebrow, small { color: #526a60; }
.panel { padding: 24px; background: white; border: 1px solid #ced9d0; border-radius: 12px; margin: 20px 0; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr)); gap: 20px; }
.grid .panel { margin: 0; }
#state { font-size: 28px; font-weight: 750; text-transform: capitalize; }
#state[data-status="succeeded"] { color: #17623d; }
#state[data-status="failed"] { color: #8a302b; }
#state[data-status="cancelled"] { color: #635241; }
button { font: inherit; color: #20342e; background: #eef3ef; border: 1px solid #9fb5a8; border-radius: 6px; padding: 10px 14px; margin: 5px 5px 5px 0; cursor: pointer; }
button:focus-visible { outline: 3px solid #c08b27; outline-offset: 3px; }
#delay { background: #235b43; color: white; }
#log { padding-left: 24px; overflow-wrap: anywhere; }
#log li { margin: 6px 0; }
code { overflow-wrap: anywhere; }
</style>
</head>
<body>
<main>
<div class="eyebrow">Fictional local demo · no payments or network calls</div>
<h1>A return is not a confirmation</h1>
<p>Provider events describe the payment screen. Mock server updates decide the payment state. The example uses a hypothetical versioned status contract, not a payment API.</p>
<section class="panel" aria-label="Current payment state">
<div id="status-region" role="status" aria-live="polite">
<div id="state" data-status="pending">Pending</div>
<p id="message">Waiting for server confirmation.</p>
<small id="revision">No server update accepted yet.</small>
</div>
<p id="observation">No provider event observed.</p>
<small>Fictional checkout: <code>demo-001</code>. Reloading or Reset restores the initial state.</small>
</section>
<div class="grid">
<section class="panel" aria-labelledby="provider-title">
<h2 id="provider-title">Provider and client events</h2>
<p>These events do not prove that money arrived or that a checkout was cancelled.</p>
<button id="provider-return" type="button">Provider returns “success”</button>
<button id="provider-close" type="button">Close provider window</button>
<button id="wait-limit" type="button">Client wait limit reached</button>
</section>
<section class="panel" aria-labelledby="server-title">
<h2 id="server-title">Mock authoritative updates</h2>
<p>Each button delivers a new revision for the same fictional checkout.</p>
<button type="button" data-server="pending">Pending</button>
<button type="button" data-server="succeeded">Succeeded</button>
<button type="button" data-server="failed">Failed</button>
<button type="button" data-server="cancelled">Cancelled</button>
<button id="delay" type="button">Confirm after 1.2 seconds</button>
</section>
</div>
<section class="panel" aria-labelledby="log-title">
<h2 id="log-title">Event log</h2>
<ol id="log"></ol>
<button id="reset" type="button">Reset demo</button>
</section>
<p><small>The status revisions and controls are invented for teaching. A production client must get its authoritative result from its backend or SDK. This page does not authenticate messages, verify a payment, persist data, or contact a provider.</small></p>
</main>
<script>
(() => {
const checkoutId = 'demo-001';
const messages = {
pending: 'Waiting for server confirmation.',
succeeded: 'The mock server reports that payment succeeded.',
failed: 'The mock server reports that payment failed.',
cancelled: 'The mock server reports that checkout was cancelled.',
};
const initial = () => ({ status: 'pending', revision: 0, accepted: 0, returned: false, closed: false, overdue: false });
let state = initial();
let timer;
let generation = 0;
let nextRevision = 0;
const log = document.querySelector('#log');
function record(text) {
const item = document.createElement('li');
item.textContent = text;
log.append(item);
while (log.children.length > 8) log.firstElementChild.remove();
}
function render() {
const label = document.querySelector('#state');
label.textContent = state.status;
label.dataset.status = state.status;
document.querySelector('#message').textContent = messages[state.status];
document.querySelector('#revision').textContent = state.revision
? `Accepted server revision ${state.revision}; accepted updates: ${state.accepted}.`
: 'No server update accepted yet.';
const observations = [];
if (state.returned) observations.push('Provider returned a success label.');
if (state.closed) observations.push('Provider window was closed.');
if (state.overdue) observations.push('Client wait limit was reached.');
document.querySelector('#observation').textContent = observations.join(' ') || 'No provider event observed.';
}
function providerEvent(kind) {
if (!['returned', 'closed', 'overdue'].includes(kind)) return;
state[kind] = true;
record(`Client event: ${kind}. Payment state remains ${state.status}.`);
render();
}
function applyServerStatus(update) {
if (!update || update.checkoutId !== checkoutId ||
!Number.isSafeInteger(update.revision) || update.revision < 1 ||
!Object.hasOwn(messages, update.status)) {
record('Ignored an invalid update or a different checkout.');
return false;
}
if (update.revision <= state.revision) {
record(`Ignored duplicate or stale revision ${update.revision}.`);
return false;
}
state.status = update.status;
state.revision = update.revision;
state.accepted += 1;
record(`Server revision ${update.revision}: ${update.status}.`);
render();
return true;
}
function reset() {
clearTimeout(timer);
generation += 1;
nextRevision = 0;
state = initial();
log.replaceChildren();
record('Demo reset. No authoritative update received.');
render();
}
function makeServerUpdate(status) {
nextRevision = Math.max(nextRevision, state.revision) + 1;
return { checkoutId, revision: nextRevision, status };
}
function delayedConfirmation() {
clearTimeout(timer);
const run = generation;
const processing = makeServerUpdate('pending');
const confirmation = makeServerUpdate('succeeded');
applyServerStatus(processing);
record('Queued a local confirmation for 1.2 seconds later.');
timer = setTimeout(() => {
if (run !== generation) return;
applyServerStatus(confirmation);
}, 1200);
}
document.querySelector('#provider-return').addEventListener('click', () => providerEvent('returned'));
document.querySelector('#provider-close').addEventListener('click', () => providerEvent('closed'));
document.querySelector('#wait-limit').addEventListener('click', () => providerEvent('overdue'));
for (const button of document.querySelectorAll('[data-server]')) {
button.addEventListener('click', () => applyServerStatus(makeServerUpdate(button.dataset.server)));
}
document.querySelector('#delay').addEventListener('click', delayedConfirmation);
document.querySelector('#reset').addEventListener('click', reset);
// Teaching/test hooks for this local simulation, not a production API.
window.paymentDemo = Object.freeze({
applyServerStatus, reset, delayedConfirmation,
snapshot: () => ({ checkoutId, ...state }),
});
reset();
})();
</script>
</body>
</html>
Keep stale updates from replacing current ones
applyServerStatus() first checks the fictional checkout, revision, and state. It rejects an update for another checkout. Then it accepts only a revision greater than the last accepted revision.
Delivering revision 2 twice changes the display once. Delivering an earlier pending revision after a success cannot move the interface backwards. After the first delayed success, use the browser console to call window.paymentDemo.applyServerStatus({ checkoutId: 'demo-001', revision: 2, status: 'succeeded' }) again and see the duplicate rejected. A conflicting payload with the same revision is also rejected. This assumes each revision identifies one stable backend snapshot; a production service must define that contract and its recovery behavior.
A genuinely newer authoritative result can supersede an earlier state. The client does not invent a permanent terminal-state lock. Which transitions are valid belongs to the service that owns the payment lifecycle.
The accepted-update counter makes duplicate behavior visible. It does not prevent duplicate charges or implement backend fulfillment idempotency. Those require server-side controls; a browser counter cannot supply them.
Make delayed work belong to the current run
The delayed-confirmation button supplies a pending snapshot and reserves a separate success revision before scheduling its local timer. A later server button receives a larger revision even while that success is waiting. This models snapshots created in one order and delivered in another. Reset cancels it and advances a generation counter, so an earlier run cannot change the fresh demo. A queued result that is older than an already accepted revision is ignored when it arrives. MDN documents timer cancellation with clearTimeout().
Try returning from the provider, closing its window, and reaching the wait limit before confirmation. Try a newer server failure while a delayed success is queued. Try Reset before the timer fires. Inspect both the displayed result and the event log.
The local automated checks exercise those sequences, duplicate and out-of-order updates, invalid payloads, keyboard activation, and narrow-screen layout. They verify this fictional client only. They do not test a provider, SDK, webhook, actual charge, settlement, or production integration. Reloading restores the initial state because the page stores nothing.
Disclosure: AI generated the article and code example. The local simulation was checked with an automated browser; no human technical review or real-payment test is claimed.
Top comments (0)