4 min read
A repeated request must not create a second order
Idempotency keys, a journal table and a unique constraint: how integrations stay correct when the network fails halfway.
On this page
The shop sends a new order to the ERP. The ERP creates it, and then the connection drops before the answer arrives. The shop sees a timeout and tries again. Now there are two orders, two reservations of the same stock and, a week later, a confused accountant.
Nothing in this story is exotic. It is the normal life of an integration. The protection has three parts: every business operation gets its own idempotency key, the operation is written to a journal in the integration's own database before the ERP is called, and a unique constraint in PostgreSQL refuses the second copy.
Retries happen at every layer
HTTP clients retry on timeouts. Queue consumers get a message again after a restart. Webhooks from marketplaces and CRMs are delivered at least once, which in practice means sometimes twice. Operators press the button again when the screen does not react.
You cannot remove retries, and you should not try: without them a short network glitch becomes a lost order. The job is to make a repeated request harmless.
Idempotency keys: one per business operation
An idempotency key names the operation, not the attempt. A random UUID generated for each HTTP call is useless here, because the retry gets a new one.
Build the key from business identity:
kaspi:order:512344101:createfor creating an order from a marketplace;payment:INV-2026-0418:postfor posting a payment;stock:WH1:SKU-8812:2026-09-16T10:00for a stock snapshot.
The same event always produces the same key, no matter how many times it arrives.
Write the intent before calling the other system
Keep a journal of operations in your own database, and write to it before the ERP sees anything:
CREATE TABLE operations (
key text PRIMARY KEY,
status text NOT NULL CHECK (status IN ('pending', 'done', 'failed')),
result jsonb,
attempts integer NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL DEFAULT now()
);
The primary key does the real work. Two workers that try to start the same operation cannot both insert the row, so the check lives in the database and not in an if that races.
The handler then reads almost like the business rule:
func (s *Sync) CreateOrder(ctx context.Context, o Order) (ERPRef, error) {
key := "kaspi:order:" + o.MarketplaceID + ":create"
op, err := s.journal.Begin(ctx, key) // insert or lock the existing row
if err != nil {
return ERPRef{}, err
}
if op.Status == StatusDone {
return op.Result, nil // already created: answer the same way again
}
ref, err := s.erp.FindOrCreateOrder(ctx, o, key)
if err != nil {
return ERPRef{}, s.journal.Fail(ctx, key, err)
}
return ref, s.journal.Done(ctx, key, ref)
}
A repeated request for a finished operation gets the stored result back. The caller cannot tell whether it was the first attempt or the fifth, and that is the point.
I put a journal into every exchange of orders or payments, even the smallest one. The table is seven lines long. A duplicate payment is untangled by hand, together with the accountant.
When the other side has no idempotency
Many ERPs and CRMs accept no idempotency key at all. Then the integration has to look before it creates:
- Store the external identifier in the target system, for example in a "marketplace order number" field.
- Before creating, search by that field while holding a row lock on the journal row (
SELECT ... FOR UPDATE). - Create only if nothing is found, then mark the operation as done.
The lock matters. Without it two workers search at the same moment, both find nothing and both create.
Check it by breaking it
A test that only runs the happy path proves nothing here. The useful tests cut the connection right after the ERP call, restart the worker in the middle of an operation and send the same webhook twice in parallel. After each of them the count of orders must be exactly one.
Checklist
- The key comes from business identity, not from the attempt.
- The intent is written to the journal before the side effect.
- Uniqueness is a database constraint, not a check in code.
- Where the other system has no idempotency, search before create under a lock.
- A repeated request gets the same answer as the first one.
How this works in an order exchange between a marketplace and an accounting system is shown in How to connect Kaspi.kz with 1C. If you need an exchange like that, packages and prices are on the CRM, ERP and marketplace integration page.
Questions
What is an idempotency key?
An identifier of a business operation, for example kaspi:order:512344101:create. The same event always produces the same key, so a repeated request with it does not create a second record.
Why not use a random UUID for each request?
A retry gets a new UUID, and the system takes it for a new operation. The key has to be built from business data: the order, invoice or warehouse number.
What if 1C does not accept an idempotency key?
Store the external number in a field of the 1C document and search by it before creating, while holding a lock on the journal row. Create the record only if nothing is found.