Skip to content
Docs

Order payloads, webhook events, and the call API

Place a call for an order, and we ring the customer in Bangla and send the result to your server. This page covers every endpoint, webhook event and limit — start with the quickstart and skip to what you need.

Base URL
https://api.echo.bd
Auth header
X-API-Key: echo_live_…
Encoding
JSON over HTTPS, UTF-8
Webhook retries
8 attempts over about an hour

Quickstart

Three steps, one afternoon. If your store can already fire a webhook on new orders, step two is a single HTTP call away.

  1. Mint an API key

    Dashboard → Developers → Create key. The plaintext key (it starts with echo_live_) is shown once and stored only as a hash, so copy it into your server's environment before you close the dialog.

  2. Place the call

    POST the order to /api/calls from your server. We answer immediately and dial in the background — nothing about your checkout waits on a phone ringing.

  3. Handle the webhook

    When the call ends we POST the outcome to your webhook URL. Verify the signature, act on result, and reply 2xx. That is the whole integration.

How it fits together

The handful of ideas every endpoint below leans on.

Shop (merchant)
Everything is scoped to one shop: its balance, numbers, call flow, contacts and webhook. An API key belongs to one shop and can never see another's data.
Call
One outbound order-confirmation call. It has an id, your order_id, a status while it runs and a result when it ends.
Call flow
The script, keypad, voice and timing the customer gets. A shop can keep several named flows in Dashboard → Call flow — one is the default — and an order picks one with flow_id; a single request can still override the wording and the keys.
Wallet
A call reserves its cost up front and settles it at hang-up. No balance, no plan or no outbound number means the call is refused before anything dials.
Webhook
Where results are delivered: a URL saved on the shop, or one set on a single order with callback_url.
Scope
What a key may do. Keys you mint yourself can do everything; keys issued to a partner app carry only what you approved.

Authentication

Four credentials open four different doors. Server code uses an API key; the others exist so that shop platforms, browsers and partner apps never have to.

CredentialSent asUse it for
API keyX-API-Key: echo_live_…Your own server, and partner apps after OAuth. Works on the order, call, SMS and webhook endpoints below.
Console sessionAuthorization: Bearer <session>The signed-in dashboard. Not for integrations — sessions expire and belong to a person.
Webhook token in the URLnone — the token in the path is the credentialPOST /v1/orders/{token}, for shop platforms that cannot set headers. Treat the URL as a password.
Site keynone — an Origin allow-list protects itPOST /v1/collect/{site_key}, called from a customer's browser by the Tag Manager collector.

API keys

X-API-Key: echo_live_9f2c...  # server-side onlyContent-Type: application/json
Every key starts with echo_live_. The header is X-API-Key; there is no Authorization: Bearer form for keys.
  • Only the shop owner can create or revoke keys — Dashboard → Developers, or POST /api/keys / GET /api/keys / DELETE /api/keys/{id} with a console session.
  • The plaintext key is shown once, at creation. We store only a hash, so a lost key is replaced, not recovered. The list shows a prefix and your label.
  • Rotate by minting the new key first, deploying it, then revoking the old one. A revoked key stops working immediately.
  • Keys are bound to one shop. They cannot reach another shop's calls, contacts or balance.

Scopes

A scope limits what a key may do. Keys you mint in the dashboard are unrestricted — they carry every scope below. Keys handed to a partner app through OAuth carry only the scopes you approved on the consent screen. A call outside a key's scopes is a 403 whose detail names the missing one.

ScopeAllowsSpends balance
calls:writePlace calls, upload audio clips, preview voices, hang calls upYes
calls:readRead calls, events, recordings and call audioNo
wallet:readRead the balance and recent chargesNo
reports:readRead call statisticsNo
sms:writeSend and retry SMSYes
sms:readRead SMS history and customer repliesNo
campaigns:writeCreate and run bulk SMS and IVR campaignsYes
webhooks:manageRead and change where results are deliveredNo
softphone:useLet your team talk to customers from a connected appYes

Place a call

POST /api/calls queues an order-confirmation call and answers immediately. One request per order.

POST/api/callsX-API-Key · calls:write

Queues the call and answers immediately — your checkout never waits on a phone ringing. One request per order.

curl -X POST https://api.echo.bd/api/calls \  -H "X-API-Key: $ECHO_API_KEY" \  -H "Idempotency-Key: ORD-10231" \  -H "Content-Type: application/json" \  -d '{    "order_id":      "ORD-10231",    "phone":         "01723306206",    "customer_name": "Rahim Uddin",    "amount":        1250,    "items": [      { "name": "Honey 500g",   "qty": 2 },      { "name": "Cotton Pants", "qty": 1 }    ],    "callback_url":  "https://yourshop.com/echo/result",    "meta": { "warehouse": "dhaka-1" }  }'

