Skip to the documentation
CLI

Contacts, audiences and broadcasts

Every command for the address book, audiences, broadcasts and the suppression list, with worked examples.

How they fit together

Four namespaces cover the people you write to. Contacts are the workspace address book, audiences are named lists of contacts, a broadcast sends one message to everybody in some audiences, and the suppression list holds the addresses the workspace will not send to. Each command calls one SDK method, so the SDK pages describe the same calls in more depth.

  • A contact has no id. Its address is the key every contacts command takes, trimmed and lower cased, so [email protected] and [email protected] are one contact. An audience has an aud_ id, a broadcast a brd_ id, and a suppression the id suppressions list prints.
  • Every contact is in the default audience for as long as it exists. That audience cannot be deleted, emptied or thinned, and builtin is default on it.
  • The address book belongs to the workspace, so every member and every key reads and writes the same one.
  • Each namespace also answers to its singular, as in openemail contact get, and the usual aliases work: ls, show, new, edit and rm. In suppressions, whose verbs are add and remove, new leads to add and rm to remove.

openemail <namespace> <verb> --help shows every flag with its type, the scopes, the endpoint and what the command returns. Add --json for the same page as data.

Contacts

The workspace address book: the people a member has written to from the app composer, plus anybody saved by hand. Mail that arrives adds nobody, and neither does a send through the API or the CLI.

CommandWhat it does
openemail contacts listOne page of the saved contacts, most recently mailed first. --source keeps manual or auto contacts, and --q searches names and addresses
openemail contacts get <email>One contact, with every audience it is in
openemail contacts create --email <value>Save a new contact, with --name, --notes and --audience-ids. An address already in the book is refused with 409 contact_exists
openemail contacts update <email>Change --name or --notes, where null clears one. The address itself cannot change
openemail contacts delete <email>Delete the contact with its notes, photo and memberships, and hide the address so the composer does not record it again
openemail contacts set-audiences <email> --audience-ids <a,b>Make the audiences the contact is in exactly this list. The default audience is always kept
openemail contacts list-peopleEveryone on the Contacts page: the saved contacts and, with threads:read, every address seen in mail, with thread counts. --sort, --q, --email and --blocked narrow it
openemail contacts save <email>Save an address, keep one recorded from a send, or bring back a deleted one. Never an error, whatever state the address is in
openemail contacts delete-many <emails...>Delete and hide 1 to 200 addresses in one call
openemail contacts set-photo <email> <data>Upload the photo from a file, or from stdin with -: PNG, JPEG, WebP or GIF up to 5 MB
openemail contacts remove-photo <email>Take the photo off and delete the stored image
openemail contacts block <email>Put the address on the workspace blocklist, so mail from it is refused. A plus tag is dropped
openemail contacts unblock <email>Take off every blocklist rule that blocks the address, a whole-domain rule included
openemail contacts list-threads <email>The threads the address wrote or was written to, in every folder. --q searches inside them
openemail contacts activity <email>Mail received from and sent to the address over a window, 90 days unless --minutes says otherwise, with the threads waiting on a reply and the median reply time each way

Audiences

Named lists of contacts, up to 100 in a workspace. An address has to be a contact before it joins one, except through import-contacts, which saves new addresses as it goes.

CommandWhat it does
openemail audiences listOne page of the audiences, the default one first and the rest newest first, each with its contactCount
openemail audiences growthHow the audiences grew over a window, 30 days unless --days or --minutes says otherwise: joins and unsubscribes per bucket, and totals
openemail audiences get <id>One audience, with a fresh contactCount
openemail audiences create --name <value>Create an empty audience, with an optional --description. Names are not unique
openemail audiences update <id>Change --name or --description. Membership is not touched
openemail audiences delete <id>Delete the audience and keep its contacts. The default audience cannot be deleted
openemail audiences empty <id>Take every contact out and keep the audience, with its id, name and description
openemail audiences list-contacts <id>One page of the contacts in the audience, with when each joined and whether it unsubscribed. --sort, --q, --source and --statuses narrow it
openemail audiences add-contact <id> --email <value>Put one existing contact in the audience. Adding somebody who is already there changes nothing
openemail audiences remove-contact <id> <email>Take one contact out. A contact that is not in the audience is a 404
openemail audiences add-contacts <id> --emails <a,b>Put up to 200 existing contacts in, and report the addresses that are not contacts in missing
openemail audiences remove-contacts <id> --emails <a,b>Take up to 200 contacts out, and report the ones that were not in it
openemail audiences import-contacts <id> --contacts <json|@file|->Import up to 500 { email, name } rows, saving the addresses that are not contacts yet

Broadcasts

One message to everybody in up to 10 audiences, sent as a separate copy for each person, with the merge fields filled in and an unsubscribe link. Each copy is an ordinary email with its own msg_ id, events and webhooks.

