Skip to content

Architecture

A timed-out invoice request should not create a second invoice

How idempotency keys, database constraints and an outbox make invoice posting safe when a browser or service retries a request.

By · Published · 7 min read

A request can succeed even when the browser sees a timeout. The server may have committed an invoice and then lost its connection before the response reached the till. A user who presses “try again” is making a reasonable choice with incomplete information. If the server treats that retry as a new instruction, one sale can become two invoices.

This is a failure of communication, not proof that the first operation failed. The server and client disagree about what happened. An invoice endpoint needs a way to recognise that a repeated request is the same business action.

Give one intended action a stable identity

The client generates an idempotency key for the operation and sends it with the request. The key is unique within a defined scope, usually the organisation and operation type. The server records the key, a hash of the normalized request, the final invoice identifier and the response needed to answer a retry.

A unique database constraint on that scope and key is essential. Application code that first checks whether a key exists and then inserts it can race: two requests may both see no row and both continue. The database constraint makes one request the owner of that key. The other waits or receives the already-recorded outcome.

Commit the invoice and key together

The idempotency record and the business changes should have a clear transaction boundary. For an invoice, that can include the invoice header and lines, stock movements, ledger postings and document number allocation. If the transaction rolls back, there should be no completed idempotency record claiming success. If it commits, a later request with the same key should return the original invoice result.

Store enough information to reject key reuse with different input. A client bug that sends the same key for a changed cart should produce a clear conflict, not a response for an unrelated invoice. Be deliberate about how long keys are retained: financial records may need protection from duplicate submission long after a short-lived browser session has ended.

What should the retry response contain?

For a completed request, return the same invoice ID and a stable representation of its outcome. For an operation still being processed, wait briefly or return a retryable status with instructions to use the same key. Do not tell the client to create a fresh key merely because the first response was slow; that would defeat deduplication.

External services need their own delivery record

A database transaction cannot usually include email, a tax portal or a payment provider. If invoice posting triggers an external call, save an outbox event in the same transaction as the invoice. A worker sends it later and records each attempt. This keeps the invoice durable even when the external service is unavailable, while giving the integration a separate retry and reconciliation path.

The receiving service should get a stable event ID too. Delivery is commonly at-least-once: a worker can time out after the receiver processed an event, then retry it. The receiver's deduplication is what prevents the repeated delivery from repeating the external action.

How should a team test retry behavior?

Test a response lost after commit, two concurrent requests with the same key, a retry with changed input, a transaction failure before commit and a delayed external delivery. Confirm that one intended action produces one invoice and that operators can see what happened. “The endpoint returned 200 once” is not a test of retry safety.

Does idempotency mean exactly-once processing?

No. It makes a repeated request safe within the system that stores and enforces the key. Across database and external-service boundaries, design for repeated delivery and deduplicate at each boundary. The useful guarantee is precise: the same scoped key and same request do not apply the invoice operation twice.

What if a caller reuses a key with different invoice data?

Reject it and log enough context to diagnose the client. Silently returning the earlier invoice would hide a caller defect and could make the user think different data was posted.

References

Author

Raktim Ranjit is a software engineer and the founder of NodeDR Infotech. He builds and maintains the software described here.

Have something in mind?

Let’s build something useful.

Tell me about the idea, product, or workflow you’re working through.

Tap to say hello