HashTech

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://hashtech.com.bd/api/v1

This one works for everybody and never changes. The API also answers on your own addresshttps://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. 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_idthree 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://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:

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.

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 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://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.