Base URL
https://hashtech.com.bd/api/v1
This one works for everybody and never changes. The API also answers on
your own address —
https://your-name.hashtech.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://hashtech.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://hashtech.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 when you create the key what it may do. 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 |
| audios:read | List your audio |
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.
Scopes cannot be edited afterwards — create a new key and revoke the old one.
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.
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-HashTech-Timestamp: 1786000951
X-HashTech-Signature: sha256=9f2c…
X-HashTech-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_HASHTECH_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_HASHTECH_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, HASHTECH_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-HashTech-Timestamp') || '';
const signature = req.get('X-HashTech-Signature') || '';
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.HASHTECH_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);
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 |
| GET | /api/v1/audios | Your audio that is ready to use |
| 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 |
| 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 |
| 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://hashtech.com.bd/docs/api/openapi.json
Postman: Import → Link → the URL above → Continue.
Client library:
openapi-generator-cli generate -i https://hashtech.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.