Payments are experiencing issues due to temporary restrictions in Russia. If your payment does not go through, please submit a support request.Our support team is available 24/7 — we are always here to help with hosting and server issues.We are now accepting requests for dedicated server rental and colocation services in our data center.Reminder: we recommend enabling backups for additional data protection.A new VPS/VDS lineup with NVMe storage and improved performance is now available.Maintenance work on some servers has been completed. All services are operating normally.
Article11 min readViews0

ETag and If-Match: How to Avoid Overwriting Stock During Dual API Updates

A practical guide to protecting APIs from stale records: two clients read the same product, the first updates the stock, the second receives a 412 and retries the update only after a new GET.

Comments 0

Two scanners target a single package: concurrent product updates require version checks
In this article

If two clients read the same product and then send full updates, the later request might return an old field value that the first client already changed. If the API provides a strong ETag, the client includes it in the If-Match header during a PUT or other mutating request. A mismatched tag blocks the write: the client receives a 412, re-reads the resource, and only then decides whether to merge the changes.

In a simple example, both clients read a price of 1,500 rubles and a stock of 10. Client A updates the stock to 8. Client B still holds the old snapshot and wants to change the price to 1,400 rubles. A standard full PUT from B could inadvertently restore the stock to 10 alongside the new price. A conditional request prevents this silent overwrite.

What ETag and If-Match Actually Check

In RFC 9110, If-Match is described as a condition for mutating methods: the server compares the received entity tag with the current one and checks the condition before executing the method. Strong comparison is used for If-Match because the client wants to prevent the write if the representation has changed in any way.

  • An ETag is an opaque identifier for a specific resource representation. Clients must not extract version numbers or timestamps from it.

  • A strong tag looks like "v2". A weak tag starts with W/ and does not match during a strong If-Match comparison.

  • If-Match: "v2" means: perform the update only if the current representation still has this tag.

  • A 412 response means the provided condition is false. This is not a command to retry the same request after a few seconds.

Two Clients and One Stale Snapshot

State changes in this order: the second write succeeds only after a new GET and conscious merging. In this educational scenario, field ownership is predefined: A manages stock, B manages price. Without such a rule, automatic merging would be guessing with good posture.

Step

Request

Result

Current state

1

GET A and GET B

both receive ETag "v1"

price 1 500; remaining 10

2

PUT A with If-Match "v1"

200; new ETag "v2"

price 1 500; remaining 8

3

PUT B with old If-Match "v1"

412; record not updated

price 1 500; remaining 8

4

GET B, then PUT with If-Match "v2"

200; new ETag "v3"

price 1 400; remaining 8

Two clients receive ETag v1: the first updates the stock and creates v2; the second, with the old tag, receives 412, then re-reads the resource and creates v3.
Learning sequence: an outdated If-Match stops the write, and the new PUT is built only after a subsequent GET and field verification.

If the version check is removed, the third step writes body B entirely: the price changes, and the stock reverts from 8 to 10. The server responds with success, although one confirmed fact is already lost.

Reproducible local testbed

Verification performed on October 16, 2026, on CPython 3.12.14 using only the standard library. The server listens on a random port of the local loopback interface, does not access the external network, and stores a single learning record in memory. After completion, the process stops; the example is not connected to a live store.

from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from threading import Thread from urllib.error import HTTPError from urllib.request import Request, urlopen import json state = {"price": 1500, "stock": 10, "version": 1} def etag() -> str: return f'"v{state["version"]}"' class Handler(BaseHTTPRequestHandler): def do_GET(self) -> None: body = json.dumps( {"price": state["price"], "stock": state["stock"]} ).encode() self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.send_header("ETag", etag()) self.end_headers() self.wfile.write(body) def do_PUT(self) -> None: expected = self.headers.get("If-Match") if expected != etag(): self.send_response(412) self.send_header("ETag", etag()) self.end_headers() return length = int(self.headers["Content-Length"]) payload = json.loads(self.rfile.read(length)) state["price"] = payload["price"] state["stock"] = payload["stock"] state["version"] += 1 body = json.dumps( {"price": state["price"], "stock": state["stock"]} ).encode() self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.send_header("ETag", etag()) self.end_headers() self.wfile.write(body) def log_message(self, format: str, *args: object) -> None: pass

def get(url: str) -> tuple[dict, str]: with urlopen(url) as response: return json.load(response), response.headers["ETag"] def put(url: str, payload: dict, tag: str | None) -> tuple[dict, str]: headers = {"Content-Type": "application/json"} if tag is not None: headers["If-Match"] = tag request = Request( url, data=json.dumps(payload).encode(), method="PUT", headers=headers, ) with urlopen(request) as response: return json.load(response), response.headers["ETag"]

