API Reference
API Reference
GitHub
Subscribers

Subscriber Management

Create and manage email subscribers

A subscriber is one person in a project’s marketing audience: an address, a lifecycle status, and whatever custom fields your campaigns render against. There is one row per address per project — the address is the identity, and the id is just how you reference it.

Subscribers are not contacts

Contacts are derived from mail you have actually sent and are read-only. Subscribers are a list you maintain, and are what campaigns send to. The two never merge.

Every route here is project-scoped and needs a subscribers permission.

Status

StatusMeaningSet by
subscribedReceives campaignsThe default on create
unsubscribedOpted outThe subscriber, or you
bouncedThe address failedDelivery feedback
complainedReported as spamDelivery feedback

Only subscribed receives campaign mail. The last two are written by the delivery path rather than by hand, and resetting one to subscribed because the person asked you to is a decision, not a correction — the address failed or complained for a reason that is still true.

Create

POST /api/v1/subscribers
curl -X POST http://localhost:3000/api/v1/subscribers \
  -H "Authorization: Bearer myk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "email": "jane@example.com",
    "name": "Jane Doe",
    "timezone": "America/New_York",
    "language": "en",
    "custom_fields": {"company": "Acme", "plan": "pro"}
  }'

Only email is required. Status defaults to subscribed, and subscribed_at is stamped at the same moment. The address is trimmed and lowercased, and an address the project already holds is refused with 409 — use import if you want an upsert instead.

The project’s subscriber cap is checked first, so a create over the plan limit answers 429.

List

GET /api/v1/subscribers?limit=20&offset=0&status=subscribed&search=acme
ParameterNotes
limitDefault 20, clamped to the API ceiling
offsetRows to skip. page is honoured as a zero-based alias when offset is absent
statusOne of the four statuses
searchMatches email or name

total counts what the filters match, not the project — so a search hitting one row out of five thousand reports one, and the pager offers one page rather than fifty mostly empty ones.

Read one

GET /api/v1/subscribers/{id}

Update

PATCH /api/v1/subscribers/{id}

This behaves like a replace, not a patch

Two things about this route will surprise you if you treat the method literally:

  • email is required on every call. Omitting it fails validation, even when you only meant to change the name.
  • Omitted fields are cleared. name, timezone and language are written from the request unconditionally, so a body carrying only email and name blanks the timezone and language you had.

custom_fields is the one exception — leave it out and the stored object is kept. Send the subscriber back whole, which is what a GET immediately before makes easy.

{
  "email": "jane@example.com",
  "name": "Jane Smith",
  "timezone": "America/New_York",
  "language": "en",
  "custom_fields": {"company": "New Corp", "plan": "enterprise"}
}

Moving status to unsubscribed stamps unsubscribed_at. Changing the address to one another subscriber holds is refused with 409.

Delete

DELETE /api/v1/subscribers/{id}

Returns 204. This removes the audience row. It does not create a suppression , so nothing stops the address being added again by the next import or mailed by a transactional send. If the intent is “never mail this person again”, suppress the address as well.

Custom fields

custom_fields is a flat JSON object, up to 50 keys.

They are flattened into the template data

A campaign merges the subscriber’s custom fields into the top level of the render data. So a field called company is written {{ company }} — not {{ .custom_fields.company }}, which resolves to nothing.

Two keys are always overwritten after the merge: email and name come from the subscriber record itself, so a custom field of either name never reaches a template.

Beyond personalization, custom fields are what dynamic subscriber lists segment on.

In bulk

See Bulk Import for the JSON and CSV routes, which upsert rather than refusing an address that already exists.