Body

  • order_id

    string · required

    Your own reference, 1–200 characters. Returned untouched on the webhook and used to spot a repeated order.

  • phone

    string · required

    A Bangladeshi mobile number in any common format — 01723306206, +8801723306206 and 8801723306206 all work. Anything else is a 400, before any money is reserved.

  • flow_id

    string · optional

    Which of your call flows runs this call — one for order confirmation, another for abandoned carts. Copy the ID from Dashboard → Call flow, or list them with GET /api/call-flows. Left out, your default flow is used. An ID that is not one of yours is a 400 — never swapped for the default. On a webhook address that cannot take a body field, add ?flow=<id> to the URL instead.

  • customer_name

    string · optional

    Spoken in the script as @customer_name.

  • amount

    number · optional

    Order total, 0 or more. Becomes @total_price.

  • currency

    string · optional · default "BDT"

    Up to 8 characters. Informational — it is returned on the call, not converted.

  • items

    array · optional

    Up to 200 of { name, qty, price? }. Becomes @products and @quantity. qty defaults to 1.

  • language

    string · optional

    Up to 8 characters, such as bn. Left out, the shop's default language is used.

  • welcome_text

    string · optional

    The message to speak, up to 2000 characters, with @variables. Left out, the call flow's welcome message is used. prompt_text is the older name for the same field.

  • closing_text

    string · optional

    Spoken at the end of the call, with @variables.

  • variables

    object · optional

    Up to 50 extra @variables, each value up to 500 characters — { "courier": "Pathao" } becomes @courier.

  • menus

    array · optional

    Up to 12 keypad options for this call only: { key, status, response_text?, audio_id?, forward_to? }. Left out, your saved call flow decides. See Keypad & script.

  • repeat_on_silence

    boolean · optional

    false means play the message once and do not repeat it — use it for announcements that ask for no key. true or left out follows the call flow.

  • voice_provider / voice_name

    string · optional

    Speak in a specific voice instead of the call flow's. List the choices with GET /api/tts/voices.

  • audio_id

    string · optional

    Play an uploaded recording instead of a synthesised voice. The id comes from POST /api/audio.

  • callback_url

    string · optional

    An http(s) address for this order's webhooks. Left out, they go to the URL saved on your shop; with neither, nothing is sent. The address must resolve to a public host; private and internal addresses are refused.

  • meta

    object · optional

    Opaque to us, up to 8000 bytes of JSON. Returned unchanged on every webhook.

Headers

  • Idempotency-Key

    string

    Makes a retry safe. The same key returns the original call, however long ago it was sent. Use your order id. Without it, a repeat of the same order_id within 10 minutes is treated as a retry anyway.

Response

200
200 OK​{  "id":          "b35e7c842f194a6d9b0e5c1d7a3e8f42",  "status":      "queued",  "order_id":    "ORD-10231",  "dial_number": "01723306206"}
Keep id — the webhook calls it call_id. dial_number is the number as we will dial it.

Send the same Idempotency-Key again (any time later), or the same order_id within 10 minutes without a key, and you get the original call back — same id — and nobody is rung twice.

What is checked before anything dials

  • 400 — the phone is not a Bangladeshi mobile number.
  • 402 — no active plan, the plan fee has lapsed past its grace period, or the balance cannot cover the call. A hold is taken on the wallet only after every other check passes, so a refused order never shows a hold appearing and vanishing.
  • 403 — the key lacks calls:write, or the shop has no outbound number or may not place calls. The body carries a stable error code.
  • 429 — more than 120 orders in a minute, or the concurrent-call cap for your plan is full.

Lifecycle

status on a call moves through these values. The first seven mean the call is still live; the last three are final.

  • scheduled

    Accepted and held for the delay set in your call flow.

  • queued

    Accepted; dials as soon as a line is free.

  • synthesizing

    The spoken message is being prepared.

  • originating

    The call is being placed.

  • ringing

    The customer's phone is ringing.

  • in_progress

    Answered; the script is playing.

  • awaiting_input

    Waiting for the customer to press a key.

  • completed

    Finished — result says how.

  • no_answer

    Nobody picked up and no retries remain — or it was answered but no key was pressed.

  • failed

    Could not be placed — error says why.

Order by webhook URL

The no-header door for Shopify, WooCommerce, Zapier and anything else that can only POST to an address.

Shop platforms such as Shopify, WooCommerce and Zapier can fire a webhook but cannot set headers. Every shop therefore has a private URL that takes the same order body as POST /api/calls. Find it in Dashboard → Setup; the token in the path is the credential, so treat the whole URL like a password.

POST/v1/orders/{webhook_token}token in the URL

Same body and same response as Place a call. No scope applies — the URL can only ever place orders for its own shop.

# The token is the credential — no header needed.curl -X POST https://api.echo.bd/v1/orders/$ECHO_WEBHOOK_TOKEN \  -H "Content-Type: application/json" \  -d '{ "order_id": "ORD-10231", "phone": "01723306206", "amount": 1250 }'

Query & headers

  • dry_run

    query · boolean · default false

    ?dry_run=1 validates the payload and reports what would happen — the number as it would be dialled, the @variables the script would speak, and a list of problems — without dialling or charging. Dry runs do count against the per-IP rate limit (they are not counted against the per-shop limit), so do not loop them.

  • flow

    query · string

    ?flow=<flow id> runs every order sent to this URL through that call flow — paste one URL into the new-order webhook and another, with a different flow, into the abandoned-checkout webhook. A flow_id in the body wins. An id that is not one of yours is a 400. See Keypad & script.

  • Idempotency-Key

    header

    Honoured when the sender can set it. Platforms usually cannot, so the 10-minute order_id window does the work.

Read & control calls

List calls, read one, follow its timeline, hang it up, or place a test call.

Read what happened to a call, watch it as it runs, or end it. All of these accept an API key with calls:read (reads) or calls:write (hang-up and test calls) as well as a console session.

GET/api/callscalls:read

A page of calls, newest first.

  • limit

    integer · default 100

    Page size, 1–500.

  • offset

    integer

    How many calls to skip.

  • from / to

    date

    Created-at range.

  • result

    string

    Only calls with this result — confirmed, cancelled, …

  • direction

    string

    outbound (orders and campaigns) or inbound.

  • q

    string

    Search by phone number or order id.

GET/api/calls/{call_id}calls:read

One call. A call that belongs to another shop is a plain 404.

  • id, order_id, customer_name

    string

    Who and which order.

  • phone / dial_number

    string

    The number as supplied, and as dialled.

  • amount, currency, language

    mixed

    As sent on the order.

  • status / result

    string

    Lifecycle status, and the outcome once it ends (null while running).

  • dtmf_digit

    string | null

    The key pressed.

  • error

    string | null

    Why it failed.

  • billsec / cost

    number | null

    Billed seconds and BDT cost. null until the call has been billed.

  • customer_cost, forward_cost, forward_sec

    number | null

    The cost split when the call was bridged to a person: the automated leg, the agent leg, and the agent leg's seconds.

  • direction, attempts

    string, integer

    How it started, and how many dial attempts were made.

  • parent_call_id, agent_user_id

    string | null

    Set on a bridged leg, and when a person on your team handled the call.

  • created_at, updated_at, answered_at, completed_at

    string

    ISO-8601 UTC timestamps.

