<!-- https://missblue.dev/docs/contacts -->

# Contacts

The address book belongs to the project, not to one person. Three people answer the same number; when one works out who a handle is, the other two should not have to work it out again — and the customer should not get "Hi, who is this?" from the second person they speak to.

Base URL: `https://api.missblue.dev`

Every request carries `Authorization: Bearer $MISS_BLUE_KEY`.

### GET /v1/contacts

Every name this project has given a handle.

`reach` says whether iMessage is known to work with each one, from your own traffic. See Lookup for what the three states mean.

Example:

```bash
curl -X GET https://api.missblue.dev/v1/contacts \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{
  "total": 1,
  "data": [
    {
      "id": "73ef7753-7ca6-493e-b9ff-9eef708828e7",
      "handle": "+15551230002",
      "name": "Head of catering",
      "reach": "reachable",
      "created_at": "2026-08-23T09:12:04Z",
      "updated_at": "2026-08-23T09:12:04Z"
    }
  ]
}
```

### PUT /v1/contacts

Name a handle, or rename one already named, and queue it for Apple Contacts.

PUT rather than POST: naming a handle is idempotent, and naming one that already has a name is a rename rather than a conflict. A caller should not have to know which it is doing. The save answers at once and queues the push to Apple Contacts on the book's numbers that have texted them or heard from them; a number that has not gets their card, with this name, when it first texts them, and that first message waits for it so it carries the number's Name & Photo. `sync.status` is when_texted when no number has texted them yet. `sync` in the answer says what was queued; to see it land, read `syncs[].status` on GET /v1/contacts/{id} (queued, then synced or failed) or listen for the contact.synced webhook, sent as each number takes it. This changed: a save used to wait for each number and answer with counts of synced and failed.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `handle` | `string` | yes | Phone number or Apple ID email. |
| `name` | `string` | yes | Clearing a name is a delete; there is a route for that. |
| `sync` | `boolean` | no | Default true. false keeps the name in Miss Blue only. If one of the book's own numbers texts them, that number gets a card with their number and no name (syncs[].nameless), so its Name & Photo (shared with Contacts only) reaches them; no contact.synced is sent for it. A contact already on one of the book's numbers with its name goes, with its current name, to any other of them that texts it. |
| `sync_number_id` | `uuid` | no | Push only to this number. One of this book's own numbers, not a shared line. Any other of the book's numbers that texts them later gets their card too. |
| `custom_data` | `object` | no | Fields to set, merged into the ones the contact has: a field given a value is set, one given null, "" or only spaces is removed, one not named is kept. Absent changes none. Numbers and true/false are stored as text; a list or an object is 400. A project's first save of somebody its organization has starts with the organization's fields. At most 50 per contact after the merge; names 1 to 80 characters, without }} or %} or both kinds of quote; values up to 2,000. Messages read them as {{ contact.custom.<name> }}, as they are when each message sends. |

Request body:

```json
{
  "handle": "+15551230002",
  "name": "Ari Chen",
  "custom_data": {
    "product": "Blue hoodie",
    "amount": "$48",
    "checkout_link": "https://shop.example/c/91"
  }
}
```

Example:

```bash
curl -X PUT https://api.missblue.dev/v1/contacts \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "handle": "+15551230002",
    "name": "Ari Chen",
    "custom_data": {
      "product": "Blue hoodie",
      "amount": "$48",
      "checkout_link": "https://shop.example/c/91"
    }
  }'
```

Response:

```json
{
  "id": "73ef7753-7ca6-493e-b9ff-9eef708828e7",
  "handle": "+15551230002",
  "name": "Ari Chen",
  "photo_attachment_id": null,
  "project_id": "e3b7c534-7e5d-4669-b90a-5b617bfe77f0",
  "custom_data": { "product": "Blue hoodie", "amount": "$48", "checkout_link": "https://shop.example/c/91" },
  "reach": "unknown",
  "created_at": "2026-08-23T09:12:04Z",
  "updated_at": "2026-08-28T10:04:00Z",
  "sync": {
    "status": "queued",
    "numbers": [
      { "number_id": "1d17fe68-c7ad-4adf-874c-9a0e3d254df5", "number_handle": "+16465550142" }
    ],
    "check": "GET /v1/contacts/73ef7753-7ca6-493e-b9ff-9eef708828e7 shows syncs[].status for each number: queued, then synced or failed. A contact.synced webhook is sent as each number takes it."
  }
}
```

Errors:

- `400` — sync_number_id is not one of this book's numbers, or is given with sync: false; or custom_data would take the contact past 50 fields, or has a name or value outside the limits. Nothing is saved, not even the name.

### GET /v1/contacts/{id}

Pull everything known about one project contact.

Includes its project and organization context, custom data, and where it stands in Apple Contacts on each number: `syncs[].status` is queued, synced or failed, or when_texted for a number that will get the card when it first texts them, with the last successful sync and the last error. Queued waits for a number that is away however long it takes, and is retried a few times over about an hour and a half if the number refuses it, with last_error saying why, before it is failed. `syncs[].nameless` is true for a card with the number alone and no name: put there because that number texted them, so its Name & Photo (shared with Contacts only) reaches them, while the name stays in Miss Blue. No contact.synced is sent for one.

