---
title: "Create or update contacts by e-mail"
method: POST
path: "/v1/sites/{site_id}/contacts/upsert"
tags: ["Contacts"]
---

# Create or update contacts by e-mail

`POST /v1/sites/{site_id}/contacts/upsert`

## Request

- Base URL: `https://api.getsitecontrol.com`
- URL: `https://api.getsitecontrol.com/v1/sites/{site_id}/contacts/upsert`
- Auth: HTTP bearer

Creates or updates contacts by e-mail, up to 100 per call: each item finds
the contact by `email`, without case, and updates it, or creates it when the
address is free. Call it to add a list of people or to sync many contacts
from another system without looking each one up first. One contact with the
contact back in the answer is `upsert_contact`.

The answer reports every item in the order given: `created`, `updated`,
`restored` (the address belonged to a deleted contact, which is back with
its old data and the item applied over it), `unchanged` or `failed` with a
`reason`; one failed item writes nothing of its own and does not stop the
others.

A field left out is kept; null clears `first_name`, `last_name`, `phone`,
`country`, `timezone` and `language`; `fields` are keyed by field ids from
`list_contact_fields`, merged by key, and null removes a value. Tags go by
name, compared without case: `add_tags` adds them and creates a tag the site
does not have, `remove_tags` takes them off; tags not named stay.
`changed` names the fields that were written.

Status: `subscribed` is allowed from unsubscribed, and subscribes again a
contact who opted out, as the admin does; leave `status` out to keep what the
contact chose. `unsubscribed` is allowed from subscribed or from awaiting
double opt-in. A bounced contact changes status only with a new e-mail, and a
contact awaiting double opt-in is never subscribed here. A status change that
is not allowed does not stop the rest: the other fields are written and
`reason` says why.

A new contact is subscribed unless `status` says `unsubscribed`, which stores
it as never subscribed; it cannot be created bounced. Its `source` is `api`,
with the API key as `id`, or the user when the call is made with a user's
access token. A contact created here starts "new contact" automations when
subscribed and emits `contact.created`; a restored one does neither. Its
e-mail is checked in the background. A found contact keeps the stored case
of its e-mail and its source.

Items of one contact, here, in `upsert_contact`, `update_contacts` and
`delete_contacts`, run one after another, so concurrent calls keep each
other's tags and field keys.

## Path parameters

- `site_id` integer, required

## Headers

- `Idempotency-Key` string: Makes a retry of a POST safe: a repeat with the same key, body and GSC-Version returns the stored response with `Idempotency-Replayed: true` instead of running again. The key protects against repeated execution while the key store works. Saving the change and storing the response under the key are not atomic: if the process or the key store fails between them, the change is kept, the key is released after 15 minutes and a retry runs the request again. After a timeout or a 5xx, read the resource to check the result before retrying. 5xx and 429 responses and responses over 256 KB are not stored, so a repeat runs the request again. A key is kept for 24 hours. The same key with another body or version is 422; a repeat while the first request runs is 409.

## Request body

- ContactsUpsert
  - `items` ContactUpsertItem[], required: Up to 100 contacts, each e-mail once
    - `email` string, required: Finds the contact, without case; kept as stored when found, stored in lower case when created
    - `status` 'subscribed' | 'unsubscribed': `subscribed` or `unsubscribed`; `bounced` is set only by e-mail delivery. Left out: unchanged, a new contact is subscribed; may not be null
    - `first_name` string, nullable: null clears it
    - `last_name` string, nullable: null clears it
    - `country` string, nullable: ISO 3166-1 alpha-2 code or an English country name; stored as the code; null clears it
    - `phone` string, nullable: In the international format, or national with `country` in the same request; stored normalized; null clears it
    - `timezone` string, nullable: IANA time zone name such as `Europe/Berlin`; null clears it
    - `language` string, nullable: ISO 639-1 code such as `de`; stored in lower case; null clears it
    - `fields` object: Values by field id from `list_contact_fields`: a string for text, a whole number for integer, a number, an ISO 8601 string for a datetime (UTC without an offset), YYYY-MM-DD for a date, true or false for a boolean. Fields of the other type are read-only. Merged into the stored values: null removes a value, keys not given stay as they are
    - `add_tags` string[]: Tag names to add, compared without case; a tag the site does not have is created. Tags the contact has stay
    - `remove_tags` string[]: Tag names to remove, compared without case; a name with no tag, or a tag the contact does not have, is no change. Never creates a tag

## Response `200`

Successful Response

- ContactsBatch
  - `items` ContactBatchItem[], required: Every item, in the order given
    - `index` integer, required: Position of the item in the request, from 0
    - `id` integer, nullable: The contact; a failed item of `update_contacts` or `delete_contacts` keeps the id it was given. Null when no contact was found
    - `result` 'created' | 'updated' | 'restored' | 'unchanged' | 'failed', required: created: a new contact, by `upsert_contacts` or `upsert_contact`. updated: something was written, `changed` names it; in `delete_contacts` the contact was deleted. restored: the address belonged to a deleted contact, which is back with its old data and the item applied over it. unchanged: nothing to change, nothing written; in `delete_contacts` the contact was deleted before. failed: nothing of this item was written, see `reason`.
    - `changed` string[], required: Names of the fields whose stored value changed, from is_deleted, email, status, first_name, last_name, phone, country, timezone, language, fields, tags; empty when none did. A new contact names `email`, `status` and every field it was given; a restored one starts with `is_deleted`
    - `reason` 'contact_not_found' | 'contact_email_taken' | 'contact_tag_invalid' | 'contact_field_invalid' | 'contact_unreadable' | 'contact_status_transition' | 'contact_value_invalid': contact_not_found: no contact of the site has this id or e-mail; in `update_contacts` also a deleted one. contact_email_taken: the e-mail belongs to another contact of the site, live or deleted, or another request took it at the same moment. contact_tag_invalid: a tag name is empty or too long, see `detail`. contact_field_invalid: an unknown field, a field of the other type or a value of another type, see `detail`. contact_unreadable: the stored contact cannot be read; it can only be deleted. contact_status_transition: the status change is not allowed, the other fields were written; `detail` says why. contact_value_invalid: an e-mail, country, phone, time zone or language that cannot be read, `detail` names it.
    - `detail` string, nullable
    - `contact` ContactRef
      - `id` integer, required
      - `email` string, required: As stored
      - `status` 'subscribed' | 'unsubscribed' | 'bounced', required: After the call
  - `created` integer, required
  - `updated` integer, required
  - `restored` integer, required
  - `unchanged` integer, required
  - `failed` integer, required

## Other responses

- `400`: Unsupported GSC-Version
- `401`: Missing or invalid API key or access token
- `403`: `forbidden`: the key or token lacks `contacts.edit`
- `404`: Not found
- `409`: `idempotency_in_progress`: a repeat of a request still running
- `413`: Request body too large
- `422`: `validation_failed`: the body is invalid: more than 100 items, an id or an e-mail repeated, a tag both added and removed. `idempotency_key_reused`: the `Idempotency-Key` was sent before with another body or version
- `429`: `rate_limited`: a request limit is spent. Every call spends the contact creation limit once, whatever the number of items
- `503`: `temporarily_unavailable`: the request took too long, or limits or idempotency keys cannot be enforced; retry later

---

[API](https://skmtc.dev/getsitecontrol/apis/getsitecontrol-public-api.md) - [All operations](https://skmtc.dev/getsitecontrol/apis/getsitecontrol-public-api/llms.txt) - [OpenAPI document](https://skmtc.dev/getsitecontrol/apis/getsitecontrol-public-api/revisions/084fcad80e75?raw)