server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) thread = Thread(target=server.serve_forever, daemon=True) thread.start() url = f"http://127.0.0.1:{server.server_port}/product/42" try: client_a, tag_a = get(url) client_b, tag_b = get(url) assert client_a == client_b == {"price": 1500, "stock": 10} assert tag_a == tag_b == '"v1"' updated_a, tag_v2 = put(url, {"price": 1500, "stock": 8}, tag_a) assert updated_a == {"price": 1500, "stock": 8} assert tag_v2 == '"v2"' try: put(url, {"price": 1400, "stock": 10}, tag_b) except HTTPError as error: assert error.code == 412 assert error.headers["ETag"] == '"v2"' else: raise AssertionError("stale update was accepted") current, current_tag = get(url) assert current == {"price": 1500, "stock": 8} assert current_tag == '"v2"' for invalid_tag in (None, 'W/"v2"'): try: put(url, {"price": 1400, "stock": 8}, invalid_tag) except HTTPError as error: assert error.code == 412 assert error.headers["ETag"] == '"v2"' else: raise AssertionError("invalid precondition was accepted") merged, tag_v3 = put(url, {"price": 1400, "stock": 8}, current_tag) assert merged == {"price": 1400, "stock": 8} assert tag_v3 == '"v3"' try: put(url, {"price": 1400, "stock": 10}, tag_b) except HTTPError as error: assert error.code == 412 assert error.headers["ETag"] == '"v3"' else: raise AssertionError("repeated stale update was accepted") print('GET A/B -> {"price": 1500, "stock": 10}, ETag "v1"') print('PUT A If-Match "v1" -> 200, stock 8, ETag "v2"') print('PUT B If-Match "v1" -> 412, state unchanged') print('GET B -> {"price": 1500, "stock": 8}, ETag "v2"') print('PUT without If-Match -> 412, state unchanged') print('PUT If-Match W/"v2" -> 412, state unchanged') print('PUT B If-Match "v2" -> 200, price 1400, stock 8, ETag "v3"') print('repeat stale If-Match "v1" -> 412, state unchanged') finally: server.shutdown() server.server_close()

GET A/B -> {"price": 1500, "stock": 10}, ETag "v1" PUT A If-Match "v1" -> 200, stock 8, ETag "v2" PUT B If-Match "v1" -> 412, state unchanged GET B -> {"price": 1500, "stock": 8, ETag "v2" PUT without If-Match -> 412, state unchanged PUT If-Match W/"v2" -> 412, state unchanged PUT B If-Match "v2" -> 200, price 1400, stock 8, ETag "v3" repeat stale If-Match "v1" -> 412, state unchanged

The process completed with code 0. Eight observations were verified: identical v1 for two clients, successful update A, rejection of old B, stock preservation, rejection of requests without If-Match, rejection of weak tags, successful merge by v2, and repeated rejection of old v1 after v3 appeared.

Why 412 cannot be blindly retried

Description of status 412 in RFC 9110 links it to a false condition on the current resource state. A pause fixes nothing: the old tag will not become new on its own. The client needs a fresh snapshot and a business decision.

  1. Retrieve the current representation and the new ETag.

  2. Compare fields from the old snapshot, the local change, and the current resource.

  3. Merge only those fields for which a compatible rule was defined in advance.

  4. Show the conflict to a human or stop the package if both parties modified the same business fact.

  5. Send a new request with If-Match based on the re-read representation.

Edge cases that change the result

  • If-Match is missing. In the test server, the modifying request is rejected with 412. In a real API, the header requirement must be part of the contract.

  • W/"v2" arrived. A weak tag fails a strong If-Match comparison; the example confirms the 412 rejection.

  • If-Match: * was used. This checks for the existence of the current representation but does not protect against overwriting a newer version.

  • The response to the successful PUT was lost. The RFC allows separate behavior if the server can prove the change was already applied; check the specific contract rather than assuming.

  • The ETag changes due to a different representation or serialization method. The client stores the tag only for the specific resource and response variant to which the update applies.

There is a catch: ETag protects the HTTP request boundary, but it does not decide who owns the price, stock, or description. It detects a state mismatch; the conflict resolution rule remains part of the integration.

How to apply this to a store integration

For 1C-Bitrix: Site Management, this mechanism is appropriate only where the actual external API documents ETag and If-Match for mutating operations. Do not assume that any REST method or exchange with 1C supports conditional writes. First, record the product, API version, resource, method, permissions, and the exact response contract for 412.

If the dispute concerns the quantity of goods, first determine where the final stock count resides. Version checking does not turn two independent systems into two equal sources of truth.

Contract clause

What to document before development

Resource

product, product variant, or stock balance

Representation

which fields are included in the version and when the ETag changes

Method

PUT, PATCH, or another documented mutating request

Condition

whether If-Match is required and whether tag lists are accepted

Conflict

412, error body, new ETag, and the need to repeat the GET

Merge

which fields can be merged automatically and which require stopping

Log

Object ID, old and new tags, result without secrets and personal data

Conditional write and idempotency of repeated webhooks solve different problems. Idempotency protects a single business outcome from repeated delivery, while If-Match prevents applying a change to an unexpected version of the resource. A reliable integration may require both mechanisms.

Acceptance protocol

  1. Create an anonymized product or placeholder with a price of 1,500 and a stock of 10; obtain a strong ETag with two clients.

  2. Change the balance with the first client and confirm the new ETag.

  3. Send the old full snapshot with the second client; expect 412 and an unchanged balance of 8.

  4. Repeat the old request without a pause and after a pause; the state must not change.

  5. Check the request without If-Match, with a weak tag, and with If-Match: * according to the actual API contract.

  6. Re-read the resource, apply the allowed merge rule, and send a new tag; expect a price of 1,400 and a balance of 8.

  7. Run two updates of the same field simultaneously and prove by the final state and log that the conflict was not hidden.

Consider the check passed not by two green HTTP responses, but by preserved business facts. After a 412 refusal, the old body must not enter storage; a new GET must return the actual state, and a retry is possible only with a new tag and a clear rule.

Example boundaries

The test server is intentionally strict: any missing, weak, or stale If-Match receives a 412. This is a verifiable model, not a promise of behavior for a specific API. It does not show authorization, field validation, main database transactions, caching, proxies, or distributed locks.

Before deployment, verify the documentation and test environment of the selected service. If the API does not provide a strong ETag for a mutable resource, you need another documented version number, a condition in the request body, or server-side concurrent update checking. Simply adding an arbitrary header from the client will not work: the server must protect the record.

Discussion 0

Share your experience and ask questions. Comments without links appear after editorial review.

No comments yet. Start the discussion.