CommandWhat it does
openemail broadcasts preview --audience-ids <a,b>Count who a broadcast to these audiences would reach, and who it would skip as unsubscribed or suppressed. Sends nothing
openemail broadcasts send --audience-ids <a,b> --from <value>Send with --subject and --html or --text, or a stored --template, now or at --scheduled-at
openemail broadcasts listOne page of broadcasts, newest first, with live counts. --audience-id keeps the ones sent to that audience
openemail broadcasts get <id>One broadcast, with its status and live counts: the command to poll while it sends
openemail broadcasts stats <id>Totals of delivered, bounced, opened, clicked and unsubscribed, and a series per --grain bucket, an hour unless you say
openemail broadcasts list-recipients <id>Who each copy went to and what happened to it. --filter keeps one group, such as bounced or not_opened
openemail broadcasts get-recipient <id> <email-id>One person's copy, with the subject, HTML and text exactly as they received them
openemail broadcasts cancel <id>Stop a broadcast that is scheduled, queued or still sending. Copies that have gone cannot be recalled

Suppressions

The addresses this workspace will not send to: hard bounces and complaints, recorded as they happen, and any address you add by hand. A send to one is refused for that recipient before anything leaves.

CommandWhat it does
openemail suppressions listOne page of the list, newest first. --reason keeps bounce, complaint or manual, and --q searches
openemail suppressions get <id>One row: the address, the reason, the detail the bounce or complaint carried, and whether it can be removed
openemail suppressions add --email <value>Stop sending to an address. Adding one that is there already returns the row it holds
openemail suppressions remove <id>Allow mail to the address again. A hard bounce cannot be removed

Suppressions and the blocklist are different lists. suppressions add stops mail going out to an address, and contacts block refuses mail coming in from it.

Scopes

Most commands need the read or write scope of their namespace. A few need another, because they read or change something else:

ScopeCommands
contacts:readcontacts list, get and list-people
contacts:writecontacts create, update, delete, save, delete-many, set-photo and remove-photo, and audiences import-contacts beside audiences:write
audiences:readaudiences list, growth, get and list-contacts, and broadcasts preview, so a key that cannot send can still show the count
audiences:writeEvery other audiences command, and contacts set-audiences. contacts create --audience-ids needs it beside contacts:write
threads:readcontacts list-threads and activity, and the addresses seen in mail in list-people
settings:readsuppressions list and get
settings:writesuppressions add and remove, and contacts block and unblock
emails:readbroadcasts list, get, stats, list-recipients and get-recipient
emails:sendbroadcasts send, which needs audiences:read too, and broadcasts cancel
  • A key limited to particular addresses or domains reads and writes the same address book as every other key. It sees only the broadcasts sent from an address or domain it holds, gets the saved contacts alone from list-people, and is refused with 422 capability_unsupported by contacts list-threads, activity, block and unblock, and by suppressions add and remove.
  • A browser sign-in by a member who reaches only some addresses is refused with 422 capability_unsupported on every contacts, audiences and broadcasts command. suppressions add refuses a browser sign-in by anybody but the workspace owner.

Worked examples

Build an audience from a file, then count who a broadcast to it would reach. import-contacts saves the addresses that are not contacts yet, and running it again creates and adds nothing twice.

contacts.json
[  { "email": "[email protected]", "name": "Ada Lovelace" },  { "email": "[email protected]", "name": "Grace Hopper" },  { "email": "[email protected]" }]
Build the audience and count it
AUDIENCE=$(openemail audiences create --name 'Product updates' --json | jq -r .id)openemail audiences import-contacts "$AUDIENCE" --contacts @contacts.jsonopenemail broadcasts preview --audience-ids "$AUDIENCE"

Check a broadcast with --dry-run, which prints the request and sends nothing, then send it. The broadcast is created at once and sent in the background, so poll get to follow it. This body does not place {{unsubscribeUrl}}, so every copy gets a one-line unsubscribe footer.

broadcast.json
{  "audienceIds": ["aud_4c1b8e2a7d9f05c36b4e8a71"],  "from": "Acme <[email protected]>",  "subject": "{{firstName|Hello}}, the September release is out",  "html": "<p>Hi {{firstName|there}},</p><p>Here is what changed this month.</p>",  "scheduledAt": "2026-10-01T09:00:00Z"}
Check the broadcast, then send it
openemail broadcasts send --data @broadcast.json --dry-runBROADCAST=$(openemail broadcasts send --data @broadcast.json --yes --json | jq -r .id)openemail broadcasts get "$BROADCAST"openemail broadcasts stats "$BROADCAST" --grain day

See who a broadcast did not reach. --ndjson prints one recipient per line, and --all --json one document with every page.

Who it did not reach
openemail broadcasts list-recipients brd_5a8c1e3f7b2d94a06c8e1f3b --filter bounced --ndjson | jq -r .emailopenemail broadcasts list-recipients brd_5a8c1e3f7b2d94a06c8e1f3b --filter not_opened --all --json | jq ".items | length"openemail suppressions list --reason bounce --all --max 50

Copy the subscribed members of one audience into another. jq turns the stream into the body add-contacts takes, and --data - reads it from stdin. --max 200 keeps it to the 200 addresses one call accepts.

Copy subscribed members
openemail audiences list-contacts aud_9f2c4b7e1a0d63d84c5f2e7b --statuses subscribed --max 200 --ndjson \  | jq -s '{ emails: map(.email) }' \  | openemail audiences add-contacts aud_1c4e7a9b2d0f36e85a7c1b4d --data -

