Send an SMS
https://api.norreach.app/v1/messagesPOST /messages with channel: "sms" sends one text to a saved contact's phone number, from one of your numbers that can text. 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.
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": "sms",
"text": "Hi Maja! Three nurses are free in June — shall I call you?",
"sender_id": "num_4d9e1f3a-5b6c-4d7e-8f9a-0b1c2d3e4f5a"
}'{
"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
Who it's from
sender_id is the id of a phone number from GET /senders that lists sms in its channels. A voice-only number (voice_only: true) can't text — it answers 422 validation_failed (sender_cannot_send). Without sender_id the workspace's default texting number is used.
Length
Up to 10,000 characters. Texts are delivered in segments: 160 characters per text in the plain alphabet (153 each when split), 70 (67) when the text has emoji or characters outside it — each segment is a text on the bill.
Opt-out line
Your workspace's opt-out line (such as "Reply STOP to opt out.") is added exactly as on texts sent from the app, unless your text already says STOP.
Quiet hours
Texts wait for daytime where the contact is: sent now during their quiet hours, it answers 422 send_blocked (quiet_hours); scheduled with send_at, it is held until their morning.
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
Scheduled for 9:00 the next day
{
"contact_id": "7d1c3b2a-5e4f-4a1b-9c8d-0e1f2a3b4c5d",
"channel": "sms",
"text": "Good morning Maja — still on for 10?",
"send_at": "2026-11-21T09:00:00+01:00"
}Authorizations
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
Run every check and return what would happen, changing nothing.
Headers
Any unique string (≤ 255). The same key with the same request returns the first answer for 24 hours.
Body
application/json
An id (UUID).
emailsmswhatsappThe message (required unless template or html).
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.
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).
Send under this campaign's settings (approval mode, sending window).
Approve it now as the key's creator (needs approvals:decide).
Response
202application/json— Send a message
The request's id — poll GET /messages/{id} or GET /calls/{id} with it.
messagecallAn id (UUID).
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.
awaiting_approvalqueuedscheduleddry_runThe Approvals key (approve it with POST /approvals/{key}/approve).
Dry run: what would happen.
Show child attributesHide child attributes
awaiting_approvalqueuedscheduledMessages: not before this time (UTC) — send_at, normalized. Quiet hours and the sending window can move it later.
Messages: the sender asked for (null = the workspace's default for the channel).
Errors
Errors are application/problem+json with a stable code.
| Status | Code | Meaning |
|---|---|---|
| 400 | bad_request | The request couldn't be read. |
| 400 | unknown_field | The request has a field this operation doesn't know. |
| 400 | idempotency_key_required | This operation needs an Idempotency-Key header. |
| 401 | unauthorized | A valid API key is required. |
| 403 | insufficient_scope | This API key doesn't have the scope this operation needs. |
| 403 | feature_not_enabled | The API isn't switched on for this workspace. |
| 403 | role_forbidden | The key's creator's role can't do this. |
| 404 | not_found | That doesn't exist. |
| 409 | idempotency_in_progress | A request with this Idempotency-Key is still running. |
| 409 | outreach_paused | Outreach is paused for this workspace. |
| 409 | no_sender | A channel this needs has no connected sender. |
| 413 | too_large | The request body is too large. |
| 422 | validation_failed | The request has invalid fields. |
| 422 | idempotency_mismatch | This Idempotency-Key was used with a different request. |
| 422 | send_blocked | This can't be sent to this person. |
| 429 | rate_limited | Too many requests. Slow down and try again. |
| 429 | send_cap_reached | The workspace's sending limit for now is reached. |
| 500 | server_error | Something went wrong on our side. |
| 503 | unavailable | The API isn't available right now. Try again shortly. |