# 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:

```http
ETag: "c3f1a9e2b7d4"
```

| Returns an `ETag` | Operations |
| --- | --- |
| Contacts | [`GET /contacts/{contact_id}`](/api/contacts#retrieve-a-contact), [`POST /contacts`](/api/contacts#create-a-contact), [`POST /contacts/upsert`](/api/contacts#create-or-update-a-contact-by-phone-number), [`PATCH /contacts/{contact_id}`](/api/contacts#update-a-contact) |
| Campaigns | [`GET /campaigns/{campaign_id}`](/api/campaigns#retrieve-a-campaign), [`POST /campaigns`](/api/campaigns#create-a-campaign), [`PATCH /campaigns/{campaign_id}`](/api/campaigns#update-a-campaign), and the start, pause, resume and stop actions |

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}`](/api/contacts#update-a-contact) and [`PATCH /campaigns/{campaign_id}`](/api/campaigns#update-a-campaign) require `If-Match` with the `ETag` from your latest read. First read the contact:

```bash
curl https://voice-api.relationsai.com/v1/contacts/ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1 \
     -H "Authorization: Bearer <token>"
```
```js
const response = await fetch("https://voice-api.relationsai.com/v1/contacts/ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", {
  headers: {
    Authorization: "Bearer <token>",
  },
});
console.log(response.status, await response.json());
```
```python

response = requests.get(
    "https://voice-api.relationsai.com/v1/contacts/ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1",
    headers={
        "Authorization": "Bearer <token>",
    },
    timeout=30,
)
print(response.status_code, response.json())
```

```http
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):

```bash
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"
}'
```
```js
const response = await fetch("https://voice-api.relationsai.com/v1/contacts/ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", {
  method: "PATCH",
  headers: {
    Authorization: "Bearer <token>",
    "If-Match": "<etag>",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    name: "Asha R. Rao",
  }),
});
console.log(response.status, await response.json());
```
```python

response = requests.patch(
    "https://voice-api.relationsai.com/v1/contacts/ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1",
    headers={
        "Authorization": "Bearer <token>",
        "If-Match": "<etag>",
    },
    json={
        "name": "Asha R. Rao",
    },
    timeout=30,
)
print(response.status_code, response.json())
```

```http
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"
}
```

| Situation | Response |
| --- | --- |
| `If-Match` matches the current version | `200 OK` with the updated object and its new `ETag`. |
| `If-Match` missing | [`precondition_required`](/errors/precondition_required) (428). Nothing changed. |
| The object changed since you read it | [`version_conflict`](/errors/version_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`](/errors/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:

```json
{
  "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: [cURL script](/examples/fundamentals/etag-update.sh) · [JavaScript](/examples/fundamentals/etag-update.mjs) · [Python](/examples/fundamentals/etag-update.py)

## Concurrency elsewhere

- **Creates and actions** (`POST`, `DELETE`) are made safe to retry by [idempotency keys](/fundamentals/idempotency), 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`](/errors/campaign_operation_in_progress) with `Retry-After`.
- **Contacts by phone number**: two creates for the same number answer [`contact_already_exists`](/errors/contact_already_exists) for the second one. Use [`POST /contacts/upsert`](/api/contacts#create-or-update-a-contact-by-phone-number) 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.
