# Create or update contacts in a batch

`POST /v1/contacts/batch`

Requires an API key with the `contacts:write` scope. [About keys and scopes](/docs/api/authentication)

Identify for up to 100 contacts in one request, each resolved and written exactly as `POST /v1/contacts/identify` would, in order. One contact refused does not stop the rest: its entry carries the error a single identify would have answered. Counts against the rate limit as one request per contact; for a larger sync, send several batches. Agent keys are refused with 403.

## Request body

- `contacts` (CreateContactInput[], required, at least 1 item, at most 100 items)
  - `externalId` (string | null, optional, 1–255 characters) — Your own id for the contact, up to 255 characters. Identify matches on it first.
  - `email` (string | null, optional, email)
  - `firstName` (string | null, optional, 1–100 characters)
  - `lastName` (string | null, optional, 1–100 characters)
  - `phone` (string | null, optional) — Phone number; normalised to E.164 (a leading `+` and country code).
  - `timezone` (string | null, optional, up to 100 characters) — IANA timezone, e.g. `Europe/London`; used for send-time scheduling.
  - `country` (string | null, optional) — ISO 3166-1 alpha-2 code. Omitted, it is inferred from the phone, else the timezone.
  - `dates` (ContactDatesInput, optional) — Named calendar dates, merged key by key; `null` removes one.
  - `attributes` (object, optional) — Custom attributes, merged key by key; `null` removes one. At most 50 keys and 16 KB.
  - `emailConsent` (boolean, optional) — Consent to email. Omitted, the current value is kept.
  - `pushConsent` (boolean, optional) — Consent to push. Omitted, the current value is kept.
  - `consentStatement` (ConsentStatementInput, optional) — How the contact agreed, recorded with any consent this request grants.
    - `basis` (string, required, one of `opted_in`, `existing_customer`) — The legal basis the contacts agreed under.
    - `evidence` (string, required, 1–500 characters) — Where and how they agreed, e.g. "Checkout opt-in on shop.example.com".

## Returns

Returns `200 OK` with a `IdentifyContactsBatchResult` in `data`. Otherwise returns an error; [Errors](/docs/api/errors) lists every code and which to retry.

- `results` (object[], required)
  - One of: **Contact written**
    - `created` (boolean, required) — Whether a new contact was created.
    - `contact` (ContactDetail, required)
      - `id` (string, required, uuid)
      - `externalId` (string | null, required) — Your own id for the contact, if set.
      - `email` (string | null, required, email)
      - `firstName` (string | null, required)
      - `lastName` (string | null, required)
      - `phone` (string | null, required) — Phone number in E.164 format.
      - `timezone` (string | null, required) — IANA timezone, e.g. `Europe/London`.
      - `country` (string | null, required) — ISO 3166-1 alpha-2 code: as written, or inferred from the phone, else the timezone.
      - `dates` (object, required) — Named calendar dates, e.g. `birthday`, as `YYYY-MM-DD` or `--MM-DD`.
      - `attributes` (object, required) — Custom attributes, keyed by attribute name.
      - `emailConsent` (boolean, required) — Whether the contact has consented to email.
      - `pushConsent` (boolean, required) — Whether the contact has consented to push.
      - `createdAt` (string, required, date-time)
      - `updatedAt` (string, required, date-time)
      - `emailSuppression` (object, required)
        - `suppressed` (boolean, required) — Whether email to this contact is blocked.
        - `reason` (string | null, required) — Why email to this contact is suppressed: `bounced`, `complained`, `complained_transactional`, `unsubscribed`, `manual`, `imported`, or `other_workspace` (a bounce or complaint in another workspace with the same owner or partner).
  - One of: **Contact refused**
    - `error` (object, required)
      - `code` (string, required, one of `BAD_REQUEST`, `ALREADY_EXISTS`, `CONFLICT`, `IN_USE`, `FORBIDDEN`, `INTERNAL_ERROR`, `NOT_FOUND`, `RATE_LIMITED`, `REVISION_CONFLICT`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED`, `VALIDATION_ERROR`, `API_KEY_IP_NOT_ALLOWED`, `AGENT_CONFIRMATION_REQUIRED`, `EMBED_ORIGIN_NOT_ALLOWED`, `WORKSPACE_SUSPENDED`, `WORKSPACE_DELETION_PENDING`, `CONTACT_SEND_IN_PROGRESS`, `EXPORT_UNAVAILABLE`, `NOTHING_TO_EXPORT`, `EVENT_NAME_LIMIT_REACHED`, `PLAN_LIMIT_REACHED`, `PLAN_FEATURE_UNAVAILABLE`, `CAMPAIGN_AUDIENCE_CONFLICT`, `EMPTY_AUDIENCE`, `INVALID_CAMPAIGN_CONTENT`, `DRAFT_INCOMPLETE`, `INVALID_SCHEDULE`, `UNSUPPORTED_CHANNEL`, `POSTAL_ADDRESS_MISSING`, `CONSENT_MISSING`, `SUPPRESSED`, `SIGNUP_BLOCKED`, `INVALID_SENDER_ADDRESS`, `SENDING_DOMAIN_DMARC_MISSING`, `SENDING_DOMAIN_IDENTITY_CONFLICT`, `SENDING_DOMAIN_UNVERIFIED`, `IDEMPOTENCY_KEY_REUSED`, `CONTENT_NOT_RETAINED`, `RECIPIENT_EMAIL_MISSING`, `TEMPLATE_NOT_PUBLISHED`, `TEMPLATE_ENVELOPE_INCOMPLETE`, `PUSH_APP_NOT_READY`, `PUSH_DESTINATION_MISSING`)
      - `message` (string, required)
      - `details` (object, optional)
      - `requestId` (string, optional)

## Example

```bash
curl -X POST https://api.kirora.io/v1/contacts/batch \
  -H "Authorization: Bearer $KIRORA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "contacts": [
      {}
    ]
  }'
```

Response `200`:

```json
{
  "data": {
    "results": [
      {
        "created": true,
        "contact": {
          "id": "018f2a4e-6b1c-7d3e-9f40-37386ae05a6b",
          "externalId": "user_8812",
          "email": "amaka@juniperkitchen.com",
          "firstName": "Amaka",
          "lastName": "Eze",
          "phone": "+447700900123",
          "timezone": "Europe/London",
          "country": "GB",
          "dates": {},
          "attributes": {},
          "emailConsent": true,
          "pushConsent": true,
          "createdAt": "2030-03-14T09:30:00Z",
          "updatedAt": "2030-03-14T09:30:00Z",
          "emailSuppression": {
            "suppressed": null,
            "reason": null
          }
        }
      }
    ]
  }
}
```
