Docs
Messaging

Send an email

POSThttps://api.norreach.app/v1/messages

POST /messages with channel: "email" sends one email to a saved contact, from one of your connected inboxes or domain senders. Asks for a new email, SMS or WhatsApp message to a SAVED contact — never a free-typed address, so every message goes to someone the workspace holds a record (and a lawful basis) for. It is an API send: it waits in Approvals when the workspace (or campaign_id's campaign) approves before sending, or when the key lacks approvals:decide; approve: true (with approvals:decide) approves it in the same request. When it goes out the engine runs the full send gate again (suppression, do-not-contact, lawful basis, quiet hours, sending window). Up front: blocked → 422 send_blocked with reason; the API's hourly cap (500 messages per workspace) → 429 send_cap_reached. Optional: sender_id (an id from GET /senders), send_at (schedule it, up to 30 days ahead; cancel with POST /messages/{id}/cancel) and, for email, html. WhatsApp outside the 24-hour window needs an approved template. Poll the result with GET /messages/{id}, or subscribe to the message.* webhook events.

⚠ messages:sendRole must allow: inbox.replyIdempotency-Key requiredSupports dry_runReaches peopleOperation id: send_message
POST/messages
curl -X POST "https://api.norreach.app/v1/messages" \
  -H "Authorization: Bearer $NORREACH_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
  "contact_id": "7d1c3b2a-5e4f-4a1b-9c8d-0e1f2a3b4c5d",
  "channel": "email",
  "subject": "Nurses for the summer",
  "html": "<p>Hi {{first_name}},</p><p>We have <b>three nurses</b> free in June. <a href=\"https://www.example.com/staffing\">See who</a>.</p>",
  "sender_id": "dom_3c8d0e2f-4a5b-4c6d-9e7f-8091a2b3c4d5.anna"
}'
Send a message
{
  "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",
  "note": "It waits in Approvals until someone says yes."
}

Channel rules

Subject, text and HTML