GET/api/calls/{call_id}/eventscalls:read

The call's timeline — each step the engine took (queued, ringing, the key pressed and the result decided, hang-up) in order.

POST/api/calls/{call_id}/hangupcalls:write · manager or above

Ends a live call. Returns the call. Billing settles for the seconds used.

POST/api/quick-callcalls:write · 10 / min

A test call to one number — the console's “call me” button. It skips the flow's schedule and delay, and takes phone plus optional text, language, audio_id, voice_provider and voice_name. Billed like any call. Returns the same shape as Place a call.

Recordings & audio

Hear what the customer heard, play the conversation back, and upload your own voice.

Three different audio things hang off a call, with different lifetimes. Mixing them up is the usual source of a surprise 410.

WhatEndpointKept
What the customer heard (the spoken prompt or closing, WAV)GET /api/calls/{call_id}/audio?kind=prompt|closing30 days, then 410 Gone
The conversation recording — a signed link as JSONGET /api/calls/{call_id}/recordingLink valid 30 minutes; request a fresh one each time
The conversation recording — the file itselfGET /api/calls/{call_id}/recording/fileStreamed through the API; the recording itself is not deleted on a schedule

Your own recordings

Play an uploaded clip instead of a synthesised voice by passing its id as audio_id on an order, or on a keypad option. Uploading is part of some plans only (403 otherwise); listing, playing and deleting existing clips always work.

POST/api/audiocalls:write · manager or above

Multipart form: file (required) and language (optional). WAV, MP3, OGG, FLAC, M4A, WebM or AMR, up to 25 MB, judged by content and not by file name. It is converted for telephony and returns { id, name, media, duration, language, created_at }.

GET/api/audiocalls:read

Your clips.

GET/api/audio/{clip_id}/filecalls:read

Download a clip.

DELETE/api/audio/{clip_id}calls:write · manager or above

Delete a clip.

Synthesised voices

GET/api/tts/voicescalls:read

Every voice you can pick — pass its provider and voice as voice_provider / voice_name on an order.

GET/api/tts/voices/sample?provider=&voice=calls:read

A stored sample of a voice — free to replay, unlike a preview.

POST/api/tts/previewcalls:write · 30 / min

Speak a short text (up to 400 characters) in a chosen voice and language and return the audio. GET /api/tts/preview takes the same values as query parameters.

Keypad & script

What the customer actually hears and what each key does — and how to change either for a single order.

What the customer hears, and what each key does. Every line is editable in Dashboard → Call flow; this is the flow a new shop starts with.

Welcome script

আসসালামু আলাইকুম। আপনি @quantity টি @products অর্ডার করেছিলেন, ডেলিভারি চার্জ সহ মোট @total_price টাকা। অর্ডারটি কনফার্ম করতে ১ চাপুন, কোনো প্রশ্ন থাকলে ২ চাপুন, আর ক্যান্সেল করতে ৩ চাপুন। ধন্যবাদ।

  • confirmedEnds the call and reports confirmed.

    ধন্যবাদ। আপনার অর্ডারটি কনফার্ম করা হয়েছে।

  • forwardedBridges the live call to the number you set on the menu.

    একটু অপেক্ষা করুন, আমাদের প্রতিনিধির সাথে সংযোগ করে দিচ্ছি।

  • cancelledEnds the call and reports cancelled.

    অর্ডারটি বাতিল করা হয়েছে। আমাদের সাথে থাকার জন্য ধন্যবাদ।

Change it for one order

Send menus on the order and it replaces the saved keypad for that call only. Each option is { key, status, response_text?, audio_id?, forward_to? }: status is what comes back as result (up to 40 characters, so a key you invent reports its own name), response_text is spoken after the press, and forward_to bridges the live call to a Bangladeshi mobile or 096 IP number. A forwarded key with no forward_to sends the customer to your own call centre on the same call. Up to 12 options.

menus
"menus": [  { "key": "1", "status": "confirmed" },  { "key": "2", "status": "callback",    "response_text": "ঠিক আছে, আমরা আবার কল করব।" },  { "key": "9", "status": "forwarded", "forward_to": "01711000000" }]
In the console flow editor keys are the digits 0–9; on an order the key field takes up to four characters.
  • repeat_on_silence: false plays the message once. Use it for an announcement that asks for no key; otherwise a silent customer hears it repeated and the call stays billable until they hang up.
  • A call's delay before dialling, and how many times a no-answer is retried, come from the call flow it runs (Dashboard → Call flow) — the one named by flow_id, else the default. They are not request fields.
  • A multi-question flow reports each answered question in collected on the webhook.

Several call flows

A shop can keep several named flows — one to confirm orders, another to recover abandoned carts — each with its own message, keys, voice, delay, repeats and retries. One is the default. An order picks a flow with flow_id (or ?flow= on a webhook URL); without one it runs the default. An id that is not one of the shop's is refused with 400 rather than swapped for the default, and fields sent on the order still win over the flow's. Flows are created and edited in Dashboard → Call flow; the API reads them so your app can let a merchant choose one per trigger.

GET/api/call-flowscalls:write

Every flow the shop has, the default first: { flows: [{ id, name, is_default, language, welcome_text, keys, call_delay_minutes, order_sources, created_at, updated_at }], default_id, max_flows }. default_id is null until the shop saves its first flow.

GET/api/call-flows/{flow_id}calls:write

