COURIER_API_KEY.
How flags build the request
Every command maps to one REST endpoint, and its flags become the request body. Three rules decide the shape, and the first two are behind most failed commands.- The first positional argument fills the command’s ID.
courier profiles retrieve user_123and--user-id user_123send the same request. A second positional is rejected, so a command with two IDs takes named flags for both. - A repeated flag builds an array.
--user,--list,--patch, and--statuseach take one item. Pass the flag once per item, not a JSON array, which the CLI rejects. - A dotted flag sets one nested field.
--message.towritesmessage.to, so you can build a send one field at a time.
--help to any command for its flags, and --debug to print the request it sent.
Send
Send a template to a user
Only a published template sends. The response carries arequestId, which is also the message ID for a single-recipient send.
Send to an email address without a template
content replaces template, so send one or the other. routing with method: single sends on the first channel in the list that can reach the recipient.
Retry a send without sending twice
The CLI sets theIdempotency-Key header. Reuse the key when a call times out or the connection drops, and Courier replays the first response instead of sending again. The replay includes errors. For 24 hours a key that got a 400 keeps returning it, so use a new key once you fix the request.
Tag a send so you can find it later
Settags or a trace_id in the send’s metadata, then filter the message list by either. A trace_id from your own system, such as an order ID, finds every message one request produced without storing Courier’s IDs.
Send a body from a file
The CLI reads a JSON request body from stdin. A flag doesn’t override a piped body. Where both set the same field, the file wins without a warning, so put everything in the file.Send to a list
Subscribe the user to the list, then send to the list ID. Every subscriber gets the message.Send in bulk
Four calls in order. The job holds the recipients, and nothing sends until you run it.message.event is required and takes a template ID. Put the email in profile.email, not to.email, because routing reads the profile. courier bulk list-users JOB_ID shows the per-recipient outcome once the job has run. See for the full walkthrough.
Debug a delivery
Read what happened to one message
history is the one to reach for first. It returns each status change with its reason, which tells you whether a message was never routed, was rejected by the provider, or was delivered and never opened. content shows what actually rendered, which separates a template bug from a delivery one.
Find failed messages
Filter by status and time, then trim the output with--transform. It takes a GJSON path, so the output is one {id, status, reason} object per message. lists what each status and reason means.
Resend or cancel a message
resend creates a new message with a new ID. cancel only stops a message that is still in the delivery pipeline.
Check a template’s delivery rates
get-metrics returns sent, delivered, opened, and clicked counts over time. The counters are nested, so every delivered message is also counted in sent. Read them as rates, not as parts of a total. covers the window limits.
Manage recipients
Create, update, and delete a profile
create merges into an existing profile. replace overwrites it, so include every field you want to keep. update takes JSON Patch operations, one --patch flag per operation.
Import profiles from a CSV
Loop over the rows and create one profile per line. End each call with< /dev/null. Without it the CLI reads the rest of the file as its request body, so the loop stops after the first row with a merge error. jq builds the JSON, which keeps a stray quote in the data from breaking the command.
Register a push token
--provider-key is firebase-fcm, apn, expo, or onesignal. Use the key of the provider that issued the token.
Issue a JWT for Inbox
Issue the token a client SDK, such as Inbox, signs in with. Theuser_id scope decides whose messages it can read, so issue one per signed-in user and never share one across users.
Manage preferences
Sendhas_custom_routing and the full custom_routing array together, because the write replaces the topic’s routing rather than merging into it.
Add a user to a tenant
tenants update creates the tenant or replaces it, so pass every field you want to keep, such as --brand-id.
Manage templates
A write leaves a draft.publish is what makes it live, and a send resolves the published version, so a content change nothing published reaches no recipient. Read the draft with --version draft before you publish it.
Run journeys and broadcasts
Start a journey for a user
Only a published journey starts.data is available to every node for conditions and template variables.
Find journey runs that failed
list-steps shows each node the run reached and what happened there, including the message ID of every send. Runs are kept for 95 days.
Cancel journey runs
Cancel by token or by run ID, not both, or the call returns400. A token cancels every run that shares it, and you set it in the journey’s settings. See .
Schedule a broadcast
--scheduled-to has no offset, because --timezone supplies it. Use broadcasts send with the same recipient flags to send now. A scheduled broadcast locks its content, so cancel with broadcasts cancel before editing it.
Release a digest early
release sends every recipient’s held digest for that schedule now, instead of at its next scheduled time.
Script the CLI
Page through every result
List commands return one page at a time.paging.more says whether another exists, and paging.cursor fetches it. The args array keeps the loop working in both bash and zsh.
Pull one field into a variable
Combine--transform with --raw-output to capture one value. --raw-output drops the JSON quotes from a string result, so the variable holds the bare ID.
Use it in CI
Pass the key from your secret store and readjson, because the default human format isn’t a stable contract. Double-quote any JSON that holds a shell variable. Inside single quotes $USER_ID is sent literally, and the send goes to a user named $USER_ID.
The CLI exits non-zero on an API error, so a failed send fails the step without extra handling.
Global flags
Every command takes these.FAQ
What can the Courier CLI do?
What can the Courier CLI do?
The Courier CLI covers the full REST API from a terminal: sends, profiles, lists, templates, journeys, broadcasts, bulk jobs, tenants, preferences, and delivery logs. Each command maps to one endpoint, so anything in the has a CLI equivalent.
Why does a Courier CLI command reject a JSON array?
Why does a Courier CLI command reject a JSON array?
A Courier CLI flag that takes a list, such as
--user, --list, --patch, or --status, takes one item per flag. Repeat the flag once per item and the CLI builds the array. Passing the whole array to one flag fails with a parse error.How do I pass two IDs to a Courier CLI command?
How do I pass two IDs to a Courier CLI command?
The Courier CLI accepts one positional argument, which fills the command’s first ID. A command that needs two, such as a user and a topic, takes named flags like
--user-id and --topic-id for both.Should I use the Courier CLI or the MCP server?
Should I use the Courier CLI or the MCP server?
Use the CLI for scripts, CI pipelines, and commands you run yourself, because its output is predictable and it exits non-zero on failure. Use the when an AI agent should decide which calls to make. Both call the same API with the same key.
Does the Courier CLI need Node.js?
Does the Courier CLI need Node.js?
The Courier CLI is a native binary. npm installs it and downloads the binary for your platform, and after that it runs without Node.js. You can also download the binary directly from GitHub Releases.