HelloTalk

Developer API

Voice calls from
your own software

Wherever you send an SMS today, place a voice call instead. Payment reminders, order confirmations, a review asked for after the sale — one HTTP request. What the customer pressed comes back to your server, and that same keypress can put them straight through to one of your agents.

Base URL

https://hellotalk.com.bd/api/v1

This one works for everybody and never changes. The API also answers on your own address — https://your-name.hellotalk.com.bd/api/v1 — which is ready to copy on the Developer API page of your portal.

Your own address Stays in your own branding — use it when you hand an integration over to your own clients. Change the address and the old one stops working.
The address above Never changes — the safe one wherever a URL is hard to edit later: a POS terminal sitting in a shop, a script written once and forgotten.

What cannot happen: which account a request belongs to is decided by the API key, never by the address. Use your key on somebody else's address and you get wrong_account_host; send it to an address belonging to nobody and you get wrong_host — each with the correct base_url alongside.

Getting started

  1. 1 Create a key on the Developer API page of your portal, and choose there what it is allowed to do (scopes). The key is shown once.
  2. 2 Upload the message the customer will hear to your Audio library (mp3 or wav — we convert it to the format the phone network needs).
  3. 3 Take its audio_id — three ways to find it, below.
  4. 4 Place the call.

Where to find an audio_id

  • • On the Developer API page — your audio listed with its number and a copy button
  • • In the Audio library — under each name, audio_id: 12
  • • From code — with the endpoint below
curl https://hellotalk.com.bd/api/v1/audios \
  -H "Authorization: Bearer kp_xxxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
{
  "data": [
    { "id": 12, "name": "Payment reminder", "duration_sec": 14, "created_at": "…" },
    { "id": 9,  "name": "Review request",   "duration_sec": 11, "created_at": "…" }
  ]
}

Only audio that is ready appears here. Conversion takes a few seconds after an upload, and during those seconds the file is absent from the list and a call using that audio_id returns 422. That is deliberate: a call placed before the file exists would have played the customer silence.

curl -X POST https://hellotalk.com.bd/api/v1/calls \
  -H "Authorization: Bearer kp_xxxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Idempotency-Key: order-8842" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "01712345678",
    "audio_id": 12,
    "reference": "order-8842",
    "webhook_url": "https://shop.example.com/hashtech"
  }'
202 Accepted
{
  "id": "call_1042",
  "status": "queued",
  "to": "8801712345678",
  "reference": "order-8842",
  "created_at": "2026-08-14T10:22:31+06:00"
}

What a key is allowed to do

Decide what a key may do when you create it. A key ends up living in your shop's software, on a POS terminal or inside a plugin — places not always under your control. The fewer permissions it carries, the less a leak costs you.

scope what it permits
calls:write Place and cancel calls
calls:read Read call status and history, and the account's call log
recordings:read Download call recordings — separate from reading who called, because listening is a bigger thing than knowing
audios:read List your audio
softphone:connect Sign a browser phone in as one of your extensions — only for a web app that is your agents' phone. Not ticked unless you tick it.

Recommended: software that only confirms orders should get calls:write and not calls:read — the history is a list of your customers' phone numbers. Webhook results still arrive either way; they need no scope.

Attempting something the key does not cover returns 403 with scope_required, and required_scope naming the one it needed. A key's scopes can be changed later on the Developer API page, under Edit permissions. The key itself stays the same, and the change applies from its next request.

The same call will not go twice

Your server's HTTP client may retry on a timeout, or a WooCommerce hook may fire twice. Put an id in the Idempotency-Key header and the second request places no new call — it replays the first one's response, with an Idempotent-Replay: true header.

Nothing stops you calling the same number repeatedly. No number is ever blocked — only a repeated key is. Call a customer as often as you need to; use a different key each time, or send none at all.

The key names an attempt, not an order

Customer did not pick up and you want to try again tomorrow? The same key places no new call — tomorrow it replays yesterday's "no answer". For a deliberate second attempt within 24 hours, the key has to name the attempt:

✅ order-8842-attempt-1 · invoice-99-2026-08-15
⚠️ order-8842 — right for a hook firing twice, wrong for a deliberate retry

Send a different request under the same key — another number, another reference — and the call is not quietly dropped: you get 409 idempotency_key_reused. While testing, the usual cause is an old header still sitting in Postman.

When you must send one: calls like an order confirmation, where phoning twice about the same event annoys the customer and writes two contradictory answers into your system. When you need not: payment reminders or review requests, where calling again is the normal thing to do.