One flow in full — script, menus, question tree, voice and timing.

The flow a call ran comes back as flow_id on the call, on order.created and on order_confirmation_result.

Variables

Anything you send on the order can be spoken back. These five are derived for you; every key you put in variables becomes one too.

  • @customer_name

    customer_name

  • @quantity

    items — total qty

  • @products

    items — spoken names

  • @total_price

    amount

  • @order_id

    order_id

SMS

Send a text, follow its delivery, read the replies.

Send a text, read the history and the replies. SMS is billed per segment from the same wallet. A shop that has its own number assigned (BYON) has messaging turned off, and sending is refused with 403 and the code sms_blocked_own_number.

POST/api/smssms:write · 60 / min

Queue one message. Returns 202 — it goes out through the same sender as a campaign, so delivery is reported afterwards, not in this response.

  • phone

    string · required

    6–20 characters; a Bangladeshi mobile.

  • text

    string · required

    1–4000 characters, trimmed. A blank text is refused.

  • from_number

    string · optional

    One of your own numbers to send as. Left out, your default caller id.

cURL
curl -X POST https://api.echo.bd/api/sms \  -H "X-API-Key: $ECHO_API_KEY" \  -H "Content-Type: application/json" \  -d '{ "phone": "01723306206", "text": "আপনার অর্ডার ডেলিভারির পথে আছে।" }'
POST/api/sms/bulkconsole session · 6 / min

Up to 500 contacts at once — pick them with contact_ids, labels or a search, plus text. Past that, use a campaign, which has pacing, a pause button and a per-message report.

POST/api/sms/previewconsole session

Body { "text": "…" }. Returns how many segments the text costs and in which alphabet, using the same rule billing uses. Bangla is UCS-2 at 70 characters a segment, against 160 for plain English (GSM-7), so a Bangla message costs roughly twice the segments of the same length in English.

POST/api/sms/{message_id}/retrysms:write

Send a message that did not arrive, again.

Reading messages

EndpointReturns
GET /api/smsHistory, with filters status, direction (inbound / outbound), sender, q, from, to, limit, offset.
GET /api/sms/{message_id}One message.
GET /api/sms/conversationsOne row per customer number — the inbox list.
GET /api/sms/conversation?phone=The thread with one number.
GET /api/sms/senders/statsCounts per team member who sent.

A message's status is queued, sending, sent, delivered, failed or undelivered. sent means the carrier took it; only delivered means the handset did. These routes are meant for a console session; a partner app reads the same data through the Partner API, and a shop that wants pushes uses the sms.batch webhook.

  1. Send

    POST /api/sms returns 202 and a queued message.
  2. Follow it

    Read GET /api/sms/{id}, or receive sms.batch on your webhook as the status changes.
  3. Handle a failure

    Retry with POST /api/sms/{id}/retry once the cause (balance, number) is fixed.

Webhook events

When something happens we POST it to your server, signed. This is the half of the integration that decides whether a parcel gets packed.

We POST JSON to your server when something happens. Four events exist; the X-EchoBD-Event header says which. The one every integration needs is order_confirmation_result — it decides whether a parcel gets packed.

EventWhenDelivery
order.createdWe accepted an order and queued the call.Queued, retried
order_confirmation_resultThe call ended and has a result.Queued, retried
sms.batchMessages you sent changed state (delivered, failed…).Queued, retried
call.ringing · call.claimed · call.endedA customer is ringing in, is picked up, or hangs up. Sent only to shops with a connected partner app on duty.One try, 2 s, never retried

order_confirmation_result

POST https://yourshop.com/echo/resultContent-Type: application/jsonX-EchoBD-Event: order_confirmation_resultX-EchoBD-Delivery-Id: 0b1c7a8e-6d1f-4f3a-9a52-2d3f3a1d9a10X-EchoBD-Signature: 5d41402abc4b2a76b9719d911017c592...​{  "event":        "order_confirmation_result",  "call_id":      "b35e7c842f194a6d9b0e5c1d7a3e8f42",  "order_id":     "ORD-10231",  "phone":        "01723306206",  "result":       "confirmed",  "confirmed":    true,  "dtmf_digit":   "1",  "collected":    {},  "last_question": null,  "status":       "completed",  "billsec":      18,  "cost":         1.9,  "forwarded_to": null,  "answered_at":  "2026-07-16T10:15:50+00:00",  "completed_at": "2026-07-16T10:16:08+00:00",  "error":        null,  "flow_id":      "6f1c2d3e-4a5b-4c6d-8e7f-9a0b1c2d3e4f",  "meta":         { "warehouse": "dhaka-1" }}
order.created and the result both go to the order's callback_url, or the URL saved on your shop. sms.batch and call.* go to the saved URL only.
  • event

    string

    Always order_confirmation_result on this payload. Also sent in the X-EchoBD-Event header.

  • call_id

    string

    Our id for the call — the id returned when you placed it. Delivery is at least once, so de-duplicate on it.

  • order_id

    string

    The reference you sent. Match on this to find the order.

  • phone

    string

    The number we dialled, in local 11-digit form.

  • result

    string

    The outcome. One of the values under Call results, or the status name of a keypad option you defined — act on this, not on the digit.

  • confirmed

    boolean

    True only when result is confirmed. A convenience for the common branch.

  • dtmf_digit

    string | null

    The key that produced the result, or null if none was pressed.

  • collected

    object

    Digits the customer entered on "collect" questions in a multi-question flow, as { name: digits }. {} for a flow with one question.

  • last_question

    string | null

    Where the customer was when the call ended. On a call that dropped early, the question they abandoned.

  • status

    string

    The call's lifecycle status at this point — see Place a call.

  • billsec

    number

    Billed seconds of connected audio. 0 when nobody answered — never null.

  • cost

    number

    What the call cost, in BDT, rounded to 2 decimals. 0 when nothing was billed.

  • forwarded_to

    string | null

    Where the customer was sent when result is forwarded: the number on the key, or your own EchoBD number when they went to your call centre.

  • answered_at

    string | null

    ISO-8601 UTC timestamp of pickup, or null when unanswered.

  • completed_at

    string | null

    ISO-8601 UTC timestamp of hang-up.

  • error

    string | null

    Why a call failed, when it did.

  • flow_id

    string | null

    The call flow that ran — useful when one endpoint receives order-confirmation and abandoned-cart results. null for a campaign call, or one placed before flows had ids.

  • meta

    object

    Whatever you sent on the order, returned unchanged.

