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
contactscommand takes, trimmed and lower cased, so[email protected]and[email protected]are one contact. An audience has anaud_id, a broadcast abrd_id, and a suppression the idsuppressions listprints. - Every contact is in the default audience for as long as it exists. That audience cannot be deleted, emptied or thinned, and
builtinisdefaulton 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,editandrm. Insuppressions, whose verbs areaddandremove,newleads toaddandrmtoremove.
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.
| Command | What it does |
|---|---|
| openemail contacts list | One 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-people | Everyone 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.
| Command | What it does |
|---|---|
| openemail audiences list | One page of the audiences, the default one first and the rest newest first, each with its contactCount |
| openemail audiences growth | How 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.
| Command | What 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 list | One 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.
| Command | What it does |
|---|---|
| openemail suppressions list | One 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:
| Scope | Commands |
|---|---|
| contacts:read | contacts list, get and list-people |
| contacts:write | contacts create, update, delete, save, delete-many, set-photo and remove-photo, and audiences import-contacts beside audiences:write |
| audiences:read | audiences list, growth, get and list-contacts, and broadcasts preview, so a key that cannot send can still show the count |
| audiences:write | Every other audiences command, and contacts set-audiences. contacts create --audience-ids needs it beside contacts:write |
| threads:read | contacts list-threads and activity, and the addresses seen in mail in list-people |
| settings:read | suppressions list and get |
| settings:write | suppressions add and remove, and contacts block and unblock |
| emails:read | broadcasts list, get, stats, list-recipients and get-recipient |
| emails:send | broadcasts 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 422capability_unsupportedbycontacts list-threads,activity,blockandunblock, and bysuppressions addandremove. - A browser sign-in by a member who reaches only some addresses is refused with 422
capability_unsupportedon everycontacts,audiencesandbroadcastscommand.suppressions addrefuses 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.
[ { "email": "[email protected]", "name": "Ada Lovelace" }, { "email": "[email protected]", "name": "Grace Hopper" }, { "email": "[email protected]" }]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.
{ "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"}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 daySee who a broadcast did not reach. --ndjson prints one recipient per line, and --all --json one document with every page.
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 50Copy 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.
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.
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.txtStop sending to an address, allow one again, and block a sender. removable says which rows suppressions remove will take.
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:
| Namespace | Asks to confirm |
|---|---|
| contacts | delete, delete-many, remove-photo and unblock |
| audiences | delete, empty, remove-contact and remove-contacts |
| broadcasts | send and cancel |
| suppressions | remove |
--yesconfirms for you. Unattended, with--jsonor--no-input, in CI or without a terminal, a command that would ask stops withRefusing to run unattended. Pass --yes to confirm.and exit code2.--dry-runprints the request the command would send and exits with code0, without asking and without changing anything.- With a browser sign-in,
audiences deleteasks for a verification code first, as the web app does.--yesnever skips it, and unattended the command stops with exit code4. Runopenemail verifybeforehand, or use an API key, which is never asked. audiences emptynever 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:
--allreads 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.--jsonprints one{ items, hasMore, nextCursor }document,--allincluded.- A malformed or stale cursor is a 400
invalid_cursor. Start again without one.
| Command | Page size |
|---|---|
| openemail contacts list | 1 to 200, 50 unless --limit says otherwise |
| openemail contacts list-people | 1 to 100, 25 unless --limit says otherwise |
| openemail contacts list-threads | 1 to 100, 25 unless --limit says otherwise |
| openemail audiences list | 1 to 100, 25 unless --limit says otherwise |
| openemail audiences list-contacts | 1 to 200, 50 unless --limit says otherwise |
| openemail broadcasts list | 1 to 100, 25 unless --limit says otherwise |
| openemail broadcasts list-recipients | 1 to 200, 50 unless --limit says otherwise |
| openemail suppressions list | 1 to 100, 25 unless --limit says otherwise |
Good to know
contacts createrefuses an address already in the book with 409contact_exists, so a retry never overwrites a name somebody edited.contacts savenever refuses: it saves, keeps or brings back the address, whatever state it is in.contacts deletealso takes an address that was only ever seen in mail, which takes that person offlist-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 updatecannot change it. Moving a contact is adeleteand acreate. contacts set-photoreads the image from a file, or from stdin with-. Pass--content-type, such asimage/jpeg: without it the image can go asapplication/octet-stream, which the server refuses with 422invalid_image.broadcasts send --scheduled-attakes an ISO 8601 time such as2026-10-01T09:00:00Z, or an ISO 8601 duration such asPT2HorP1D, up to 365 days out. The short delayssend --attakes, such as2h, are refused here.- Merge fields work in
--subject,--htmland--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-keytobroadcasts sendwhen 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
unsubscribedAtset, and later broadcasts to that audience skip it.audiences list-contacts --statuses unsubscribedlists them. - A hard bounce stays on the suppression list.
suppressions removerefuses it with 409suppression_not_removable, andremovableon each row says so in advance.