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

# Contacts & groups

> The organization's address book and groups — including the lookup that tells you who just emailed you.

**Required permission:** Contacts → Read to list, Contacts → Write to create,
edit or delete.

<Warning>
  **These are organization contacts. A key never sees anybody's personal address
  book.**

  Contacts a person adds in the web app belong to that person and are invisible
  to every API key — as are their groups. Contacts a key creates **are** visible
  to your team in the dashboard, because an address book nobody can reach is not
  an address book.
</Warning>

## Who just emailed me?

The reason this endpoint is worth having. Pair it with the
[`message.received` webhook](/api/webhooks) and one call turns a sender into a
person:

```bash theme={null}
curl "https://app.ruber.me/api/v1/contacts?email=billing@supplier.com" \
  -H "Authorization: Bearer $RUBER_API_KEY"
```

The address is matched case-insensitively, so the value straight out of a
webhook payload works without cleaning it up first.

## List contacts

```
GET https://app.ruber.me/api/v1/contacts
```

| Parameter |                                        |
| --------- | -------------------------------------- |
| `email`   | Exact address match, case-insensitive. |
| `search`  | Matches **name and organisation**.     |
| `limit`   | 1–200. Defaults to 50.                 |
| `page`    | 0-based.                               |

```json theme={null}
{
  "data": [
    {
      "id": "68835c58-f872-4bdc-b87d-537f56252a79",
      "display_name": "Alice Chen",
      "given_name": "Alice",
      "family_name": "Chen",
      "organisation": "Acme",
      "job_title": "Head of Billing",
      "emails": [{ "label": "Work", "value": "alice@acme.com" }],
      "phones": [{ "label": "Mobile", "value": "+44 7700 900000" }],
      "websites": [],
      "addresses": [],
      "custom": [],
      "birthday": "--04-01",
      "colour": "slate",
      "starred": false,
      "has_photo": false,
      "created_at": "2026-09-09T13:34:04.739Z",
      "updated_at": "2026-09-09T13:34:04.739Z"
    }
  ],
  "total": 1,
  "page": 0,
  "has_more": false
}
```

<Note>
  **`search` deliberately does not read `notes`.** A search that reaches into free
  text turns a lookup into a way to read the whole address book by fishing for
  words. Name and organisation are what a lookup is for.
</Note>

## Create a contact

```
POST https://app.ruber.me/api/v1/contacts
```

```bash theme={null}
curl -X POST https://app.ruber.me/api/v1/contacts \
  -H "Authorization: Bearer $RUBER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "display_name": "Alice Chen",
    "organisation": "Acme",
    "emails": [{ "label": "Work", "value": "Alice@Acme.com" }]
  }'
```

`display_name` is the only required field.

| Field                                                  |              |                                                                 |
| ------------------------------------------------------ | ------------ | --------------------------------------------------------------- |
| `display_name`                                         | **required** | Up to 200 characters.                                           |
| `given_name`, `middle_name`, `family_name`, `nickname` |              | Up to 100 each.                                                 |
| `organisation`, `job_title`                            |              | Up to 200 each.                                                 |
| `pronouns`                                             |              | Up to 60.                                                       |
| `birthday`                                             |              | `YYYY-MM-DD`, or `--MM-DD` when the year is unknown.            |
| `emails`                                               |              | Up to 20 `{label, value}`. Each `value` must be a real address. |
| `phones`, `custom`                                     |              | Up to 20 each.                                                  |
| `websites`, `addresses`                                |              | Up to 10 each.                                                  |
| `notes`                                                |              | Up to 8,000 characters.                                         |
| `colour`                                               |              | `slate`, `red`, `amber`, `green`, `blue`, `violet`, `pink`.     |
| `starred`                                              |              | Boolean.                                                        |

Addresses are stored lowercased and trimmed, which is what makes `?email=`
match. Empty rows are dropped rather than stored — an empty row is somebody
leaving a field alone, not an error.

## Read, replace, delete

```
GET    /v1/contacts/{id}
PATCH  /v1/contacts/{id}
DELETE /v1/contacts/{id}
```

<Warning>
  **PATCH replaces the whole contact; it does not merge.** A field you leave out
  is **cleared**.

  Sending `{"display_name": "Alice Chen"}` to a contact that had an email address
  leaves it with no email address — and it will then stop appearing in `?email=`
  lookups. Read the contact, change what you want, and send it back whole.

  The one exception is the photograph, which is never touched by a write, so
  replacing a record cannot silently remove somebody's picture.
</Warning>

## Groups

```
GET    /v1/contact-groups
POST   /v1/contact-groups
PATCH  /v1/contact-groups/{id}
DELETE /v1/contact-groups/{id}
```

```json theme={null}
{
  "id": "d2f3b653-3456-41f1-b82c-5e2f33962b8f",
  "name": "Suppliers",
  "colour": "green",
  "member_count": 2,
  "members": ["68835c58-…", "9a71bd02-…"],
  "created_at": "2026-09-09T13:35:00.539Z",
  "updated_at": "2026-09-09T13:35:00.539Z"
}
```

| Field     |              |                                                  |
| --------- | ------------ | ------------------------------------------------ |
| `name`    | **required** | Up to 80 characters, unique in the organization. |
| `colour`  |              | A named colour, or a hex code like `#3b82f6`.    |
| `members` |              | Contact ids. **Omit to leave membership alone.** |

Membership travels with the group rather than living at its own endpoint: a
group is small, and "the group is these contacts" is one fact — splitting it
across two calls would let a caller leave a group half-updated with no way to
tell.

<Note>
  **`members` omitted means "leave it alone"; `members: []` means "empty it".**
  That distinction is what lets a rename be only a rename.

  Every id must be a contact in your organization. One that is not — including a
  member's personal contact — is refused with `400`, and the group is **not**
  created or changed. Nothing is half-applied.
</Note>

Deleting a group removes the grouping and **keeps the contacts**. A group is a
label on people, not a container of them.

## Not available yet

**Contact activity and correspondents.** The web app shows how often you have
written to somebody and who else was involved. Both are computed from an index
that is only refreshed when a person opens the app, and that is keyed to that
person — so an API version would return figures that are quietly out of date
and incomplete. It is left out rather than shipped wrong.

**Photographs.** `has_photo` tells you one exists. Fetching it is not supported:
the app serves photographs from a session-authenticated route, and the
alternative — a direct storage URL — would make every contact's picture
readable by anyone who guessed the address.

## Errors

| Code              | HTTP  | Cause                                                                                                                              |
| ----------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `invalid_request` | `400` | Missing `display_name`, a bad email or birthday, a field over its limit, a duplicate group name, or a member id that is not yours. |
| `unauthorized`    | `401` | Missing, malformed, unknown or revoked key.                                                                                        |
| `forbidden`       | `403` | The key lacks `contacts:read` or `contacts:write`.                                                                                 |
| `not_found`       | `404` | No organization contact or group with that id. A personal one answers the same way.                                                |
| `rate_limited`    | `429` | Over 600 requests in a minute.                                                                                                     |

See [Errors](/api/introduction#errors) for the full response shape.