order.created carries status queued (dialling as soon as a line is free) or scheduled (held for your configured delay). It is sent for orders only, not for campaign calls. sms.batch lists the changed messages oldest first, so applying them in order ends on the newest state; more: true means another batch is already on its way. De-duplicate its messages by id — a batch can be sent twice.

Reply 2xx fast

Acknowledge first, do your work in a queue. Anything that is not a 2xx within 15 seconds counts as a failed try and is sent again.

De-duplicate

Delivery is at least once. Drop a repeat using X-EchoBD-Delivery-Id (stable across retries) or call_id, so a second parcel is never packed.

Trust result, not the digit

You can remap the keypad at any time. Branch on result and today's 1 becoming tomorrow's 3 costs you nothing.

Headers

HeaderValueNotes
X-EchoBD-Eventorder.created · order_confirmation_result · sms.batch · call.*Which event this is. Route on it before parsing the body.
X-EchoBD-Delivery-IdUUIDOne id per delivery, the same on every retry of it. Not sent on the live call.* signals, which are never retried.
X-EchoBD-Timestampunix secondsWhen this attempt was signed. Fresh on every attempt, so a retry an hour later is still within tolerance. Reject anything more than 5 minutes from your clock.
X-EchoBD-Signature-V2hex HMAC-SHA256HMAC-SHA256, keyed with your signing secret, of the string "{timestamp}.{raw body}", as lowercase hex. Verify this one: it cannot be replayed outside the tolerance window.
X-EchoBD-Signaturehex HMAC-SHA256The original signature: HMAC-SHA256 of the raw request body alone. Unchanged and still sent, so existing verifiers keep working, but it carries no timestamp and so cannot detect a replay. Prefer V2.
X-EchoBD-Signature-V2-Previous · X-EchoBD-Signature-Previoushex HMAC-SHA256Only for 24 hours after you rotate the signing secret: the same two signatures made with the OLD secret, so you can switch without dropping a delivery. Accept either the current or the previous one.
X-EchoBD-Keyyour signing secretOnly on deliveries to the https URL saved on your shop. It is the secret itself, in the clear, so anyone who can read your request logs, proxy or access logs can read it and forge deliveries. Do not rely on it alone: always verify X-EchoBD-Signature-V2 as well.

Verify the signature

Verify X-EchoBD-Signature-V2: the lowercase hex HMAC-SHA256, keyed with your signing secret, of the text {timestamp}.{raw body} — the value of X-EchoBD-Timestamp, a dot, then the raw request body. Compute it on the bytes you received, before any JSON parsing, compare in constant time, and reject a timestamp more than 5 minutes from your clock. The timestamp is what stops someone replaying a captured request later; it is renewed on every retry, so a late retry still passes.

// Laravel — verify before you act on the body.$ts   = (string) $request->header('X-EchoBD-Timestamp');$body = $request->getContent();​abort_unless(abs(time() - (int) $ts) <= 300, 401); // 5-minute tolerance​$expected = hash_hmac('sha256', $ts . '.' . $body, config('services.echo.secret'));​abort_unless(    hash_equals($expected, (string) $request->header('X-EchoBD-Signature-V2')),    401);
Hash the raw body, not a re-serialised object — key order and whitespace both change the digest. Bangla text is sent as UTF-8, not escaped.

Rotating the secret

After you rotate, we keep signing with the old secret as well for 24 hours and add X-EchoBD-Signature-V2-Previous and X-EchoBD-Signature-Previous. Accept a request if V2 matches your new secret or the Previous header matches your old one, deploy the new secret, then drop the old one. After 24 hours the Previous headers disappear.

Configure & manage webhooks

Where results are sent, the secret that signs them, and the log of every delivery.

Results go to the address on the order (callback_url) if there is one, otherwise to the URL saved on your shop. With neither, nothing is sent and the result is still on the call. The saved URL is set in Dashboard → Developers or through the API below.

GET/api/webhooks/endpointwebhooks:manage · any member

Returns { "url": "https://…" }, or null when none is saved. Members below the owner see the address with credentials hidden (user:pass@ and the query string shown as ***); the owner and API keys see it whole.

PUT/api/webhooks/endpointwebhooks:manage · owner

Body { "url": "https://yourshop.com/echo/result" }. An empty or null url clears it. The address must start with http:// or https://, be at most 2000 characters and resolve to a public host — private and internal addresses are a 400. Use https: the signing key is only ever sent to an https URL.

This is how a connected partner app points results at itself the moment you authorise it: the webhooks:manage scope is the consent.

Signing secret

GET/api/webhooks/signing-secretconsole session · owner

Returns { "secret", "header": "X-EchoBD-Signature", "algorithm" }. A shop from before per-shop secrets gets one created on first read.

POST/api/webhooks/signing-secret/rotateconsole session · owner

Mints a new secret immediately. Deliveries already waiting are signed with the new secret from their next attempt, and for 24 hours also with the old one (the -Previous headers), so you can switch your endpoint without a gap.

Delivery log

GET/api/webhooksconsole session

Recent deliveries, newest first: limit 1–200 (default 50), optional status. Returns { deliveries, failed_total }. Each row has id, call_id, event, url, status, attempts, last_status_code, last_error, next_attempt_at, created_at, delivered_at; the payload itself is left out.

