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

# Create or update a contact by e-mail

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

## Request

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

Creates or updates one contact by e-mail and returns it whole, as
`get_contact` does. Call it to push a contact from another system, such as a
CRM, without looking it up first; for many contacts at once, `upsert_contacts`.

The contact is found by `email`, without case, and updated; when the address
is free it is created. The address of a deleted contact restores that
contact with its old data and applies the request over it. `result` says
which: `created` and `restored` answer 201, `updated` and `unchanged`
(nothing to change, nothing written) answer 200.

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.

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

- ContactUpsert: The fields every contact write takes; an upsert finds by `email`.
  - `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`

The contact existed: updated, or nothing to change

- ContactWritten
  - `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` Contact, required
    - `id` integer, required
    - `email` string, required
    - `status` 'subscribed' | 'unsubscribed' | 'bounced', required: subscribed: subscribed to e-mails. unsubscribed: opted out, never subscribed, or has not confirmed the subscription yet. bounced: e-mails to the address bounced, or it was cleaned from the list.
    - `first_name` string, nullable
    - `last_name` string, nullable
    - `phone` string, nullable
    - `country` string, nullable: ISO 3166-1 alpha-2
    - `timezone` string, nullable: IANA time zone name
    - `language` string, nullable: ISO 639-1 code
    - `fields` object, required: Set values of custom fields by field id, typed by the field: a date as YYYY-MM-DD, a datetime in ISO 8601
    - `tags` ContactTag[], required
      - `id` integer, required
      - `name` string, required
    - `source` ContactSource, required
      - `type` 'widget' | 'automation' | 'manual' | 'import' | 'shopify' | 'mailchimp' | 'klaviyo' | 'api' | 'other', required: Where the contact came from; widget, automation, import, shopify, mailchimp, klaviyo name the source. api: added through the public API or an AI assistant (MCP). manual: added by hand in the admin. other: a source not listed here.
      - `id` integer, nullable: Depends on the type: the widget, the automation, the admin user, the import task, the Shopify customer, the Mailchimp id; for api the API key, or the user whose access added it when there is no key
    - `email_stats` ContactEmailStats, required: E-mails to the contact, the keys of `EmailMetrics`; `send` includes e-mails rejected on sending, also counted in `bounce`.
      - `send` integer, required
      - `deliver` integer, required
      - `bounce` integer, required
      - `open` integer, required
      - `click` integer, required
      - `unsubscribe` integer, required
      - `complaint` integer, required
    - `created_at` string, date-time, required: When the contact was added at its source; imports keep the original date
    - `updated_at` string, date-time, required: Last change; equals created_at when none was recorded
    - `warnings` string[], required: Empty unless the contact could not be read in full; wording is not stable, do not parse

## Other responses

- `201`: Successful Response
- `400`: Unsupported GSC-Version
- `401`: Missing or invalid API key or access token
- `403`: Access denied
- `404`: Not found
- `409`: `contact_email_taken`: another request took the e-mail at the same moment; repeat the call. `idempotency_in_progress`: a repeat of a request still running
- `413`: Request body too large
- `422`: `contact_field_invalid`: an unknown field, a field of the other type or a value of another type. `contact_unreadable`: the stored contact cannot be read; it can only be deleted. `validation_failed`: the body is invalid: an e-mail, country, phone, time zone, language or tag name that cannot be read, 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. A call that creates or restores a contact also spends the contact creation limit, its own per key; an update does not
- `503`: Temporarily unavailable; 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)
