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.

Quick startWordPress? No code neededBase URL: https://api.textbari.com/api/v1

Getting started

How it works

1

Customer orders

A cash-on-delivery order is placed on your website.

2

You call our API

Your server sends the order to POST /orders.

3

We call the customer

A Bangla voice call asks them to press 1 or 2.

4

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

Every request is signed with your secret key. Anyone who sees the secret can send orders as you, so keep it on your server (in an environment variable) and never put it in JavaScript that runs in a visitor's browser. Browser calls are blocked anyway.

Getting started

Quick start: your first call in 10 minutes

  1. 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) and webhooks: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.
  2. 2. Save them as environment variables

    .env
    TEXTBARI_KEY_ID=pk_your_key_id
    TEXTBARI_SECRET=your_64_character_secret
  3. 3. 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.

    // 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. 4. Check the connection

    const { status, data } = await textbari('GET', '/api/v1/me');
    console.log(status === 200 ? 'Connected as ' + data.account.email : data);
  5. 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. 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.

  1. Create an API key with verify:create and webhooks:manage (step 1 above).
  2. Download the plugin, then in WordPress go to Plugins → Add New → Upload Plugin.
  3. Open WooCommerce → Call Verification, paste the key ID and secret, and click Save. This also registers the webhook.
  4. Click "Test connection", then "Send test order".
Download the WordPress plugin

Don't set a webhook URL in the dashboard as well

A webhook URL saved in Dashboard → IVR Audio takes priority over the one the plugin registers. If one is set there, the plugin stops receiving answers. Leave that field empty when you use the plugin.
Your site must use HTTPS, and the plugin only works with WooCommerce. You can keep the secret out of the database by adding 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

HeaderValue
AuthorizationBearer <key_id>:<signature>
X-API-TimestampCurrent Unix time in seconds, e.g. 1767225600. Must be within 2 minutes of our clock.
X-API-Signature-VersionSend 2
X-API-NonceA new random string for every request (a UUID is ideal). Required for POST and PATCH; never reuse one.
Content-Typeapplication/json

Building the signature

  1. 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.
  2. 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.
  3. HMAC-SHA256 that text with your secret as the key, in lowercase hex. That is the signature.
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…51ac31
A complete signed request
POST 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”

Hashing one JSON string but sending another. Build the JSON once, hash it, and send that same text. Don't let your HTTP library re-encode it.

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 sendWe store and call
0171234567801712345678
+880171234567801712345678
880171234567801712345678
00880171234567801712345678
171234567801712345678
017-1234-567801712345678

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

StatusMeaning
pendingWaiting for the call, being called, or waiting for a retry.
completedHandled: the customer pressed 1 (confirmed), or pressed 3 and was connected to your team. Use the order.confirmed webhook for a strict “confirmed”.
cancelledThe customer pressed 2.
failedWe 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_ref with 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

POST/api/v1/ordersScope: verify:create

Creates an order and queues the verification call. Call it right after the customer checks out.

FieldTypeDescription
phone_numberrequiredstringCustomer phone number. See Phone numbers.
order_refstring ≤ 50Your own order number. Strongly recommended: it makes repeats safe and lets you look the order up later.
order_valuestring | numberOrder total, e.g. "1250.50" or 1250.5. Numbers only, no currency text.
firstnamestring ≤ 100Customer first name.
lastnamestring ≤ 100Customer last name.
product_quantityintegerNumber of items in the order.
addressstring ≤ 255Delivery address, read out by {{address}} in your voice template (first 150 characters).
product_namestring ≤ 255Product name(s), read out by {{productname}}.
product_pricestring | numberProduct price, read out by {{productprice}}. Without it, that field reads the order total.
shipping_chargestring | numberDelivery charge, read out by {{shippingcharge}}.
idempotency_keystring ≤ 128Optional. 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.

201 Created
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"
  }
}
Advanced fields (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

GET/api/v1/orders/{order_ref}Scope: verify:read

Returns 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);
200 OK
{
  "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

GET/api/v1/callsScope: calls:read
FieldTypeDescription
limitinteger1–100, default 20.
offsetintegerSkip this many results (for paging), default 0.
statusstringOnly orders with this status, e.g. completed.
order_refstringOnly this order reference.
const { data } = await textbari('GET', '/api/v1/calls?limit=10&status=completed');
console.log(data.total, data.calls);
200 OK
{
  "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

GET/api/v1/meNo scope needed

Returns 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);
200 OK
{
  "success": true,
  "account": { "user_id": 7, "email": "you@your-shop.com", "status": "active" }
}

Endpoints

Webhook settings

GET/api/v1/webhookScope: webhooks:manage
PATCH/api/v1/webhookScope: webhooks:manage

Read or set where we send events for orders created with this key.

FieldTypeDescription
webhook_urlstring | nullMust start with https://. Send null to remove it.
webhook_eventsstring[] | nullWhich 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
});
GET 200 OK
{
  "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

EventWhenExtra fields
order.confirmedThe customer pressed 1. Sent once.dtmf, dtmf_digit
order.cancelledThe customer pressed 2. Sent once.dtmf, dtmf_digit
call.transferThe customer pressed 3 to reach your team.destination
call.ringingWe started calling.call_status
call.answeredThe customer answered.call_status
dtmf.collectedOn every key press.dtmf_digits
call.completedA call attempt ended normally.call_status, hangup_cause
call.failedA 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.

Example payloads
// 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.

What we send
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 2xx within 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.

402 Payment Required
{
  "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."
}
StatusCodeWhat it meansWhat to do
400INVALID_PHONEThe phone number is not a valid Bangladeshi number.Ask the customer to correct it. Do not retry.
400INVALID_ORDER_VALUEorder_value is not a number.Send digits only, e.g. "1250.50".
400FST_ERR_VALIDATIONA field is missing or has the wrong type.Read the message, fix the request.
401TIMESTAMP_SKEWYour 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.
402QUOTA_EXCEEDEDYour plan’s calls for this month are used up.Upgrade the plan or wait for the reset date in the message.
402INSUFFICIENT_BALANCEPay-as-you-go wallet is too low.Top up the wallet.
403ACCOUNT_SUSPENDEDThe 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.
429CONCURRENT_LIMITToo many calls are running at once for your plan.Retry after the Retry-After seconds.
429ANOMALY_DETECTEDToo 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).
503ORDER_BUSYThe same order_ref is being processed by another request.Retry after Retry-After seconds.
503MAINTENANCE_MODEWe 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-Limit and X-RateLimit-Remaining.
  • Over the limit you get 429. Wait about a minute (or the Retry-After seconds 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/v1 and 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: 2 but 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/webhook to see what is saved, and check the event filter.