Add customer messaging to your product with one API.
Send messages through managed lines, receive customer replies through webhooks, and build two-way messaging directly into your application. iMessage where available, SMS fallback, one REST contract.
Credentials are provisioned with your managed line by our team — no fake instant signup, no sandbox that pretends to deliver. What you see below is the real contract.
POST /v1/send-message
X-API-Key: key_••••••••••••••••
X-API-Secret: secret_••••••••••••
{
"number": "+14155550123",
"content": "Hi Sarah — your appointment
is confirmed for tomorrow at 2 PM.",
"line_handle": "8eb3ecaf-6487-485d-
b01c-b18bdcb57180"
}// Run the example to see the live shapeInteractive preview with example data — nothing is sent. Shapes match the live-captured API fixtures.
Send your first message.
The smallest successful integration: credentials in, one POST out, one webhook back. Every response shape below is a live capture from the production API contract.
1 Get API access
You need three things: a SendiMessage organization, an active managed line, and an API key + secret. All three are provisioned by our team after a short use-case review — there is no self-serve signup, and your credentials are delivered securely, shown exactly once.
2 Set credentials
Export both values server-side, then hit GET /me — a read-only credential check that confirms the pair works before you send anything.
export SENDIMESSAGE_API_KEY="key_..."export SENDIMESSAGE_API_SECRET="secret_..."# Verify the pair without sending anything:curl "https://api.sendimessage.com/v1/me" \--header "X-API-Key: ${SENDIMESSAGE_API_KEY}" \--header "X-API-Secret: ${SENDIMESSAGE_API_SECRET}"
3 Send a message
One POST. The 201 response returns status: "QUEUED" and a message_handle — sending is asynchronous, and the channel actually used (iMessage or SMS) is reported after the send confirms, on GET /status or the outbound webhook.
curl --request POST \--url "https://api.sendimessage.com/v1/send-message" \--max-time 15 \--header "Accept: application/json" \--header "Content-Type: application/json" \--header "X-API-Key: ${SENDIMESSAGE_API_KEY}" \--header "X-API-Secret: ${SENDIMESSAGE_API_SECRET}" \--data '{"number": "+15551234567","content": "Hello from the API","media_url": null,"status_callback": "https://example.com/callbacks/status","line_handle": "8eb3ecaf-6487-485d-b01c-b18bdcb57180"}'
4 Receive the reply
Register a webhook subscribed to the receive event. When the customer answers, the reply reaches your endpoint as one signed POST — flat JSON with from, content and the conversation_handle that groups the thread.
curl --request POST \--url "https://api.sendimessage.com/v1/account/webhooks" \--header "Content-Type: application/json" \--header "X-API-Key: ${SENDIMESSAGE_API_KEY}" \--header "X-API-Secret: ${SENDIMESSAGE_API_SECRET}" \--data '{"url": "https://example.com/webhooks/sendimessage","secret": "your-signing-secret","events": ["receive", "outbound"]}'
Verify the SendiMessage-Signature header before trusting any payload — the docs include a worked example you can check byte-for-byte, plus Node.js and Python verification code.
Try the SendiMessage flow.
Interactive API preview — build the real request, then run the delivery lifecycle. Nothing is sent; stage names are simulation stages, and the response panels use the live-captured contract shapes.
Routing is automatic (iMessage-first, SMS fallback) — there is no channel field on the real request.
A line is your sending identity.
A managed line is the messaging identity provisioned for your organization — a real number we operate for you. Your API credential sees every line in your organization; a send picks one by its line_handle, or omits it to let any active line send.
limitscount new conversations only — replies inside existing threads are never limited.- Limit values are per-line configuration set at provisioning: read them from
GET /lines, don't hardcode them. - A blocked line carries
blocked_at/block_reasonand answers 422 to sends until the block lifts (you getline_blocked/line_unblockedwebhooks).
{"status": "ok","lines": [{"number": "+15555550100","label": "Support line","is_active": true,"blocked_at": null,"block_reason": null,"limits": {"hourly": { "limit": 25, "remaining": 24 },"daily": { "limit": 120, "remaining": 119 }},"line_handle": "8eb3ecaf-6487-485d-b01c-b18bdcb57180"}]}
Five statuses, no surprises.
| Status | Meaning |
|---|---|
QUEUED | Accepted, waiting for the sending device. The state of every 201 response. |
SENT | The device sent it — service now says iMessage or SMS. |
DELIVERED | History rows only, once the device confirms delivery. |
RECEIVED | An inbound message — history rows and the receive webhook. |
ERROR | The send failed — error_message says why. |
SENT ≠ DELIVERED. SENT means the device handed the message off; DELIVERED requires a confirmation and only ever appears on history rows.
You send. The line picks the channel.
- There is no channel selector on the send request — routing is automatic, iMessage-first with SMS fallback. Fallback is one-way (never SMS → iMessage) and can't be toggled per request.
serviceisnullon the 201 — the channel used comes back after the device confirms, asiMessageorSMS(history rows can also carryRCS).- Want to know ahead of time?
GET /lookup?number=…predicts the recipient's service (7-day cache, 202 while a live check runs), and contacts learn a sticky service from real traffic.
Receive customer replies.
Replies arrive on your line, thread into a conversation, and reach your application as one signed receive event — the exact payload below is the webhook for the reply on the phone.
{"event": "receive","message_handle": "96699B14-AFDB-4B33-AA66-C4B093533DA3","from": "+14155550123","to": "+15555550100","content": "Can we move it to 3 PM?","media_url": null,"service": "iMessage","is_outbound": false,"status": "RECEIVED","date_sent": "2026-08-21T09:07:35Z","conversation_handle": "ee60f9b5-2ddb-4ea8-8ad7-bc70cf85727f"}
conversation_handle groups the whole thread — page it with GET /v2/messages when your application needs context. STOP-family keywords set durable opt-out state automatically.
Four events. Real payloads.
Every payload below matches the current dispatch code exactly — id, type, created_at, and a data envelope. No invented fields.
A per-message status_callback URL is accepted on send but not currently delivered — use an account-level webhook or poll GET /status for status updates.
{
"id": "evt_01j6x6z9k3f8v9m2n4p5q6r7s8",
"type": "receive",
"created_at": "2026-08-21T09:07:35+00:00",
"data": {
"message": {
"message_handle": "msg_01j6x6z6k3f8v9m2n4p5q6r7s8",
"direction": "inbound",
"from": "+14155550123",
"to": "+15555550100",
"text": "Can we move it to 3 PM?",
"channel": "imessage",
"status": "received",
"line_handle": "line_01j6x6yak3f8v9m2n4p5q6r7s8"
}
}
}Fires once per new inbound message, the moment the line reports it.
Delivery semantics — the honest version
- Delivery
- One signed HTTP POST per event, retried on failure with exponential backoff (up to 6 attempts, capped at 12h between attempts)
- Timeout
- 5 seconds
- Your response
- Not inspected — respond 2xx fast and process asynchronously
- Signature
- SendiMessage-Signature: t={timestamp},v1={hex HMAC-SHA256 of "{timestamp}.{raw body}"} (when a secret is set)
- Endpoint disabling
- An endpoint is disabled after 10 cumulative delivery failures
- status_callback
- Accepted on send but not currently delivered — use webhooks or poll GET /status instead
- Missed events
- Recover by reconciling from GET /v2/messages
GET /v2/messages, so a missed or delayed delivery is always recoverable by reconciliation.Signature you can verify right now
secret: example-signing-secret
timestamp: 1755766055
body: {"id":"evt_01j6x6z9k3f8v9m2n4p5q6r7s8","type":"receive","created_at":"2026-08-21T09:07:35+00:00"…
header: t=1755766055,v1=3205662b519f85a6ce7bd748e52d419ed03730876448e7a7c9d50dd4efd4b3d8
# hex HMAC-SHA256 of "{timestamp}.{raw body}" — verify against the exact
# raw bytes received; a re-serialized copy will not match.Also check that the timestamp is recent — verification should reject a header older than a few minutes to guard against replay. Verification code in Node.js and Python (and the SDK helper verifyWebhookSignature) lives in Webhook Signatures.
Official SDKs, or plain HTTP.
Four dependency-free official clients cover the full public API — including the verifyWebhookSignature helper. Every other ecosystem gets raw-HTTP examples against the same contract, so nothing you build depends on an SDK.
npm install sendimessagepip install sendimessagecomposer require sendimessage/sdkgo get github.com/sendimessage/sendimessage-goPlain HTTP — copy-paste examples in the referenceThe errors you'll actually meet.
The API returns human-readable messages, not machine codes — branch on the HTTP status first. These six cover almost every integration mistake; the Errors guide documents both response shapes and the full message list.
401Unauthenticated.
X-API-Key / X-API-Secret missing, wrong, or revoked. Check both headers; verify with GET /me.
422content or media_url required
Send at least one of content / media_url on POST /send-message.
422Unknown or inactive line
line_handle doesn't match an active line — list your lines with GET /lines.
422Contact has opted out of messages
The recipient opted out. Treat as a stop signal, never retry; they can reply START to opt back in.
422Line is blocked
The line is blocked (see line_blocked webhook). Wait for line_unblocked or send via another line.
429Too Many Attempts.
Rate limit hit — honor the Retry-After header, then retry.
API rate limits ≠ line limits.
HTTP request limits
- All requests
- 300 / min per credential — all key pairs issued to the same account admin share one bucket
- Sends
- 60 / min per account (POST /send-message)
- Media uploads
- 30 / min per account (POST /media)
- Lookups
- 20 / min per account (GET /lookup)
- Headers
- X-RateLimit-Limit + X-RateLimit-Remaining on every response; 429 adds Retry-After
Messaging line limits
- What they cap
- New conversations started by a line — replies inside existing threads are never limited
- Where to read them
- GET /lines → limits.hourly / limits.daily with live remaining counters
- Values
- Per-line configuration set at provisioning — read them from the API, don't hardcode
- When exceeded
- Sending is paced/held by the platform — you never get a 429 from a line limit
Three well-trodden paths.
Build messaging into an AI agent
Your agent decides what to say; the API sends it and replies come back as webhooks. SendiMessage is the transport, not the reasoning.
Read the guide →Continue a Voice AI call over text
Call ends → confirmation text → customer replies → webhook → workflow continues. Platform-neutral.
Read the guide →Connect your CRM
Lead triggers a message; the reply lands on the CRM timeline via the receive webhook. SendiMessage is the messaging layer, not a CRM.
Read the guide →Plan, test, and debug your SendiMessage integration before going live.
Learn by watching.
Short walkthroughs that pair with the documentation — every request uses the verified API contract.
Ready when you are.
Evaluating? The pricing is public. Ready? Request a line and our team provisions it with you. Unsure? Book a technical demo and watch the API → message → reply → webhook loop live.