GET/api/webhooks/{delivery_id}console session

One delivery.

POST/api/webhooks/{delivery_id}/retryconsole session · owner

Revives a failed delivery with a fresh run of attempts and sends it now. One still pending is left to its own schedule; one already delivered is a 400.

Delivery & retries

Exactly how hard we try, and what counts as success.

Every result is written to a queue in the same transaction as the call it describes, so a crash cannot lose it, and then delivered at least once.

  • A delivery succeeds when your endpoint answers 2xx within 15 seconds. Anything else — a 3xx, 4xx, 5xx, a timeout, a refused connection — is a failed try.
  • We try up to 8 times. The wait before each retry doubles: 30 s, 1 min, 2 min, 4 min, 8 min, 16 min, 32 min — about 64 minutes in all.
  • After the last try the delivery is marked failed. It stays in the delivery log and can be re-sent by hand with the retry call, or from Dashboard → Developers.
  • Redirects are not followed. A 301 to your real URL counts as a failure — save the final address.
  • The address is checked again at send time. If it resolves to a private or internal host the delivery fails at once and is not retried; a name that simply does not resolve is an ordinary failure and is retried.
  • The destination is fixed when the event is queued. Changing your saved URL later does not redirect deliveries already waiting.
  • Order is not guaranteed across events. Use completed_at (and for SMS, the batch order) rather than arrival time to decide which is newer.
TryWaits before it
1immediately
230 seconds
31 minutes
42 minutes
54 minutes
68 minutes
716 minutes
832 minutes

Call results

The outcome vocabulary on every result webhook.

Seven outcomes cover an order call. Branch on result rather than the keypad digit — you can remap keys whenever you like, and the result is what stays stable.

  • confirmed

    The customer confirmed the order on the keypad. Ship it.

  • cancelled

    The customer cancelled. Do not pack the parcel.

  • forwarded

    The customer asked for a person and was put through to your call centre, or to the number on the key (forwarded_to). The order is undecided until someone on your side says otherwise.

  • no_input

    The call was answered but no key was pressed before the script ran out.

  • no_answer

    Nobody picked up. Retries follow the schedule in your call flow; this arrives after the last one.

  • voicemail

    An answering machine picked up, so the order cannot be confirmed this way. Treat it like no_answer. Billed at zero.

  • failed

    The call could never be placed — an unreachable number or a carrier failure. error says why. Nothing was charged for the attempt.

A keypad option you define yourself reports its own status as the result — a “call me back” key can return callback. Treat any result you do not recognise as undecided, not as cancelled. Inbound and call-centre calls carry other values (answered, agent_handled, abandoned and so on) when you read them with GET /api/calls; they never arrive on the order webhook.

Site collector (Google Tag Manager)

Orders read off a confirmation page by a tag, for stores that cannot send a webhook.

For stores that cannot call a webhook at all. A tag in Google Tag Manager reads the order off your confirmation page and posts it to us from the customer's browser. You set it up in Dashboard → Setup; this is what it does underneath, for the developer who has to debug it.

POST/v1/collect/{site_key}site key · Origin allow-list

Body is a JSON object, sent as text/plain on purpose so the browser skips the preflight and a beacon survives the customer closing the tab. The tag cannot name a call flow, so the connection carries one — chosen in Dashboard → Setup, the shop's default flow until then.

{  "page": "https://yourshop.com/thank-you",  "fields": {    "phone": "01723306206",    "customer_name": "Rahim Uddin",    "order_id": "10231",    "total_price": "৳ 1,250",    "delivery_area": "Mirpur"  },  "items": [    { "name": "Honey 500g", "qty": "2", "price": "450" }  ],  "missed": []}
Money and quantities are parsed from whatever the page shows — currency symbols, thousands separators and Bangla digits are accepted.

How the fields map

  • fields.phone

    string

    The only value with no fallback. No usable Bangladeshi mobile means no call.

  • fields.order_id

    string

    Left out, one is derived from the phone, total and basket, so a refresh does not ring twice — and the dry run says it was derived.

  • fields.customer_name, fields.total_price

    string

    Become customer_name and amount.

  • any other fields.*

    string

    Become @variables you can speak, such as @delivery_area.

  • items[]

    array

    { name, qty, price }. Rows without a name are ignored as layout debris.

  • missed

    string[]

    Fields the tag itself could not find. Counted for the health check.

The order is placed with meta.source = "gtm" and the page URL, and flows through exactly the same checks, billing and webhooks as an API order.

What protects it

  • The site key is in the page source, so it proves nothing. The Origin allow-list is the protection: only pages on a listed host may post. An empty list refuses everything. A leading dot (.myshop.com) also allows subdomains; scheme and port are ignored. A page that is not listed gets 403 this site is not authorised to send orders.
  • A new connection only accepts ?dry_run=1 until a person has watched a real test order arrive and switched it on. Before that a real post is 409.
  • Limits are 120 orders a minute per shop and 10 a minute per caller IP.
  • A post with no usable phone answers 200 with ok: false, the reason and missed — a configuration problem, so the tag must not retry. Five of these in a row raise a breakage alert on the shop, which is how a theme change that broke a selector gets noticed. Dry runs never count.
  • A successful post answers { ok: true, id, status, order_id, missed }.

OAuth for partner apps

Let a merchant connect your app with one consent screen, and receive a key limited to what they approved.

