Mail guides
07 · Contacts, topics, segments and broadcasts

Mail people who asked for it.

Contacts, topics and a one-click unsubscribe that works in every mailbox. Consent is recorded, never assumed.


1

Contacts and consent

A contact is an address in your workspace with optional names and custom properties. It can carry a marketing consent: where it came from and when. Consent decides who a topic reaches by default (section 3); without it, a contact gets only the topics it opts in to itself.

POST /contacts
curl -X POST 'https://api.mail.productcraft.co/v1/workspaces/<ws>/contacts' \
  -H 'authorization: Bearer pcft_live_...' \
  -H 'content-type: application/json' \
  -H 'idempotency-key: contact-ada' \
  -d '{
    "email": "ada@example.com",
    "first_name": "Ada",
    "properties": { "company": "Acme" },
    "consent": { "source": "signup-form", "consented_at": "2026-10-24T09:00:00Z" }
  }'

Properties must be defined first with POST /contact-properties ({ "key": "company", "type": "string" }). Read a contact by id or by address: GET /contacts/ada@example.com. Changes emit contact.created, contact.updated and contact.deleted webhook events.


2

Import a CSV

Up to 10 MB and 100,000 rows. The header needs an email column; first_name, last_name, unsubscribed, consent, consented_at, consent_source and any property key are read too. A row is consented only when it says so. An import never re-subscribes someone who unsubscribed and never overrides a topic opt-out.

POST /contacts/imports
curl -X POST 'https://api.mail.productcraft.co/v1/workspaces/<ws>/contacts/imports' \
  -H 'authorization: Bearer pcft_live_...' \
  -H 'content-type: application/json' \
  -d '{
    "csv": "email,first_name,consent,consented_at\nada@example.com,Ada,true,2026-10-01T09:00:00Z\n",
    "on_conflict": "upsert",
    "topic_ids": ["<topic id>"]
  }'
# → 202 { "id": "...", "status": "queued", ... }
# poll GET /contacts/imports/{id} for processed_rows, created_count, errors[]

3

Topics

A topic is something people can choose to hear about: product news, tips, events. default_subscription is opt_out (only contacts who opt in receive it) or opt_in (every contact with recorded consent receives it unless they opt out). Contacts choose per topic on the hosted page, or you set it with PATCH /contacts/{contact}/topics.

POST /topics
curl -X POST 'https://api.mail.productcraft.co/v1/workspaces/<ws>/topics' \
  -H 'authorization: Bearer pcft_live_...' \
  -H 'content-type: application/json' \
  -d '{ "name": "Product updates", "default_subscription": "opt_out" }'

4

Send on a topic

Add topic_id to a template send (or to rows of send-batch). Mail checks the recipient first: someone who unsubscribed, opted out, or never opted in is refused with 422 RECIPIENT_SUPPRESSED and details.reason unsubscribe or no_consent, plus an email.suppressed event. A topic send:

  • renders {{{unsubscribe_url}}} in your template as a personal link to the preferences page;
  • carries List-Unsubscribe and List-Unsubscribe-Post: List-Unsubscribe=One-Click (RFC 8058), so Gmail, Yahoo and Apple Mail show their own unsubscribe button;
  • goes out on a separate sending stream, so marketing results never affect the reputation of your transactional mail.

A template that renders {{{unsubscribe_url}}} without a topic gets the same link and headers; its unsubscribe covers all such mail. Transactional sends (no topic, no link) are never affected by an unsubscribe.

POST /templates/{name}/send
curl -X POST 'https://api.mail.productcraft.co/v1/workspaces/<ws>/templates/october-news/send' \
  -H 'authorization: Bearer pcft_live_...' \
  -H 'content-type: application/json' \
  -d '{
    "from": "Acme <news@mail.acme.com>",
    "to": "ada@example.com",
    "topic_id": "<topic id>",
    "data": { "first_name": "Ada" }
  }'

5

The preferences page

The link opens a page in your workspace brand with an Unsubscribe button for the topic the email was sent on and a checkbox per public topic. Opening it changes nothing; the buttons do. Mailbox providers' one-click requests go to the same URL and unsubscribe immediately, as RFC 8058 requires. Links last a year.

