> ## Documentation Index
> Fetch the complete documentation index at: https://docs.lettr.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Bulk create contacts

> Create up to 1000 contacts in a single request. Send `contacts` to give each row its own properties, lists and topics, or the legacy `emails` array when the whole batch shares one property map. Emails are normalized and deduplicated; addresses that already exist are reported in `already_existed`, are always attached to the requested lists and topics and always have a row-level `opt_out` applied, and have their properties merged only when `update_existing` is set. Rows that fail validation are skipped and returned in `errors` — the request only fails with 422 when nothing could be written. The response carries the contact ids, so a follow-up call to the bulk list or topic endpoints needs no lookup. Requires the `audience:write` scope and is blocked for sandbox API keys.

Create up to 1000 contacts in a single request. Emails are normalized and deduplicated. Requires the `audience:write` scope and is blocked for sandbox API keys.

Send **either** `emails` (a flat list of addresses that all share the batch-wide `list_ids`, `properties` and `topics`) **or** `contacts` (one row per contact, each with its own properties, lists and topic subscriptions). Row-level values are applied on top of the batch-wide ones: a row's `properties` key overrides the batch-wide value for that key, and a row-level `opt_out` beats a batch-level `opt_in`.

## Define properties first

Every key you send in `properties` must already exist as a property on your team. This endpoint does not create properties on the fly. Register each field once with [create a property](/api-reference/audience/create-a-property) (`POST /audience/properties`), then upsert contacts that use it. If you're syncing from a CRM or your own database, make "define your fields" the first step: list existing properties with [list properties](/api-reference/audience/list-audience-properties) and create the ones that are missing.

An unknown key is handled differently depending on where you send it:

| Where the unknown key is | Result |
| - | - |
| Batch-wide `properties` | The whole request is rejected with `422` and `error_code: validation_error`. No contacts are written. |
| A row's `properties` (in `contacts`) | Only that row is skipped and reported in `errors` with `unknown_property_key`. The rest of the batch commits. |

The `422` response names the missing keys. A skipped row carries the same text in its `error` field.

```json theme={null}
{
  "message": "Validation failed.",
  "error_code": "validation_error",
  "errors": {
    "properties": [
      "Unknown property keys: 'plan', 'signup_source'. Register via POST /audience/properties first."
    ]
  }
}
```

<Warning>
  **A `201` does not mean every row landed.** Rows that fail validation are skipped and reported in `errors` — the rest of the batch still commits. Always check `error_count` / `errors` rather than treating a 2xx as full success.
</Warning>

<Note>
  `already_existed` and `updated` overlap by design. They answer different questions — "was the address already in the audience?" versus "did this request change the contact?" — so they do not sum to the number of submitted rows. A contact that already existed and got attached to a list is counted in both.
</Note>

`update_existing` governs property merges and nothing else. By default (`update_existing: false`) existing contacts keep their properties; set it to `true` to merge instead — submitted keys overwrite, absent keys are preserved.

Lists, topic opt-ins and topic `opt_out` entries are applied either way. A row-level `opt_out` drops an existing subscription whether or not the flag is set, so a consent or suppression import does not need it.

The `contacts` array in the response returns `{ id, email, created }` for every contact that exists after the request, in submission order. Feed those ids straight into [bulk attach contacts to lists](/api-reference/audience/bulk-attach-contacts-to-lists) or [bulk subscribe contacts to topics](/api-reference/audience/bulk-subscribe-contacts-to-topics) without a follow-up lookup.

## Limits

| Limit | Value |
| - | - |
| Rows per batch (`emails` or `contacts`) | 1000 |
| Batch-wide `list_ids` | 50 |
| Batch-wide `topics` | 50 |
| Property value length | 1000 characters |

For larger imports, split the contacts into batches of up to 1,000 and send one request per batch.

## Row error codes

Each entry in `errors` carries the zero-based `index` of the submitted row, the `email`, a human-readable `error`, and one of these `error_code` values:

