# Manage contacts and consent

> Contacts with typed properties, segments, topics, the consent ledger, double opt-in, CSV import and export, and sunsetting.

Source: https://docs.minket.com/audience/
Updated: 2026-10-29

## The model

| Object | Routes | What it is |
|---|---|---|
| Contact | `/v1/contacts` | A person, per project: email, name, typed properties, subscriptions, engagement dates and a status. |
| Property | `/v1/contacts/properties` | A typed custom field: `string`, `number`, `boolean` or `date`. |
| Segment | `/v1/segments` | A list of contacts. Static segments hold explicit members; dynamic segments match rules. |
| Topic | `/v1/topics` | A subscription type such as `Product updates`, opt-in or opt-out by default. |
| Consent entry | `/v1/contacts/{id}/consent` | One line of the ledger: what a contact agreed to or withdrew, when, through which channel. |
| Import | `/v1/contacts/import` | A CSV processed in the background, with counts and the rows it could not use. |

Contacts belong to the project, not to an environment or a mode: live and test keys see the same audience, and test keys only read it ([API keys](https://docs.minket.com/keys/#scopes)). The audience routes need a full-access key. [Broadcasts](https://docs.minket.com/broadcasts/) and [automations](https://docs.minket.com/automations/) send to it.

## Contacts

```sh tab=curl
curl https://api.minket.com/v1/contacts \
  -H "Authorization: Bearer $MINKET_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "jane@example.com",
    "first_name": "Jane",
    "properties": { "plan": "pro", "seats": 3 },
    "topics": [{ "id": "019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b", "subscription": "opt_in" }],
    "segments": [{ "id": "019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5d" }],
    "source": "signup form"
  }'
```

```ts tab
await minket.contacts.create({ email: "jane@example.com", properties: { plan: "pro" } });
await minket.contacts.update("jane@example.com", { unsubscribed: true });
```

The contact object has `id`, `email`, `first_name`, `last_name`, `unsubscribed`, `status`, `properties`, `topics` (`[{ "id", "subscription" }]` for every topic of the project), `segment_ids`, `last_sent_at`, `last_opened_at`, `last_clicked_at`, `created_at` and `updated_at`.

- Emails are unique per project and stored lowercased, so `Jane@Example.com` and `jane@example.com` are the same contact; creating it twice answers `409 conflict`.
- `GET /v1/contacts/{id}` accepts an id or an email address. `GET /v1/contacts` lists newest first and filters by `email` (part of the address), `status`, `segment_id` and `topic_id` (contacts subscribed to it).
- `PATCH /v1/contacts/{id}` takes any create field except `email`: `properties` merge (`null` removes one), `segments` replaces the contact's static segments, `topics` sets the topics it lists. `DELETE` answers `{ "object": "contact", "id", "deleted": true }`; the address's suppressions stay.
- `status` follows what happens to the contact: `unsubscribed` when it unsubscribes from marketing mail, `bounced` after a permanent bounce, `complained` after a complaint (which also unsubscribes it), `sunset` when [sunsetting](https://docs.minket.com/audience/#sunsetting) marks it. Resubscribing makes an unsubscribed contact `active` again; a bounce or a complaint is not undone by a subscription.
- `last_sent_at`, `last_opened_at` and `last_clicked_at` come from live mail sent to the contact.
- Changes emit `contact.created`, `contact.updated` and `contact.deleted`, project-level events ([webhooks](https://docs.minket.com/webhooks/#endpoints)).
- `POST /v1/contacts/events` with `{ "event", "email" or "contact_id", "payload" }` records a custom event in the contact's history and starts the [automations](https://docs.minket.com/automations/) it triggers; the answer counts them in `runs_started`.

### Properties

Define a property before writing it: `POST /v1/contacts/properties` with `{ "key": "plan", "type": "string", "fallback_value": "free" }`. Keys are lowercase letters, digits and underscores starting with a letter, up to 50 characters; a project has up to 100 properties. A write with an undefined key is refused with `422` naming it, and every value must match its type:

| Type | Values |
|---|---|
| `string` | Up to 1,000 characters. |
| `number` | Finite, below 10^21 in magnitude, and 0 or at least 10^-6 (the range where every system prints a number the same way, which segment rules rely on). |
| `boolean` | `true` or `false`. |
| `date` | ISO 8601: `2026-10-24`, or a time with an offset, stored in UTC. |

Deleting a property removes its value from every contact.

## Segments

```json
{
  "name": "Engaged, Pro",
  "kind": "dynamic",
  "rules": {
    "all": [
      { "field": "unsubscribed", "op": "eq", "value": false },
      { "field": "last_clicked_at", "op": "within_days", "value": 90 },
      { "field": "property:plan", "op": "in", "value": ["pro", "team"] }
    ]
  }
}
```

- `rules` is `{ "all": [...] }` or `{ "any": [...] }` with up to 50 rules. Fields: `email`, `first_name`, `last_name`, `unsubscribed`, `status`, `created_at`, `last_sent_at`, `last_opened_at`, `last_clicked_at`, `property:<key>` and `topic:<id>` (the contact's subscription to the topic, `opt_in` or `opt_out`). Operators: `eq`, `neq`, `contains`, `not_contains`, `starts_with`, `ends_with`, `in`, `not_in`, `gt`, `gte`, `lt`, `lte`, `is_set`, `is_not_set`, `before`, `after`, `within_days`, `not_within_days`.
- Text comparisons ignore case. `gt` and its kin compare numbers and strings of plain decimal digits; `before`, `after` and the `within_days` pair compare dates (`date` properties, the timestamp fields, or strings in UTC ISO form), and a rule's value may carry an offset. `neq`, `not_contains` and `not_in` include contacts without a value.
- Dynamic segments are evaluated whenever they are read, by the same rules automations evaluate; `contacts_count` is the live count, and the console's segment builder shows it as you edit.
- Static segments (`"kind": "static"`, the default) take members through `POST /v1/segments/{id}/contacts` with `{ "contact_ids": [...] }` or `{ "emails": [...] }`, up to 1,000 at a time (every one must be a contact of the project), answering `{ "object": "segment", "id", "added" }`; `DELETE` on the same path removes members. `GET /v1/segments/{id}/contacts` lists a segment's contacts, static or dynamic.
- A segment's kind is fixed when it is created; `PATCH` renames it or replaces a dynamic segment's rules.

## Topics and consent

A topic is a subscription type: `POST /v1/topics` with `{ "name": "Product updates", "description": "…", "default_subscription": "opt_out", "public_name": "Product updates, monthly" }`. `public_name` is what the preference center shows. A contact's subscription to a topic is their own recorded choice, or the topic's default when they have made none. The default is fixed when the topic is created: changing it later would change consent nobody gave, so a different default is a new topic.

Consent is enforced when mail is sent, whatever sends it:

- A contact who unsubscribed from marketing mail is suppressed on the marketing stream: the unsubscribe writes a `reason = unsubscribe` suppression, and the send is accepted and reported as `suppressed`. Resubscribing lifts that suppression; complaint and bounce suppressions stay.
- Mail sent with a `topic_id` goes only to recipients subscribed to the topic: a contact who opted out, or an address on an opt-out topic without an opt-in, is suppressed for that email.
- Marketing mail goes to [one reader per email](https://docs.minket.com/sending/#streams), so its unsubscribe link and one-click header name that reader, and the hosted pages change only that reader's consent.

Every subscription change is a ledger entry, readable with `GET /v1/contacts/{id}/consent`, newest first:

| Field | Values |
|---|---|
| `action` | `subscribe`, `unsubscribe`, `resubscribe`, `double_optin_sent`, `double_optin_confirmed`, `import`, `sunset` |
| `source` | `api`, `console`, `import`, `preference_center`, `one_click`, `list_unsubscribe`, `double_opt_in`, `automation`, `system` |
| `topic_id` | The topic, or null for marketing mail in general |
| `ip`, `user_agent`, `evidence`, `created_at` | What is known about the request that caused it |

The [hosted pages](https://docs.minket.com/preferences/) write to the same ledger.

### Double opt-in

`POST /v1/contacts/{id}/optin` with `{ "from": "Acme <news@news.acme.example>", "topic_id": "…" }` emails the contact a confirmation link through the normal send pipeline (the domain gate, suppressions, caps and content checks apply; `subject` and `reply_to` are optional). The ledger records `double_optin_sent` with the email's id. The link works for 72 hours and opens a confirmation page whose Confirm subscription button subscribes the contact to the topic (or to marketing mail when no topic is given) and records `double_optin_confirmed`. Opening the link changes nothing, so a mail filter that follows every link in a message cannot confirm a signup for someone else's address. A contact who unsubscribed after the email was sent is not resubscribed by an old link.

```json
{ "object": "optin", "contact_id": "…", "topic_id": "…", "email_id": "…", "expires_at": "2026-10-30T09:00:00.000Z" }
```

### Sunsetting

Sunsetting is off until you turn it on for a project (Contacts, Sunset policy in the console). Every night, contacts that were sent mail at least once but opened and clicked nothing for the period (30 to 730 days, 365 by default) are marked `sunset`, with a `sunset` ledger entry, and broadcasts leave them out. The `suppress` policy also suppresses their address on the marketing stream. Nothing is deleted, and an open or a click brings a contact back to `active`. Opens are a weak signal ([Apple Mail Privacy Protection](https://docs.minket.com/delivery/#open-and-click-tracking), and some security scanners open mail too) where clicks are reliable, so keep the period long.

## Import and export

`POST /v1/contacts/import` queues a CSV import and answers `202` with an import object:

```json
{
  "content": "ZW1haWwsZmlyc3RfbmFtZSxwbGFuCmphbmVAZXhhbXBsZS5jb20sSmFuZSxwcm8K",
  "column_map": { "email": "email", "first_name": "first_name", "properties": { "plan": { "column": "plan" } } },
  "on_conflict": "upsert",
  "segments": [{ "id": "019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5d" }],
  "topics": [{ "id": "019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b", "subscription": "opt_in" }]
}
```

- The file is `content`, base64, up to 4 MB; up to 50 MB, create an upload first (`POST /v1/uploads` with `purpose: "import"`), PUT the file to its `upload_url`, then pass `upload_id`. The console imports files up to 4 MB.
- The file is UTF-8 CSV with a header row (RFC 4180 quoting, CRLF or LF). Up to 100,000 rows, 100 columns and 1,000 characters per field; a file breaking a limit, or with a quote that is never closed, fails as a whole with a sentence saying where.
- `column_map` names the header of each field; properties must be defined and their cells are converted to their type (`yes`, `no`, `true`, `false`, `1`, `0` for booleans). Empty cells leave a value unchanged.
- `on_conflict` is `upsert` (update existing contacts) or `skip`. Consent from a file is never stronger than a contact's own choices: for an existing contact an import can unsubscribe (an `unsubscribed` cell that is true, a topic set to `opt_out`) but never resubscribe, and a topic opt-in applies only where the contact has made no choice. New contacts take the file's consent, recorded with the `import` source and the import's id.
- The import runs in the background and resumes where it stopped after a failure; a step that fails five times in a row fails the import, with an `error` saying how far it got. Poll `GET /v1/contacts/import/{id}`: `status` (`queued`, `in_progress`, `completed`, `failed`), `counts` (`total`, `created`, `updated`, `skipped`, `failed`), `error` for a file that failed, and `errors`, the first 100 rows it could not use as `{ "row", "message" }` (the header is row 1). `GET /v1/contacts/import` lists imports, optionally by `status`.

`POST /v1/contacts/export` with an optional `segment_id` and `status` returns `{ "object": "export", "download_url", "expires_at" }`: a CSV of up to 200,000 contacts (id, email, names, status, dates and every property) behind a link valid until `expires_at`, an hour at most. Cells that a spreadsheet would run as a formula start with `'`.