A key is remembered for 24 hours. After that the same key places a new call, so a reused order number causes no trouble.

What the customer pressed

Send gather and the call waits for a keypress after the message. If nobody presses anything the call is still answered — only digit comes back null.

{
  "to": "01712345678",
  "audio_id": 12,
  "reference": "order-8842",
  "gather": { "digits": ["1", "2"], "repeat": 2, "timeout": 7 },
  "webhook_url": "https://shop.example.com/hashtech"
}

repeat — how many more times the message plays when nobody presses (1–5, default 2). timeout — seconds to wait each time (3–30, default 7).

Press 1 to reach an agent

The keypress need not only be reported back — it can put the customer straight through to one of your queues or extensions. For "press 1 to discuss this", sending them to the collections desk.

{
  "to": "01712345678",
  "audio_id": 12,
  "gather":  { "digits": ["1", "2"] },
  "connect": { "on_digit": "1", "queue_id": 4 }
}

Queue or extension? Prefer queue_id. An extension that is busy or offline drops the customer's call, while a queue already has hold music and a place to wait. The difference shows itself the moment several people press 1 at once.

Several keys to several departments? That is an IVR menu. Build it in the portal, then send "ivr_id": 3 instead of gather — the whole menu runs after the message.

Once connected: the API call's account of things ends there. The webhook fires immediately with "connected": true rather than waiting to see how long the conversation with the agent lasts. From that point it is an ordinary call, and it appears in the portal's Call Log.

Click-to-call — from your CRM

The call button beside a customer's number. from is your agent — an extension number, or a queue for whoever is free — and to is the customer. The agent's phone rings first. Only once they have picked up is the customer dialled, so nobody you rang is ever left holding for an agent who has not turned up.

POST /api/v1/click-to-call
Idempotency-Key: crm-click-7f3a

{
  "from": "101",
  "to": "01712345678",
  "customer_name": "Rahim Uddin",
  "reference": "lead-4821",
  "webhook_url": "https://crm.example.com/hooks/calls"
}

customer_name is what the agent's phone shows while it rings — send it, and they know who they are about to speak to before they pick up. Leave it out and the number is looked up in your Contacts; failing that, the number itself is shown. Or "from": {"queue_id": 7} to ring whichever member of a team is free. The response is 202 with a call id; read it back at GET /api/v1/calls/{id}.

Two legs, one status

A click-to-call is two calls, and what your CRM files is the second one. The status says where it is:

queued Not dialled yet
ringing The agent's phone is ringing
connecting The agent picked up; the customer is being dialled
completed The customer answered — customer.talk_sec is the conversation
no_answer The agent did not pick up. The customer was never rung.
customer_busy / customer_no_answer / customer_failed The agent picked up; the customer could not be reached
failed / cancelled / expired As for any call

The call.completed webhook fires once, after the customer leg has ended — not when the agent picks up — and carries agent, customer and your reference, so the activity can be filed against the lead without a lookup.

Not held to calling hours. A recorded-message call waits for a civil hour; a click-to-call is a person at a desk choosing to ring this customer now, and goes out at once. The extension does have to be allowed to make outside calls, and the customer must not already be on a click-to-call from you — both come back as 409 with the reason.

A phone inside your own web app

Click-to-call is enough for most CRMs: the button is yours, and the agent's phone — our browser softphone, a desk phone, or an app — rings. If your web app should be the phone, it can sign in as the agent's extension with any WebRTC SIP client (JsSIP, SIP.js). Calls to that extension, click-to-calls included, then ring in your app, and calls from it go out under the extension's own permissions.

# On YOUR server, for the agent who is signed in to your app
GET /api/v1/extensions/101/softphone
Authorization: Bearer kp_…

{
  "wsUri": "wss://…:8089/ws",
  "uri": "sip:101-web@…",
  "authUser": "101-web",
  "password": "…",
  "displayName": "Karim",
  "number": "101",
  "iceServers": [ … ]
}

Your server passes it to that agent's page, and the page registers:

const c = await (await fetch('/your-app/phone-config')).json();  // your own route

const ua = new JsSIP.UA({
  sockets: [new JsSIP.WebSocketInterface(c.wsUri)],
  uri: c.uri, authorization_user: c.authUser, password: c.password,
  display_name: c.displayName,
});
ua.start();

