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). The audience routes need a full-access key. Broadcasts and automations send to it.

Contacts

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"
  }'

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 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).
  • POST /v1/contacts/events with { "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 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.

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, 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.

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, 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 '.

Updated