For apps that act on many shops' behalf — a CRM, an inbox, a helpdesk. The merchant approves exactly what the app may do, and the app receives an ordinary API key limited to those scopes. There are no refresh tokens and the key does not expire; it works until it is revoked. Client registration is by arrangement with us — you receive a client_id and client_secret and register your redirect URIs.

  1. Send the merchant to the consent screen

    Open /oauth/authorize on the dashboard host with client_id, redirect_uri, scope (space- or comma-separated), state, code_challenge and code_challenge_method=S256 (PKCE is optional but strongly recommended; only S256 is accepted). The merchant must be signed in; the screen lists each scope in plain words and marks the ones that spend money. Unknown scopes are dropped, not errors. An owner of more than one business picks which one to connect, with each business's numbers shown; pass business_hint (your name for the business) to preselect the one whose name matches. Each business gets its own key, and the token response names it in merchant_id.
  2. They approve; we redirect back with a code

    The browser lands on your redirect_uri with code and your state. The code works once and expires after 5 minutes. Check state before doing anything.
  3. Exchange it from your server

    POST /api/oauth/token with the code, your secret and, if you used PKCE, the code_verifier (43–128 characters). Never from a browser — the secret would be in it.
POST/api/oauth/tokenclient secret · 30 / min per IP

JSON body, then the key comes back.

curl -X POST https://api.echo.bd/api/oauth/token \  -H "Content-Type: application/json" \  -d '{    "grant_type":    "authorization_code",    "code":          "…",    "client_id":     "your-client-id",    "client_secret": "…",    "redirect_uri":  "https://yourapp.com/echo/callback",    "code_verifier": "…"  }'
Store the key against the merchant and send it as X-API-Key. webhook_signing_secret is present when the grant includes webhooks:manage, and webhook_token when it also includes calls:write — keep both.

Managing a connection

  • Approving again replaces the app's previous key — the old one is revoked. To ask for more scopes, send the merchant through the consent screen again.
  • POST /api/oauth/revoke — the app revokes its own key (send that key as X-API-Key).
  • GET /api/oauth/grants and DELETE /api/oauth/grants/{client_id} — the merchant, signed in, sees what is connected and cuts an app off. Done from Dashboard → Developers.
  • GET /api/oauth/authorize/context and POST /api/oauth/authorize are the consent screen's own calls (merchant session). Your app does not call them.
ScopeAllowsSpends balance
calls:writePlace calls, upload audio clips, preview voices, hang calls upYes
calls:readRead calls, events, recordings and call audioNo
wallet:readRead the balance and recent chargesNo
reports:readRead call statisticsNo
sms:writeSend and retry SMSYes
sms:readRead SMS history and customer repliesNo
campaigns:writeCreate and run bulk SMS and IVR campaignsYes
webhooks:manageRead and change where results are deliveredNo
softphone:useLet your team talk to customers from a connected appYes

Partner API

What a connected app can read and do with its key: readiness, stats, a call mirror, SMS, the softphone and contacts.

What a connected app uses once it holds a key. Every endpoint here takes X-API-Key only — a browser session is not accepted — and answers 403 this key was not granted: <scope> when the grant is too narrow. Everything is scoped to the shop that granted the key.

Identity & readiness

GET/api/partner/meany key

Who granted this key: shop id, name and slug, status, the scopes it holds with their labels, who connected it, the wallet (when wallet:read is held), and the shop's saved callback_url and webhook_token.

GET/api/partner/preflightany key

Can this shop act right now, and if not, precisely why. Returns can_send_sms, can_place_call, number, has_plan, balance, call_rate_per_minute, sms_rate_per_segment, capabilities, blockers[], has_own_number, sms_blocked_reason, granted_scopes, affordable_call_seconds. Call it before showing a send button.

GET/api/partner/stats?days=reports:read

Totals for a window: calls, answered, confirmed, cancelled, no_answer, no_input, failed, forwarded, spend, billed_seconds, answer_rate, confirm_rate, sms_sent, sms_delivered and a per-day series.

Calls & recordings

GET/api/partner/callscalls:read

The shop's calls. limit, bucket, direction, q and order. order=recent (default) is for a screen. To keep your own copy use order=updated_asc with the cursor updated_after + after_id: it walks calls by when they last changed, so an outcome settled hours later (billing, a recording) comes back round. The pair is the cursor, never the timestamp alone, and more says another page is waiting. Each row carries flow_id, the call flow it ran.

GET/api/partner/calls/{call_id}/recordingcalls:read

A short-lived signed link to the call's recording.

SMS

EndpointScopeReturns
GET /api/partner/sms/conversationssms:readOne row per customer number; filters q, number, tag, from, to, limit, offset.
GET /api/partner/sms/conversation?phone=sms:readThe thread with one number (limit).
GET /api/partner/smssms:readHistory; filters status, direction, q, from, to, limit, offset.
POST /api/partner/sms/{message_id}/retrysms:writeSend a message that did not arrive, again.

Sending a new message uses POST /api/sms with the same key and sms:write.

Softphone & call queue

For apps that let a team talk to customers from their own UI, on the shop's minutes. All need softphone:use. Your app identifies its own users with an external_ref (1–100 characters) and an optional display_name; we create the seat on first use.

EndpointBody / paramsDoes
POST /api/partner/softphone/credentials{ external_ref, display_name? }Returns SIP-over-WebSocket credentials: ws_url, uri, username, password, realm, display_name.
POST /api/partner/softphone/dial{ external_ref, phone, display_name?, meta? }Places an outbound call for that seat. 20 / min per app.
GET /api/partner/softphone/outcome/{call_id}—How a call the browser just watched actually ended — a carrier refusal is not “no answer”.
POST /api/partner/softphone/presence{ external_ref, state }state is available, break or offline.
GET /api/partner/queue?external_ref=—Inbound callers waiting for this seat.
POST /api/partner/queue/take{ external_ref, call_id }Claim a waiting caller.
POST /api/partner/queue/dismiss{ external_ref, call_id }Decline one.
POST /api/partner/contacts{ phone, name, tags? }Save or update a contact in the shop's book.

No-code options