const media = { pcConfig: { iceServers: c.iceServers }, mediaConstraints: { audio: true, video: false } };
ua.on('newRTCSession', ({ session, originator }) => {
  if (originator === 'remote') session.answer(media);   // or show a ring and let the agent answer
});
ua.call('01712345678', media);                          // customers in local form, 01XXXXXXXXX
The key stays on your server. It can fetch the sign-in of every extension on the account, and whoever holds a sign-in can call anything that extension may. Hand each agent their own line's config and nobody else's.
  • Fetch it before every registration; never store it. iceServers carries a TURN credential that expires within the hour, and without TURN a call on office wifi or mobile data connects with no sound. If the server refuses the password, fetch again.
  • To revoke a sign-in, change the extension's password in the portal — that changes this one too, and every phone on the extension signs in again. Revoking the key stops new fetches, but not a sign-in already handed out.
  • One extension holds at most two browser registrations at once (your app, our portal's softphone). A third signs the oldest out.
  • Sound trouble? Sign the same extension in to the portal's own softphone. If it is clear there, the difference is in the client code.

The result, on your server

When the call ends, one POST goes to your webhook_url. If your server does not answer 2xx it is retried — after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours.

POST /hashtech  HTTP/1.1
X-HelloTalk-Timestamp: 1786000951
X-HelloTalk-Signature: sha256=9f2c…
X-HelloTalk-Event: call.completed

{
  "id": "call_1042",
  "reference": "order-8842",
  "to": "8801712345678",
  "status": "answered",
  "digit": "1",
  "connected": false,
  "answered_sec": 14,
  "ended_at": "2026-08-14T10:23:05+06:00"
}

Verify the signature — do not skip this

Without it, anyone can post a fake "order confirmed" to your URL. The secret is on the Developer API page of your portal. The timestamp is inside the signature, so capturing an old request and replaying it later does not work either.

<?php   // PHP
$body      = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_HELLOTALK_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_HELLOTALK_SIGNATURE'] ?? '';

$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, HELLOTALK_SECRET);

if (! hash_equals($expected, $signature) || abs(time() - (int) $timestamp) > 300) {
    http_response_code(403);
    exit;
}

$call = json_decode($body, true);
// Find your order by $call['reference']; decide what to do from $call['digit']
http_response_code(200);
// Node.js (Express — take the raw body with express.raw())
const crypto = require('crypto');

const timestamp = req.get('X-HelloTalk-Timestamp') || '';
const signature = req.get('X-HelloTalk-Signature') || '';
const expected  = 'sha256=' + crypto
  .createHmac('sha256', process.env.HELLOTALK_SECRET)
  .update(timestamp + '.' + req.body)      // req.body = Buffer
  .digest('hex');

const ok = signature.length === expected.length
  && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
  && Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;

if (!ok) return res.sendStatus(403);
res.sendStatus(200);

Call activity — every call, in your CRM

Everything above is about calls your software placed. This is the rest of them: the inbound call an agent took, the one nobody answered, the outbound call somebody dialled from their desk phone. A CRM that files these against the contact has the whole history of a customer in one place, which is the reason to integrate at all.

Two ways to get them, and you will usually want both: a webhook that pushes each call the moment it ends, and a pull for backfilling a day or catching a delivery your server was down for.

The call.ended webhook

Save a URL under Call activity webhook on the Developer API page of your portal and send yourself a test from there. From then on every call is POSTed to it as it ends — signed exactly like call.completed, with the same secret and the same headers, so the verifier you wrote above handles both. Retried on the same schedule if your server does not answer 2xx. Nothing arrives while the URL is blank.

POST /hooks/calls  HTTP/1.1
X-HelloTalk-Timestamp: 1789200951
X-HelloTalk-Signature: sha256=4b7e…
X-HelloTalk-Event: call.ended

{
  "id": "cdr_58213",
  "direction": "inbound",
  "from": "01712345678",
  "to": "600",
  "customer": "8801712345678",
  "customer_display": "01712345678",
  "extension": null,
  "agent": "102",
  "number": { "id": 3, "label": "Head office" },
  "outcome": "answered",
  "missed": false,
  "started_at": "2026-09-20T11:02:14+06:00",
  "ended_at": "2026-09-20T11:04:01+06:00",
  "ring_sec": 9,
  "talk_sec": 98,
  "recording": { "available": true, "url": "https://hellotalk.com.bd/api/v1/call-log/58213/recording" },
  "call_id": null,
  "quality": {
    "grade": "poor",
    "note": "The agent's internet lost 65% of the audio sent to them — they heard gaps or silence.",
    "agent":   { "heard_loss_pct": 65.0, "sent_loss_pct": 0.4, "jitter_ms": 12.5, "rtt_ms": 26.0, "grade": "poor" },
    "carrier": { "heard_loss_pct": 0.0,  "sent_loss_pct": 0.1, "jitter_ms": 1.2,  "rtt_ms": null, "grade": "good" }
  }
}