| Code | Meaning |
| - | - |
| `missing_email` | The row had no email address. |
| `invalid_email` | The address failed validation. |
| `invalid_property_value` | A property value was the wrong type or too long. |
| `unknown_property_key` | A property key is not defined for the team. Create it with `POST /audience/properties` and resend the row. |
| `unknown_list` | A list id does not exist. |
| `unknown_topic` | A topic id does not exist. |
| `invalid_topic_subscription` | A `subscription` value was neither `opt_in` nor `opt_out`. |


## OpenAPI

````yaml POST /audience/contacts/bulk
openapi: 3.1.0
info:
  title: Lettr API
  version: 1.0.0
  description: >-
    Lettr Email API - Send transactional emails with tracking, attachments, and
    personalization.
  contact:
    name: Lettr Support
    url: https://lettr.com
  license:
    name: Proprietary
    url: https://lettr.com/terms
servers:
  - url: https://app.lettr.com/api
    description: Production
security: []
tags:
  - name: Emails
    description: Email sending operations
  - name: Templates
    description: Email template management operations
  - name: Domains
    description: Domain management operations
  - name: Webhooks
    description: Webhook management operations
  - name: Audience
    description: 'Audience management: lists, contacts, topics, properties, and segments'
  - name: Campaigns
    description: >-
      Campaign operations: listing, stats, engagement events, and
      dispatch/scheduling
paths:
  /audience/contacts/bulk:
    post:
      tags:
        - Audience
      summary: Bulk create contacts
      description: >-
        Create up to 1000 contacts in a single request. Send `contacts` to give
        each row its own properties, lists and topics, or the legacy `emails`
        array when the whole batch shares one property map. Emails are
        normalized and deduplicated; addresses that already exist are reported
        in `already_existed`, are always attached to the requested lists and
        topics and always have a row-level `opt_out` applied, and have their
        properties merged only when `update_existing` is set. Rows that fail
        validation are skipped and returned in `errors` — the request only fails
        with 422 when nothing could be written. The response carries the contact
        ids, so a follow-up call to the bulk list or topic endpoints needs no
        lookup. Requires the `audience:write` scope and is blocked for sandbox
        API keys.
      operationId: bulkCreateAudienceContacts
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BulkStoreAudienceContactsRequest'
      responses:
        '201':
          description: Contacts created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BulkStoreAudienceContactsResponse'
        '401':
          $ref: '#/components/responses/UnauthorizedError'
        '403':
          $ref: '#/components/responses/ForbiddenError'
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationErrorResponse'
        '500':
          $ref: '#/components/responses/ServerError'
        '503':
          $ref: '#/components/responses/ServiceUnavailableError'
      security:
        - bearerAuth: []