Example:

```bash
curl -X GET https://api.missblue.dev/v1/contacts/73ef7753-7ca6-493e-b9ff-9eef708828e7 \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Response:

```json
{
  "contact": {
    "id": "73ef7753-7ca6-493e-b9ff-9eef708828e7",
    "handle": "+15551230002",
    "name": "Ari Chen",
    "photo_attachment_id": "d8f25b47-dda6-41ec-a303-4bdfb9d6953e",
    "project_id": "e3b7c534-7e5d-4669-b90a-5b617bfe77f0",
    "custom_data": { "crm_id": "lead_2048", "tier": "gold" },
    "reach": "reachable",
    "created_at": "2026-08-23T09:12:04Z",
    "updated_at": "2026-08-28T10:04:00Z"
  },
  "project_name": "Client Support",
  "workspace_id": "3480fdd8-2ef3-4edd-9949-bf78b4cd3e35",
  "workspace_name": "Acme Agency",
  "syncs": [{
    "number_id": "1d17fe68-c7ad-4adf-874c-9a0e3d254df5",
    "number_handle": "+16465550142",
    "number_label": "Main support",
    "status": "synced",
    "last_attempted_at": "2026-08-28T10:03:00Z",
    "last_synced_at": "2026-08-28T10:03:02Z",
    "last_error": null,
    "nameless": false
  }]
}
```

### PUT /v1/contacts/{id}/block

Block or unblock a contact across its organization.

Requires a member session covering the whole organization and project_id or workspace_id for the contact's address book. Outgoing messages fail with contact_blocked (403); unsent queued messages are cancelled. New incoming messages and notifications are suppressed in Miss Blue. Existing history remains. Unblocking does not replay discarded messages. Calls and delivery to the underlying Apple account are unaffected. Deleting the contact does not remove the block.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `project_id` | `string` | no | Query parameter. Project address book; use workspace_id instead for an organization contact. |
| `workspace_id` | `string` | no | Query parameter. Organization address book; mutually exclusive with project_id. |
| `blocked` | `boolean` | yes | true to block; false to unblock. |

Request body:

```json
{
  "blocked": true
}
```

Example:

```bash
curl -X PUT https://api.missblue.dev/v1/contacts/73ef7753-7ca6-493e-b9ff-9eef708828e7/block \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "blocked": true
  }'
```

### PUT /v1/contacts/{id}

Update a contact's name and custom data, and queue it for Apple Contacts.

custom_data, when given, is the complete replacement map; absent keeps the fields the contact has (a rename without it used to clear them). To set a few fields and keep the rest, save by handle with PUT /v1/contacts. It supports up to 50 fields; keys are at most 80 characters and values at most 2,000. Messages read them as {{ contact.custom.<name> }}. Like a save, it answers at once and queues the push to Apple Contacts on the book's numbers: `contact.sync` says what was queued, and `syncs` shows each number as queued until it takes it. The detailed contact and sync state are returned.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `name` | `string` | yes | The contact's name. |
| `custom_data` | `object` | no | Replaces what the contact had. Absent or null keeps it. |
| `sync` | `boolean` | no | Default true. false saves the change in Miss Blue only. |

Request body:

```json
{
  "name": "Ari Chen",
  "custom_data": {
    "crm_id": "lead_2048",
    "tier": "gold"
  }
}
```

Example:

```bash
curl -X PUT https://api.missblue.dev/v1/contacts/73ef7753-7ca6-493e-b9ff-9eef708828e7 \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Ari Chen",
    "custom_data": {
      "crm_id": "lead_2048",
      "tier": "gold"
    }
  }'
```

Response:

```json
{
  "contact": {
    "id": "73ef7753-7ca6-493e-b9ff-9eef708828e7",
    "handle": "+15551230002",
    "name": "Ari Chen",
    "project_id": "e3b7c534-7e5d-4669-b90a-5b617bfe77f0",
    "custom_data": { "crm_id": "lead_2048", "tier": "gold" },
    "reach": "reachable",
    "sync": {
      "status": "queued",
      "numbers": [
        { "number_id": "1d17fe68-c7ad-4adf-874c-9a0e3d254df5", "number_handle": "+16465550142" }
      ],
      "check": "GET /v1/contacts/73ef7753-7ca6-493e-b9ff-9eef708828e7 shows syncs[].status for each number: queued, then synced or failed. A contact.synced webhook is sent as each number takes it."
    }
  },
  "project_name": "Client Support",
  "workspace_id": "3480fdd8-2ef3-4edd-9949-bf78b4cd3e35",
  "workspace_name": "Acme Agency",
  "syncs": [{
    "number_id": "1d17fe68-c7ad-4adf-874c-9a0e3d254df5",
    "number_handle": "+16465550142",
    "number_label": "Main support",
    "status": "queued",
    "last_attempted_at": "2026-08-28T10:03:00Z",
    "last_synced_at": "2026-08-28T10:03:02Z",
    "last_error": null,
    "nameless": false
  }]
}
```

### POST /v1/contacts/{id}/sync

Save this contact to the Mac and its iCloud contact account.

The way to push a contact again, to one number, when a save's queued push did not take. Unlike a save, it waits for that number's answer. Name the project number whose Mac should receive it. The server resolves the Mac from that authorised number; an agent id is never accepted. The first sync may wait for the Mac operator to approve Contacts access. A project contact that lands also sends the contact.synced webhook.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `number_id` | `uuid` | yes | A number in this project. |

Request body:

```json
{
  "number_id": "1d17fe68-c7ad-4adf-874c-9a0e3d254df5"
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/contacts/73ef7753-7ca6-493e-b9ff-9eef708828e7/sync \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "number_id": "1d17fe68-c7ad-4adf-874c-9a0e3d254df5"
  }'
