Docs
Messaging

Send many messages

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

Up to 100 messages in one request — each item exactly one POST /messages: its own saved contact, its own send checks, its own approval (approve applies to every item, with the same rules), its own result. Fields shared by every item can go in defaults (an item's own fields win). The answer lists every item IN INPUT ORDER: status 202 with its result (poll each result.id with GET /messages/{id}), or the item's own problem (404 not_found, 422 send_blocked / validation_failed, 429 send_cap_reached …) — one bad item never stops the others. The hourly cap (500 API messages per workspace) counts every accepted item: items are taken in order, and once the hour's allowance is used up the remaining items answer send_cap_reached individually. One Idempotency-Key covers the whole batch (a replay answers the same per-item results); ?dry_run=true checks every item and changes nothing.

⚠ messages:sendRole must allow: inbox.replyIdempotency-Key requiredSupports dry_runReaches peopleOperation id: send_messages_batch
POST/messages/batch
curl -X POST "https://api.norreach.app/v1/messages/batch" \
  -H "Authorization: Bearer $NORREACH_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
  "defaults": {
    "channel": "email",
    "subject": "Nurses for the summer",
    "html": "<p>Hi {{first_name}},</p><p>We have nurses free in June.</p>"
  },
  "items": [
    {
      "contact_id": "7d1c3b2a-5e4f-4a1b-9c8d-0e1f2a3b4c5d"
    },
    {
      "contact_id": "8e2d4c3b-6f5a-4b2c-8d9e-1f2a3b4c5d6e",
      "channel": "sms",
      "text": "Hi! Nurses free in June — shall I call you?"
    }
  ]
}'
Send many messages
{
  "object": "batch",
  "data": [
    {
      "index": 0,
      "status": 202,
      "result": {
        "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.",
        "scheduled_for": null,
        "sender_id": null
      }
    },
    {
      "index": 1,
      "status": 422,
      "problem": {
        "type": "https://api.norreach.app/problems/send_blocked",
        "title": "This can't be sent to this person.",
        "status": 422,
        "code": "send_blocked",
        "detail": "The contact is on your suppression list.",
        "reason": "suppressed",
        "docs": "https://docs.norreach.app/errors#send_blocked"
      }
    }
  ],
  "counts": {
    "accepted": 1,
    "failed": 1
  },
  "dry_run": false
}

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

itemsobject[]required

The messages, at most 100.

Show child attributes
items[].contact_idstring (uuid)required

An id (UUID).

items[].channelenum

Required here or in defaults.

One of:emailsmswhatsapp
items[].textstring

The message (required unless template or html).

items[].subjectstring

Email: the subject line (required for email).

items[].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.

items[].templateobject

WhatsApp only: an approved template (required outside the 24-hour window).

Show child attributes
items[].template.namestringrequired

An approved WhatsApp template of this workspace (GET /templates).

items[].template.languagestring

Its language code, e.g. sv or en_US (default: the template's own).

items[].template.paramsstring[]

Values for the template's variables, in order.

items[].campaign_idstring (uuid)

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

items[].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.

items[].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).

defaultsobject

Shared fields: every item that leaves a field out takes it from here.

Show child attributes
defaults.channelenum
One of:emailsmswhatsapp
defaults.textstring

The message (required unless template or html).

defaults.subjectstring

Email: the subject line (required for email).

defaults.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.

defaults.templateobject
Show child attributes
defaults.template.namestringrequired

An approved WhatsApp template of this workspace (GET /templates).

defaults.template.languagestring

Its language code, e.g. sv or en_US (default: the template's own).

defaults.template.paramsstring[]

Values for the template's variables, in order.

defaults.campaign_idstring (uuid)

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

defaults.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.

defaults.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).

approveboolean

Approve every accepted item now as the key's creator (needs approvals:decide).

Response

200application/json— Send many messages

object"batch"required
dataobject[]required

One entry per item, in input order.

Show child attributes
data[].indexintegerrequired

The item's position in items (0-based).

data[].statusintegerrequired

What this item alone would have answered: 202 (200 on a dry run), else its problem's status.

data[].resultobject

The send request, when it was accepted.

Show child attributes
data[].result.object"send_request"required
data[].result.idstring (uuid) | nullrequired

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

data[].result.kindenumrequired
One of:messagecall
data[].result.channelstringrequired
data[].result.contact_idstring (uuid)required

An id (UUID).

data[].result.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
data[].result.approval_keystring | nullrequired

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

data[].result.notestringrequired
data[].result.wouldobject

Dry run: what would happen.

Show child attributes
data[].result.would.statusenumrequired
One of:awaiting_approvalqueuedscheduled
data[].result.scheduled_forstring | null

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

data[].result.sender_idstring | null

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

data[].problemobject

Why this item wasn't accepted — the same problem document a single POST /messages answers.

Show child attributes
data[].problem.typestringrequired
data[].problem.titlestringrequired
data[].problem.statusintegerrequired
data[].problem.codestringrequired
data[].problem.detailstring
countsobjectrequired
Show child attributes
counts.acceptedintegerrequired
counts.failedintegerrequired
dry_runbooleanrequired

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.
409idempotency_in_progressA request with this Idempotency-Key is still running.
409outreach_pausedOutreach is paused for this workspace.
413too_largeThe request body is too large.
422validation_failedThe request has invalid fields.
422idempotency_mismatchThis Idempotency-Key was used with a different request.
429rate_limitedToo many requests. Slow down and try again.
500server_errorSomething went wrong on our side.
503unavailableThe API isn't available right now. Try again shortly.