components:
  schemas:
    BulkStoreAudienceContactsRequest:
      type: object
      description: >-
        Bulk create contacts, up to 1000 per request. Send either `emails` (one
        shared property map for the whole batch) or `contacts` (a row per
        contact, each with its own properties, lists and topics) — not both.
        Emails are normalized (lowercased, trimmed) and deduplicated, with the
        last row for an address winning. Batch-wide `list_ids` and `topics` are
        unioned into every row.
      properties:
        emails:
          type: array
          minItems: 1
          maxItems: 1000
          items:
            type: string
            format: email
            maxLength: 255
          example:
            - jane@example.com
            - joe@example.com
          description: >-
            Legacy shape: a flat list of addresses that all share `properties`
            and `list_id`.
        list_id:
          type:
            - string
            - 'null'
          format: uuid
          description: >-
            Optional list to add all contacts to. Kept for backwards
            compatibility; folded into `list_ids`.
        properties:
          type:
            - object
            - 'null'
          additionalProperties:
            type: string
            maxLength: 1000
          description: >-
            Custom property values applied to every contact in the batch. Works
            with both shapes; with `contacts`, a key set on a row overrides the
            batch value for that key.
        contacts:
          type: array
          minItems: 1
          maxItems: 1000
          description: >-
            One row per contact. A row that fails validation is skipped and
            reported in the response `errors` array — the rest of the batch is
            still written.
          items:
            $ref: '#/components/schemas/BulkAudienceContactRow'
        list_ids:
          type:
            - array
            - 'null'
          maxItems: 50
          items:
            type: string
            format: uuid
          description: Lists every contact in the batch is added to.
        topics:
          type:
            - array
            - 'null'
          maxItems: 50
          items:
            $ref: '#/components/schemas/AudienceTopicSubscription'
          description: >-
            Topic subscriptions applied to every contact in the batch. A
            row-level `opt_out` wins over a batch-level `opt_in` for that
            contact.
        update_existing:
          type: boolean
          default: false
          description: >-
            When true, contacts that already exist have their properties merged
            — submitted keys overwrite, others are preserved. When false their
            properties are left alone and they are counted in `already_existed`.
            This flag governs property merges only: lists, topic opt-ins and
            topic `opt_out` entries are always applied, so a row-level `opt_out`
            drops an existing subscription whether or not the flag is set.
      anyOf:
        - required:
            - emails
        - required:
            - contacts
    BulkStoreAudienceContactsResponse:
      type: object
      required:
        - message
        - data
      properties:
        message:
          type: string
          example: Contacts created successfully.
        data:
          type: object
          required:
            - created
            - updated
            - already_existed
            - error_count
            - errors
            - contacts
          properties:
            created:
              type: integer
              minimum: 0
              example: 2
            already_existed:
              type: integer
              minimum: 0
              example: 0
              description: Submitted addresses that were already in the audience.
            updated:
              type: integer
              minimum: 0
              description: >-
                Existing contacts the request changed — properties merged, a
                list or topic attached, or a subscription dropped.
              example: 0
            error_count:
              type: integer
              minimum: 0
              example: 0
            errors:
              type: array
              description: Rows that were skipped. Empty when the whole batch was accepted.
              items:
                $ref: '#/components/schemas/BulkAudienceContactError'
            contacts:
              type: array
              description: >-
                Every contact in the batch that exists after the request, in
                submission order.
              items:
                $ref: '#/components/schemas/BulkAudienceContactRef'
    ValidationErrorResponse:
      type: object
      required:
        - message
        - error_code
        - errors
      properties:
        message:
          type: string
          description: Human-readable error message
          example: Validation failed.
        error_code:
          type: string
          description: Error code (always `validation_error` for 422 responses)
          const: validation_error
          example: validation_error
        errors:
          type: object
          description: Field-specific validation errors
          additionalProperties:
            type: array
            items:
              type: string
          example:
            from:
              - The sender email address is required.
            to:
              - At least one recipient email address is required.
    BulkAudienceContactRow:
      type: object
      description: One contact in a bulk create payload.
      required:
        - email
      properties:
        email:
          type: string
          format: email
          maxLength: 255
          example: jane@example.com
        properties:
          type:
            - object
            - 'null'
          additionalProperties:
            type: string
            maxLength: 1000
          description: >-
            Custom property values for this contact. Each key must match a
            property defined for the team.
          example:
            first_name: Jane
        list_ids:
          type:
            - array
            - 'null'
          maxItems: 50
          items:
            type: string
            format: uuid
          description: Lists this contact is added to, on top of the batch-wide ones.
        topics:
          type:
            - array
            - 'null'
          maxItems: 50
          items:
            $ref: '#/components/schemas/AudienceTopicSubscription'
          description: Topic subscriptions for this contact.
    AudienceTopicSubscription:
      type: object
      description: A topic and the subscription state to apply to it.
      required:
        - id
      properties:
        id:
          type: string
          format: uuid
        subscription:
          type: string
          enum:
            - opt_in
            - opt_out
          default: opt_in
          description: >-
            `opt_in` subscribes the contact. `opt_out` suppresses the topic —
            including topics that auto-subscribe new contacts — and, with
            `update_existing`, unsubscribes a contact that was already
            subscribed.
    BulkAudienceContactError:
      type: object
      description: A row that was skipped, with its position in the submitted array.
      required:
        - index
        - email
        - error_code
        - error
      properties:
        index:
          type: integer
          minimum: 0
          description: Zero-based position of the row in the request.
        email:
          type:
            - string
            - 'null'
        error_code:
          type: string
          enum:
            - missing_email
            - invalid_email
            - invalid_property_value
            - unknown_property_key
            - unknown_list
            - unknown_topic
            - invalid_topic_subscription
        error:
          type: string
    BulkAudienceContactRef:
      type: object
      description: >-
        Identity of a contact touched by the request, so the caller can chain
        into the bulk list and topic endpoints without looking ids up again.
      required:
        - id
        - email
        - created
      properties:
        id:
          type: string
          format: uuid
        email:
          type: string
          format: email
        created:
          type: boolean
          description: True when this request created the contact.
    UnauthorizedResponse:
      type: object
      required:
        - message
      description: Error response for authentication failures (401).
      properties:
        message:
          type: string
          description: Human-readable error message
          example: API key is required.
    ErrorResponse:
      type: object
      required:
        - message
        - error_code
      description: Error response for non-validation errors (400, 409, 500, 502).
      properties:
        message:
          type: string
          description: Human-readable error message
          example: The sender domain could not be determined from the email address.
        error_code:
          $ref: '#/components/schemas/ErrorCode'
    ErrorCode:
      type: string
      description: >-
        Error codes returned by the API.


        | Code | HTTP Status | Description |

        |------|-------------|-------------|

        | `validation_error` | 422 | Request validation failed. Check the
        `errors` object for field-specific messages. |

        | `invalid_domain` | 400 | The sender domain could not be determined
        from the email address. |

        | `unconfigured_domain` | 400 | The sender domain is not configured or
        approved for sending. |

        | `send_error` | 400, 500 | General error during email send preparation
        or domain registration. |

        | `retrieval_error` | 400, 500 | Failed to retrieve resources (e.g.,
        sandbox key without associated user, upstream error). |

        | `transmission_failed` | 502 | Email transmission to the upstream
        provider failed. |

        | `resource_already_exists` | 409 | The resource (e.g., domain) already
        exists. |

        | `not_found` | 404 | The specified resource was not found. |

        | `template_not_found` | 404 | The specified template, project, or
        template version was not found. |

        | `insufficient_scope` | 403 | The API key does not have the required
        scope for this endpoint. |

        | `schedule_cancellation_failed` | 409, 500 | The scheduled email can no
        longer be cancelled (already sent, already cancelled, or being injected
        right now), or the cancellation itself failed. |

        | `quota_exceeded` | 429 | Monthly sending quota exceeded. Upgrade your
        plan to continue sending. |

        | `daily_quota_exceeded` | 429 | Daily sending quota exceeded. Try again
        tomorrow. |

        | `campaign_not_sendable` | 422 | The campaign cannot be sent in its
        current state (not a draft, or missing subject/sender/content). |

        | `campaign_not_scheduled` | 422 | The campaign is not scheduled, so it
        cannot be unscheduled. |
      enum:
        - validation_error
        - idempotency_key_conflict
        - idempotency_in_progress
        - invalid_domain
        - unconfigured_domain
        - send_error
        - retrieval_error
        - transmission_failed
        - resource_already_exists
        - not_found
        - template_not_found
        - insufficient_scope
        - schedule_cancellation_failed
        - quota_exceeded
        - daily_quota_exceeded
        - campaign_not_sendable
        - campaign_not_scheduled
      example: invalid_domain
  responses:
    UnauthorizedError:
      description: Unauthorized - Missing or invalid API key
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/UnauthorizedResponse'
          examples:
            missing_key:
              summary: Missing API key
              value:
                message: API key is required.
            invalid_key:
              summary: Invalid API key
              value:
                message: Invalid API key.
    ForbiddenError:
      description: Forbidden - API key does not have the required scope
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            message: >-
              Your API key does not have the required permissions for this
              action.
            error_code: insufficient_scope
    ServerError:
      description: Internal server error
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            message: An unexpected error occurred. Please try again later.
            error_code: send_error
    ServiceUnavailableError:
      description: >-
        Service unavailable - the API key could not be verified, so the request
        was not processed. This does not mean the key is invalid; retry after
        the delay given in the Retry-After header.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema:
            type: integer
            example: 5
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            message: Unable to verify your API key right now. Please retry shortly.
            error_code: auth_unavailable
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: API key for authentication

````

This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.