```

Response:

```json
{
  "request_id": "bb56a0f4-4f40-4e85-9cd6-c3268f51dc85",
  "contact_id": "73ef7753-7ca6-493e-b9ff-9eef708828e7",
  "number_id": "1d17fe68-c7ad-4adf-874c-9a0e3d254df5",
  "synced": true
}
```

Errors:

- `404` — The contact or number is outside this project.
- `409` — This number is temporarily unavailable. The push stays queued and goes when the number is back.
- `400` — The number is a shared line, which never holds one organization's contacts.
- `400` — The Mac refused or could not save the contact.

### PUT /v1/contacts/{id}/photo

Use an uploaded JPEG or PNG as this contact's address-book photo.

Upload the image through Attachments first. This updates the contact photo in the Mac/iCloud address book when synced; Apple's account-wide outbound Messages name and photo sharing remains a separate Messages setting.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `attachment_id` | `uuid` | yes | An uploaded JPEG or PNG no larger than 10 MiB. |
| `sync` | `boolean` | no | Default true: queues the photo for Apple Contacts on the book's numbers, and the answer's `sync` says where. false keeps it in Miss Blue only. |

Request body:

```json
{
  "attachment_id": "d8f25b47-dda6-41ec-a303-4bdfb9d6953e"
}
```

Example:

```bash
curl -X PUT https://api.missblue.dev/v1/contacts/73ef7753-7ca6-493e-b9ff-9eef708828e7/photo \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "attachment_id": "d8f25b47-dda6-41ec-a303-4bdfb9d6953e"
  }'
```

Response:

```json
{
  "id": "73ef7753-7ca6-493e-b9ff-9eef708828e7",
  "handle": "+15551230002",
  "name": "Ari Chen",
  "photo_attachment_id": "d8f25b47-dda6-41ec-a303-4bdfb9d6953e",
  "project_id": "e3b7c534-7e5d-4669-b90a-5b617bfe77f0",
  "custom_data": {},
  "reach": "unknown",
  "created_at": "2026-08-23T09:12:04Z",
  "updated_at": "2026-08-28T10:04:00Z",
  "sync": {
    "status": "queued",
    "numbers": [
      { "number_id": "1d17fe68-c7ad-4adf-874c-9a0e3d254df5", "number_handle": "+16465550142" }
    ],
    "check": "GET /v1/contacts/73ef7753-7ca6-493e-b9ff-9eef708828e7 shows syncs[].status for each number: queued, then synced or failed. A contact.synced webhook is sent as each number takes it."
  }
}
```

### POST /v1/contacts/import

Name many handles at once.

Saves what is usable and tells you precisely which rows were not, rather than refusing the whole file over one bad line — a five-hundred-row import that fails on row four hundred means somebody edits and retries, several times.

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `contacts` | `array` | yes | Objects with `handle` and `name`, and optionally `custom_data`, merged the same way as PUT /v1/contacts. A row whose fields break a limit is skipped with the reason, fields and name together. |

Request body:

```json
{
  "contacts": [
    {
      "handle": "+15551230002",
      "name": "Head of catering",
      "custom_data": {
        "plan": "Gold"
      }
    }
  ]
}
```

Example:

```bash
curl -X POST https://api.missblue.dev/v1/contacts/import \
  -H "Authorization: Bearer $MISS_BLUE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "contacts": [
      {
        "handle": "+15551230002",
        "name": "Head of catering",
        "custom_data": {
          "plan": "Gold"
        }
      }
    ]
  }'
```

Response:

```json
{ "saved": 1, "skipped": [] }
```

### DELETE /v1/contacts/{id}

Remove a name from the project's book.

Anyone on the project may remove any name, not only whoever added it. A shared book that only its author can tidy is a book nobody tidies.

Example:

```bash
curl -X DELETE https://api.missblue.dev/v1/contacts/73ef7753-7ca6-493e-b9ff-9eef708828e7 \
  -H "Authorization: Bearer $MISS_BLUE_KEY"
```

Errors:

- `404` — No such contact in this project's book.
