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 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 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
Take its
audio_id— three ways to find it, below. - 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:
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.
"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.
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
- Fetch it before every registration; never store it.
iceServerscarries 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 statusexpired. - 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 as8801712345678.
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.