Developer docs
Verify orders with one API call
Send us an order from your website. We call the customer, they press 1 to confirm or 2 to cancel, and we tell your website the answer. Works with any website that has a server — PHP, Node.js, Python, Laravel, WordPress and more.
Getting started
How it works
Customer orders
A cash-on-delivery order is placed on your website.
You call our API
Your server sends the order to POST /orders.
We call the customer
A Bangla voice call asks them to press 1 or 2.
We tell your site
A webhook reports confirmed or cancelled.
The customer can also press 3 to talk to your team. If they don't answer, we call again automatically, following the retry settings in your dashboard.
Call the API from your server, never from the browser
Getting started
Quick start: your first call in 10 minutes
1. Create an API key
Sign in, open Dashboard → API Keys and create a key. Tick
verify:create(send orders),verify:read(read orders),calls:read(list calls) andwebhooks:manage(set your webhook URL). You get two values:- a key ID like
pk_3f9a…(35 characters), and - a secret of 64 characters. Store it safely. You can reveal it again later with your account password.
- a key ID like
2. Save them as environment variables
.envTEXTBARI_KEY_ID=pk_your_key_id TEXTBARI_SECRET=your_64_character_secret3. Add this helper to your project
It signs every request for you (see Authentication for what it does). Copy it once; every example below uses it.
Request helper// textbari.js — Node.js 18+ (no extra packages needed) const crypto = require('crypto'); const BASE = 'https://api.textbari.com'; const KEY_ID = process.env.TEXTBARI_KEY_ID; // pk_... const SECRET = process.env.TEXTBARI_SECRET; // 64-character secret async function textbari(method, path, body) { const raw = method === 'GET' || body === undefined ? '' : JSON.stringify(body); const [p, q = ''] = path.split('?'); const ts = Math.floor(Date.now() / 1000).toString(); const nonce = crypto.randomUUID(); const bodyHash = crypto.createHash('sha256').update(raw).digest('hex'); const canonical = [method, p, q, ts, nonce, bodyHash].join('|'); const signature = crypto.createHmac('sha256', SECRET).update(canonical).digest('hex'); const res = await fetch(BASE + path, { method, headers: { Authorization: `Bearer ${KEY_ID}:${signature}`, 'X-API-Timestamp': ts, 'X-API-Signature-Version': '2', 'X-API-Nonce': nonce, 'Content-Type': 'application/json', }, body: raw || undefined, // send EXACTLY the bytes you hashed }); const data = await res.json().catch(() => ({})); return { status: res.status, data }; } module.exports = { textbari };4. Check the connection
const { status, data } = await textbari('GET', '/api/v1/me'); console.log(status === 200 ? 'Connected as ' + data.account.email : data);5. Send an order
Use your own phone number for the first test. Your phone rings within a few seconds.
const { textbari } = require('./textbari'); // Call this from your server right after the customer places a cash-on-delivery order. const { status, data } = await textbari('POST', '/api/v1/orders', { phone_number: '01712345678', order_ref: 'ORD-1001', // your own order number order_value: '1250.50', firstname: 'Rahim', }); if (status === 201 || status === 200) { console.log('Verification call queued, platform order id:', data.order.id); } else { console.error('Failed:', status, data.code, data.message); }6. Get the answer
Set a webhook to be told the moment the customer presses a key, or look the order up whenever you like.
Getting started
WordPress & WooCommerce: no code needed
Using WooCommerce? Install our plugin and every new order is sent automatically, and confirmed or cancelled orders update on their own.
- Create an API key with
verify:createandwebhooks:manage(step 1 above). - Download the plugin, then in WordPress go to Plugins → Add New → Upload Plugin.
- Open WooCommerce → Call Verification, paste the key ID and secret, and click Save. This also registers the webhook.
- Click "Test connection", then "Send test order".
Don't set a webhook URL in the dashboard as well
define('CALL_VERIFICATION_API_SECRET', '…'); to wp-config.php.Basics
Authentication
Every request is signed with your secret using HMAC-SHA256. The secret itself is never sent. The helper in the quick start does all of this; read on if you want to write your own.
Headers on every request
| Header | Value |
|---|---|
Authorization | Bearer <key_id>:<signature> |
X-API-Timestamp | Current Unix time in seconds, e.g. 1767225600. Must be within 2 minutes of our clock. |
X-API-Signature-Version | Send 2 |
X-API-Nonce | A new random string for every request (a UUID is ideal). Required for POST and PATCH; never reuse one. |
Content-Type | application/json |
Building the signature
- Take the exact JSON text you are going to send and hash it with SHA-256 (lowercase hex). For a GET request, hash an empty string.
- Join these with a
|: the method in capitals, the path including/api/v1, the query string without the?(or nothing), the timestamp, the nonce, and the body hash. - HMAC-SHA256 that text with your secret as the key, in lowercase hex. That is the signature.
canonical = METHOD | PATH | QUERY | TIMESTAMP | NONCE | SHA256_HEX(BODY)
signature = HMAC_SHA256_HEX(key = YOUR_SECRET, message = canonical)
Example (POST /api/v1/orders):
POST|/api/v1/orders||1767225600|3f1c2a9e-8b7d-4c6e-9a1b-2d3e4f5a6b7c|f2e0f293…51ac31POST https://api.textbari.com/api/v1/orders
Authorization: Bearer pk_your_key_id:21e78ecb7d432e2c5b4b3cbc9282b161ce067a92fbc719acac0bc10ca2d919f6
X-API-Timestamp: 1767225600
X-API-Signature-Version: 2
X-API-Nonce: 3f1c2a9e-8b7d-4c6e-9a1b-2d3e4f5a6b7c
Content-Type: application/json
{"phone_number":"01712345678","order_ref":"ORD-1001","order_value":"1250.50"}The #1 cause of “Invalid signature”
If your server clock is wrong you get 401 TIMESTAMP_SKEW. Every response carries an X-Server-Time header with our time, so you can correct for the difference, or simply fix your server time with NTP.
Basics
Phone numbers
We call Bangladeshi numbers. Any of these formats work, and spaces, dashes, dots and brackets are ignored:
| You send | We store and call |
|---|---|
01712345678 | 01712345678 |
+8801712345678 | 01712345678 |
8801712345678 | 01712345678 |
008801712345678 | 01712345678 |
1712345678 | 01712345678 |
017-1234-5678 | 01712345678 |
Anything else returns 400 INVALID_PHONE. The order is not created and nobody is called, so show the customer a message to check their number.
Basics
Order status & retries
| Status | Meaning |
|---|---|
pending | Waiting for the call, being called, or waiting for a retry. |
completed | Handled: the customer pressed 1 (confirmed), or pressed 3 and was connected to your team. Use the order.confirmed webhook for a strict “confirmed”. |
cancelled | The customer pressed 2. |
failed | We could not reach the customer after all retries. |
- The customer's first answer is final. Pressing another key later does not change it.
- If the customer doesn't answer, or answers without pressing 1, 2 or 3, we call again after the delay set in your dashboard (3 retries by default).
- Sending the same order twice is safe. If you send the same
order_refwith the same phone number again, you get the existing order back (200) and the customer is not called twice. A different phone number creates a new order. - Orders without a plan or wallet balance are kept and called as soon as you have one (for up to about 3 days).
Endpoints
Create an order
/api/v1/ordersScope: verify:createCreates an order and queues the verification call. Call it right after the customer checks out.
| Field | Type | Description |
|---|---|---|
phone_numberrequired | string | Customer phone number. See Phone numbers. |
order_ref | string ≤ 50 | Your own order number. Strongly recommended: it makes repeats safe and lets you look the order up later. |
order_value | string | number | Order total, e.g. "1250.50" or 1250.5. Numbers only, no currency text. |
firstname | string ≤ 100 | Customer first name. |
lastname | string ≤ 100 | Customer last name. |
product_quantity | integer | Number of items in the order. |
address | string ≤ 255 | Delivery address, read out by {{address}} in your voice template (first 150 characters). |
product_name | string ≤ 255 | Product name(s), read out by {{productname}}. |
product_price | string | number | Product price, read out by {{productprice}}. Without it, that field reads the order total. |
shipping_charge | string | number | Delivery charge, read out by {{shippingcharge}}. |
idempotency_key | string ≤ 128 | Optional. A repeat with the same key within 24 hours returns the same order. |
const { textbari } = require('./textbari');
// Call this from your server right after the customer places a cash-on-delivery order.
const { status, data } = await textbari('POST', '/api/v1/orders', {
phone_number: '01712345678',
order_ref: 'ORD-1001', // your own order number
order_value: '1250.50',
firstname: 'Rahim',
});
if (status === 201 || status === 200) {
console.log('Verification call queued, platform order id:', data.order.id);
} else {
console.error('Failed:', status, data.code, data.message);
}Response
201 for a new order, 200 when the order already existed. The call starts within a few seconds.
HTTP/1.1 201 Created
{
"success": true,
"order": {
"id": 42,
"order_ref": "ORD-1001",
"phone": "01712345678",
"status": "pending",
"charge": 0,
"created_at": "2026-01-01T10:00:00.000Z"
}
}caller_id_number, caller_id_name, ivr_flow_key, playback and keypress settings) are optional. A caller ID is only used if it belongs to your account; otherwise it is ignored. Most integrations don't need them, because your dashboard settings are used by default.Endpoints
Get an order
/api/v1/orders/{order_ref}Scope: verify:readReturns the latest order with this reference, every call attempt, and the keys the customer pressed. URL-encode the reference if it contains special characters.
const { status, data } = await textbari('GET', '/api/v1/orders/' + encodeURIComponent('ORD-1001'));
console.log(data.order.status, data.order.dtmf_digits);{
"success": true,
"order": {
"id": 42,
"order_ref": "ORD-1001",
"phone": "01712345678",
"status": "completed",
"charge": "0.00",
"created_at": "2026-01-01T10:00:00.000Z",
"call_attempts": [
{
"id": 77,
"uuid": "8f0c6d2e-…",
"status": "completed",
"hangup_cause": "NORMAL_CLEARING",
"started_at": "2026-01-01T10:00:03.000Z",
"ended_at": "2026-01-01T10:00:41.000Z"
}
],
"dtmf_digits": "1"
}
}Attempt statuses: initiated, ringing, answered, completed, failed, busy, no_answer, cancelled. Returns 404 if no order has this reference.
Endpoints
List calls
/api/v1/callsScope: calls:read| Field | Type | Description |
|---|---|---|
limit | integer | 1–100, default 20. |
offset | integer | Skip this many results (for paging), default 0. |
status | string | Only orders with this status, e.g. completed. |
order_ref | string | Only this order reference. |
const { data } = await textbari('GET', '/api/v1/calls?limit=10&status=completed');
console.log(data.total, data.calls);{
"success": true,
"calls": [
{
"order_ref": "ORD-1001",
"phone": "01712345678",
"status": "completed",
"charge": "0.00",
"created_at": "2026-01-01T10:00:00.000Z",
"call_attempts": [ … same shape as above … ],
"dtmf_digits": "1"
}
],
"total": 128,
"limit": 10,
"offset": 0
}Endpoints
Test your key
/api/v1/meNo scope neededReturns the account the key belongs to. Use it to check your setup and signature code.
const { status, data } = await textbari('GET', '/api/v1/me');
console.log(status === 200 ? 'Connected as ' + data.account.email : data);{
"success": true,
"account": { "user_id": 7, "email": "you@your-shop.com", "status": "active" }
}Endpoints
Webhook settings
/api/v1/webhookScope: webhooks:manage/api/v1/webhookScope: webhooks:manageRead or set where we send events for orders created with this key.
| Field | Type | Description |
|---|---|---|
webhook_url | string | null | Must start with https://. Send null to remove it. |
webhook_events | string[] | null | Which events to send. null or [] means all events. Leave the field out to keep the current setting. |
await textbari('PATCH', '/api/v1/webhook', {
webhook_url: 'https://your-site.com/textbari/webhook',
webhook_events: ['order.confirmed', 'order.cancelled'], // null = all events
});{
"success": true,
"webhook": {
"webhook_url": "https://your-site.com/textbari/webhook",
"webhook_events": ["order.confirmed", "order.cancelled"]
}
}Webhooks
Receiving webhooks
A webhook is a URL on your website that we POST to when something happens, such as the customer pressing 1. It saves you from checking orders again and again.
Where to set the URL
- Per API key with PATCH /api/v1/webhook. You can choose which events you want.
- For the whole account in Dashboard → IVR Audio → Webhook URL. If this is set, it is used instead of the per-key URL, for every event and every key.
Events
| Event | When | Extra fields |
|---|---|---|
order.confirmed | The customer pressed 1. Sent once. | dtmf, dtmf_digit |
order.cancelled | The customer pressed 2. Sent once. | dtmf, dtmf_digit |
call.transfer | The customer pressed 3 to reach your team. | destination |
call.ringing | We started calling. | call_status |
call.answered | The customer answered. | call_status |
dtmf.collected | On every key press. | dtmf_digits |
call.completed | A call attempt ended normally. | call_status, hangup_cause |
call.failed | A call attempt failed. A retry may follow, so this does not mean the order failed. | call_status, hangup_cause |
Every event has event, order_ref, order_id and timestamp. Most websites only need order.confirmed and order.cancelled.
// order.confirmed — the customer pressed 1
{ "event": "order.confirmed", "order_ref": "ORD-1001", "order_id": 42,
"timestamp": "2026-01-01T10:00:20.000Z", "dtmf": "1", "dtmf_digit": "1" }
// order.cancelled — the customer pressed 2
{ "event": "order.cancelled", "order_ref": "ORD-1001", "order_id": 42,
"timestamp": "2026-01-01T10:00:20.000Z", "dtmf": "2", "dtmf_digit": "2" }
// call.failed — one attempt failed (a retry may follow)
{ "event": "call.failed", "order_ref": "ORD-1001", "order_id": 42,
"timestamp": "2026-01-01T10:00:41.000Z", "call_status": "failed", "hangup_cause": "NO_ANSWER" }Verify the signature
Always check that a webhook really came from us. We send X-CV-Timestamp and X-CV-Signature: sha256=<hex>, where the hex is HMAC-SHA256 of timestamp + "." + raw body, keyed with the secret of the API key that created the order.
POST /textbari/webhook HTTP/1.1
Content-Type: application/json
X-CV-Timestamp: 1767225605
X-CV-Signature: sha256=433788af8f686331f8b984ec74f0647140e9c632918b2f84050ecb4b4e8074af
{"event":"order.confirmed","order_ref":"ORD-1001","order_id":42,"timestamp":"2026-01-01T00:00:05.000Z","dtmf":"1","dtmf_digit":"1"}// Express example. Use the RAW body — the signature is over the exact bytes we sent.
const crypto = require('crypto');
const express = require('express');
const app = express();
const SECRET = process.env.TEXTBARI_SECRET;
app.post('/textbari/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const ts = req.get('X-CV-Timestamp') || '';
const given = (req.get('X-CV-Signature') || '').replace(/^sha256=/, '');
const raw = req.body.toString('utf8');
// 1) reject old requests 2) check the HMAC in constant time
if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(401);
const expected = crypto.createHmac('sha256', SECRET).update(ts + '.' + raw).digest('hex');
const ok = given.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
if (!ok) return res.sendStatus(401);
const event = JSON.parse(raw);
if (event.event === 'order.confirmed') {
// mark event.order_ref as confirmed in your database
} else if (event.event === 'order.cancelled') {
// mark event.order_ref as cancelled
}
res.sendStatus(200); // answer quickly (within 10 seconds)
});
app.listen(3000);Delivery rules
- Reply with any
2xxwithin 10 seconds. Otherwise we retry up to 3 more times (after 1, 2 and 4 seconds). A retry has the same signature, so treat a repeat as already handled. - Your URL must be HTTPS on a public address. Redirects are not followed.
- Test calls you start from the dashboard are not tied to an API key, so their webhooks are sent without a signature.
Reference
Errors
Errors come back as JSON with an HTTP status, an error, a message and usually a code. Check the code in your program; the message is for people.
{
"error": "Payment Required",
"code": "QUOTA_EXCEEDED",
"message": "Monthly call quota exceeded for your plan (300/300 used this billing month). Resets on 2026-02-01."
}| Status | Code | What it means | What to do |
|---|---|---|---|
| 400 | INVALID_PHONE | The phone number is not a valid Bangladeshi number. | Ask the customer to correct it. Do not retry. |
| 400 | INVALID_ORDER_VALUE | order_value is not a number. | Send digits only, e.g. "1250.50". |
| 400 | FST_ERR_VALIDATION | A field is missing or has the wrong type. | Read the message, fix the request. |
| 401 | TIMESTAMP_SKEW | Your server clock is more than 2 minutes off. | Sync your clock (NTP) or use X-Server-Time. |
| 401 | — | Invalid API key, invalid signature, missing header, or a nonce used twice. | See Troubleshooting below. |
| 402 | QUOTA_EXCEEDED | Your plan’s calls for this month are used up. | Upgrade the plan or wait for the reset date in the message. |
| 402 | INSUFFICIENT_BALANCE | Pay-as-you-go wallet is too low. | Top up the wallet. |
| 403 | ACCOUNT_SUSPENDED | The account is suspended (for example an unpaid invoice). | Sign in to the dashboard and pay, or contact support. |
| 403 | — | The key is missing a scope (message names it). | Create a key with that scope. |
| 404 | — | The order was not found. | Check the order_ref. |
| 429 | CONCURRENT_LIMIT | Too many calls are running at once for your plan. | Retry after the Retry-After seconds. |
| 429 | ANOMALY_DETECTED | Too many orders in a minute, or repeated failures for one number. | Slow down; check the number. |
| 429 | — | Too many requests. | Wait and retry (see Rate limits). |
| 503 | ORDER_BUSY | The same order_ref is being processed by another request. | Retry after Retry-After seconds. |
| 503 | MAINTENANCE_MODE | We are doing maintenance. | Retry later (Retry-After). |
| 5xx | — | A temporary problem on our side. | Retry with a delay. Sending the same order_ref again is safe. |
Reference
Rate limits
- 100 requests per minute per API key, and 100 per minute per IP address.
- Responses include
X-RateLimit-LimitandX-RateLimit-Remaining. - Over the limit you get
429. Wait about a minute (or theRetry-Afterseconds when present) and try again. - Separately, more than 10 new orders per minute triggers
ANOMALY_DETECTED. Contact us if your store normally sells faster than that.
Reference
Troubleshooting
“Invalid signature”
- You hashed a different JSON string from the one you sent (spacing, key order or escaping changed). Hash and send the same text.
- The path must include
/api/v1and be exactly what you request, e.g./api/v1/orders. - The query string goes in the signature without the leading ?.
- Both hashes must be lowercase hex. The timestamp must be in seconds, not milliseconds.
- You sent
X-API-Signature-Version: 2but left the nonce out of the signed text, or the other way round. - The secret has a space or line break copied by accident.
“Missing X-API-Nonce header” / “Replay detected”
POST and PATCH need a nonce, and every request needs a new one. Generate a fresh UUID each time.
“Invalid API key”
Use only the key ID (pk_…), not the “full key” with a dot in it. Check that the key wasn't revoked.
My webhook isn't called
- The URL must be public HTTPS (not localhost) and reply 2xx within 10 seconds.
- If a webhook URL is set in Dashboard → IVR Audio, it is used instead of the per-key URL.
- Use
GET /api/v1/webhookto see what is saved, and check the event filter.