subject is required. Send text, html, or both: with only html, the plain-text version is made from it; with both, text is the plain-text alternative. HTML is made safe on our side — formatting, links, images, tables and inline styles stay; scripts, style sheets, forms, frames, embeds and event handlers are removed; links must be http(s) or mailto, images http(s). In HTML, merge fields such as {{first_name}} and {{company}} are filled in when it goes out (with the contact's values made safe for HTML).

Who it's from

sender_id is the id of an email inbox or a domain sender address from GET /senders. Without it a new email goes out the workspace's usual way, and a reply from the conversation's own inbox. A sender that is disconnected by the time it goes out sends nothing — the message fails with that reason; it never switches to another sender.

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 — bulk outreach — carry the unsubscribe link and open / click tracking; run those as a campaign.

Attachments

Not available through the API yet — send files as links.

Contacts only

contact_id is a saved contact — there is no field for a typed-in address or number. The contact's own email or phone is used, and only if they have a recorded lawful basis and aren't on your suppression or do-not-contact lists.

Approvals and following it

Every message is an API send: it waits in Approvals when the workspace approves before sending or the key lacks approvals:decide; with approvals:decide, approve: true approves it in the same request. Poll GET /messages/{id} with the answer's id, or subscribe to the message.* webhook events.

Scheduling

Add send_at (RFC 3339 with a zone, in the future, at most 30 days ahead) and nothing goes out before then. The send checks still run at that time, so quiet hours or your sending window can move it to the next allowed moment. It can be approved before then; until it goes out, POST /messages/{id}/cancel stops it.

Many at once

To send to many contacts, use POST /messages/batch: up to 100 items in one request, each with its own result.

More examples

Plain text, scheduled

JSON
{
  "contact_id": "7d1c3b2a-5e4f-4a1b-9c8d-0e1f2a3b4c5d",
  "channel": "email",
  "subject": "Quick question",
  "text": "Hi Maja, do you have five minutes on Thursday?",
  "send_at": "2026-11-20T08:30:00+01:00"
}

Approve it in the same request (needs approvals:decide)

JSON
{
  "contact_id": "7d1c3b2a-5e4f-4a1b-9c8d-0e1f2a3b4c5d",
  "channel": "email",
  "subject": "Your offer",
  "text": "Hi Maja, here it is.",
  "approve": true
}

Authorizations

Authorizationstringheaderrequired

Bearer authentication: send Authorization: Bearer <key>, with an API key or an OAuth access token. The key needs the scope messages:send. API keys and scopes

Query parameters

dry_runbooleanquery

Run every check and return what would happen, changing nothing.

Headers

Idempotency-Keystringheaderrequired

Any unique string (≤ 255). The same key with the same request returns the first answer for 24 hours.

Body

application/json

contact_idstring (uuid)required

An id (UUID).

channelenumrequired
One of:emailsmswhatsapp
subjectstring

Email: the subject line (required for email).

textstring

The message (required unless template or html).

htmlstring

Email only: the email as HTML. It is made safe on our side (formatting, links, images, tables and inline styles are kept; scripts, forms, frames and event handlers are removed; links http(s)/mailto only). text is its plain-text alternative — derived from the HTML when you leave it out. Your signature and opt-out line are added exactly as for messages sent from the app.

sender_idstring

Send from this sender: an id from GET /senders — one of the workspace's own connected senders for the channel (an email inbox or domain sender address; for SMS a number that can text — voice-only numbers can't; for WhatsApp the workspace's business number). Default: the workspace's usual sender for the channel.

send_atstring

Schedule it: an RFC 3339 time with a zone (e.g. 2026-11-20T09:00:00+01:00), in the future and at most 30 days ahead. Nothing goes out before it; the send checks still apply at that time, so quiet hours or your sending window can move it to the next allowed moment. It can be approved before then, and cancelled until it goes out (POST /messages/{id}/cancel).

campaign_idstring (uuid)

Send under this campaign's settings (approval mode, sending window).

approveboolean

Approve it now as the key's creator (needs approvals:decide).

Response

202application/json— Send a message

object"send_request"required
idstring (uuid) | nullrequired

The request's id — poll GET /messages/{id} or GET /calls/{id} with it.

kindenumrequired
One of:messagecall
channelstringrequired
contact_idstring (uuid)required

An id (UUID).

statusenumrequired

awaiting_approval = it waits in Approvals; queued = approved, it goes out within a minute through the send checks; scheduled = approved, it goes out at scheduled_for through the send checks.

One of:awaiting_approvalqueuedscheduleddry_run
approval_keystring | nullrequired

The Approvals key (approve it with POST /approvals/{key}/approve).

notestringrequired
wouldobject

Dry run: what would happen.

Show child attributes
would.statusenumrequired
One of:awaiting_approvalqueuedscheduled
scheduled_forstring | null

Messages: not before this time (UTC) — send_at, normalized. Quiet hours and the sending window can move it later.

sender_idstring | null

Messages: the sender asked for (null = the workspace's default for the channel).

Errors

Errors are application/problem+json with a stable code.

StatusCodeMeaning
400bad_requestThe request couldn't be read.
400unknown_fieldThe request has a field this operation doesn't know.
400idempotency_key_requiredThis operation needs an Idempotency-Key header.
401unauthorizedA valid API key is required.
403insufficient_scopeThis API key doesn't have the scope this operation needs.
403feature_not_enabledThe API isn't switched on for this workspace.
403role_forbiddenThe key's creator's role can't do this.
404not_foundThat doesn't exist.
409idempotency_in_progressA request with this Idempotency-Key is still running.
409outreach_pausedOutreach is paused for this workspace.
409no_senderA channel this needs has no connected sender.
413too_largeThe request body is too large.
422validation_failedThe request has invalid fields.
422idempotency_mismatchThis Idempotency-Key was used with a different request.
422send_blockedThis can't be sent to this person.
429rate_limitedToo many requests. Slow down and try again.
429send_cap_reachedThe workspace's sending limit for now is reached.
500server_errorSomething went wrong on our side.
503unavailableThe API isn't available right now. Try again shortly.