If your store runs on a platform we already speak, none of the above is work you have to do — you paste a URL and orders start calling themselves.

  • WooCommerce

    Store

    New orders call themselves; status writes back on the press.

  • Shopify

    Store

    Confirmed and cancelled orders tagged automatically.

  • Custom website

    Store

    Built your own storefront? Point it at one URL and new orders call themselves.

  • Facebook page

    Social

    Selling from comments and Messenger? Those orders get called too, same as any other channel.

  • REST API & Webhooks

    Any platform

    Not on a listed platform? One POST places the call, one webhook brings the result back.

Rate limits

Per-minute limits for each endpoint, and the size limits on what you send.

Limits are sliding one-minute windows. Going over answers 429 with a Retry-After header in seconds, and nothing from that request is queued. These are the defaults; if a limit is in your way and is not a runaway loop, ask support to raise it for your account.

WhatEndpointLimitCounted per
Place a callPOST /api/calls120 / minshop
Order by webhook URLPOST /v1/orders/{token}120 / min per shop, 300 / min per IPshop and caller IP
Quick callPOST /api/quick-call10 / minshop
Send one SMSPOST /api/sms60 / minshop
Send SMS in bulkPOST /api/sms/bulk6 / minshop
Site collector (GTM)POST /v1/collect/{site_key}120 / min per shop, 10 / min per IPshop and caller IP
OAuth token exchangePOST /api/oauth/token30 / mincaller IP
Softphone dialPOST /api/partner/softphone/dial20 / minconnected app
Voice previewPOST /api/tts/preview30 / minshop

Sizes

FieldLimit
order_id1–200 characters
items200 rows; name up to 200 characters
variables50 keys; each value up to 500 characters
meta8,000 bytes of JSON
welcome_text · closing_text · response_text2,000 characters
menus12 options
SMS text4,000 characters; bulk up to 500 recipients
Audio upload25 MB
GET /api/callslimit up to 500 per page

Errors

Every status the API answers with, the shapes the body takes, and what to do about each.

Failures are an HTTP status and a detail. Most are one sentence; a few carry a stable error code you can branch on, and request validation lists every offending field. Read the status first, then the shape.

{ "detail": "'abc' is not a Bangladeshi mobile number we can ring" }
StatusMeaningWhat to do
200Accepted (also returned when the order was a repeat — see the original call's id).Store the id and wait for the webhook.
400The phone is not a Bangladeshi mobile number, or the file/body is empty or unreadable.Read detail — it names the problem.
401Missing, revoked or malformed X-API-Key (or session).Mint a fresh key in Dashboard → Developers.
402No active plan, plan fee lapsed past grace, or not enough balance for this call.Fix the account in Dashboard → Billing, then retry.
403The key lacks the scope (detail names it), the shop has no outbound number or may not place calls (detail.error is a stable code such as no_number_assigned), or the plan excludes the feature.Re-authorise with the scope, or ask support to enable calling.
404Unknown id, or an id that belongs to another shop. Also an unknown webhook token.Check the id and the key's shop.
410Gone — the call audio was deleted after its keep period.Nothing to retry.
413An uploaded file is too large.Keep audio under 25 MB.
422The body failed validation — wrong type, too long, too many items.detail lists each field.
429A rate limit or the concurrent-call cap was hit.Wait Retry-After seconds. Nothing was queued.
5xxOur problem, not your payload.Retry with backoff; with an Idempotency-Key a retry is safe.
  • The site collector also returns 409 when a connection has not been switched on yet — see Site collector.
  • Webhooks you cannot receive are retried; see Delivery & retries.

Testing

How to check an integration without ringing a real customer.

ToolCostsChecks
?dry_run=1 on POST /v1/orders/{token} and /v1/collect/{site_key}NothingThe number, the @variables the script would speak, and a list of problems. Nothing is dialled or charged. It does count against the per-IP rate limit (not the per-shop one), so a test script that loops is still throttled.
POST /api/quick-call, or Dashboard → Setup → test orderA normal callThe real thing end to end, to your own phone — including the webhook.
Idempotency-KeyNothingReplaying a request returns the same call, so a test script that retries cannot ring a customer twice (10-minute order_id window without one).

dry_run is a query parameter on the two public doors only. POST /api/calls has no dry run — to check a key without placing a call, read with it:

cURL
curl -s "https://api.echo.bd/api/calls?limit=1" -H "X-API-Key: $ECHO_API_KEY"

To test your webhook handler, place one call to yourself and let it finish; the result arrives signed exactly like production. A failed delivery can be re-sent from the delivery log. If your endpoint is local, expose it with a tunnel — we refuse private and internal addresses.

Conventions

Formats, paging and habits that hold across every endpoint.

  • Two prefixes. Authenticated endpoints live under /api; the public doors that take a token or site key live under /v1. Both are on https://api.echo.bd.
  • HTTPS and JSON. Requests and responses are UTF-8 JSON. Send Content-Type: application/json (the site collector is the one exception).
  • Formats. Timestamps are ISO-8601 in UTC. Ids are strings. Money is a number in BDT. Phone numbers are accepted in any common Bangladeshi form and returned in local 11-digit form.
  • Paging. Lists take limit and offset. The partner call mirror uses a cursor (updated_after + after_id) because an offset cannot catch up a changing set.
  • Be liberal in what you read. Fields get added over time. Ignore the ones you do not know, and do not fail on a result or status value you have not seen.
  • Tenant isolation. An id from another shop is a plain 404, never a 403, so ids cannot be probed.
  • Caller IP. Per-IP limits use the address we see behind our edge proxy; calls from one office or NAT share one budget.
  • Idempotency. Send an Idempotency-Key on every order, and de-duplicate webhooks on X-EchoBD-Delivery-Id. Between them nobody is rung twice and no parcel is packed twice.

Something here not answering your question?

Tell us what your store runs on and we will send back the exact payload it should be sending — or the plugin that means it does not have to.