Integrate TalksTry into your own systems.
Send order updates, alerts and replies from your backend over the same network your customers already chat on. This page is the contract: the flow end to end, the calls that make it up, and what comes back when something goes wrong.
Getting access
Organisation API access
For a business sending to its customers. Reviewed by our team, then any scope: OTP, single sends, broadcast, templates and member management, in sandbox and live.
Apply for API access →Developer sandbox
For a personal account experimenting. Self-serve, no review, and permanently limited to otp:send, otp:verify and messages:send at 20 requests a minute and 500 a day. It can never hold broadcast, template or member scopes — not as a setting, but as a property of the credential. Its messages are real messages, so it can only send to your own number and to numbers whose owners confirmed a code you sent them.
Integration flow
- 1
Register the organization
Submit the business form and get verified.
An organization account is created from the business registration form. Verification is manual — an admin approves the organization before any credentials exist, so this step is done once and never from code.
- 2
Create API credentials
Client ID + secret, from the console's API keys page.
An approved organisation gets a sandbox key immediately; live keys are created in the console. The secret is shown exactly once, at creation, and stored only as a SHA-256 hash — a lost secret is rotated, never recovered. Create one credential per environment: a leaked staging key should never be able to message real customers.
- 3
Exchange credentials for an access token
POST /auth/token — client credentials grant.
Tokens are short-lived (1 hour). Cache the token and reuse it until it expires; requesting a fresh token per API call will hit the auth rate limit long before the messaging one.
- 4
Call the API
Send messages, read threads, manage contacts.
Every request carries the bearer token and an Idempotency-Key. Retrying with the same key returns the original result instead of sending a second message — the difference between a network blip and a customer receiving your notification twice.
- 5
Receive webhooks
Delivery and read receipts, pushed to you.
Outcomes are asynchronous: sent, delivered and read events arrive at your HTTPS endpoint, signed with your webhook secret. Verify the signature, respond 2xx quickly, and do the real work off the request — we wait 10 seconds and then treat the attempt as failed. A failure is retried 5 times over about 15 minutes; a 4xx that says the request itself is wrong is not retried at all. Delivery is at-least-once: if your 2xx never reaches us we send again, so key your processing on the event id.
- 6
Go live
Swap the sandbox key for a production one.
Nothing about the request shape changes when you switch — only the credential does. Both environments deliver real messages to real people; the difference is what the credential may do and who it may reach. An approved organisation's key can message its customers; a personal sandbox key can only reach numbers on its verified test list.
Authentication
Credentials are created on the API keys page and exchanged for a one-hour access token. The secret never travels on a normal API call — only on this one.
curl -X POST https://backend.talkstry.com/v1/auth/token \
-H "Content-Type: application/json" \
-d '{
"grant_type": "client_credentials",
"client_id": "live_8f3c1d9a4b2e",
"client_secret": "••••••••"
}'{
"access_token": "eyJhbGciOiJIUzI1NiIs…",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "otp:send otp:verify messages:send broadcast:send",
"environment": "live"
}The scope in that response is what the key may actually do. Scopes are fixed when a key is created — a call outside them returns 403 not_permitted.
otp:sendSend a one-time code to a numberotp:verifyCheck a code the recipient typed backmessages:sendSend a single message, and read its receiptsbroadcast:sendMessage every member of your organisationtemplates:manageRegister and edit message templatesmembers:manageManage the accounts your organisation owns
Sending a message
A send is accepted, not delivered: the call returns as soon as the message is queued, and the outcome arrives on your webhook. TheIdempotency-Keyis what makes a retry safe — repeat it and you get the original message back rather than a second one.
The recipient needs a TalksTry account. Messages are delivered inside TalksTry, not over SMS — a number with no account returns 404 not_found and nothing is sent.
curl -X POST https://backend.talkstry.com/v1/messages/send \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Idempotency-Key: order-10432-shipped" \
-H "Content-Type: application/json" \
-d '{
"to": "+919876543210",
"text": "Your order #10432 has shipped."
}'{
"id": "6ab357834ec807348eb477eb",
"status": "queued",
"to": "+919876543210",
"created_at": "2026-09-22T09:14:02.511Z"
}
// Retrying with the SAME key and the SAME text returns the original:
// { "id": "...", "status": "duplicate", ... } — nothing new was sent.
// The SAME key with DIFFERENT text is a 409 idempotency_conflict.Webhooks
Every event is signed with your webhook secret. Verify the signature before trusting the body — the endpoint is public, so anyone can post to it.
Delivery is at-least-once. If your 2xx is lost on the way back — a timeout, a dropped connection — we cannot tell that from your server being down, so we send the same event again. Every request carries the same Talkstry-Event-Id, and for a receipt that id is derived from the message, so it is stable across retries. Record the ids you have processed and ignore a repeat.
{
"id": "evt_8b43159ce6615eded7886624",
"type": "message.delivered",
"created_at": "2026-09-22T09:14:05.002Z",
"data": {
"message_id": "6ab3a746b25688d30d5c875a",
"status": "delivered",
"occurred_at": "2026-09-22T09:14:04.881Z"
}
}// Express. The signature covers the RAW body, so the JSON parser must not
// have touched it yet.
app.post(
"/webhooks/talkstry",
express.raw({ type: "application/json" }),
(req, res) => {
// Talkstry-Signature: t=<unix seconds>,v1=<hex hmac>
const header = req.header("Talkstry-Signature") || "";
const parts = Object.fromEntries(
header.split(",").map((part) => part.split("=")),
);
// The timestamp is part of what was signed, so an old capture cannot be
// replayed. Reject anything outside your tolerance BEFORE comparing.
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!Number.isFinite(age) || age > 300) return res.sendStatus(401);
const expected = crypto
.createHmac("sha256", process.env.TALKSTRY_WEBHOOK_SECRET)
.update(`${parts.t}.${req.body}`)
.digest("hex");
// timingSafeEqual throws when the lengths differ, so check that first.
const given = Buffer.from(parts.v1 || "", "utf8");
const mine = Buffer.from(expected, "utf8");
if (given.length !== mine.length || !crypto.timingSafeEqual(given, mine)) {
return res.sendStatus(401);
}
// Acknowledge first, work afterwards: we time out at 10s and retry.
res.sendStatus(200);
queue.add("talkstry-event", JSON.parse(req.body.toString()));
},
);Message templates
A template is a body a reviewer has approved. Register one in the console, and once it is approved send it by name with its variables filled in — the body comes from the approved record, so what goes out is what was reviewed.
Placeholders are {{1}}, {{2}} … numbered from 1 with no gaps, and variables fills them in order. Sending a template that is still under review returns 403 template_not_approved, and editing an approved template sends it back for review.
curl -X POST https://backend.talkstry.com/v1/messages/send \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Idempotency-Key: order-10432-shipped" \
-H "Content-Type: application/json" \
-d '{
"to": "+919876543210",
"template": {
"name": "order_shipped",
"variables": ["#10432", "Friday"]
}
}'Filling the variables
variables is a flat array, matched to the placeholders by position: the first value fills {{1}}, the second {{2}}, and so on. There are no names to match — only order.
Approved body
Hi {{1}}, your order {{2}} ships on {{3}}.
Your call
"variables": ["Asha", "#10432", "Friday"]
| | |
{{1}} {{2}} {{3}}
What the recipient reads
Hi Asha, your order #10432 ships on Friday.| Rule | What happens if you break it |
|---|---|
| Pass exactly as many values as the body has placeholders | 400 invalid_request — the message names the count it wanted and the count it got |
| Values must be strings or numbers | 400 invalid_request — objects, arrays, null and true are refused |
| At most 20 values | 400 from validation, before the template is even looked up |
| The template must be approved | 403 template_not_approved |
Two details worth knowing, because both surprise people:
- A placeholder repeated in the body is still one variable. Hi {{1}}, see you {{2}}, {{1}}! takes two values, and the first appears twice.
- Your values are never re-scanned. A value that itself contains {{2}} is delivered as that literal text — it cannot be made to stand in for another slot.
# A body with no placeholders takes no variables.
# Sending an empty array is fine; sending values is not.
curl -X POST https://backend.talkstry.com/v1/messages/send \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"to": "+919876543210",
"template": { "name": "store_closed_today" }
}'Addressing by name or by id
Use name or id — whichever your integration already stores — but never both in one call, since the two could name different templates and we will not guess which you meant.
# Address a template by id instead of by name —
# useful when your database stores the id we returned.
curl -X POST https://backend.talkstry.com/v1/messages/send \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"to": "+919876543210",
"template": {
"id": "66f0a1c4e9b21d4a7c3f8e12",
"variables": ["Asha", "#10432", "Friday"]
}
}'Switching one off
A template can be deactivated in the console without being deleted. Sending it then returns 403 template_disabled, and it stops being listed as an option on /otp/send. Its approval survives, so reactivating it needs no second review — which is the point: a seasonal notice can be parked and brought back without queueing again.
Templates for one-time codes
An organisation must send one-time codes through an approved template. A bare POST /otp/send returns 400 template_required, and the response lists your approved authentication templates in data.availableTemplates. A personal sandbox key is exempt and gets the default wording.
An OTP template is stricter than a normal one. It must sit in the authentication category, and it must have exactly one placeholder — the code goes there. A marketing template is refused, because a code must not inherit a review that was granted for something else.
# Approved body (category: authentication)
# {{1}} is your Acme login code. It expires in 5 minutes.
#
# You do NOT pass variables here — the code fills {{1}} itself.
curl -X POST https://backend.talkstry.com/v1/otp/send \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"phone": "+919876543210",
"template": { "name": "login_code" }
}'
{
"status": "sent",
"template": "login_code",
"expires_in": 300
}Note that /otp/send takes no variables: we generate the code and fill the single slot ourselves, so there is nothing left for you to pass. The code is never returned to you — only the recipient sees it.
Free-form text still works for /messages/send — the template requirement applies to one-time codes only. Editing an approved template sends it back for review, so an approved OTP notice cannot quietly become a marketing blast.
Delivery receipts
A message moves through sent → delivered → read. Poll it, or subscribe to message.delivered and message.read and let us tell you — webhooks are cheaper for both of us than a polling loop.
curl https://backend.talkstry.com/v1/messages/6ab3a746b25688d30d5c875a \
-H "Authorization: Bearer $ACCESS_TOKEN"
{
"id": "6ab3a746b25688d30d5c875a",
"status": "read",
"created_at": "2026-09-22T09:13:58.004Z",
"delivered_at": "2026-09-22T09:14:04.881Z",
"read_at": "2026-09-22T09:15:22.117Z"
}A message whose recipient has blocked the sender comes back with undeliverable_reason rather than sitting at sent forever.
The personal sandbox
Any personal TalksTry account can turn on a developer sandbox from Settings, with no review: OTP and single-message send, 20 requests a minute and 500 a day. Broadcast, templates and member management are never available to it.
A sandbox message is a real message, so a sandbox key can only send to your own number and to numbers whose owners have confirmed a code you sent them. Anything else returns 403 sandbox_recipient_not_allowed. When you need to reach your own customers, apply for API access.
API reference
Every endpoint, with a runnable sample in your language.
/auth/tokenCreate an access token
Exchanges a client id and secret for a bearer token valid for one hour. This is the only call that carries your secret — cache the token and reuse it until it expires rather than requesting one per API call.
Body
grant_typestringrequired- Always "client_credentials".
client_idstringrequired- From the API keys page. Its prefix is the environment: live_… or sandbox_…
client_secretstringrequired- Shown once, at key creation. Stored only as a hash — a lost secret is rotated, never recovered.
Response · 200
access_tokenstring- Send as Authorization: Bearer on every other call.
token_typestring- Always "Bearer".
expires_innumber- Seconds until the token stops working. Currently 3600.
scopestring- Space-separated scopes this key holds. Fixed when the key was created.
environmentstring- "live" or "sandbox" — the key's environment, not a request option.
Errors
- 401
invalid_client— The client id or secret is wrong, or the key was revoked. The same message is returned for both, so this endpoint cannot be used to discover which client ids exist. - 429
rate_limited— More than 30 token requests a minute from one IP.
/auth/tokencurl -X POST https://backend.talkstry.com/v1/auth/token \
-H "Content-Type: application/json" \
-d '{
"grant_type": "client_credentials",
"client_id": "live_8f3c1d9a4b2e",
"client_secret": "sk_a7f2c9e1b4d68350a1c2"
}'{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "otp:send otp:verify messages:send broadcast:send",
"environment": "live"
}/organizationIdentify the credential
Returns who the calling key belongs to and what it may do. Useful as a health check at boot: if this returns 200 with the scopes you expect, your credentials and environment are wired correctly.
Response · 200
idstring- The account this key acts as.
namestring- Organisation name, or the account holder's name for a sandbox key.
emailstring- Account email.
typestring- "organization" or "sandbox_user".
environmentstring- "live" or "sandbox".
scopesarray- What this key may do.
sandboxobject- Present only for a sandbox key: its status and rate limits.
Errors
- 401
invalid_token— The token is missing, expired or its key was revoked.
/organizationcurl -X GET https://backend.talkstry.com/v1/organization \
-H "Authorization: Bearer $ACCESS_TOKEN"{
"id": "6a7338745946e15320ae0988",
"name": "Acme Logistics",
"email": "ops@acme.example",
"type": "organization",
"environment": "live",
"scopes": [
"otp:send",
"otp:verify",
"messages:send",
"broadcast:send"
],
"sandbox": null
}/messages/sendmessages:sendSend a message
Queues a message to one recipient and returns immediately — a 202 means accepted, not delivered. The outcome arrives on your webhook, or you can poll the message. Send either free-form text or an approved template, never both.
Headers
Idempotency-Keystringrequired- A unique id for this send.Repeat it and you get the original message back instead of a second one. Derive it from the business event (order-10432-shipped), never from a timestamp — a timestamp changes on retry, which is exactly when you need it not to.
Body
tostringrequired- Recipient in E.164 form.The number must already have a TalksTry account — messages are delivered inside TalksTry, not over SMS.
textstringoptional- The message body, up to 4000 characters. Required unless you send a template.
templateobjectoptional- An approved template to send instead of text.
template.namestringoptional- The template's registered name. Send this or template.id, not both.
template.idstringoptional- The id returned when the template was created. Same thing by a different handle — use whichever your integration already stores.
template.variablesarrayoptional- Strings or numbers filling {{1}}, {{2}} … by position — there are no names, only order. At most 20.The count must equal the number of DISTINCT placeholders in the approved body: a placeholder used twice is still one variable. Your values are never re-scanned, so a value containing {{2}} is delivered as that literal text.
Response · 202
idstring- The message id. Use it to poll status, and it appears in every webhook about this message.
statusstring- "queued" for a new send; "duplicate" when this Idempotency-Key was already used with the same body, meaning nothing new was sent.
tostring- Echo of the recipient.
created_atstring- When the message was accepted.
Errors
- 400
invalid_request— A field is missing or the wrong type — including an Idempotency-Key header that was not sent, a variable count that does not match the template, or a variable that is not a string or number. - 403
template_disabled— The template exists and is approved, but the organisation has switched it off in the console. - 403
template_not_approved— The named template is still under review, or was rejected. - 403
sandbox_recipient_not_allowed— A sandbox key addressed a number that is not on its verified test list. - 404
not_found— No TalksTry account is registered for that number. - 409
idempotency_conflict— The same Idempotency-Key was used earlier with DIFFERENT content. Nothing is sent — use a new key. - 429
rate_limited— More than 600 sends a minute on this key.
- A 202 is not a delivery. Subscribe to message.delivered and message.read, or poll GET /messages/{id}.
- A duplicate raises no second webhook and is not counted against your allowance — nothing happened twice.
- Send template.name or template.id, never both: the two could name different templates and we will not guess which you meant.
/messages/sendcurl -X POST https://backend.talkstry.com/v1/messages/send \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Idempotency-Key: order-10432-shipped" \
-H "Content-Type: application/json" \
-d '{
"to": "+919876543210",
"text": "Your order #10432 has shipped and arrives Friday."
}'{
"id": "6ab3a746b25688d30d5c875a",
"status": "queued",
"to": "+919876543210",
"created_at": "2026-09-22T09:14:02.511Z"
}/messages/{id}messages:sendRetrieve a message
The current status and receipts for a message you sent. Polling works, but webhooks are cheaper for both of us — use this to reconcile, not as your primary loop.
Path & query
idstringrequired- The id returned when the message was accepted.
Response · 200
idstring- The message id.
statusstring- "sent", "delivered", "read" or "failed".
tostring- Recipient account id.
created_atstring- When it was accepted.
delivered_atstring- When it reached the recipient's device. null until then.
read_atstring- When the recipient opened it. null until then.
undeliverable_reasonstring- Present only when the message can never be delivered — for example the recipient has blocked the sender.
Errors
- 404
not_found— No message with that id was sent from this account.
/messages/{id}curl -X GET https://backend.talkstry.com/v1/messages/6ab3a746b25688d30d5c875a \
-H "Authorization: Bearer $ACCESS_TOKEN"{
"id": "6ab3a746b25688d30d5c875a",
"status": "read",
"to": "699bdf1868fc992a64b2032d",
"created_at": "2026-09-22T09:13:58.004Z",
"delivered_at": "2026-09-22T09:14:04.881Z",
"read_at": "2026-09-22T09:15:22.117Z"
}/otp/sendotp:sendSend a one-time code
Generates a code and delivers it to the recipient as an ordinary TalksTry message from your account. The code is never returned to you — you verify it by passing back what the user typed. An organisation MUST name one of its approved authentication templates; a personal sandbox key may omit it and gets our default wording.
Body
phonestringrequired- Recipient in E.164 form. Must have a TalksTry account.
templateobjectrequired- The approved template to send the code in. Required for an organisation credential; optional for a personal sandbox key, which has no templates.It must be in the authentication category and have exactly one placeholder, {{1}}, which the code fills. A marketing template is refused: a code must not inherit a review granted for something else.
template.namestringoptional- The template's registered name. Send this or template.id, not both.
template.idstringoptional- The template's id, as an alternative to its name.
Response · 202
otpEventIdstring- Identifies this challenge. Keep it if you want to correlate with your own records.
channelstring- Always "in_app" — codes go as TalksTry messages, not SMS.
statusstring- "sent".
templatestring- The template the code went out in. Absent when the default wording was used.
Errors
- 400
template_required— An organisation credential sent no template. The response lists your approved authentication templates in `data.availableTemplates`. - 400
invalid_request— The named template does not have exactly one placeholder — there is nowhere for the code to go, or nothing to fill a second slot with. - 403
not_permitted— The named template is not in the authentication category. - 403
template_disabled— The template exists and is approved, but the organisation has switched it off in the console. - 403
template_not_approved— The named template is still under review, or was rejected. - 404
not_found— No TalksTry account for that number, or no such template on this account. - 429
rate_limited— One code per number per minute. The response carries Retry-After and retry_after.
- The code expires in five minutes and can be used once.
- The template requirement is the same idea as review itself: a business sending codes to its customers has its wording read first. A personal sandbox key is exempt — it can only reach numbers whose owners confirmed a code, so there is nobody to protect from it.
/otp/sendcurl -X POST https://backend.talkstry.com/v1/otp/send \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"phone": "+919876543210",
"template": {
"name": "login_code"
}
}'{
"otpEventId": "6ab39addff03a25a02910704",
"channel": "in_app",
"status": "sent",
"template": "login_code"
}/otp/verifyotp:verifyVerify a one-time code
Checks the code the recipient typed back to you. A code is single-use: verifying it consumes it, so a second attempt with the same code fails even though it was correct.
Body
phonestringrequired- The same number the code was sent to.
codestringrequired- 4 to 8 digits, as the recipient typed it.
Response · 200
verifiedboolean- true only when the code was correct and unused.
statusstring- "verified" or "invalid".
messagestring- Human-readable outcome.
Errors
- 400
invalid_request— Wrong code, expired code, or a code already used. The body says which.
/otp/verifycurl -X POST https://backend.talkstry.com/v1/otp/verify \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"phone": "+919876543210",
"code": "482913"
}'{
"verified": true,
"status": "verified",
"message": "Code verified"
}/organizations/broadcastbroadcast:sendBroadcast to your organisation
Sends one message to every member account your organisation owns. Each member gets a real one-to-one conversation, not a shared thread, so replies come back to you individually.
Headers
Idempotency-Keystringrequired- A unique id for this broadcast.Applied per member, so a retry cannot double-send to anyone.
Body
textstringrequired- The message body, up to 4000 characters.
Response · 202
broadcastIdstring- Your Idempotency-Key, echoed.
recipientsnumber- How many members were addressed.
queuednumber- How many were accepted.
failednumber- How many could not be queued.
statusstring- "queued".
Errors
- 403
not_permitted— The key lacks broadcast:send — a sandbox key can never hold it. - 404
not_found— The organisation has no members to broadcast to. - 429
rate_limited— More than 30 broadcasts a minute.
/organizations/broadcastcurl -X POST https://backend.talkstry.com/v1/organizations/broadcast \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Idempotency-Key: notice-maintenance-2026-09" \
-H "Content-Type: application/json" \
-d '{
"text": "Office closed on Monday for maintenance."
}'{
"broadcastId": "notice-maintenance-2026-09",
"recipients": 42,
"queued": 42,
"failed": 0,
"status": "queued"
}Errors
Every response carries a Talkstry-Request-Id header, successes included. Log it. When something needs explaining, quoting one identifies the exact call — a timestamp does not — and you can look it up yourself in the console under Logs, where it resolves however old it is.
| Status | Code | Meaning | What to do |
|---|---|---|---|
| 400 | invalid_request | A field is missing or malformed. | `message` names the first problem in plain words — fix that and retry. |
| 401 | invalid_token | Token missing, expired or revoked. | Request a new token; do not retry the same one. |
| 403 | not_permitted | The credential lacks that scope. | A key's scopes are fixed at creation — create a new key with the scope you need. |
| 403 | template_not_approved | That template is still under review, or was rejected. | Check its status in the console. Editing an approved template sends it back for review. |
| 403 | sandbox_recipient_not_allowed | A sandbox key may only reach its verified test numbers. | Add the number in Settings → Developer sandbox, or apply for API access. |
| 404 | not_found | No such message or template — or the number you addressed has no TalksTry account. | Messages are delivered inside TalksTry, so the recipient must already have an account. |
| 409 | idempotency_conflict | Same Idempotency-Key, different body. | Use a new key for a genuinely new request. |
| 429 | rate_limited | Too many requests in the window. | Back off for `Retry-After` seconds. |
| 5xx | server_error | Our side failed. | Retry with exponential backoff and the same Idempotency-Key. |
Rate limits
POST /auth/token
30 requests / minute (per IP)
POST /messages/send
600 requests / minute
GET /messages/{id}
1,200 requests / minute
POST /otp/send
300 requests / minute
POST /otp/verify
600 requests / minute
POST /organizations/broadcast
30 requests / minute
Any personal sandbox key
20 / minute and 500 / day, whichever comes first
Every response carries X-RateLimit-Remaining. On a 429, wait the number of seconds in Retry-After — retrying sooner only pushes the window further out.