Basics
Every endpoint lives under https://sndiq.com/api/v1, takes and returns JSON, and requires HTTPS. Each API key belongs to one workspace and can only reach that workspace's data.
Authentication
Workspace owners and admins create keys in the dashboard under Developers. A key looks like sq_live_ followed by 48 characters. It is shown once, when you create it: we store only a hash, so a lost key cannot be recovered. Create a new one and revoke the old.
Send the key as a bearer token on every request:
Authorization: Bearer sq_live_...
A missing header returns 401 with the code missing_api_key. A wrong or revoked key returns 401 with invalid_api_key. After 30 failed attempts from one IP address within 5 minutes, that address gets 429 with too_many_auth_failures until the window passes.
Treat keys like passwords: keep them out of client-side code and public repositories, and revoke a key right away if you think it was exposed. Revoking takes effect on the next request.
Scopes
Each key carries the scopes you choose when you create it. A request that needs a scope the key lacks returns 403 with the code missing_scope. Every key can call GET /me, whatever its scopes.
messages:send: Send messagesmessages:read: Read messages and delivery statuscontacts:read: Read contactscontacts:write: Create, update and delete contactscampaigns:read: Read campaignscampaigns:write: Create, update and launch campaignswebhooks:manage: Create, change and delete webhooks, and resend deliveriesregistration:read: Read brand and campaign registrationsnumbers:read: Read sending numbersbilling:read: Read the credit balance and ledger
Every scope unlocks the endpoints below that name it. Suppressions use contacts:read and contacts:write. Scopes cannot be changed on an existing key; create a new key instead.
Errors
Errors use standard HTTP status codes and always have the same shape:
{
"error": {
"type": "permission_error",
"code": "missing_scope",
"message": "This API key does not have the messages:send scope.",
"request_id": "req_6f1c2a9b0d4e8f7a1b2c3d4e"
}
}
type is the broad category and code the specific reason; branch on those, not on message, which may change.
authentication_error(401): the key is missing, wrong or revoked.permission_error(403): the key lacks a scope, or the workspace is suspended or closed. A paused workspace can still read; only sending is refused, asworkspace_inactive.invalid_request_error(400, 404, 405, 409): the request itself is the problem, such as a missing or invalid field, an unknown path or record, an unsupported method or an idempotency conflict.send_error(422): the request was valid but the text cannot be sent, such as a number with no consent on record or one that opted out.rate_limit_error(429): too many requests. See rate limits below.api_error(500, 503): something failed on our side. These are safe to retry.
Every response, success or error, carries an X-Request-Id header with the same value as request_id. Include it when you contact support and we can find the exact request.
Rate limits
Each key can make 300 requests per rolling minute. Every successful response tells you where you stand:
X-RateLimit-Limit: 300
X-RateLimit-Remaining: 297
Over the limit, the API returns 429 with the code rate_limited and a Retry-After header giving the seconds to wait. Rejected requests do not count against the limit, so waiting that long is enough.
Idempotency
A network failure can leave you unsure whether a request went through. To retry a POST safely, send an Idempotency-Key header with a unique value of up to 255 characters, such as a UUID:
Idempotency-Key: 4f9a2c1e-7b3d-4e8a-9c6f-1d2e3f4a5b6c
- Retrying with the same key and the same request (method, path, query string and body, byte for byte) returns the stored response instead of running the request again. Replayed responses carry an
Idempotent-Replayed: trueheader. - Reusing a key with a different request returns
409withidempotency_key_reused. - Retrying while the first request is still running returns
409withidempotency_key_in_use. Wait a moment and retry. - Server errors (5xx) are not stored, so a retry with the same key runs the request again.
Keys are scoped to your workspace and kept for 24 hours. GET requests do not need one.
GET /me
Returns the workspace a key belongs to and the key's scopes. Use it to check that a key works.
curl https://sndiq.com/api/v1/me \
-H "Authorization: Bearer sq_live_..."
{
"workspace": {
"id": 42,
"slug": "acme-dental",
"name": "Acme Dental",
"kind": "tenant"
},
"key": {
"name": "Order system",
"prefix": "sq_live_1a2b3c4d",
"scopes": ["messages:send", "messages:read"]
}
}Messages
A message is one text, sent or received. Every message endpoint returns messages in this shape:
{
"id": 90211,
"direction": "out",
"status": "delivered",
"from": "+12025550100",
"to": "+12025550123",
"body": "Your order shipped today.",
"segments": 1,
"encoding": "gsm7",
"error_code": null,
"error_message": null,
"cost": 0.0079,
"created_at": "2026-10-06T15:04:05Z",
"sent_at": "2026-10-06T15:04:06Z",
"delivered_at": "2026-10-06T15:04:09Z"
}
directionisoutfor a text you sent andinfor a reply.fromandtofollow the text: a reply is from the contact to your number.statusis one ofqueued,sending,sent(handed to the carrier),delivered,failed,undelivered, orreceivedfor an inbound text.error_codenames why a text failed or is waiting, anderror_messagesays it in plain words.costis in US dollars once the provider reports it, elsenull.segmentsis what the text bills as: 160 GSM-7 characters fit in one segment and 153 per segment once split; one character outside GSM-7, such as an emoji or a curly quote, makes it Unicode at 70 and 67.- Times are UTC.
POST /messages
Sends one text. Needs the messages:send scope. The text runs through the same compliance checks as the dashboard: the number must have SMS consent on record in your workspace and must not be suppressed. Send an Idempotency-Key so a retry never sends twice.
curl https://sndiq.com/api/v1/messages \
-H "Authorization: Bearer sq_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1001-shipped" \
-d '{"to": "+12025550123", "body": "Your order shipped today."}'
to(required): a US mobile number, in any common format.body(required): the text, up to 10 segments.from(optional): one of your workspace's active numbers. Leave it out and the text goes from the number this contact last talked to, so a conversation stays on one number.
A queued text returns 201:
{
"message": {
"id": 90211,
"status": "queued",
"from": "+12025550100",
"to": "+12025550123",
"segments": 1,
"encoding": "gsm7",
"held": null
}
}
Queued is not sent: follow the message with GET /messages?id=. held is quiet_hours when it is outside sending hours on the contact's clock, or frequency_cap when the contact's state limits how often they can be texted; the text waits and goes out once allowed. A retry with the same Idempotency-Key after the 24-hour replay window returns 200 with the message it queued the first time, never a second text.
Refusals:
400invalid_request_error:invalid_json,invalid_parameter(a field that is not a string),invalid_phone,empty_bodyorbody_too_long.422send_error: the request was fine but the text cannot go out.no_consent: no SMS consent on record for this number.suppressed: the number opted out or is blocked.no_number: thefromnumber is not active in your workspace, or the workspace has no active number.workspace_inactive: the workspace, or the agency it belongs to, is paused or suspended, so nothing new is queued.insufficient_credits: the workspace's balance does not cover this text's segments; add credits and send again.503api_error: sending is briefly unavailable. Retry with the sameIdempotency-Key.
GET /messages
Lists your workspace's messages, newest first, or returns one. Needs the messages:read scope.
curl "https://sndiq.com/api/v1/messages?direction=out&status=failed&since=2026-10-01" \
-H "Authorization: Bearer sq_live_..."
direction:inorout.status: one of the statuses above.phone: the contact's number.sinceanduntil: dates asYYYY-MM-DDin UTC, both inclusive.limit: 1 to 100, default 25.before: thenext_beforevalue from the previous page.
{
"data": [ { "id": 90211, "direction": "out", "status": "failed", ... } ],
"has_more": true,
"next_before": 90211
}
While has_more is true, pass next_before as before for the next page. A filter the API cannot read returns 400 with invalid_parameter rather than being ignored.
For one message, pass its id: GET /messages?id=90211 returns {"message": {...}}, or 404 with message_not_found when no message with that id is in your workspace.
Contacts
A contact is one US mobile number in your workspace, with its name, email, custom fields, lists and consent. The phone number is the contact: it is unique in the workspace and never changes. Every contact endpoint returns contacts in this shape:
{
"id": 1842,
"phone": "+12025550101",
"first_name": "Jordan",
"last_name": "Lee",
"email": "[email protected]",
"custom_fields": { "plan": "gold" },
"timezone": null,
"state": "DC",
"consent": {
"status": "granted",
"source": "web_form",
"captured_at": "2026-10-01T14:30:00Z",
"recorded_at": "2026-10-01T14:30:02Z"
},
"suppressed": false,
"list_ids": [12],
"last_message_at": null,
"created_at": "2026-10-01T14:30:02Z",
"updated_at": "2026-10-01T14:30:02Z"
}
phoneis in E.164 form. Only US numbers are accepted.statecomes from the area code,nullwhen the number cannot be placed.timezoneis a time zone set for this contact, ornull; with none set, quiet hours are judged on every zone the area code reaches.consentis the latest SMS consent record.statusisgranted,revokedornone(no record yet); a contact is only ever texted while it isgranted.captured_atis when the person agreed,nullwhen that is unknown, andrecorded_atis when we stored the record.suppressedistruewhile any suppression blocks the number, such as a STOP reply. Suppressed numbers are never texted, whatever their consent.list_idsare the lists the contact is on.last_message_atis the time of the latest text sent to or received from the number.- Times are UTC in ISO 8601.
GET /contacts
Lists contacts, newest first. Needs the contacts:read scope.
page: the page to return, from 1. A page past the end returns the last page.per_page: from 1 to 100, default 50. Anything else returns400withinvalid_per_page.list_id: only contacts on this list. An unknown list returns404withlist_not_found.
curl "https://sndiq.com/api/v1/contacts?page=1&per_page=50" \
-H "Authorization: Bearer sq_live_..."
{
"data": [ { "id": 1842, "phone": "+12025550101", ... } ],
"page": 1,
"per_page": 50,
"total": 1,
"has_more": false
}GET /contacts/lookup
Returns one contact by id or by phone. Needs the contacts:read scope. Encode the plus sign in a phone number as %2B.
curl "https://sndiq.com/api/v1/contacts/lookup?phone=%2B12025550101" \
-H "Authorization: Bearer sq_live_..."
Without either parameter it returns 400 with missing_parameter. A contact that is not in your workspace returns 404 with contact_not_found.
POST /contacts
Creates a contact, or updates the one already on that phone number. Needs the contacts:write scope. Returns the contact with 201 when it was created and 200 when it already existed. Send an Idempotency-Key to retry safely.
curl https://sndiq.com/api/v1/contacts \
-H "Authorization: Bearer sq_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 4f9a2c1e-7b3d-4e8a-9c6f-1d2e3f4a5b6c" \
-d '{
"phone": "+12025550101",
"first_name": "Jordan",
"last_name": "Lee",
"custom_fields": { "plan": "gold" },
"consent": {
"status": "granted",
"source": "web_form",
"text": "Yes, text me order updates and offers from Acme. Msg and data rates may apply. Reply STOP to opt out.",
"affirmative_action": "Checked the SMS box at checkout",
"page_url": "https://shop.test.local/checkout",
"ip": "203.0.113.7",
"captured_at": "2026-10-01T14:30:00Z"
}
}'
phone(required): a US mobile number. Any common format works; it is stored in E.164.first_name,last_name,email: optional strings. On an existing contact a blank or missing value never erases what is stored.custom_fields: an optional object of names (up to 64 characters) to string or number values. A name sent with a value replaces that name's value; names left out, or sent blank, keep theirs.consent(required): where this person's consent stands. Consent never defaults to yes.
consent.status is one of:
granted: they agreed to receive texts. Needssourceandtext, the exact words they agreed to.revoked: they withdrew consent. Needssource;textcan say what they said or did.none: nothing is recorded. A new contact has no consent and is not texted; an existing contact keeps the consent it has.
The other consent fields: source is web_form, keyword, manual or api. affirmative_action (what they did to agree), page_url (a full http or https address), ip and captured_at (ISO 8601, not in the future) are optional; without captured_at the consent is recorded as captured at the time of the request. Consent records are never edited: each granted or revoked adds one to the contact's history.
Recording consent does not lift a suppression. A number that replied STOP stays suppressed until the person texts START, and suppressed in the response tells you.
Number verification. A number that is not yet a contact is checked before it is created, against known litigators and the other lists you choose under Settings in the dashboard. A number that fails a check is not added and the request answers 422 with number_refused; error.reason says why and message says it in words:
{
"error": {
"type": "invalid_request_error",
"code": "number_refused",
"message": "Number verification refused this number, so it was not added: on a litigator list.",
"request_id": "req_8c1f0a",
"reason": "litigator"
}
}
reason is litigator, complainers (a known complainer list), state_dnc (a state do-not-call list), landline, undeliverable (the number cannot receive texts) or unverified (the number could not be checked). The first three also add a suppression, litigator or dnc, which is never lifted, and a litigator is suppressed for every workspace; a landline or an undeliverable number is only refused, since a number can later change. When a check cannot be made, the request answers 503 with verification_unavailable and a Retry-After header, and nothing is added: retry it. When your workspace has used its checks for the day it answers 429 with verification_limit and a Retry-After header. A contact that already exists is updated without a new check.
Errors, all 400 unless noted: invalid_json (the body is not a JSON object), invalid_phone, invalid_email, invalid_field (a field has the wrong type), consent_required (no consent object) and invalid_consent (the status, source, text, address, IP or time is missing or wrong; message says which); 422 number_refused, 429 verification_limit and 503 verification_unavailable, above.
Lists
GET /contacts/lists returns every list in the workspace. Needs the contacts:read scope.
{
"data": [
{
"id": 12,
"name": "Spring customers",
"kind": "static",
"status": "active",
"contact_count": 418,
"created_at": "2026-09-14T16:02:11Z"
}
]
}
POST /contacts/lists adds an existing contact to a static list. Needs the contacts:write scope. Send list_id and either contact_id or phone:
curl https://sndiq.com/api/v1/contacts/lists \
-H "Authorization: Bearer sq_live_..." \
-H "Content-Type: application/json" \
-d '{"list_id": 12, "phone": "+12025550101"}'
{
"list_id": 12,
"contact_id": 1842,
"added": true
}
added is false when the contact was already on the list. Errors: 400 with invalid_list_id, missing_parameter or list_not_static; 404 with list_not_found or contact_not_found. Create the contact first with POST /contacts.
Brand and campaign registration
Carriers deliver business texts only from a registered brand and campaign. Sndiq reviews each registration and files it with the carriers for you; these endpoints read where each one stands. Both need the registration:read scope.
GET /brands returns every brand in the workspace, newest first:
{
"data": [
{
"id": 4,
"display_name": "Maple Dental",
"legal_name": "Maple Dental Group LLC",
"dba": "",
"website": "https://www.mapledental.example",
"legal_form": "private_company",
"vertical": "HEALTHCARE_AND_LIFESCIENCES",
"status": "carrier_approved",
"reason": null,
"campaign_count": 1,
"submitted_at": "2026-10-02T15:20:00Z",
"created_at": "2026-10-02T15:01:12Z",
"updated_at": "2026-10-03T09:12:40Z"
}
]
}
GET /campaigns-registration returns every registered campaign, newest first, or one brand's with ?brand_id=4. carrier_limits holds each network's answer: AT&T throughput is per minute, T-Mobile's per day, and null means unlimited.
{
"data": [
{
"id": 9,
"brand_id": 4,
"type": "10dlc",
"name": "Appointment reminders",
"use_case": "ALERTS",
"status": "carrier_approved",
"reason": null,
"numbers": ["+12025550143"],
"carrier_limits": [
{"network": "ATT", "state": "ACTIVE", "message_class": "A", "throughput": 4500, "brand_tier": null}
],
"submitted_at": "2026-10-03T10:00:00Z",
"created_at": "2026-10-03T09:40:00Z",
"updated_at": "2026-10-04T08:00:00Z"
}
]
}
status is one of draft, submitted (in Sndiq review), changes_requested, approved, filed (with the carriers), carrier_approved, carrier_rejected or rejected. reason is the reviewer's note or the carriers' answer when there is one. A bad brand_id returns 400 with invalid_brand_id.
Pages
Lists that page take page (from 1) and per_page (1 to 100) and answer with the same fields around data:
{
"data": [ ... ],
"page": 1,
"per_page": 25,
"total": 61,
"has_more": true
}
A page past the end returns the last page. A page or per_page that is not a whole number in range returns 400 with invalid_parameter. GET /contacts and GET /messages page as described in their own sections.
Campaigns
A campaign is a one-time send to a static list, paced by its throttle and each recipient's quiet hours. Reading needs campaigns:read; creating and changing status needs campaigns:write. Every campaign endpoint returns campaigns in this shape:
{
"id": 17,
"name": "October restock",
"status": "running",
"paused_reason": null,
"list_id": 12,
"variants": ["{Hi|Hello} {first_name|there}, the fall line is back. Reply STOP to opt out."],
"variant_mode": "rotate",
"from_number": null,
"daily_limit": 5000,
"per_minute": 60,
"start_at": "2026-10-08T14:00:00Z",
"local_time": false,
"track_links": true,
"created_at": "2026-10-07T15:00:00Z",
"launched_at": "2026-10-07T15:02:10Z",
"completed_at": null,
"metrics": {
"audience": 418, "remaining": 120, "skipped": 6, "queued": 292, "in_queue": 4,
"sent": 288, "delivered": 281, "failed": 3, "undelivered": 2, "segments": 288,
"clicks": 41, "human_clicks": 37, "bot_clicks": 4, "clickers": 33,
"sales": 5, "cost": 2.2752, "revenue": 249.95,
"delivery_rate": 0.9894, "ctr": 0.1174, "epc": 6.7554, "profit": 247.6748
}
}
statusisdraft,scheduled,running,paused,completedorcanceled.paused_reasonsays why when Sndiq paused it itself, such as running out of credits.metricsare the numbers the dashboard shows.sentmeans a provider took the text;failedcovers provider refusals and carrier undelivered verdicts,undeliveredthe second alone.human_clicksleave bots out andclickersare recipients with at least one.salesandrevenuecount matched conversions, refunds as negative revenue.delivery_rateis delivered over texts with a final outcome,ctrclickers over delivered andepcrevenue over human clicks, eachnullwhen there is nothing to divide by;profitis revenue less cost, in US dollars. Before a campaign starts,audienceis the list's active contacts now.start_atis UTC, or withlocal_timea wall-clock time such as2026-10-08T10:00that each recipient reaches on their own clock.
POST /campaigns
Creates a draft, checked exactly as the dashboard's composer checks it. Needs campaigns:write. Returns 201 with {"campaign": {...}}. Nothing sends until you launch it.
curl https://sndiq.com/api/v1/campaigns \
-H "Authorization: Bearer sq_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: october-restock" \
-d '{
"name": "October restock",
"list_id": 12,
"variants": ["{Hi|Hello} {first_name|there}, the fall line is back: https://shop.test.local/fall Reply STOP to opt out."],
"per_minute": 60,
"start_at": "2026-10-08T14:00:00Z"
}'
name(required) andlist_id(required): an active static list in your workspace.variants(required): one to ten message texts. Spintax is{one|two}, merge fields are{first_name},{last_name},{email},{phone}or{custom:Field name}, with an optional fallback after a bar. The longest version must fit in 10 segments, links counted as the tracked links they become.variant_mode:rotate(default) splits recipients evenly;leadersends most to the variant with the best reply rate net of opt-outs.from_number: one of your active numbers. Leave it out and each text goes from the number that contact last talked to, else the next of your numbers in turn.per_minute: 1 to 500, default 60.daily_limit: 1 to 1,000,000, or leave it out for none.start_at: ISO 8601 with a zone, or withlocal_time: truea wall-clock time with none. Leave it out to start at launch.track_links:true(default) turns links into tracked short links.
Errors, all 400: invalid_json, invalid_parameter (a field has the wrong type) and invalid_campaign, whose message names the field and the problem, such as list_id: That audience is not an active list in this workspace.
GET /campaigns
Lists campaigns, newest first, 25 to a page by default. Needs campaigns:read. For one campaign pass its id: GET /campaigns?id=17 returns {"campaign": {...}}, or 404 with campaign_not_found.
Launch, pause, resume and cancel
POST /campaigns/launch, /campaigns/pause, /campaigns/resume and /campaigns/cancel each take {"id": 17} and return {"campaign": {...}} as it now stands. They need campaigns:write.
curl https://sndiq.com/api/v1/campaigns/launch \
-H "Authorization: Bearer sq_live_..." \
-H "Content-Type: application/json" \
-d '{"id": 17}'
- Launch takes a draft to
scheduled(a start time still ahead, or local time) orrunning. Every recipient then goes through the same checks as any text: consent, suppressions, quiet hours, state frequency caps and your balance. - Pause stops new queueing within seconds; texts already queued still go. Resume picks up where it stopped. Cancel ends it for good.
Errors: 404 campaign_not_found; 409 invalid_campaign_status when the status does not allow it (only a draft launches, only a paused campaign resumes); 409 campaign_guardrail_paused on a resume when our sending guardrails paused the campaign (a high opt-out or carrier block rate; paused_reason says which), since only our support team can resume it; 422 campaign_not_ready when it cannot go yet, such as no active sending number, with the reason in message.
GET /campaigns/report
The campaign report, by ?id=17. Needs campaigns:read.
{
"campaign_id": 17,
"status": "completed",
"metrics": { "audience": 418, "sent": 410, "delivered": 401, ... },
"statuses": { "delivered": 401, "undelivered": 6, "failed": 3, "skipped": 8 },
"failures": [
{ "status": "undelivered", "error_code": "filtered", "error_message": "A carrier spam filter blocked the message.", "count": 6, "retryable": true }
],
"skips": { "no_consent": 5, "suppressed": 3 },
"days": [
{ "date": "2026-10-07", "queued": 418, "sent": 410, "delivered": 401, "failed": 9, "cost": 3.2391 }
]
}
statuses says where every recipient stands, failures groups failed texts by error, skips counts recipients left out and why, and days counts by UTC day of queueing.
Suppressions
A suppressed number is never texted from your workspace, whatever its consent. GET /suppressions needs contacts:read; adding and removing need contacts:write.
{
"id": 311,
"phone": "+12025550101",
"contact_id": 1842,
"reason": "stop_keyword",
"source": "Inbound text",
"note": "",
"text": "STOP",
"active": true,
"removable": false,
"created_at": "2026-10-07T15:10:00Z",
"lifted_at": null,
"lift_note": null
}
reasonisstop_keyword,carrier(the carrier reported an opt-out),complaint,manual,invalid(not a working mobile number),litigator,dnc(number verification found it on a state do-not-call or known complainer list) orreview(a reply that may have been a stop, held until someone reads it).textis the reply that caused it, when there was one.GET /suppressionstakesstatus(active, the default,liftedorall),reason,phone,pageandper_page(default 50), newest first.POST /suppressionswith{"phone": "+12025550101", "note": "Asked by phone not to be texted"}adds amanualsuppression. It returns201with{"suppression": {...}}, or200when that number already had one. Errors:400missing_parameterorinvalid_phone.POST /suppressions/removewith{"id": 311, "note": "..."}lifts one, which only works whereremovableis true:manual,invalidandreview. A STOP or a carrier opt-out is lifted only by the person texting START; a complaint, a litigator flag or a do-not-call flag never is. Errors:404suppression_not_found;409suppression_locked, with the reason inmessage.
GET /numbers
Your workspace's sending numbers, active first. Needs numbers:read. Sndiq provides and registers numbers; ask for more in the dashboard under Numbers.
{
"data": [
{
"id": 3,
"phone": "+12025550100",
"type": "10dlc",
"status": "active",
"mps": 4,
"burst": 4,
"daily_cap": null,
"sent_today": 1280,
"registration_campaign": { "id": 9, "name": "Appointment reminders", "status": "carrier_approved" }
}
]
}
type is 10dlc, toll_free or short_code; status is active, pending, paused or retired. mps is message segments per second, with burst the most it sends at once, and daily_cap a carrier's daily limit, null for none. sent_today counts UTC today.
GET /balance
Your credit balance and the ledger behind it, newest first, 25 entries to a page. Needs billing:read. A text takes its segments times credits_per_segment when it is queued, and a text that fails is refunded.
{
"balance": 48210,
"credits_per_segment": 1,
"price_per_credit": 0.0079,
"value": 380.86,
"ledger": [
{ "id": 9921, "amount": -1, "kind": "send", "description": "Text #90211", "message_id": 90211, "balance_after": 48210, "created_at": "2026-10-07T15:04:05Z" }
],
"page": 1,
"per_page": 25,
"total": 3204,
"has_more": true
}
kind is purchase, manual_grant, manual_debit, send, refund or adjustment. value and price_per_credit are US dollars, null while no price is set.
Webhooks
Webhooks tell your server when something happens, so you never have to poll. Add an endpoint in the dashboard under Developers or with the API below, pick its events, and Sndiq sends each one as a signed POST with a JSON body. A workspace can have up to 10 endpoints. The URL must be https on a public host name; IP addresses, localhost and internal names are refused, and each request goes only to a public IPv4 address the host resolves to at that moment.
The events:
message.sent: A provider took an outbound text.message.delivered: The carrier delivered an outbound text.message.failed: An outbound text failed or was undelivered.message.received: A contact texted one of your numbers.contact.opted_out: A contact texted STOP or complained, or their carrier reported an opt-out.contact.opted_in: A contact texted START, UNSTOP or YES.link.clicked: A person opened a tracked link (bot clicks are left out).conversion.received: A conversion postback was recorded.
An endpoint gets only events that happen after it is created, usually within a few seconds.
Event payloads
Every event has the same envelope. id is the event's id, the same on every try and for every endpoint; created_at is when it happened.
{
"id": "evt_3f2a9c1d8e7b6a5f4c3d2e1f",
"type": "message.delivered",
"created_at": "2026-10-07T15:04:09Z",
"workspace_id": 42,
"data": {
"message": {
"id": 90211,
"direction": "out",
"status": "delivered",
"from": "+12025550100",
"to": "+12025550123",
"body": "Your order shipped today.",
"segments": 1,
"encoding": "gsm7",
"error_code": null,
"error_message": null,
"cost": 0.0079,
"created_at": "2026-10-07T15:04:05Z",
"sent_at": "2026-10-07T15:04:06Z",
"delivered_at": "2026-10-07T15:04:09Z"
}
}
}
message.sent,message.delivered,message.failedandmessage.receivedcarrydata.messagein the shapeGET /messagesreturns, as it stands when the event goes out. Each happens at most once per message. A delivered text always has amessage.senttoo; a failed one has one only if a provider took it first.message.failedcovers a provider refusal and a carrier's undelivered verdict;error_codeanderror_messagesay why.
contact.opted_out, when a contact texts STOP or complains or their carrier reports an opt-out. reason is stop_keyword, carrier or complaint, and text is what they wrote, when they wrote something. contact.id is null when the number is not a contact.
"data": {
"contact": { "id": 1842, "phone": "+12025550101" },
"reason": "stop_keyword",
"text": "STOP",
"suppression_id": 311
}
contact.opted_in, when a contact texts START, UNSTOP or YES. It lifts their STOP and records their consent.
"data": {
"contact": { "id": 1842, "phone": "+12025550101" },
"text": "START",
"message_id": 90240
}
link.clicked, when a person opens a tracked link. Clicks Sndiq judges to be bots, such as link previews and scanners, are left out.
"data": {
"click": {
"id": 5521,
"url": "https://shop.test.local/fall",
"code": "aB3dE9x",
"contact": { "id": 1842, "phone": "+12025550101" },
"campaign_id": 17,
"message_id": 90211,
"clicked_at": "2026-10-07T15:06:12Z"
}
}
conversion.received, when a postback is recorded. A repeat of a transaction already counted is left out. matched is false when it could not be tied to a campaign; a refund has a negative amount.
"data": {
"conversion": {
"id": 77,
"event": "purchase",
"amount": 49.99,
"currency": "USD",
"transaction_id": "ord_1001",
"network": null,
"click_id": "aB3dE9x",
"campaign_id": 17,
"message_id": 90211,
"matched": true,
"received_at": "2026-10-07T16:10:00Z"
}
}
A test sent from the dashboard or POST /webhooks/ping has the type webhook.ping and "data": {"webhook_id": 5}.
Verifying signatures
Every request carries three headers:
Sndiq-Event-Id: the event'sid.Sndiq-Timestamp: when this try was sent, in Unix seconds.Sndiq-Signature:v1=and the hex HMAC-SHA256 of the timestamp, a period and the raw request body, keyed with the endpoint's signing secret.
The signing secret starts with whsec_ and is shown once, when the endpoint is created. Check the signature against the raw body before parsing it, compare in constant time, and refuse a timestamp more than five minutes from your clock so a captured request cannot be replayed. In PHP:
$body = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_SNDIQ_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_SNDIQ_SIGNATURE'] ?? '';
$expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $body, getenv('SNDIQ_WEBHOOK_SECRET'));
if (!hash_equals($expected, $signature) || abs(time() - (int) $timestamp) > 300) {
http_response_code(400);
exit;
}
$event = json_decode($body, true);
// Skip $event['id'] if you have handled it already, then answer quickly.
http_response_code(200);
In Node.js with Express:
const crypto = require('crypto');
app.post('/sndiq/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.get('Sndiq-Timestamp') || '';
const given = Buffer.from(req.get('Sndiq-Signature') || '');
const expected = Buffer.from('v1=' + crypto
.createHmac('sha256', process.env.SNDIQ_WEBHOOK_SECRET)
.update(timestamp + '.' + req.body)
.digest('hex'));
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
if (!fresh || given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body);
res.sendStatus(200);
});Retries and resending
Answer with any 2xx status within 10 seconds and the delivery is done. Anything else, including a redirect, a timeout or a refused connection, is tried again after 1 min, 5 min, 15 min, 30 min, 1 h, 2 h, 4 h, 8 h, 8 h: 10 tries over about 24 hours, after which the delivery is marked failed. Each try is signed afresh with a new timestamp; the body and the event id stay the same.
Delivery is at least once: a network failure after your server has handled an event can bring the same event again. Use the event id to skip one you have already handled. Events are not guaranteed to arrive in order; use created_at and the message's own status when order matters.
When an endpoint stops answering, its other events wait for the next pass instead of piling up behind it. A turned-off endpoint gets no new events, and what was already waiting goes out when you turn it back on.
The dashboard lists recent deliveries under Developers with their status, tries and the last answer. Resend sends a delivered or failed one again within a minute, with the same event id and a fresh set of tries. Deliveries are kept for 30 days.
Managing webhooks over the API
All of these need the webhooks:manage scope. An endpoint looks like this; its secret is never returned again after it is created:
{
"id": 5,
"url": "https://example.com/sndiq/webhooks",
"events": ["message.delivered", "message.received", "contact.opted_out"],
"enabled": true,
"created_at": "2026-10-07T15:00:00Z",
"updated_at": "2026-10-07T15:00:00Z"
}
GET /webhookslists your endpoints as{"data": [...]}.POST /webhookswithurl,events(a list) and optionallyenabledcreates one and returns201with{"webhook": {...}, "secret": "whsec_..."}. Store the secret now.POST /webhooks/updatewithidand any ofurl,eventsandenabledchanges those and returns{"webhook": {...}}. The secret stays the same; to replace it, create a new endpoint and delete the old one.POST /webhooks/deletewithiddeletes the endpoint and its deliveries and returns{"id": 5, "deleted": true}.POST /webhooks/pingwithidsends awebhook.pingevent right away, once, whether the endpoint is on or off, and returns{"delivery": {...}}with your server's answer.GET /webhooks/deliverieslists deliveries, newest first, 25 to a page by default;webhook_idnarrows it to one endpoint.POST /webhooks/deliveries/resendwithidqueues a delivery to go again, like the dashboard's Resend, and returns{"delivery": {...}}.
curl https://sndiq.com/api/v1/webhooks \
-H "Authorization: Bearer sq_live_..." \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/sndiq/webhooks", "events": ["message.delivered", "contact.opted_out"]}'
A delivery:
{
"id": 8812,
"webhook_id": 5,
"event_id": "evt_3f2a9c1d8e7b6a5f4c3d2e1f",
"event_type": "message.delivered",
"status": "pending",
"attempts": 2,
"last_status": 503,
"last_error": "HTTP 503",
"last_attempt_at": "2026-10-07T15:10:09Z",
"next_attempt_at": "2026-10-07T15:15:09Z",
"delivered_at": null,
"created_at": "2026-10-07T15:04:14Z",
"payload": { "id": "evt_3f2a9c1d8e7b6a5f4c3d2e1f", "type": "message.delivered", ... }
}
status is pending (waiting for its next try), delivered or failed. Errors: 400 invalid_url, invalid_events or invalid_parameter; 404 webhook_not_found or delivery_not_found; 409 too_many_webhooks.

