Sending email, SMS & WhatsApp
One operation sends on every channel: POST /messages, with channel set to email, sms or whatsapp. The API reference also has a page per channel — Email, SMS and WhatsApp — with that channel's fields, examples and Try it. Everything here needs the messages:send scope (reading where a send stands: inbox:read).
curl -X POST https://api.norreach.app/v1/messages \
-H "Authorization: Bearer $NORREACH_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"contact_id": "7d1c3b2a-5e4f-4a1b-9c8d-0e1f2a3b4c5d",
"channel": "email",
"subject": "Nurses for the summer",
"html": "<p>Hi {{first_name}},</p><p>We have three nurses free in June.</p>",
"sender_id": "dom_3c8d0e2f-4a5b-4c6d-9e7f-8091a2b3c4d5.anna",
"send_at": "2026-11-20T08:30:00+01:00"
}'{
"object": "send_request",
"id": "5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b",
"kind": "message",
"channel": "email",
"contact_id": "7d1c3b2a-5e4f-4a1b-9c8d-0e1f2a3b4c5d",
"status": "awaiting_approval",
"approval_key": "rule_send:5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b",
"scheduled_for": "2026-11-20T07:30:00.000Z",
"sender_id": "dom_3c8d0e2f-4a5b-4c6d-9e7f-8091a2b3c4d5.anna",
"note": "Scheduled for 2026-11-20T07:30:00.000Z (UTC). It waits in Approvals; once approved it goes out at that time …"
}Saved contacts only
A message always goes to a contact saved in the workspace (contact_id) — there is no field for a typed-in email address or phone number. That is on purpose: the contact is where the lawful basis, the suppression and do-not-contact lists, the time zone (for quiet hours) and the conversation history live, so every check below has something to check against, and every reply lands on the right person in the inbox. Create or update the contact first (POST /contacts, with the basis you hold in consent), then send.
Approvals
Every message the API asks for is one item in Approvals (kind api_message) before anything is sent. It waits for a person when the workspace — or the campaign_id campaign — approves before sending, and always when the key lacks approvals:decide. With approvals:decide it is approved at once in a workspace that sends without approval, or when you pass approve: true. The answer says which: awaiting_approval (with an approval_key for POST /approvals/{key}/approve), queued, or scheduled. Someone can still change the words when they approve it; the edited words are what goes out.
The send checks
- Up front, before anything is written: the suppression and do-not-contact lists, a recorded lawful basis, and quiet hours where the contact is (texts and WhatsApp) — blocked answers 422
send_blockedwith areason(suppressed, do_not_contact, no_consent, quiet_hours, no_address, whatsapp_window_closed …). - Again when it goes out, in full: the same lists, the basis, quiet hours and the sending window. A send held by them waits for the next allowed moment; one blocked fires
message.blockedwith the reason. - Pausing all outreach (in the app, or POST /workspace/outreach/pause) stops everything: new sends answer 409
outreach_paused, and approved ones wait.
Limits
At most 500 messages per workspace per rolling hour through the API, on top of the workspace's own sending limits (Settings › Compliance & limits). Over it: 429 send_cap_reached with limit and retry_after. In a batch every accepted item counts; once the hour's allowance is used, the remaining items answer send_cap_reached one by one.
Choosing the sender
GET /senders lists what the workspace can send from, each with an id. Pass one as sender_id (on a single message, a reply, or a batch item):
curl https://api.norreach.app/v1/senders \
-H "Authorization: Bearer $NORREACH_API_KEY"| Channel | Senders you can name | Without sender_id |
|---|---|---|
| An email inbox, or one From address of a verified domain sender (a domain is listed once per address). | The conversation's own inbox for a reply; the workspace's usual way for a new email. | |
| sms | A phone number whose channels include sms. Voice-only numbers can't text. | The workspace's default texting number. |
| The workspace's verified WhatsApp business number. | The number the contact last wrote to, else the workspace's number. |
Another workspace's sender, a typed-in address or an unknown id answers 422 validation_failed (unknown_sender); a sender that can't send on the channel — a voice-only number for SMS, an inbox for WhatsApp — answers 422 (sender_cannot_send); WhatsApp not connected at all answers 409 no_sender. The sender is checked again when the message goes out: if it was disconnected meanwhile, nothing is sent and the message fails with that reason — it never switches to another sender.
Scheduling
Add send_at — an RFC 3339 time with a zone, in the future, at most 30 days ahead — and nothing goes out before it. It can be approved any time before then (the answer is scheduled once approved). At that time the send checks run as usual, so if it lands in the contact's quiet hours or outside your sending window it waits for the next allowed moment — it doesn't fail. Quiet hours don't refuse a scheduled message up front (every other check does). Free WhatsApp text must still be inside the contact's 24-hour window at that time (else 422 whatsapp_window_closes — send a template).
Until it goes out — waiting in Approvals, or approved and waiting for its time — cancel it:
curl -X POST https://api.norreach.app/v1/messages/5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b/cancel \
-H "Authorization: Bearer $NORREACH_API_KEY"Cancelled: nothing is sent, the item leaves Approvals, it is in the audit log and the message.canceled event fires. Cancelling twice answers 200 with changed: false. Once the engine has picked it up to send (within about a minute of it being due and approved) it answers 409 conflict; one a person skipped answers 409 already_decided.
Many at once
POST /messages/batch takes up to 100 items. Each item is exactly one POST /messages — its own contact, checks, approval and result — and fields shared by all can go in defaults (an item's own fields win). approve applies to every item, with the same rules as one message.
curl -X POST https://api.norreach.app/v1/messages/batch \
-H "Authorization: Bearer $NORREACH_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"defaults": { "channel": "sms", "text": "Hi! Three nurses are free in June — shall I call you?" },
"items": [
{ "contact_id": "7d1c3b2a-5e4f-4a1b-9c8d-0e1f2a3b4c5d" },
{ "contact_id": "8e2d4c3b-6f5a-4b2c-8d9e-1f2a3b4c5d6e" },
{ "contact_id": "9f3e5d4c-7a6b-4c3d-9e0f-2a3b4c5d6e7f", "channel": "email", "subject": "June", "text": "Hi! Three nurses are free in June." }
]
}'The answer is 200 with one entry per item in input order: status 202 and the item's result (its id works with GET /messages/{id} and the cancel), or the item's own problem — one bad item never stops the others. One Idempotency-Key covers the whole batch: retrying with it answers the same per-item results without sending anything twice. ?dry_run=true checks every item and writes nothing.
Email: HTML, footer and attachments
subjectis required. Sendtext,htmlor both — with onlyhtmlthe plain-text version is made from it, with bothtextis the plain-text alternative.- HTML is made safe on our side, when it arrives and again when it goes out: formatting, links, images, tables and inline styles stay; scripts, style sheets, forms, frames, embeds, media and event handlers are removed; links must be http(s) or mailto, images http(s). Merge fields such as {{first_name}} are filled in when it goes out, with the contact's values made safe for HTML.
- A one-to-one email gets exactly what the same message sent from the app's inbox gets: your inbox's signature, and its opt-out line where you set one up. Campaign emails carry the unsubscribe link and open / click tracking — run bulk outreach as a campaign (POST /campaigns/{id}/launch).
- Attachments aren't available through the API yet — link to files instead.
SMS
- Up to 10,000 characters; delivered in segments of 160 characters (153 each when split) in the plain alphabet, 70 (67) with emoji or other characters.
- Your workspace's opt-out line (such as "Reply STOP to opt out.") is added as on texts sent from the app, unless your text already says STOP.
- Texts wait for daytime where the contact is (quiet hours).
WhatsApp: the 24-hour window and templates
Free text can only be sent within 24 hours of the contact's last WhatsApp message to you. Outside that window — and to start a conversation — send an approved template: GET /templates lists them with their review state (only APPROVED ones send), and params fill its variables in order, one per variable.
{
"contact_id": "7d1c3b2a-5e4f-4a1b-9c8d-0e1f2a3b4c5d",
"channel": "whatsapp",
"template": { "name": "follow_up", "language": "sv", "params": ["Maja"] }
}Replies in a conversation
POST /threads/{id}/reply answers on the conversation's own channel and takes the same sender_id, send_at, html (email) and template (WhatsApp) fields, with the same approvals and checks. LinkedIn conversations are answered in the app.
Following a send
Poll GET /messages/{id} with the id you got: awaiting_approval, scheduled, canceled, skipped, queued, blocked (with reason), sent or failed — and the message itself once it was written. Or subscribe a webhook endpoint to these events:
| Event | When |
|---|---|
message.received | A reply or a new message arrived (email, SMS, WhatsApp or LinkedIn). |
message.sent | A message went out. |
message.delivered | An email was delivered. |
message.opened | An email was opened. |
message.clicked | A link in an email was clicked. |
message.bounced | An email bounced. |
message.blocked | A send was stopped by a rule (`data.reason`: suppressed, no_consent, quiet_hours, do_not_contact …). |
message.failed | A send failed. |
message.canceled | A message asked for through the API was cancelled before it went out (POST /messages/{id}/cancel; `data.object.id` is the send's id). |
approval.requested | Something waits in the Approvals queue. |