API documentation
Send SMS programmatically through the Sendy REST API. Authenticate with your workspace token and call the endpoints below. Don't have a workspace yet? Create one free.
Prefer no-code? Use Sendy with our built-in integrations - n8n, Make and Zapier wrap this same API.
Every request authenticates with the workspace API token as a bearer token. The base URL is https://sendy.co.il/api/v1. Create or rotate your token on the Developers page after you sign in.
Authorization: Bearer sk_live_8Jc…A missing or unknown token returns 401 INVALID_API_KEY. Keep the token secret - it grants full send access to this workspace's credits.
Send one SMS. to is a single Israeli mobile number (E.164 +9725XXXXXXXX or local 05XXXXXXXX; normalised & validated). from must be an approved sender (see List senders). body is up to 603 characters. The optional Idempotency-Key header makes retries safe.
mode declares the send purpose: "marketing" checks the recipient against this workspace's unsubscribe list and rejects with 403 RECIPIENT_UNSUBSCRIBED (nothing is charged); "transactional" skips that check and is your recorded declaration that the message is operational (verification codes, order updates, reminders) and not marketing content. When mode is omitted, the workspace default set at the top of this page applies (marketing unless declared otherwise). Sending marketing content in transactional mode violates the Terms of Service and Israel's anti-spam law (s.30A of the Communications Law) - liability is yours.
curl -X POST https://sendy.co.il/api/v1/messages \
-H "Authorization: Bearer sk_live_8Jc…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042" \
-d '{
"to": "+972501234567",
"from": "Sendy",
"body": "Your verification code is 4827",
"mode": "transactional"
}'const res = await fetch(`https://sendy.co.il/api/v1/messages`, {
method: "POST",
headers: {
Authorization: "Bearer sk_live_8Jc…",
"Content-Type": "application/json",
"Idempotency-Key": "order-1042",
},
body: JSON.stringify({
to: "+972501234567",
from: "Sendy",
body: "Your verification code is 4827",
mode: "transactional",
}),
});
const message = await res.json();{
"id": "cmqzkdkul0007p20mf0r4tz21",
"status": "queued",
"to": "+972501234567",
"from": "Sendy",
"credits_charged": 1,
"credits_refunded": 0,
"refunded": false,
"created_at": "2026-06-29T18:41:32.877Z"
}credits_charged is the billed cost: 1 credit = 1 message of up to 201 characters - in Hebrew, English, or both - computed as ceil(body length / 201). A 220-character message is 2 credits.
credits_refunded / refunded. If a message is refunded - a genuine gateway rejection, a cancellation, or a no-receipt reclaim - credits_refunded returns the amount and refunded becomes true. The net cost is credits_charged − credits_refunded. A plain delivery failure (undelivered) is not refunded.
Unsubscribed recipients. Whether a send is filtered against this workspace's unsubscribe list is controlled by mode (above), not a separate toggle. A "marketing" send whose to matches a contact that has unsubscribed is rejected with 403 RECIPIENT_UNSUBSCRIBED and no credit is charged; a "transactional" send skips that check. When mode is omitted, the workspace default set at the top of this page applies (marketing unless declared otherwise).
Poll a message by id to follow its delivery status. Scoped to your workspace - an unknown id returns 404.
curl https://sendy.co.il/api/v1/messages/cmqzkdkul0007p20mf0r4tz21 \
-H "Authorization: Bearer sk_live_8Jc…"const res = await fetch(`https://sendy.co.il/api/v1/messages/cmqzkdkul0007p20mf0r4tz21`, {
headers: { Authorization: "Bearer sk_live_8Jc…" },
});
const message = await res.json();{
"id": "cmqzkdkul0007p20mf0r4tz21",
"status": "delivered",
"credits_charged": 1,
"credits_refunded": 0,
"refunded": false,
"error_code": null,
"sent_at": "2026-06-29T18:41:36.932Z",
"delivered_at": "2026-06-29T18:41:38.579Z"
}List the workspace's approved senders - any of these values can be used as from.
curl https://sendy.co.il/api/v1/senders -H "Authorization: Bearer sk_live_8Jc…"const res = await fetch(`https://sendy.co.il/api/v1/senders`, {
headers: { Authorization: "Bearer sk_live_8Jc…" },
});
const { senders } = await res.json();{
"senders": [
{ "value": "Sendy", "type": "TEXTUAL" }
]
}Return the workspace's current credit balance.
curl https://sendy.co.il/api/v1/credits -H "Authorization: Bearer sk_live_8Jc…"const res = await fetch(`https://sendy.co.il/api/v1/credits`, {
headers: { Authorization: "Bearer sk_live_8Jc…" },
});
const { balance } = await res.json();{ "balance": 8421 }Register or update a contact - the endpoint for Make, n8n, WordPress and other server-to-server automations. It is idempotent on the phone number: the same phone always resolves to the same contact, so a retried request is safe. A new contact returns 201 with created: true; an existing one returns 200 with created: false.
Fields merge - only the fields you send (with a non-empty value) are updated; omitted fields are left as they are, so one integration never wipes another's data. A new contact must be attached to at least one segment id (see List segments); on an existing contact, segments are added, never removed. The API never changes subscription status - a contact who unsubscribed stays unsubscribed (subscribed: false) even if re-sent here.
curl -X POST https://sendy.co.il/api/v1/contacts \
-H "Authorization: Bearer sk_live_8Jc…" \
-H "Content-Type: application/json" \
-d '{
"phone": "+972501234567",
"first_name": "Dana",
"last_name": "Levi",
"email": "dana@example.com",
"birthday": "1990-05-01",
"custom_field_1": "Tel Aviv",
"segments": ["cmc0seg0001"]
}'const res = await fetch(`https://sendy.co.il/api/v1/contacts`, {
method: "POST",
headers: {
Authorization: "Bearer sk_live_8Jc…",
"Content-Type": "application/json",
},
body: JSON.stringify({
phone: "+972501234567",
first_name: "Dana",
last_name: "Levi",
email: "dana@example.com",
birthday: "1990-05-01",
custom_field_1: "Tel Aviv",
segments: ["cmc0seg0001"],
}),
});
const contact = await res.json();{
"id": "cmc0contact0001",
"phone": "+972501234567",
"first_name": "Dana",
"last_name": "Levi",
"email": "dana@example.com",
"birthday": "1990-05-01",
"custom_field_1": "Tel Aviv",
"custom_field_2": null,
"custom_field_3": null,
"subscribed": true,
"source": "MANUAL",
"segments": [{ "id": "cmc0seg0001", "name": "Newsletter" }],
"created_at": "2026-07-15T10:00:00.000Z",
"updated_at": "2026-07-15T10:00:00.000Z",
"created": true
}phone is an Israeli mobile (E.164 +9725XXXXXXXX or local 05XXXXXXXX). birthday is YYYY-MM-DD. custom_field_1-3 are your workspace's tenant-defined fields (≤200 chars) - call List custom fields to see what each slot means; unnamed slots are ignored. To clear a field or remove a segment, use the web app - v1 only merges.
List contacts with cursor pagination. Pass ?limit= (1-100, default 50) and the previous page's next_cursor as ?cursor=. To find a single contact by number, pass ?phone= - the 0-or-1 match comes back in the same envelope.
curl "https://sendy.co.il/api/v1/contacts?limit=2" \
-H "Authorization: Bearer sk_live_8Jc…"const res = await fetch(`https://sendy.co.il/api/v1/contacts?limit=2`, {
headers: { Authorization: "Bearer sk_live_8Jc…" },
});
const { data, has_more, next_cursor } = await res.json();{
"data": [
{
"id": "cmc0contact0001",
"phone": "+972501234567",
"first_name": "Dana",
"subscribed": true,
"source": "MANUAL",
"segments": [{ "id": "cmc0seg0001", "name": "Newsletter" }],
"created_at": "2026-07-15T10:00:00.000Z",
"updated_at": "2026-07-15T10:00:00.000Z"
}
],
"has_more": true,
"next_cursor": "Y21jMGNvbnRhY3QwMDAx"
}curl "https://sendy.co.il/api/v1/contacts?phone=%2B972501234567" \
-H "Authorization: Bearer sk_live_8Jc…"const params = new URLSearchParams({ phone: "+972501234567" });
const res = await fetch(`https://sendy.co.il/api/v1/contacts?${params}`, {
headers: { Authorization: "Bearer sk_live_8Jc…" },
});
const { data } = await res.json();Fetch one contact by id, including its segments. Scoped to your workspace - an unknown id returns 404.
curl https://sendy.co.il/api/v1/contacts/cmc0contact0001 \
-H "Authorization: Bearer sk_live_8Jc…"const res = await fetch(`https://sendy.co.il/api/v1/contacts/cmc0contact0001`, {
headers: { Authorization: "Bearer sk_live_8Jc…" },
});
const contact = await res.json();{
"id": "cmc0contact0001",
"phone": "+972501234567",
"first_name": "Dana",
"last_name": "Levi",
"email": "dana@example.com",
"birthday": "1990-05-01",
"custom_field_1": "Tel Aviv",
"custom_field_2": null,
"custom_field_3": null,
"subscribed": true,
"source": "MANUAL",
"segments": [{ "id": "cmc0seg0001", "name": "Newsletter" }],
"created_at": "2026-07-15T10:00:00.000Z",
"updated_at": "2026-07-15T10:00:00.000Z"
}List the workspace's active segments with their contact counts. Use a segment id in the segments array when you upsert a contact. subscribed_count is the deliverable subset (contacts still subscribed).
curl https://sendy.co.il/api/v1/segments -H "Authorization: Bearer sk_live_8Jc…"const res = await fetch(`https://sendy.co.il/api/v1/segments`, {
headers: { Authorization: "Bearer sk_live_8Jc…" },
});
const { segments } = await res.json();{
"segments": [
{ "id": "cmc0seg0001", "name": "Newsletter", "contact_count": 1280, "subscribed_count": 1195 }
]
}List the workspace's active custom-field slots and their labels. Because the labels are defined per workspace, this is how an integration learns what each custom_field_1-custom_field_3 on a contact means. Only named slots are returned; the same key is what you send on Upsert a contact.
curl https://sendy.co.il/api/v1/custom-fields -H "Authorization: Bearer sk_live_8Jc…"const res = await fetch(`https://sendy.co.il/api/v1/custom-fields`, {
headers: { Authorization: "Bearer sk_live_8Jc…" },
});
const { custom_fields } = await res.json();{
"custom_fields": [
{ "key": "custom_field_1", "slot": 1, "label": "City" },
{ "key": "custom_field_2", "slot": 2, "label": "Company" }
]
}List SMS received on your workspace's virtual number - recipient replies, keyword responses ("reply 8 to…"), and opt-out requests. Ordered oldest first with cursor pagination. Read-only: this is the polling surface for Make, n8n, Zapier and your own automations. Returns an empty list when the workspace has no virtual number.
Polling for new messages. Pass ?since= (ISO 8601) to bound the list to a recent window - e.g. poll every few minutes with since set to the last day and de-duplicate by id on your side. since filters on received_at, inclusive. For a full export, page with cursor instead.
curl "https://sendy.co.il/api/v1/inbound?since=2026-08-24T00:00:00Z&limit=50" \
-H "Authorization: Bearer sk_live_8Jc…"const params = new URLSearchParams({ since: "2026-08-24T00:00:00Z", limit: "50" });
const res = await fetch(`https://sendy.co.il/api/v1/inbound?${params}`, {
headers: { Authorization: "Bearer sk_live_8Jc…" },
});
const { data, has_more, next_cursor } = await res.json();{
"data": [
{
"id": "cmc0inbound0001",
"from": "+972501234567",
"to": "+972531234567",
"text": "אשמח לקבל פרטים נוספים",
"opt_out": "none",
"opt_out_applied_at": null,
"received_at": "2026-08-24T10:15:00.000Z"
},
{
"id": "cmc0inbound0002",
"from": "+972529876543",
"to": "+972531234567",
"text": "הסר",
"opt_out": "detected",
"opt_out_applied_at": "2026-08-24T10:16:02.312Z",
"received_at": "2026-08-24T10:16:01.000Z"
}
],
"has_more": false,
"next_cursor": null
}from is E.164 when the sender is an Israeli mobile - the same format as a contact's phone, so you can look the sender up with GET /contacts?phone=. It can also be an alphanumeric or foreign sender, passed through verbatim. opt_out is "detected" (the message is an opt-out request - Sendy unsubscribes the sender automatically; opt_out_applied_at records when), "suspected" (a removal word inside a longer message, flagged for review in the app, never auto-applied) or "none". Long inbound messages can arrive as multiple rows (one per SMS part).
Your workspace's dedicated virtual number - the number recipients reply to. Always returns 200: when the workspace has no active number, active is false and the other fields are null, so integrations can branch without handling errors. Activating or releasing a number is done in the Sendy app (מספר וירטואלי), not via the API.
curl https://sendy.co.il/api/v1/number -H "Authorization: Bearer sk_live_8Jc…"const res = await fetch(`https://sendy.co.il/api/v1/number`, {
headers: { Authorization: "Bearer sk_live_8Jc…" },
});
const number = await res.json();{
"active": true,
"number": "+972531234567",
"local_number": "0531234567",
"assigned_at": "2026-08-20T07:00:00.000Z",
"next_renewal_at": "2026-09-20T06:00:00.000Z",
"grace_until": null
}number matches the sender value in List senders - an active virtual number is automatically an approved from for sending. next_renewal_at is the next monthly renewal charge; grace_until is set only when the last renewal failed and the number will be released at that time unless credits are topped up.
List endpoints return a cursor-paginated envelope. Read data, and while has_more is true, request the next page by passing the returned next_cursor as ?cursor=. The cursor is opaque - pass it back verbatim, don't build your own. ?limit= is 1-100 (default 50).
{
"data": [ … ],
"has_more": true,
"next_cursor": "Y21jMGNvbnRhY3QwMDAx"
}
# fetch the next page:
GET /contacts?cursor=Y21jMGNvbnRhY3QwMDAxPages are keyset-based (not offset), so rows are never skipped or repeated across pages even as the list changes underneath you. When has_more is false, next_cursor is null - stop.
Pass a unique Idempotency-Key header on POST /messages (≤255 chars). If the request is retried with the same key, the original message is returned - no second charge and no second send. Omit the header for a non-deduplicated send. POST /contacts needs no key: it is inherently idempotent on the phone number, so a retry updates the same contact instead of creating a duplicate.
# Second POST with the same Idempotency-Key returns the original message:
{
"id": "cmqzkdkul0007p20mf0r4tz21",
"status": "queued",
...
"credits_charged": 1
}Each token is limited to 60 requests per minute. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; a 429 adds Retry-After (seconds).
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1782754860
Retry-After: 23
{ "error": { "code": "RATE_LIMITED", "message": "Rate limit exceeded. Please retry later." } }Every error uses the same envelope. Handle by error.code (stable) rather than the message (human-readable, may change).
{
"error": {
"code": "INSUFFICIENT_CREDITS",
"message": "Your account balance (3) is too low to send this message (2 credits)."
}
}| Code | HTTP | When |
|---|---|---|
| VALIDATION | 400 | Malformed JSON or missing/invalid fields. |
| INVALID_RECIPIENT | 400 | `to` is not a single valid Israeli mobile number. |
| MESSAGE_TOO_LONG | 400 | `body` exceeds 603 characters. |
| INVALID_PHONE | 400 | A contact `phone` is not a valid Israeli mobile number. |
| SEGMENT_NOT_FOUND | 400 | A referenced segment id is invalid or archived. |
| INVALID_API_KEY | 401 | Missing, malformed, or unknown bearer token. |
| INSUFFICIENT_CREDITS | 402 | Balance too low to cover the message. |
| SENDER_NOT_APPROVED | 403 | `from` is not an approved sender for the workspace. |
| RECIPIENT_UNSUBSCRIBED | 403 | Opt-out enforcement is on and `to` is a contact that has unsubscribed. Not charged. |
| RECIPIENT_BLOCKED | 403 | `to` is on the workspace blocklist (marketing mode only, like RECIPIENT_UNSUBSCRIBED). Not charged. |
| WORKSPACE_SUSPENDED | 403 | The workspace is suspended. |
| ACCOUNT_UNDER_REVIEW | 403 | New accounts are briefly reviewed; until then API sends are limited to your own verified sender number (for testing). Not charged. |
| NOT_FOUND | 404 | No such message or contact in this workspace. |
| CONTENT_FLAGGED | 422 | The message body matched the platform's restricted-content policy. Not charged. |
| RATE_LIMITED | 429 | Over 60 requests/minute for this token. |
| INTERNAL | 500 | Unexpected server error - the request was not processed; safe to retry. |
Returned by the send and poll endpoints. The three terminal states are delivered, undelivered and failed.
| Status | Meaning |
|---|---|
| queued | Accepted and charged; waiting for the worker to dispatch. |
| sending | Being dispatched to the gateway. |
| sent | Handed to the gateway; awaiting a delivery receipt. |
| delivered | Confirmed delivered by the carrier (terminal). |
| undelivered | Carrier could not deliver (terminal). |
| failed | Rejected or failed before/at dispatch (terminal). |
Webhooks let Sendy push events to your server as they happen - delivery-status updates for messages you send, and (later) inbound events - so you won't need to poll. You'll register an endpoint URL, receive signed JSON payloads, and verify them with a shared secret.
This is on the roadmap and not available yet. Until then, poll a message with GET /messages/{id} to track its delivery status.