JS1 Talkback Send API: one-page guide
For a programmer connecting their own software to Talkback. You send approved template messages to your customers from your number, and Talkback tells your server what happened to each one.
Base address: https://talkback.js1software.net/v1
All requests and answers are JSON (UTF-8). Times are UTC, ISO 8601 (2026-11-10T09:31:05Z). Field names in answers are
camelCase (failureCode), exactly as shown below.
Contents
- Quick start: your first message in five steps
- 1. Keys
- 2. Send a message: fields, PDF invoices, Idempotency-Key, examples in curl, C#, PHP and Node, every error
- 3. Balance, readiness and message status
- 4. Test key and magic numbers
- 5. Webhooks: events, signature check in C#, PHP and Node, retries
- 6. Status words and failure codes
- 7. Postman and OpenAPI
Quick start
- The account's Owner adds you as a Developer on the Team page of the Talkback portal. You are emailed an invite.
- On the Developers page, make a test key (
tb_test_…). Nothing it sends reaches anyone or costs anything. - Send your first message with it (any valid number works with a test key):
You getcurl -X POST https://talkback.js1software.net/v1/messages \ -H "Authorization: Bearer $TALKBACK_KEY" -H "Idempotency-Key: first-try-1" \ -H "Content-Type: application/json" \ -d '{"to":"919876543210","template":"invoice_send","params":["Ramesh Textiles","INV-1043","12,500"]}'202with"status": "delivered". - Set your webhook address on the Developers page, press Send a test, and check the signature (§5). From now on your server hears every status change and every customer reply.
- When it all works, the Owner makes a live key. Ask
GET /v1/readinesswith your template names (§3); when it says"ready": true, switch your software over to the live key.
1. Keys
| Key | Starts with | What it does |
|---|---|---|
| Live | tb_live_ |
Real messages to real customers, taken from your balance |
| Test | tb_test_ |
Nothing is sent and nothing is charged (see §4) |
- Keys are made on the Developers page of the Talkback portal. The account's Owner makes live keys; a Developer (the Owner adds you on the Team page, and you are emailed an invite) makes test keys. Either can revoke a key.
- The full key is shown once. Keep it on your server only, never in a browser or a mobile app.
- Send it on every request:
Authorization: Bearer tb_live_xxxxxxxxxxxxxxxx
A missing, wrong or withdrawn key always gets a bare 401 (no body). It is final: do not retry, fix the key.
2. Send a message
POST /v1/messages
curl -X POST https://talkback.js1software.net/v1/messages \
-H "Authorization: Bearer $TALKBACK_KEY" \
-H "Idempotency-Key: INV-1043-send-1" \
-H "Content-Type: application/json" \
-d '{"to":"919876543210","template":"invoice_send","params":["Ramesh Textiles","INV-1043","12,500"],"reference":"INV-1043"}'
| Field | Required | Rules |
|---|---|---|
to |
yes | Customer's number with the country code: 11 to 15 digits, not starting with 0 (919876543210). +, spaces and hyphens are allowed and ignored. A bare 10-digit number is refused. |
template |
yes | The approved template's name (1 to 100 characters). |
params |
if the template has placeholders | The values for {{1}}, {{2}}, … in order. Each: not empty, at most 1000 characters, no new line or tab, not more than 4 spaces in a row. Fewer values than placeholders is refused; extra values are dropped. |
language |
no | e.g. en, en_US, hi. Left out: the one language the template is approved in (or en when it is approved in several, en among them). |
reference |
no | Your own label, e.g. the invoice number. Up to 64 characters, one line. Searchable in the portal and sent back in webhooks. |
The JSON body may be at most 64 KB.
With a PDF (only for a template that has a document header, and then it is required): send multipart/form-data with a
field message holding the same JSON, and a file document (PDF only, at most 5 MB). The customer sees the file's own name.
With an image (only for a template that has an image header, and then it is required): the same, with the file under
image instead (JPEG or PNG, at most 5 MB). The file's own bytes decide what it is, not its name: a PDF sent to an image
template, or a picture sent to a PDF one, is refused with BAD_PARAMS before anything is charged.
curl -X POST https://talkback.js1software.net/v1/messages \
-H "Authorization: Bearer $TALKBACK_KEY" -H "Idempotency-Key: INV-1043-send-1" \
-F 'message={"to":"919876543210","template":"invoice_pdf","params":["Ramesh Textiles","INV-1043"],"reference":"INV-1043"}' \
-F "document=@INV-1043.pdf;type=application/pdf"
Idempotency-Key (required)
One key per message you mean to send: 1 to 200 printable characters, no spaces (e.g. INV-1043-send-1).
If a request times out or you are not sure it arrived, send it again with the same key. You get the first answer back and
the customer gets one message, never two. A repeat of an accepted key returns the stored answer without looking at the body,
so a genuinely new message (even a resend of the same invoice) needs a new key. After a refusal where nothing was charged
(PROVIDER_REFUSED, PROVIDER_UNAVAILABLE, …), a retry with the same key makes a fresh attempt with the new body.
The answer: 202 Accepted
{ "id": "msg_3f9c0a7e5b1d42c88e0f6a1b2c3d4e5f", "status": "submitted", "remaining": 482 }
Save id against your record; webhooks carry it. status is usually submitted (accepted if the hand-over is still being
confirmed). remaining is your messages left, null for a test key.
What to do with the answer
| Answer | Do |
|---|---|
202 |
Save id. The webhook tells you the rest |
429, any 5xx, a timeout or no answer |
Retry later with the same Idempotency-Key. It is always safe: the customer never gets two |
Any other 4xx |
Final. Show code and message to the user; fix the cause before sending again |
C#: a plain message
using System.Net;
using System.Net.Http.Json;
var http = new HttpClient { BaseAddress = new Uri("https://talkback.js1software.net/"), Timeout = TimeSpan.FromSeconds(30) };
http.DefaultRequestHeaders.Authorization = new("Bearer", apiKey);
var req = new HttpRequestMessage(HttpMethod.Post, "v1/messages")
{
Content = JsonContent.Create(new
{
to = "919876543210", template = "invoice_send",
@params = new[] { "Ramesh Textiles", "INV-1043", "12,500" }, reference = "INV-1043"
})
};
req.Headers.Add("Idempotency-Key", "INV-1043-send-1"); // store it with the invoice; reuse it on every retry
var res = await http.SendAsync(req); // a timeout here = retry later with the SAME key
var body = await res.Content.ReadAsStringAsync();
if (res.StatusCode == HttpStatusCode.Accepted) { /* save "id"; the webhook tells you the rest */ }
else if ((int)res.StatusCode is 429 or >= 500) { /* retry later, same Idempotency-Key */ }
else { /* final: show body's "code" + "message" to the user, do not retry as it is */ }
Send a PDF invoice: C#, PHP, Node
The template must have a document header. The file's own name (INV-1043.pdf) is what the customer sees.
// C# (.NET 6+): "http" set up as in the example above
using System.Text.Json;
using var form = new MultipartFormDataContent();
form.Add(new StringContent(JsonSerializer.Serialize(new
{
to = "919876543210", template = "invoice_pdf",
@params = new[] { "Ramesh Textiles", "INV-1043" }, reference = "INV-1043"
})), "message");
var pdf = new ByteArrayContent(await File.ReadAllBytesAsync("INV-1043.pdf"));
pdf.Headers.ContentType = new("application/pdf");
form.Add(pdf, "document", "INV-1043.pdf");
var req = new HttpRequestMessage(HttpMethod.Post, "v1/messages") { Content = form };
req.Headers.Add("Idempotency-Key", "INV-1043-send-1");
var res = await http.SendAsync(req); // then the same three branches as above
// PHP (curl extension): $apiKey = your key, kept on the server
$ch = curl_init('https://talkback.js1software.net/v1/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey, 'Idempotency-Key: INV-1043-send-1'],
CURLOPT_POSTFIELDS => [ // an array makes curl send multipart/form-data
'message' => json_encode(['to' => '919876543210', 'template' => 'invoice_pdf',
'params' => ['Ramesh Textiles', 'INV-1043'], 'reference' => 'INV-1043']),
'document' => new CURLFile('INV-1043.pdf', 'application/pdf', 'INV-1043.pdf'),
],
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); // 0 = no answer at all
if ($status === 202) {
$id = json_decode($body, true)['id']; // save it with the invoice
} elseif ($status === 0 || $status === 429 || $status >= 500) {
// retry later with the SAME Idempotency-Key
} else {
$err = json_decode($body, true); // null on a 401 (no body)
// final: show $err['code'] and $err['message'] to the user
}
// Node 18+ (fetch, FormData and Blob are built in)
import { readFile } from 'node:fs/promises';
const form = new FormData();
form.append('message', JSON.stringify({
to: '919876543210', template: 'invoice_pdf',
params: ['Ramesh Textiles', 'INV-1043'], reference: 'INV-1043',
}));
form.append('document', new Blob([await readFile('INV-1043.pdf')], { type: 'application/pdf' }), 'INV-1043.pdf');
let res;
try {
res = await fetch('https://talkback.js1software.net/v1/messages', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.TALKBACK_KEY}`, 'Idempotency-Key': 'INV-1043-send-1' },
body: form, // fetch sets the multipart Content-Type itself; do not set it
signal: AbortSignal.timeout(30_000),
});
} catch {
res = null; // no answer: retry later with the SAME Idempotency-Key
}
if (res?.status === 202) { const { id } = await res.json(); /* save it */ }
else if (!res || res.status === 429 || res.status >= 500) { /* retry later, same Idempotency-Key */ }
else { /* final: show code + message (a 401 has no body) */ }
Errors
Every refusal (except 401) is {"code": "…", "message": "…"}. Act on code; message is for a person. Nothing is charged on
any refusal.
| HTTP | code |
Meaning | Retry? |
|---|---|---|---|
| 401 | (no body) | Missing, wrong or withdrawn key | No. Fix the key |
| 402 | INSUFFICIENT_BALANCE |
No messages left, or the balance expired | No. Top up first |
| 402 | SHOP_LIMIT_REACHED |
The monthly message limit set on this number is used up | No. Next month, or raise the limit |
| 403 | ACCOUNT_OFF |
Sending is not switched on for the account | No. Contact JS1 Software |
| 403 | ACCOUNT_PAUSED |
Sending is paused for the account | No. Contact JS1 Software |
| 403 | SHOP_NOT_LIVE |
Your number is not live for sending yet | No. Contact JS1 Software |
| 403 | NUMBER_RESTRICTED |
The messaging network has restricted your number for now; the account Owner is emailed | No. Wait for the "back to normal" email, or contact JS1 Software |
| 409 | IDEMPOTENCY_KEY_REUSED |
This Idempotency-Key was used by a test key vs a live key | No. Use a new key |
| 413 | DOCUMENT_TOO_LARGE |
The whole request is over the size limit | No. Smaller PDF |
| 422 | BAD_PARAMS |
Something in the request is wrong; message says what |
No. Fix the request |
| 422 | TEMPLATE_NOT_APPROVED |
No approved template of that name (and language) on your number | No. Check the name, or wait for approval |
| 422 | LANGUAGE_REQUIRED |
The template is approved in several languages: name language |
No. Add language |
| 422 | MARKETING_NOT_OFFERED |
The template is classed as marketing, which this account does not send | No |
| 422 | DOCUMENT_TOO_LARGE |
The PDF is over 5 MB | No. Smaller PDF |
| 422 | UNKNOWN_SHOP |
You sent a shop field. Leave it out |
No |
| 422 | PROVIDER_REFUSED |
The message itself was refused by the sending network. Rarely another code from the "rejected" list in §6 | Not as it is. Fix it; the same key then tries again |
| 429 | RATE_LIMITED |
Too many requests this minute (header Retry-After: 60) |
Yes, after 60 seconds |
| 429 | TEST_LIMIT_REACHED |
Test key: 500 test sends today used up | Tomorrow (IST) |
| 503 | PROVIDER_UNAVAILABLE |
The sending network is not answering | Yes, later, same key |
| 503 | PROVIDER_NOT_READY |
Your number's sending is not set up yet (JS1 Software is told) | Yes, later, same key |
| 500 / no answer / timeout | Not known if it went | Yes, same key (always safe) |
Rate limit: 120 requests a minute per account by default. Sending has its own budget; reads (status, balance, readiness) have a separate one, so reads never use up sending.
3. Balance, readiness and message status
GET /v1/balance
curl https://talkback.js1software.net/v1/balance -H "Authorization: Bearer $TALKBACK_KEY"
{ "remaining": 482, "expiring": [ { "messages": 482, "on": "2027-11-06T00:00:00Z" } ], "mode": "enforce" }
mode: enforce = paid, each message takes 1 from the balance; shadow = free trial, messages are counted, not charged;
off = sending not switched on. expiring is empty when nothing is due to expire.
GET /v1/readiness?template=a&template=b asks "would a real send of these templates be taken right now?" without sending.
Call it with your live key before you switch a customer over to Talkback, and show the problems to them. Up to 20 templates.
curl "https://talkback.js1software.net/v1/readiness?template=invoice_send&template=invoice_pdf" \
-H "Authorization: Bearer $TALKBACK_KEY"
{
"ready": false,
"test": false,
"problems": [ { "code": "INSUFFICIENT_BALANCE", "message": "No messages left, or the balance has expired. Top up to send again." } ],
"templates": [
{ "name": "invoice_send", "approved": true, "problem": null },
{ "name": "invoice_pdf", "approved": false, "problem": "TEMPLATE_NOT_APPROVED: no approved template 'invoice_pdf' on this number." }
]
}
problems[].code uses the same codes a send would answer (ACCOUNT_OFF, ACCOUNT_PAUSED, SHOP_NOT_LIVE, NUMBER_RESTRICTED,
INSUFFICIENT_BALANCE, SHOP_LIMIT_REACHED, TEMPLATE_NOT_APPROVED, PROVIDER_NOT_READY). A template's problem starts with
its code. It does not check a customer's number or your values; the send does that. A test key always answers "ready": true, "test": true. More than 20 templates, or a name over 100 characters, is 422 BAD_PARAMS.
GET /v1/messages/{id}: one message's status, for a one-off check (support, a lost webhook). Not for learning what
happened to every message: use webhooks (§5).
{ "id": "msg_3f9c…", "status": "delivered", "failureCode": null, "updated": "2026-11-10T09:31:05Z" }
An id that is not yours, or a test id asked with a live key (or the other way), is 404.
GET /v1/public/prices: the list price, the same for everyone. No key needed. Rupees before GST.
{
"currency": "INR", "gstPercent": 18, "minAmount": 2000, "packs": [2000, 3000, 6000, 12000], "bestValue": 6000,
"rates": [ { "fromAmount": 1000, "perMessage": 0.85 }, { "fromAmount": 6000, "perMessage": 0.70 } ],
"effectiveFromUtc": "2026-11-06T00:00:00Z"
}
A pack of fromAmount rupees or more pays perMessage for each message (the highest fromAmount it reaches). An account with
its own agreed price sees that price on its Billing page, not here.
4. Test key and magic numbers
A tb_test_ key goes through every check on the request (number, values, Idempotency-Key) but nothing is sent to anyone and
nothing is charged. Use these numbers as to to force each answer:
to |
What you get |
|---|---|
910000000402 |
402 INSUFFICIENT_BALANCE |
910000000502 |
422 PROVIDER_REFUSED (the "refused" path; a retry with the same key is refused again) |
910000000999 |
202 with status failed, and a failed webhook |
| any other valid number | 202 with status delivered at once, and a delivered webhook |
- At most 500 test sends a day (IST) per account; then
429 TEST_LIMIT_REACHED. A retry with the same key does not count again. - Test messages are deleted after 7 days.
- A test key does not check your templates (name, number of values, PDF needed or not). Check those with
GET /v1/readinessusing the live key. - Test and live are kept apart: a live key cannot see a test message, and an Idempotency-Key used by one cannot be used by the other.
5. Webhooks
This is how you learn what happened. Talkback tells your server every change: sent, delivered, read, failed, and every message a customer writes to your number.
This is the integration pattern. Do not poll GET /v1/messages/{id} in a loop.
- One address per account,
https://only. Set it on the Developers page; the signing secret is shown when you set the first address. - Every event is a
POSTwith a JSON body.
message.status: a message you sent moved.
{ "event": "message.status", "eventId": "evt_…", "id": "msg_3f9c…", "reference": "INV-1043",
"status": "delivered", "failureCode": null, "at": "2026-11-10T09:31:05Z", "test": false }
status is sent, delivered, read or failed. failureCode is set only on a live failed (codes in §6); a test-key
failed carries failureCode: null, the same as GET /v1/messages/{id}. reference is
null when you sent none.
message.received: a customer wrote to your number.
{ "event": "message.received", "eventId": "evt_…", "from": "919876543210", "name": "Ramesh",
"type": "text", "text": "Please send the LR copy", "at": "2026-11-10T09:40:12Z", "test": false }
type is one of text, image, document, audio, video, location, other (a tap on a reply button is text). text is null when there is none,
and is cut at 3000 characters. name is the customer's profile name, or null.
In every event, at is UTC to the second, e.g. 2026-11-10T09:31:05Z.
webhook.test: sent when you press Send a test on the Developers page.
{ "event": "webhook.test", "eventId": "evt_…", "at": "2026-11-10T09:00:00Z", "test": true }
Headers and signature
| Header | Value |
|---|---|
Talkback-Timestamp |
Unix time in seconds |
Talkback-Signature |
v1= + lowercase hex HMAC-SHA256(secret, "{timestamp}.{raw body}") |
Talkback-Event-Id |
Same as eventId in the body |
Your secret looks like tbwh_ + 43 url-safe characters. The HMAC key is the UTF-8 bytes of the whole secret string,
tbwh_ included. Do not strip the prefix or decode it.
Verify every request: compute over the raw body bytes (before any JSON parsing), compare in constant time, and reject a timestamp more than 5 minutes from your clock.
// C#: secret = the whole "tbwh_…" string
static bool Verify(byte[] rawBody, string? ts, string? sig, string secret)
{
if (!long.TryParse(ts, out var t) || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t) > 300 || sig is null) return false;
byte[] signed = [.. Encoding.UTF8.GetBytes(ts + "."), .. rawBody];
var want = "v1=" + Convert.ToHexString(HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), signed)).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(want), Encoding.ASCII.GetBytes(sig));
}
// PHP: $secret = the whole "tbwh_…" string
$raw = file_get_contents('php://input');
$ts = $_SERVER['HTTP_TALKBACK_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_TALKBACK_SIGNATURE'] ?? '';
$ok = ctype_digit($ts) && abs(time() - (int)$ts) <= 300
&& hash_equals('v1=' . hash_hmac('sha256', $ts . '.' . $raw, $secret), $sig);
if (!$ok) { http_response_code(401); exit; }
// Node (Express): read the body raw; SECRET = the whole "tbwh_…" string
app.post('/talkback', express.raw({ type: '*/*' }), (req, res) => {
const ts = req.get('Talkback-Timestamp') || '', sig = req.get('Talkback-Signature') || '';
const want = 'v1=' + crypto.createHmac('sha256', SECRET).update(ts + '.').update(req.body).digest('hex');
const ok = /^\d+$/.test(ts) && Math.abs(Date.now() / 1000 - Number(ts)) <= 300
&& sig.length === want.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want));
if (!ok) return res.sendStatus(401);
res.sendStatus(200); // answer first
handleEvent(JSON.parse(req.body)); // then do the work
});
Answering, retries, switch-off
- Answer any 2xx within 10 seconds. Do slow work (database, emails) after answering.
- The address must be
httpson a public host. Redirects are not followed: a3xxcounts as a failure, so give the final address. - Anything else, or no answer, is retried: after 1 minute, 5 minutes, 15 minutes, then every hour, for 24 hours.
- An address that fails every try for 24 hours is switched off and the account owner is emailed. Once your server answers again, switch it back on yourself on the Developers page.
Rules your code must follow
- Events can arrive twice. Keep
eventIds you have handled and drop a repeat. - Events can arrive out of order. A
readcan come beforedelivered, and a message can jump straight todeliveredorread. Only move your record forward (sent→delivered→read), never back.failedis final and wins over anything before it, evenread(Talkback keeps the same rule). - A test-key send fires webhooks too, with
"test": true. - The secret is shown once. If you lose it, press New secret on the Developers page; the old one stops working at once.
6. Status words and failure codes
| Status | Meaning |
|---|---|
accepted |
Taken and counted; the hand-over to the sending network is being confirmed. Settles within minutes |
submitted |
Handed over, not yet on the customer's phone |
sent |
Sent to the customer's phone |
delivered |
Arrived on the customer's phone |
read |
The customer opened it |
failed |
Could not be delivered. Final. The message is still counted, because it was sent |
rejected |
Never sent and not charged (seen only on GET /v1/messages/{id}). A retry with the same Idempotency-Key tries again |
Failure codes on a failed message
failureCode |
Meaning |
|---|---|
NOT_REACHABLE |
The number could not be reached: it may not use the messaging app, or the phone has been off a long time |
TEMPLATE_REJECTED |
The template was refused for this message. Check it is still approved and the values fit it |
DOCUMENT_FAILED |
The PDF could not be attached. Send again with a smaller or fresh PDF (new Idempotency-Key) |
RATE_LIMITED |
Too many messages to this customer too quickly; held back. Try later |
UNDELIVERABLE |
Could not be delivered to this number, no more detail |
Codes on a rejected message (never sent, not charged): PROVIDER_REFUSED, PROVIDER_UNAVAILABLE, PROVIDER_NOT_READY, ACCOUNT_OFF,
TEMPLATE_NOT_APPROVED, DOCUMENT_EXPIRED (the PDF was no longer available when it was due to go: send it again), NOT_SENT.
7. Postman and OpenAPI
The same API as a machine-readable file: /docs/openapi.json (OpenAPI 3.1). It has the four calls,
every field, every answer, every code, and the webhook events.
- Postman: Import → Link →
https://talkback.js1software.net/docs/openapi.json. Then set the collection's Authorization to Bearer Token with your test key. - It is kept in step with this page: a check in our build fails if a code or a call is in one and not the other.
Questions: JS1 Software support.