Delete every contact the composer recorded at one domain. delete-many takes up to 200 addresses a call, so xargs -n 200 splits a longer list. Check the batches with --dry-run first, because there is no undo.

Delete by domain
openemail contacts list --source auto --all --ndjson \  | jq -r 'select(.email | endswith("@old-vendor.example")) | .email' > leaving.txtxargs -n 200 openemail contacts delete-many --dry-run < leaving.txtxargs -n 200 openemail contacts delete-many --yes < leaving.txt

Stop sending to an address, allow one again, and block a sender. removable says which rows suppressions remove will take.

Suppress, allow and block
openemail suppressions add --email [email protected]openemail suppressions list --q [email protected] --json | jq -r '.items[] | select(.removable) | .id'openemail suppressions remove 7b1e2c3d-4f5a-4b6c-8d7e-9f0a1b2c3d4e --yesopenemail contacts block [email protected]

Confirmations and verification codes

These commands ask you to confirm in a terminal before they run:

NamespaceAsks to confirm
contactsdelete, delete-many, remove-photo and unblock
audiencesdelete, empty, remove-contact and remove-contacts
broadcastssend and cancel
suppressionsremove
  • --yes confirms for you. Unattended, with --json or --no-input, in CI or without a terminal, a command that would ask stops with Refusing to run unattended. Pass --yes to confirm. and exit code 2.
  • --dry-run prints the request the command would send and exits with code 0, without asking and without changing anything.
  • With a browser sign-in, audiences delete asks for a verification code first, as the web app does. --yes never skips it, and unattended the command stops with exit code 4. Run openemail verify beforehand, or use an API key, which is never asked.
  • audiences empty never asks for a verification code, so check the id before you pass --yes.

Paging

Every command that lists reads one page. When more remain, pass the cursor it printed to --cursor, with the same filters, or read them all:

  • --all reads every page and streams the items: a table on a terminal, and one JSON object per line when piped or with --ndjson.
  • --max <n> stops after that many items, and implies --all.
  • --json prints one { items, hasMore, nextCursor } document, --all included.
  • A malformed or stale cursor is a 400 invalid_cursor. Start again without one.
CommandPage size
openemail contacts list1 to 200, 50 unless --limit says otherwise
openemail contacts list-people1 to 100, 25 unless --limit says otherwise
openemail contacts list-threads1 to 100, 25 unless --limit says otherwise
openemail audiences list1 to 100, 25 unless --limit says otherwise
openemail audiences list-contacts1 to 200, 50 unless --limit says otherwise
openemail broadcasts list1 to 100, 25 unless --limit says otherwise
openemail broadcasts list-recipients1 to 200, 50 unless --limit says otherwise
openemail suppressions list1 to 100, 25 unless --limit says otherwise

Good to know

  • contacts create refuses an address already in the book with 409 contact_exists, so a retry never overwrites a name somebody edited. contacts save never refuses: it saves, keeps or brings back the address, whatever state it is in.
  • contacts delete also takes an address that was only ever seen in mail, which takes that person off list-people. The mail stays. There is no undo: saving the address again starts a contact with no name, no notes and no audience beyond the default one.
  • The address is the identity of a contact, so contacts update cannot change it. Moving a contact is a delete and a create.
  • contacts set-photo reads the image from a file, or from stdin with -. Pass --content-type, such as image/jpeg: without it the image can go as application/octet-stream, which the server refuses with 422 invalid_image.
  • broadcasts send --scheduled-at takes an ISO 8601 time such as 2026-10-01T09:00:00Z, or an ISO 8601 duration such as PT2H or P1D, up to 365 days out. The short delays send --at takes, such as 2h, are refused here.
  • Merge fields work in --subject, --html and --text: {{firstName}}, {{lastName}}, {{name}}, {{email}} and {{unsubscribeUrl}}, each with a fallback after a bar, as in {{firstName|there}}. A body that does not place {{unsubscribeUrl}} gets a one-line unsubscribe footer. A template is sent as it is, so put the link in the template.
  • A broadcast is checked against the monthly sends of the plan before anything is written, and each copy counts as one send. One the allowance cannot cover is refused with 429 send_quota_exceeded, and nothing is left behind.
  • Pass your own --idempotency-key to broadcasts send when a script may run the step again. The same key answers with the broadcast it created instead of sending a new one.
  • A contact who unsubscribes from a broadcast stays in the audience with unsubscribedAt set, and later broadcasts to that audience skip it. audiences list-contacts --statuses unsubscribed lists them.
  • A hard bounce stays on the suppression list. suppressions remove refuses it with 409 suppression_not_removable, and removable on each row says so in advance.

Your inbox,
on your own terms.

Email infrastructure for businesses, AI, agents and personal email. Built for scale, privacy and control. Everything email should have had from day one.

OpenEmail

Email infrastructure for businesses, AI, agents and personal email. Built for scale, privacy and control. Everything email should have had from day one.

© 2026 OpenEmail. All rights reserved.