A payment API can create a resource successfully while the work behind that resource is still pending. If the test stops at 201 Created, it has checked resource creation, not the completion of the payment workflow.
I'm the developer of Lucidra, a desktop workspace for API and integration work. This is one of the problems behind its scenario testing. The example below is independent of Lucidra: you can run it locally without an account, a payment provider or any dependencies.
We will check three separate facts: the request created the expected resource, a matching completion event arrived with the expected payload, and the API eventually reported the final state.
The race is easy to introduce
A tempting sequence is to send the request, extract its ID, and then begin listening for a webhook. A fast callback can arrive before the listener is ready. Increasing the timeout will not recover an event the test already missed.
Instead, start a fresh receiver first and buffer events. Once the creation response gives us an ID, search that buffer for the matching event. An event for another payment must not make this test pass.
The fixture deliberately makes this awkward: it sends an unrelated event first, then the correct event, both before returning the creation response. It also keeps the final API state pending for two reads. These are controlled teaching conditions, not measurements of a real payment service.
Run both outcomes
Save the code below as payment-flow.mjs. It uses Node's built-in HTTP server, assertions, timers, fetch and AbortSignal.timeout. I ran both commands locally with Node 20.15.1; use a maintained Node release for your own setup.
node payment-flow.mjs
node payment-flow.mjs --missing-webhook
The first command exits with code 0. The second intentionally exits with code 1. Both receive a 201 response. Only the first can prove that the expected event arrived and that the resource reached paid.
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { once } from 'node:events';
import { setTimeout as sleep } from 'node:timers/promises';
// Local teaching fixture, not a real payment service or Lucidra UI recording.
const missing = process.argv.includes('--missing-webhook');
const events = [];
let state = 'pending';
let reads = 0;
const started = performance.now();
const log = (text) => console.log(`${((performance.now() - started) / 1000).toFixed(2)}s ${text}`);
const json = (res, code, data) => {
res.writeHead(code, { 'content-type': 'application/json' });
res.end(JSON.stringify(data));
};
async function body(req) {
let text = '';
for await (const chunk of req) text += chunk;
return JSON.parse(text);
}
async function listen(server) {
server.listen(0, '127.0.0.1');
await once(server, 'listening');
return `http://127.0.0.1:${server.address().port}`;
}
async function close(server) {
const done = new Promise((resolve) => server.close(resolve));
server.closeAllConnections();
await done;
}
async function http(url, options = {}) {
return fetch(url, { ...options, signal: AbortSignal.timeout(1000) });
}
async function waitForEvent(paymentId, timeoutMs) {
const deadline = performance.now() + timeoutMs;
while (performance.now() < deadline) {
const event = events.find((e) => e.type === 'payment.completed' && e.paymentId === paymentId);
if (event) return event;
await sleep(50);
}
throw new Error('Timed out waiting for matching payment.completed');
}
const inbox = createServer(async (req, res) => {
try {
if (req.method !== 'POST' || req.url !== '/events') return json(res, 404, {});
events.push(await body(req));
json(res, 200, { received: true });
} catch { json(res, 400, { error: 'Invalid event' }); }
});
const inboxUrl = await listen(inbox);
log('READY listener is armed before POST /payments');
const api = createServer(async (req, res) => {
try {
if (req.method === 'POST' && req.url === '/payments') {
// A wrong event arrives first. It must not satisfy this run.
const ids = missing ? ['pay_other'] : ['pay_other', 'pay_demo_42'];
for (const paymentId of ids) {
const delivery = await http(`${inboxUrl}/events`, {
method: 'POST', body: JSON.stringify({
type: 'payment.completed', paymentId, amount: 4200, currency: 'USD',
}),
});
assert.equal(delivery.status, 200);
}
// Deliberately deliver before the creation response reaches the test.
return json(res, 201, { id: 'pay_demo_42', state: 'pending' });
}
if (req.method === 'GET' && req.url === '/payments/pay_demo_42') {
// Deterministic eventual-state fixture, not a latency measurement.
if (++reads >= 3) state = 'paid';
return json(res, 200, { id: 'pay_demo_42', state });
}
json(res, 404, {});
} catch { json(res, 500, { error: 'Fixture failed' }); }
});
const apiUrl = await listen(api);
try {
const created = await http(`${apiUrl}/payments`, { method: 'POST' });
assert.equal(created.status, 201);
const payment = await created.json();
log(`HTTP 201 Created | ${payment.id} | state=pending`);
log('CHECK HTTP success alone does not prove completion');
const event = await waitForEvent(payment.id, 1500);
assert.equal(event.amount, 4200);
assert.equal(event.currency, 'USD');
log(`EVENT ${event.type} | ${event.paymentId}`);
log('MATCH pay_other ignored; correlated payload verified');
let paid = false;
for (let attempt = 1; attempt <= 5; attempt++) {
const response = await http(`${apiUrl}/payments/${payment.id}`);
assert.equal(response.status, 200);
const result = await response.json();
assert.equal(result.id, payment.id);
log(`POLL ${attempt}/5 | state=${result.state}`);
if (result.state === 'paid') { paid = true; break; }
if (attempt < 5) await sleep(250);
}
assert.ok(paid, 'Final state did not become paid within five attempts');
log('PASS request + matching event + final state');
} catch (error) {
log(`FAIL ${error.message}`);
process.exitCode = 1;
} finally {
await close(api);
await close(inbox);
log('CLEAN local servers closed');
}
What the passing result proves
In the passing run, the receiver contains two events, but the match requires both payment.completed and the ID returned by this request. The test then checks amount and currency. A well-formed event for another payment does not qualify.
Only after that does it poll the API. There are at most five reads, a one-second timeout on each HTTP request, and 250 milliseconds between unsuccessful reads. The webhook wait has its own 1.5-second limit. These are small local-demo budgets; a real system needs budgets derived from its contract and environment, plus an overall run deadline.
The final check verifies the returned resource ID as well as its state. Checking only state === 'paid' could pass against the wrong resource if the test constructed its URL incorrectly.
The fixture changes state on the third read to make the result reproducible. A real service changes state because of its processing; reading the resource should not drive that transition.
Why the failing run matters
The --missing-webhook flag suppresses the matching event while keeping the unrelated event and the successful creation response. The test fails with:
FAIL Timed out waiting for matching payment.completed
CLEAN local servers closed
That negative case gives the happy-path result meaning. If removing the callback does not make the test fail, the test is probably checking less than its name suggests.
Other useful negative cases are a matching ID with the wrong amount, a final state that never leaves pending, and a delivery from a previous run. Give parallel runs distinct resource identifiers and isolated capture scopes; do not reuse this demo's fixed ID against a shared environment.
Keep the scope honest
This is a small fixture with trusted data bound to loopback. It does not implement payment processing, webhook authentication, a persistent event queue, duplicate suppression or retry-safe writes. It is not a production receiver.
For a real webhook endpoint, validate the sender according to the provider's contract, handle delivery IDs and redelivery deliberately, and avoid processing the same business action twice. GitHub's webhook guidance gives concrete examples of secrets, event checks and delivery identifiers for its own webhook format; other providers have different contracts.
Cleanup belongs in finally, so a missing event does not leave the local servers running. With a real API, cleanup may also need to delete test resources. Record cleanup failures separately so they do not conceal the original assertion failure.
For debugging, preserve the correlation ID, request result, selected event, poll attempts and failed assertion. Redact credentials and sensitive payload fields before keeping or sharing that evidence.
Where this fits in Lucidra
In Lucidra, Send is where an individual request or protocol connection belongs. Receive handles incoming traffic. A multi-step flow belongs in Tests, where a scenario can connect listener setup, saved requests, webhook assertions, bounded polling and cleanup.
That separation matters: choosing WebSocket should open a connection workflow, not require creating a scenario. Scenarios become useful when the thing being tested spans several operations and incoming events.
Lucidra also has a visual Git client for whole repositories, so the source change and a regression definition can be reviewed in the same application. It does not make a passing test proof that every possible integration failure has been covered.
If you want to try the workspace, personal workspaces are free and do not require an account. The source repository is private. Installers and release notes are available for Debian/Ubuntu x86_64, Windows x86_64, and macOS Intel and Apple Silicon.
Which failure has been hardest to make reproducible in your integration tests: an early callback, a duplicate delivery, incorrect correlation, or a final state that never arrives?
Prepared with AI assistance. The example was executed locally in both passing and intentionally failing modes.
Top comments (0)