Manage contacts and consent
Contacts with typed properties, segments, topics, the consent ledger, double opt-in, CSV import and export, and sunsetting.
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 | / |
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). The audience routes need a full-access key. Broadcasts and automations send to it.
Contacts
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"
}'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.comandjane@example.comare the same contact; creating it twice answers409 conflict. GET /v1/contacts/{id}accepts an id or an email address.GET /v1/contactslists newest first and filters byemail(part of the address),status,segment_idandtopic_id(contacts subscribed to it).PATCH /v1/contacts/{id}takes any create field exceptemail:propertiesmerge (nullremoves one),segmentsreplaces the contact's static segments,topicssets the topics it lists.DELETEanswers{ "object": "contact", "id", "deleted": true }; the address's suppressions stay.statusfollows what happens to the contact:unsubscribedwhen it unsubscribes from marketing mail,bouncedafter a permanent bounce,complainedafter a complaint (which also unsubscribes it),sunsetwhen sunsetting marks it. Resubscribing makes an unsubscribed contactactiveagain; a bounce or a complaint is not undone by a subscription.last_sent_at,last_opened_atandlast_clicked_atcome from live mail sent to the contact.- Changes emit
contact.created,contact.updatedandcontact.deleted, project-level events (webhooks). POST /v1/contacts/eventswith{ "event", "email" or "contact_id", "payload" }records a custom event in the contact's history and starts the automations it triggers; the answer counts them inruns_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
{
"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"] }
]
}
}rulesis{ "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>andtopic:<id>(the contact's subscription to the topic,opt_inoropt_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.
gtand its kin compare numbers and strings of plain decimal digits;before,afterand thewithin_dayspair compare dates (dateproperties, the timestamp fields, or strings in UTC ISO form), and a rule's value may carry an offset.neq,not_containsandnot_ininclude contacts without a value. - Dynamic segments are evaluated whenever they are read, by the same rules automations evaluate;
contacts_countis the live count, and the console's segment builder shows it as you edit. - Static segments (
"kind": "static", the default) take members throughPOST /v1/segments/{id}/contactswith{ "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" };DELETEon the same path removes members.GET /v1/segments/{id}/contactslists a segment's contacts, static or dynamic. - A segment's kind is fixed when it is created;
PATCHrenames 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 = unsubscribesuppression, and the send is accepted and reported assuppressed. Resubscribing lifts that suppression; complaint and bounce suppressions stay. - Mail sent with a
topic_idgoes 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, 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 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.
{ "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, 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:
{
"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/uploadswithpurpose: "import"), PUT the file to itsupload_url, then passupload_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_mapnames the header of each field; properties must be defined and their cells are converted to their type (yes,no,true,false,1,0for booleans). Empty cells leave a value unchanged.on_conflictisupsert(update existing contacts) orskip. Consent from a file is never stronger than a contact's own choices: for an existing contact an import can unsubscribe (anunsubscribedcell that is true, a topic set toopt_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 theimportsource 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
errorsaying how far it got. PollGET /v1/contacts/import/{id}:status(queued,in_progress,completed,failed),counts(total,created,updated,skipped,failed),errorfor a file that failed, anderrors, the first 100 rows it could not use as{ "row", "message" }(the header is row 1).GET /v1/contacts/importlists imports, optionally bystatus.
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 '.