customer is the outside end whichever way the call went — the field to look your contact up by. agent is the queue member who took it; extension is who placed an outbound or internal call, and is null on an inbound one, where to names the destination — an extension number, a queue number, ivr-N or all. missed is an inbound call nobody answered. call_id is set when the call was a leg of one you placed through this API — a click-to-call's customer leg — so the two records join on your side. quality is how it sounded, from the packet statistics both ends keep — agent.heard_loss_pct is the share of audio the agent's phone never received, which is the number to look at when somebody says they could not hear the customer. It names the side, because "the agent's internet" and "the carrier" are fixed by different people. Null until a call has carried enough audio to judge.

Pulling the log

curl "https://hellotalk.com.bd/api/v1/call-log?since=2026-09-20T00:00:00%2B06:00&missed=true" \
  -H "Authorization: Bearer $KEY"

Newest first, 25 a page. Filter by since, until, direction, customer (a number, any spelling) and missed. Needs calls:read.

The recording

GET the recording.url with a key that holds recordings:read and you get the WAV. That scope is deliberately separate: a key that may read who called does not thereby get to listen. Recordings are kept for the number of days set in your portal, then deleted — after that the URL answers 404, so fetch what you want to keep.

Status reference

status meaning
queued Waiting — either it is not calling hours yet, or the concurrent-call limit is full
ringing The phone is ringing
answered The customer picked up
no_answer Nobody picked up
busy The line was busy
failed The call could not be placed
cancelled Cancelled
expired expires_in_minutes passed before it could be placed, so it never was

Every endpoint

POST /api/v1/calls Place a call
GET /api/v1/calls/{id} The status of one call
GET /api/v1/calls List them — filter by reference, status, per_page
POST /api/v1/calls/{id}/cancel Cancel it, while it is still queued
POST /api/v1/click-to-call Ring your agent, then the customer
GET /api/v1/call-log Every call on the account — filter by since, until, direction, customer, missed
GET /api/v1/call-log/{id} One call from the log
GET /api/v1/call-log/{id}/recording Its recording, as WAV (recordings:read)
GET /api/v1/audios Your audio that is ready to use
GET /api/v1/extensions/{number}/softphone Sign a browser phone in as an extension (softphone:connect)
GET /api/v1/ping Whether the key works

Errors

Every error comes back in the same shape, so you only handle it once:

{ "error": { "code": "validation_failed", "message": "…", "fields": { … } } }
401 unauthenticated No key, a wrong one, or one that was revoked
403 subscription_inactive The subscription has run out
403 feature_unavailable This plan does not include the Developer API — or SMS, for the /sms endpoints
403 scope_required This key is not permitted to do that
422 validation_failed The number, the audio_id or some other field is not right
404 not_found That call is not yours, or does not exist
409 not_cancellable The call has already started or finished
409 idempotency_conflict Two requests arrived at once under the same key — send it again in a few seconds
409 idempotency_key_reused This key was already used for a different request — send a new one
409 softphone_unavailable The account cannot take browser phones yet — ask support
400 wrong_host This address belongs to nobody — the correct base_url is in the response
403 wrong_account_host The key is one account's, the address another's
429 rate_limited Too many requests, too quickly

Rules worth knowing

  • Nothing dials at night. A call arriving outside your calling hours is not rejected — it waits in the queue and goes out by itself once the hours open. If it would be pointless by then, send expires_in_minutes: once that passes, the webhook arrives with status expired.
  • How many calls run at once depends on how many channels your IPTSP trunk has. Send more than that and the rest wait in the queue — none are lost.
  • webhook_url must be https and must be a publicly reachable address.
  • Numbers may be sent in any familiar form — 01712345678, +8801712345678 — and always come back as 8801712345678.

OpenAPI spec

A machine-readable description of this whole API — import it into Postman, or build a client library in your own language with openapi-generator. No hand-written requests needed.

https://hellotalk.com.bd/docs/api/openapi.json

Postman: Import → Link → the URL above → Continue.

Client library: openapi-generator-cli generate -i https://hellotalk.com.bd/docs/api/openapi.json -g php -o ./hashtech-client (pass python, typescript-axios or whatever you need to -g).

The spec is generated from the code, and our tests check it against every route and every scope — so it cannot drift away from what the API actually does.

Stuck on something, or need something that is not here? Get in touch — we are in Bangladesh.