Upsert & Idempotency
Create or update a record by its identity with PUT, and retry writes safely with an Idempotency-Key.
Syncing an external system into Kasar raises the same question on every row: does this record already exist? PUT answers it for you, and an idempotency key makes any retry of a create safe.
Upsert by identity
curl -X PUT https://platform.kasar.app/api/v1/objects/contacts/records \
-H "Authorization: Bearer ksr_svc_..." \
-H "Content-Type: application/json" \
-d '{ "emails": ["jean.dupont@acme.fr"], "name": { "first": "Jean", "last": "Dupont" }, "source": "referral" }'{
"data": {
"id": "8f14e45f-ceea-467f-a34e-3c5d5b9a6c01",
"name": { "first": "Jean", "last": "Dupont", "full": "Jean Dupont" },
"emails": [{ "id": "0b7e3c1a-5d2f-4e8b-9a6c-1f3d5b7e9a20", "email_address": "jean.dupont@acme.fr", "label": null, "is_primary": true }],
"source": "referral",
"created_at": "2026-10-13T09:30:00.127Z",
"updated_at": "2026-10-13T09:30:00.127Z"
}
}PUT takes no {id} in the path and needs {object}:write. Kasar looks for the record with the object's identity rule, the same rule its inbound webhooks and automations use to upsert:
| Records found | What happens | Status |
|---|---|---|
| None | The record is created, as a POST would. | 201 |
| One | That record is updated, as a PATCH would. | 200 |
| Several | Nothing is written. | 409 AMBIGUOUS_IDENTITY |
The response is { "data": <record> }, the record read back after the write, in the same shape as GET. Created or updated is told by the status, not by the body. When your grant can write the object but not read it, data is null.
Values are accepted and refused exactly as on POST and PATCH: an unknown field, a read-only field or a value outside a select's options is refused before anything is written. An update by PUT follows PATCH rules: fields absent from the body are untouched, emails and phones you send are added to the record's.
The identity rule
Each object has its own rule, set in the app (data model, Identity tab). A rule is a list of keys; a key is one field, or several fields that must all match. The rules a workspace starts with:
| Object | Identity keys, in order |
|---|---|
contacts | emails, then phones, then linkedin |
companies | LinkedIn company id, then LinkedIn handle, then domain, then name |
opportunities | none: two deals may share a name |
| a custom object | its unique fields, then its email and phone fields; failing those, its display field |
How a record is recognized:
- The keys are tried in the rule's order, and the first key that finds records decides.
- Text, emails, phones and URLs are compared normalized (case and formatting do not matter). On a list field such as
emails, sharing one value is enough: aPUTwith["jean@acme.fr"]finds the contact that has that address among others. - A key made of several fields applies only when the body gives all of them.
The identity guard
The body must name every field of at least one identity key. Otherwise the API refuses with 422 IDENTITY_KEY_NOT_SATISFIED and lists the keys:
{
"error": {
"code": "IDENTITY_KEY_NOT_SATISFIED",
"message": "No identity key of 'contacts' is fully provided. Keys: [emails], [phones], [linkedin].",
"suggestion": "Provide every field of at least one identity key so the record can be recognized."
}
}This guard is what makes PUT safe to call in a loop: without it, a payload that cannot be recognized would create a new record on every run. An object with no identity rule (opportunities, by default) gets the same 422: configure a rule, or use POST to always create.
The guard checks that the identity field is in the body, not that it holds a value. An empty value ("emails": [], "emails": "", "emails": null) recognizes nothing, so the PUT creates a new record. Send identity fields with a value.
Several records match
When two records share the identity you sent (two contacts with the same email, created before the rule existed), PUT does not pick one:
{
"error": {
"code": "AMBIGUOUS_IDENTITY",
"message": "2 records match emails: none was written.",
"suggestion": "Merge the duplicates, or update the right one by id (PATCH).",
"matching_ids": ["2d6f8a0c-4e1b-4a3c-9d5e-7f9a1b3c5d70", "8f14e45f-ceea-467f-a34e-3c5d5b9a6c01"]
}
}Concurrent upserts
Creations are serialized per identity value: a create holds a lock on each identity value it carries until it commits, and checks that no record has taken it meanwhile. Twenty parallel PUT calls with the same email produce one contact: one call answers 201, the others update that contact and answer 200.
The same lock protects every way of creating a record (POST, the app, inbound webhooks, automations): a POST whose identity is already taken is refused with 409 DUPLICATE_RECORD, with existing_id when your integration can read that record.
It also protects an update: a PUT that recognizes a contact by its email, and adds a phone that identifies another contact, is refused with 409 DUPLICATE_RECORD naming that contact, and nothing is written. The same applies to PATCH and to adding an entry.
Idempotency keys
Network timeouts make "did my write land?" a real question. POST and PUT on /objects/{object}/records accept an idempotency key: any unique string your side generates, sent in the Idempotency-Key header (X-Idempotency-Key is read too).
curl -X POST https://platform.kasar.app/api/v1/objects/contacts/records \
-H "Authorization: Bearer ksr_svc_..." \
-H "Idempotency-Key: order-sync-58f2e" \
-H "Content-Type: application/json" \
-d '{ "emails": ["jean.dupont@acme.fr"], "name": { "first": "Jean" } }'Sending the same request again with the same key, within 24 hours of the first one, performs no write: it returns the stored response, with the same status and the same body, marked with a header:
HTTP/2 201
content-type: application/json
x-idempotency-replayed: trueThe rules:
| Situation | Response |
|---|---|
| Same key, same request, first call already answered | The stored response, with X-Idempotency-Replayed: true |
| Same key, same request, first call still running | 409 IDEMPOTENCY_KEY_IN_PROGRESS, with Retry-After: 2. Retry: you will get the stored response. |
| Same key, different request | 409 IDEMPOTENCY_KEY_REUSED |
Idempotency-Key and X-Idempotency-Key both sent, with different values | 400 INVALID_PARAMETER |
- What "same request" means: the same method, the same object and the same body. The order of keys inside JSON objects does not matter, at any depth; the order of array items does.
- Only success is stored. A request that fails (
422,409, a5xx) does not consume the key: fix the body and retry with the same key. - A stuck key frees itself. If the call holding a key never answers (the connection dropped, the request was cut), the key is released two minutes later, and the next call with it runs normally.
- Keys belong to your integration. Two integrations never collide on a key, and rotating your secret keeps your keys.
{
"error": {
"code": "IDEMPOTENCY_KEY_IN_PROGRESS",
"message": "A request with this idempotency key is still being processed.",
"suggestion": "Retry in a few seconds: the stored response will then be replayed."
}
}PATCH and DELETE do not read an idempotency key. Use one on every POST that a retry could duplicate; on a PUT, the identity rule already prevents a duplicate, and the key adds the exact replay of the first response.