KasarKasar Docs
API Reference

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 foundWhat happensStatus
NoneThe record is created, as a POST would.201
OneThat record is updated, as a PATCH would.200
SeveralNothing 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:

ObjectIdentity keys, in order
contactsemails, then phones, then linkedin
companiesLinkedIn company id, then LinkedIn handle, then domain, then name
opportunitiesnone: two deals may share a name
a custom objectits 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: a PUT with ["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: true

The rules:

SituationResponse
Same key, same request, first call already answeredThe stored response, with X-Idempotency-Replayed: true
Same key, same request, first call still running409 IDEMPOTENCY_KEY_IN_PROGRESS, with Retry-After: 2. Retry: you will get the stored response.
Same key, different request409 IDEMPOTENCY_KEY_REUSED
Idempotency-Key and X-Idempotency-Key both sent, with different values400 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, a 5xx) 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.

On this page