---
title: "Create Contact"
method: POST
path: "/v1/contacts"
tags: ["contacts"]
---

# Create Contact

`POST /v1/contacts`

## Request

- Base URL: `https://api.elaan.io` (the document declares no server; this is the origin it was published from)
- URL: `https://api.elaan.io/v1/contacts`
- Auth: none declared

## Headers

- `authorization` string, nullable

## Request body

- CreateContactRequest: Initial state of a new contact: an optional `external_id` plus the same mutable collections as a full update (all optional, defaulting to empty), so a contact can be created fully populated in one call or bare and filled in via the sub-resource endpoints. `language` is settable at create; afterwards it's changed only via its own endpoint (it's deliberately *not* in the whole-resource update, so a partial-shaped PUT can't silently clear it).
  - `emails` string[]
  - `phones` string[]
  - `push_subscriptions` PushSubscriptionDTO[]
    - `value` string, required
    - `provider` 'fcm' | 'apns' | 'expo' | 'onesignal' | 'webpush', required: The service a push subscription is delivered *through* — the routing key that ties a contact's `PushSubscription` to the tenant's configured `PushTransportConfig` and selects which sender adapter delivers it. Cross-cutting vocabulary (like `Channel`): it lives in `shared` because both `contact` (the destination side) and `push_transport` / `outbound_push` (the sending side) reference the same members, and they must not drift apart. Covers direct transports (FCM / APNs / Web Push) and aggregators that fan out to the native platforms themselves (Expo, OneSignal). Subclassing `str` keeps JSON and dict keys clean.
    - `platform` 'ios' | 'android' | 'web': The device platform a subscription targets. Optional metadata — used for payload shaping once the push channel lands (APNs, Android and Web bodies differ), never for routing (that is `PushProvider`'s job).
    - `auth` string, nullable
    - `p256dh` string, nullable
  - `attributes` object
  - `preferences` PreferenceDTO[]
    - `notification_type_key` string, required
    - `channel` 'email' | 'inbox' | 'push' | 'web_push', required: A delivery channel a notification can be sent over. This is an enum (not a bare string) so adding SMS / WHATSAPP later is a one-line change and every per-channel mapping (notification-type defaults, contact preferences) extends automatically. Crucially, a newly-added channel is **opted out by default** everywhere: `NotificationType.default_for` returns False for a channel absent from `channel_defaults`, and a contact has no preference until they set one — so an existing install gains a channel that delivers to nobody until a tenant explicitly enables it. That's why adding a member needs no data migration. Subclassing `str` makes members compare/serialize as their value ("email"), which keeps JSON and dict keys clean.
    - `enabled` boolean, required
  - `external_id` string, nullable
  - `language` string, nullable

## Response `201`

Successful Response

- ContactResponse
  - `id` string, required
  - `external_id` string, nullable, required
  - `language` string, nullable, required: The contact's preferred language tag (e.g. "es"), or null for the language-less default. Set at create or via PUT /v1/contacts/{id}/language — deliberately NOT part of the whole-resource PUT, so a partial-shaped update can't silently clear it.
  - `emails` string[], required
  - `phones` string[], required
  - `push_subscriptions` PushSubscriptionDTO[], required
    - `value` string, required
    - `provider` 'fcm' | 'apns' | 'expo' | 'onesignal' | 'webpush', required: The service a push subscription is delivered *through* — the routing key that ties a contact's `PushSubscription` to the tenant's configured `PushTransportConfig` and selects which sender adapter delivers it. Cross-cutting vocabulary (like `Channel`): it lives in `shared` because both `contact` (the destination side) and `push_transport` / `outbound_push` (the sending side) reference the same members, and they must not drift apart. Covers direct transports (FCM / APNs / Web Push) and aggregators that fan out to the native platforms themselves (Expo, OneSignal). Subclassing `str` keeps JSON and dict keys clean.
    - `platform` 'ios' | 'android' | 'web': The device platform a subscription targets. Optional metadata — used for payload shaping once the push channel lands (APNs, Android and Web bodies differ), never for routing (that is `PushProvider`'s job).
    - `auth` string, nullable
    - `p256dh` string, nullable
  - `attributes` object, required
  - `preferences` PreferenceDTO[], required
    - `notification_type_key` string, required
    - `channel` 'email' | 'inbox' | 'push' | 'web_push', required: A delivery channel a notification can be sent over. This is an enum (not a bare string) so adding SMS / WHATSAPP later is a one-line change and every per-channel mapping (notification-type defaults, contact preferences) extends automatically. Crucially, a newly-added channel is **opted out by default** everywhere: `NotificationType.default_for` returns False for a channel absent from `channel_defaults`, and a contact has no preference until they set one — so an existing install gains a channel that delivers to nobody until a tenant explicitly enables it. That's why adding a member needs no data migration. Subclassing `str` makes members compare/serialize as their value ("email"), which keeps JSON and dict keys clean.
    - `enabled` boolean, required

## Other responses

- `409`: A contact with this external_id already exists
- `422`: Validation Error

---

[API](https://skmtc.dev/elaan/apis/elaan-api.md) · [All operations](https://skmtc.dev/elaan/apis/elaan-api/llms.txt) · [OpenAPI document](https://skmtc.dev/elaan/apis/elaan-api/revisions/c96a7e6399aa?raw)
