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

  1. The account's Owner adds you as a Developer on the Team page of the Talkback portal. You are emailed an invite.
  2. On the Developers page, make a test key (tb_test_…). Nothing it sends reaches anyone or costs anything.
  3. Send your first message with it (any valid number works with a test key):
    curl -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"]}'
    
    You get 202 with "status": "delivered".
  4. 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.
  5. When it all works, the Owner makes a live key. Ask GET /v1/readiness with 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)
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

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.

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

Rules your code must follow


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.

Questions: JS1 Software support.