Skip to content
RelationsAI
API fundamentals

Concurrency

When two processes update the same object, one can silently overwrite the other: your CRM sync and a colleague in the console both edit a contact, and the slower write wins. Campaigns and contacts use ETags to prevent that. This page explains the headers, the errors you can get and the read-modify-write loop that handles them.

ETags

Responses that return a campaign or a contact include an ETag header identifying that version:

Text
ETag: "c3f1a9e2b7d4"

The value is opaque: store and send it exactly as received, including the double quotes. It changes whenever the object changes, including changes made in the console and, for a campaign, changes of state while it runs. Only the ETag of a single campaign or contact is meant for If-Match: read the single object before you update it, and do not use an ETag header from any other response (for example a list). A replayed answer (Idempotent-Replayed: true) carries the ETag stored with it, which may be out of date.

If-Match on PATCH

PATCH/contacts/{contact_id} and PATCH/campaigns/{campaign_id} require If-Match with the ETag from your latest read. First read the contact:

curl https://voice-api.relationsai.com/v1/contacts/ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1 \ -H "Authorization: Bearer <token>"
Text
HTTP/1.1 200 OK ETag: "c3f1a9e2b7d4" { "id": "ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", "object": "contact", "livemode": false, "phone_number": "+12025550100", "name": "Asha Rao", "language": "en", "country_code": "US", "consent_granted": true, "do_not_call": false, "custom_fields": { "policy_number": "PN-55012", "due_date": "2026-11-05" }, "created_at": "2026-10-21T08:30:00.000Z" }

Then send the change with that ETag in If-Match (shown as <etag>; send the value exactly as received, quotes included):

curl -X PATCH https://voice-api.relationsai.com/v1/contacts/ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1 \ -H "Authorization: Bearer <token>" \ -H "If-Match: <etag>" \ -H "Content-Type: application/json" \ -d '{ "name": "Asha R. Rao" }'
Text
HTTP/1.1 200 OK ETag: "9b2e47d1a6f0" { "id": "ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", "object": "contact", "livemode": false, "phone_number": "+12025550100", "name": "Asha R. Rao", "language": "en", "country_code": "US", "consent_granted": true, "do_not_call": false, "custom_fields": { "policy_number": "PN-55012", "due_date": "2026-11-05" }, "created_at": "2026-10-21T08:30:00.000Z" }
SituationResponse
If-Match matches the current version200 OK with the updated object and its new ETag.
If-Match missingprecondition_required (428). Nothing changed.
The object changed since you read itversion_conflict (412). Nothing changed.

Both errors have retry: "no": resending the same request fails the same way. PATCH does not take an Idempotency-Key. (PATCH of a webhook endpoint takes neither header.)

If-Match: *

If-Match: * matches any current version: the update is applied without the concurrency check, so it can overwrite a change you have not seen. It still satisfies the header requirement (no 428). Use it only where last-write-wins is what you want, for example a one-off correction by an operator; in automated syncs, send the real ETag.

Other conditional headers

Only campaigns and contacts carry an ETag, and only If-Match on their PATCH is checked. No other response has an automatic ETag, and conditional GET headers (If-None-Match, If-Modified-Since) are ignored: a response is never 304 Not Modified.

Campaigns have one more rule: they can be edited in every status except running (pause first; editing a running campaign answers campaign_state_conflict), and edits take effect at the next start.

Resolving a conflict

If the contact changed between your read and your PATCH (a colleague edited it in the console, for example), the update is refused and nothing changes:

JSONJSON
{ "error": { "code": "version_conflict", "message": "The resource changed since you read it.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/version_conflict" } }

Do not resend your old body with a new ETag: that would overwrite the change you have not seen, which is exactly what the check is there to prevent. Instead:

  1. Read the object again (the same GET as above). Note its new ETag.
  2. Work out your change again from what you just read. It may now be unnecessary.
  3. Send the PATCH with the new If-Match.
  4. Repeat a small number of times (three is plenty); if it keeps conflicting, something else is writing to the object constantly, so stop and report it.

The same loop also handles a lost response: if a PATCH times out, start again from the read. If the first attempt had applied, the read shows your change is already there and nothing more is sent.

The complete runnable example creates a test contact, then reads, changes and updates it with If-Match, reading again after a 412.

Complete runnable example

Update a contact without overwriting a change you have not seen:

The same steps as a complete script, with error handling, tested against test mode on every docs build. Each script reads your test key from an environment variable named in its first lines.

Concurrency elsewhere

  • Creates and actions (POST, DELETE) are made safe to retry by idempotency keys, not ETags.
  • Campaign actions are serialised: only one start or resume runs per campaign at a time, and a second one answers campaign_operation_in_progress with Retry-After.
  • Contacts by phone number: two creates for the same number answer contact_already_exists for the second one. Use POST/contacts/upsert to create or update in one request.
  • Events can arrive out of order. Use the object's own status and timestamps, not the arrival order, to decide which is newer.