Test unsubscribe flows with the mailbox simulator addresses (success@simulator.amazonses.com), never with made-up addresses: real bounces count against your sending reputation.


6

Segments

A segment is a group of contacts to send to. A static segment is a list: add and remove contacts with POST / DELETE /contacts/{contact}/segments/{segment_id}. A rule segment has a rule instead, and includes every contact that matches it whenever you read it, so it stays current as contacts change. The type is fixed when you create the segment.

A rule is one level deep: match is all (every condition) or any (at least one), and each condition tests a field (email, first_name, last_name or a contact property key) with an operator:

  • equals: the same text, ignoring case; for a number property, the same number;
  • contains: the text appears anywhere, ignoring case (not for number properties);
  • exists: the field has a non-empty value (no value).
POST /segments
curl -X POST 'https://api.mail.productcraft.co/v1/workspaces/<ws>/segments' \
  -H 'authorization: Bearer pcft_live_...' \
  -H 'content-type: application/json' \
  -H 'idempotency-key: segment-pro-acme' \
  -d '{
    "name": "Pro customers at Acme",
    "rule": {
      "match": "all",
      "conditions": [
        { "field": "plan", "operator": "equals", "value": "pro" },
        { "field": "email", "operator": "contains", "value": "@acme.com" }
      ]
    }
  }'

Try a rule before saving it with POST /segments/preview ({ "rule": … }): it returns the count and the newest few matches and writes nothing. Page through members with GET /segments/{id}/contacts, and read the size and unsubscribed share with GET /segments/{id}/metrics. GET /contacts/{contact}/segments lists every segment a contact is in. A segment holds contacts whatever their subscription state; who actually receives a send is still decided by consent and topics.


7

Broadcasts

A broadcast is one message to a segment, a topic, or both (then only the people in the segment who are subscribed to the topic). Write it as Handlebars HTML with a subject, or point it at a saved template with template_name. Each recipient is rendered with their own contact: {{first_name}}, {{email}}, any property such as {{plan}} (with its fallback), or {{contact.plan}}.

The body must contain {{{unsubscribe_url}}}: a broadcast without it is refused with 422 UNSUBSCRIBE_LINK_REQUIRED. Every message carries the one-click List-Unsubscribe headers too.

POST /broadcasts
curl -X POST 'https://api.mail.productcraft.co/v1/workspaces/<ws>/broadcasts' \
  -H 'authorization: Bearer pcft_live_...' \
  -H 'content-type: application/json' \
  -H 'idempotency-key: broadcast-october' \
  -d '{
    "name": "October update",
    "from": "Acme <news@mail.acme.com>",
    "subject": "What shipped in October, {{first_name}}",
    "html": "<p>Hi {{first_name}}, ...</p><a href=\"{{{unsubscribe_url}}}\">Unsubscribe</a>",
    "segment_id": "<segment id>",
    "topic_id": "<topic id>"
  }'

# Send now, or pass "scheduled_at" to send later
curl -X POST 'https://api.mail.productcraft.co/v1/workspaces/<ws>/broadcasts/<id>/send' \
  -H 'authorization: Bearer pcft_live_...' \
  -H 'content-type: application/json' \
  -d '{}'

GET /broadcasts/audience?segment_id=…&topic_id=… tells you how many people a send would reach right now. When sending starts, the audience is fixed: everyone in it who has not unsubscribed and is not on your suppression list. Messages go out at a steady pace (120 a minute by default; change it with PUT /broadcast-settings or a broadcast's rate_per_minute, up to 300), separately from your transactional mail, so a campaign never holds up a sign-in code. Until it finishes you can stop it with POST /broadcasts/{id}/cancel; a scheduled one can be canceled before it starts.

GET /broadcasts/{id} returns the results: recipients, sent, delivered, bounced, complaints and unsubscribes since the send (opens and clicks once tracking is on). GET /broadcasts/{id}/recipients lists each person with their message status. In the console, everything lives under Mail → Broadcasts.