{ "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": []}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.
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.
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.
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.
| Credential | Sent as | Use it for |
|---|---|---|
| API key | X-API-Key: echo_live_… | Your own server, and partner apps after OAuth. Works on the order, call, SMS and webhook endpoints below. |
| Console session | Authorization: Bearer <session> | The signed-in dashboard. Not for integrations — sessions expire and belong to a person. |
| Webhook token in the URL | none — the token in the path is the credential | POST /v1/orders/{token}, for shop platforms that cannot set headers. Treat the URL as a password. |
| Site key | none — an Origin allow-list protects it | POST /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- 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.
| Scope | Allows | Spends balance |
|---|---|---|
| calls:write | Place calls, upload audio clips, preview voices, hang calls up | Yes |
| calls:read | Read calls, events, recordings and call audio | No |
| wallet:read | Read the balance and recent charges | No |
| reports:read | Read call statistics | No |
| sms:write | Send and retry SMS | Yes |
| sms:read | Read SMS history and customer replies | No |
| campaigns:write | Create and run bulk SMS and IVR campaigns | Yes |
| webhooks:manage | Read and change where results are delivered | No |
| softphone:use | Let your team talk to customers from a connected app | Yes |
Place a call
POST /api/calls queues an order-confirmation call and answers immediately. One request per order.
/api/callsX-API-Key · calls:writeQueues 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_idstring · required
Your own reference, 1–200 characters. Returned untouched on the webhook and used to spot a repeated order.
phonestring · 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_idstring · 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_namestring · optional
Spoken in the script as @customer_name.
amountnumber · optional
Order total, 0 or more. Becomes @total_price.
currencystring · optional · default "BDT"
Up to 8 characters. Informational — it is returned on the call, not converted.
itemsarray · optional
Up to 200 of { name, qty, price? }. Becomes @products and @quantity. qty defaults to 1.
languagestring · optional
Up to 8 characters, such as bn. Left out, the shop's default language is used.
welcome_textstring · 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_textstring · optional
Spoken at the end of the call, with @variables.
variablesobject · optional
Up to 50 extra @variables, each value up to 500 characters — { "courier": "Pathao" } becomes @courier.
menusarray · 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_silenceboolean · 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_namestring · optional
Speak in a specific voice instead of the call flow's. List the choices with GET /api/tts/voices.
audio_idstring · optional
Play an uploaded recording instead of a synthesised voice. The id comes from POST /api/audio.
callback_urlstring · 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.
metaobject · optional
Opaque to us, up to 8000 bytes of JSON. Returned unchanged on every webhook.
Headers
Idempotency-Keystring
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 OK{ "id": "b35e7c842f194a6d9b0e5c1d7a3e8f42", "status": "queued", "order_id": "ORD-10231", "dial_number": "01723306206"}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 lackscalls:write, or the shop has no outbound number or may not place calls. The body carries a stableerrorcode.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.
/v1/orders/{webhook_token}token in the URLSame 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_runquery · 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.
flowquery · 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-Keyheader
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.
/api/callscalls:readA page of calls, newest first.
limitinteger · default 100
Page size, 1–500.
offsetinteger
How many calls to skip.
from / todate
Created-at range.
resultstring
Only calls with this result — confirmed, cancelled, …
directionstring
outbound (orders and campaigns) or inbound.
qstring
Search by phone number or order id.
/api/calls/{call_id}calls:readOne call. A call that belongs to another shop is a plain 404.
id, order_id, customer_namestring
Who and which order.
phone / dial_numberstring
The number as supplied, and as dialled.
amount, currency, languagemixed
As sent on the order.
status / resultstring
Lifecycle status, and the outcome once it ends (null while running).
dtmf_digitstring | null
The key pressed.
errorstring | null
Why it failed.
billsec / costnumber | null
Billed seconds and BDT cost. null until the call has been billed.
customer_cost, forward_cost, forward_secnumber | 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, attemptsstring, integer
How it started, and how many dial attempts were made.
parent_call_id, agent_user_idstring | null
Set on a bridged leg, and when a person on your team handled the call.
created_at, updated_at, answered_at, completed_atstring
ISO-8601 UTC timestamps.
/api/calls/{call_id}/eventscalls:readThe call's timeline — each step the engine took (queued, ringing, the key pressed and the result decided, hang-up) in order.
/api/calls/{call_id}/hangupcalls:write · manager or aboveEnds a live call. Returns the call. Billing settles for the seconds used.
/api/quick-callcalls:write · 10 / minA 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.
| What | Endpoint | Kept |
|---|---|---|
| What the customer heard (the spoken prompt or closing, WAV) | GET /api/calls/{call_id}/audio?kind=prompt|closing | 30 days, then 410 Gone |
| The conversation recording — a signed link as JSON | GET /api/calls/{call_id}/recording | Link valid 30 minutes; request a fresh one each time |
| The conversation recording — the file itself | GET /api/calls/{call_id}/recording/file | Streamed 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.
/api/audiocalls:write · manager or aboveMultipart 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 }.
/api/audiocalls:readYour clips.
/api/audio/{clip_id}/filecalls:readDownload a clip.
/api/audio/{clip_id}calls:write · manager or aboveDelete a clip.
Synthesised voices
/api/tts/voicescalls:readEvery voice you can pick — pass its provider and voice as voice_provider / voice_name on an order.
/api/tts/voices/sample?provider=&voice=calls:readA stored sample of a voice — free to replay, unlike a preview.
/api/tts/previewcalls:write · 30 / minSpeak 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.
আসসালামু আলাইকুম। আপনি @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": [ { "key": "1", "status": "confirmed" }, { "key": "2", "status": "callback", "response_text": "ঠিক আছে, আমরা আবার কল করব।" }, { "key": "9", "status": "forwarded", "forward_to": "01711000000" }]repeat_on_silence: falseplays 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
collectedon 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.
/api/call-flowscalls:writeEvery 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.
/api/call-flows/{flow_id}calls:writeOne 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_namecustomer_name
@quantityitems — total qty
@productsitems — spoken names
@total_priceamount
@order_idorder_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.
/api/smssms:write · 60 / minQueue one message. Returns 202 — it goes out through the same sender as a campaign, so delivery is reported afterwards, not in this response.
phonestring · required
6–20 characters; a Bangladeshi mobile.
textstring · required
1–4000 characters, trimmed. A blank text is refused.
from_numberstring · optional
One of your own numbers to send as. Left out, your default caller id.
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": "আপনার অর্ডার ডেলিভারির পথে আছে।" }'/api/sms/bulkconsole session · 6 / minUp 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.
/api/sms/previewconsole sessionBody { "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.
/api/sms/{message_id}/retrysms:writeSend a message that did not arrive, again.
Reading messages
| Endpoint | Returns |
|---|---|
| GET /api/sms | History, with filters status, direction (inbound / outbound), sender, q, from, to, limit, offset. |
| GET /api/sms/{message_id} | One message. |
| GET /api/sms/conversations | One row per customer number — the inbox list. |
| GET /api/sms/conversation?phone= | The thread with one number. |
| GET /api/sms/senders/stats | Counts 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.
Send
POST /api/sms returns 202 and a queued message.Follow it
Read GET /api/sms/{id}, or receive sms.batch on your webhook as the status changes.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.
| Event | When | Delivery |
|---|---|---|
| order.created | We accepted an order and queued the call. | Queued, retried |
| order_confirmation_result | The call ended and has a result. | Queued, retried |
| sms.batch | Messages you sent changed state (delivered, failed…). | Queued, retried |
| call.ringing · call.claimed · call.ended | A 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" }}eventstring
Always order_confirmation_result on this payload. Also sent in the X-EchoBD-Event header.
call_idstring
Our id for the call — the id returned when you placed it. Delivery is at least once, so de-duplicate on it.
order_idstring
The reference you sent. Match on this to find the order.
phonestring
The number we dialled, in local 11-digit form.
resultstring
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.
confirmedboolean
True only when result is confirmed. A convenience for the common branch.
dtmf_digitstring | null
The key that produced the result, or null if none was pressed.
collectedobject
Digits the customer entered on "collect" questions in a multi-question flow, as { name: digits }. {} for a flow with one question.
last_questionstring | null
Where the customer was when the call ended. On a call that dropped early, the question they abandoned.
statusstring
The call's lifecycle status at this point — see Place a call.
billsecnumber
Billed seconds of connected audio. 0 when nobody answered — never null.
costnumber
What the call cost, in BDT, rounded to 2 decimals. 0 when nothing was billed.
forwarded_tostring | 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_atstring | null
ISO-8601 UTC timestamp of pickup, or null when unanswered.
completed_atstring | null
ISO-8601 UTC timestamp of hang-up.
errorstring | null
Why a call failed, when it did.
flow_idstring | 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.
metaobject
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
| Header | Value | Notes |
|---|---|---|
| X-EchoBD-Event | order.created · order_confirmation_result · sms.batch · call.* | Which event this is. Route on it before parsing the body. |
| X-EchoBD-Delivery-Id | UUID | One id per delivery, the same on every retry of it. Not sent on the live call.* signals, which are never retried. |
| X-EchoBD-Timestamp | unix seconds | When 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-V2 | hex HMAC-SHA256 | HMAC-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-Signature | hex HMAC-SHA256 | The 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-Previous | hex HMAC-SHA256 | Only 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-Key | your signing secret | Only 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);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.
/api/webhooks/endpointwebhooks:manage · any memberReturns { "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.
/api/webhooks/endpointwebhooks:manage · ownerBody { "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
/api/webhooks/signing-secretconsole session · ownerReturns { "secret", "header": "X-EchoBD-Signature", "algorithm" }. A shop from before per-shop secrets gets one created on first read.
/api/webhooks/signing-secret/rotateconsole session · ownerMints 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
/api/webhooksconsole sessionRecent 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.
/api/webhooks/{delivery_id}console sessionOne delivery.
/api/webhooks/{delivery_id}/retryconsole session · ownerRevives 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
2xxwithin 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.
| Try | Waits before it |
|---|---|
| 1 | immediately |
| 2 | 30 seconds |
| 3 | 1 minutes |
| 4 | 2 minutes |
| 5 | 4 minutes |
| 6 | 8 minutes |
| 7 | 16 minutes |
| 8 | 32 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.
/v1/collect/{site_key}site key · Origin allow-listBody 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.
How the fields map
fields.phonestring
The only value with no fallback. No usable Bangladeshi mobile means no call.
fields.order_idstring
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_pricestring
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.
missedstring[]
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 gets403 this site is not authorised to send orders. - A new connection only accepts
?dry_run=1until a person has watched a real test order arrive and switched it on. Before that a real post is409. - Limits are 120 orders a minute per shop and 10 a minute per caller IP.
- A post with no usable phone answers
200withok: false, thereasonandmissed— 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.
Send the merchant to the consent screen
Open/oauth/authorizeon the dashboard host withclient_id,redirect_uri,scope(space- or comma-separated),state,code_challengeandcode_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; passbusiness_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 inmerchant_id.They approve; we redirect back with a code
The browser lands on yourredirect_uriwithcodeand yourstate. The code works once and expires after 5 minutes. Checkstatebefore doing anything.Exchange it from your server
POST /api/oauth/tokenwith the code, your secret and, if you used PKCE, thecode_verifier(43–128 characters). Never from a browser — the secret would be in it.
/api/oauth/tokenclient secret · 30 / min per IPJSON 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": "…" }'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 asX-API-Key).GET /api/oauth/grantsandDELETE /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/contextandPOST /api/oauth/authorizeare the consent screen's own calls (merchant session). Your app does not call them.
| Scope | Allows | Spends balance |
|---|---|---|
| calls:write | Place calls, upload audio clips, preview voices, hang calls up | Yes |
| calls:read | Read calls, events, recordings and call audio | No |
| wallet:read | Read the balance and recent charges | No |
| reports:read | Read call statistics | No |
| sms:write | Send and retry SMS | Yes |
| sms:read | Read SMS history and customer replies | No |
| campaigns:write | Create and run bulk SMS and IVR campaigns | Yes |
| webhooks:manage | Read and change where results are delivered | No |
| softphone:use | Let your team talk to customers from a connected app | Yes |
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
/api/partner/meany keyWho 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.
/api/partner/preflightany keyCan 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.
/api/partner/stats?days=reports:readTotals 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
/api/partner/callscalls:readThe 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.
/api/partner/calls/{call_id}/recordingcalls:readA short-lived signed link to the call's recording.
SMS
| Endpoint | Scope | Returns |
|---|---|---|
| GET /api/partner/sms/conversations | sms:read | One row per customer number; filters q, number, tag, from, to, limit, offset. |
| GET /api/partner/sms/conversation?phone= | sms:read | The thread with one number (limit). |
| GET /api/partner/sms | sms:read | History; filters status, direction, q, from, to, limit, offset. |
| POST /api/partner/sms/{message_id}/retry | sms:write | Send 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.
| Endpoint | Body / params | Does |
|---|---|---|
| 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
StoreNew orders call themselves; status writes back on the press.
Shopify
StoreConfirmed and cancelled orders tagged automatically.
Custom website
StoreBuilt your own storefront? Point it at one URL and new orders call themselves.
Facebook page
SocialSelling from comments and Messenger? Those orders get called too, same as any other channel.
REST API & Webhooks
Any platformNot 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.
| What | Endpoint | Limit | Counted per |
|---|---|---|---|
| Place a call | POST /api/calls | 120 / min | shop |
| Order by webhook URL | POST /v1/orders/{token} | 120 / min per shop, 300 / min per IP | shop and caller IP |
| Quick call | POST /api/quick-call | 10 / min | shop |
| Send one SMS | POST /api/sms | 60 / min | shop |
| Send SMS in bulk | POST /api/sms/bulk | 6 / min | shop |
| Site collector (GTM) | POST /v1/collect/{site_key} | 120 / min per shop, 10 / min per IP | shop and caller IP |
| OAuth token exchange | POST /api/oauth/token | 30 / min | caller IP |
| Softphone dial | POST /api/partner/softphone/dial | 20 / min | connected app |
| Voice preview | POST /api/tts/preview | 30 / min | shop |
Sizes
| Field | Limit |
|---|---|
| order_id | 1–200 characters |
| items | 200 rows; name up to 200 characters |
| variables | 50 keys; each value up to 500 characters |
| meta | 8,000 bytes of JSON |
| welcome_text · closing_text · response_text | 2,000 characters |
| menus | 12 options |
| SMS text | 4,000 characters; bulk up to 500 recipients |
| Audio upload | 25 MB |
| GET /api/calls | limit 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" }| Status | Meaning | What to do |
|---|---|---|
| 200 | Accepted (also returned when the order was a repeat — see the original call's id). | Store the id and wait for the webhook. |
| 400 | The phone is not a Bangladeshi mobile number, or the file/body is empty or unreadable. | Read detail — it names the problem. |
| 401 | Missing, revoked or malformed X-API-Key (or session). | Mint a fresh key in Dashboard → Developers. |
| 402 | No active plan, plan fee lapsed past grace, or not enough balance for this call. | Fix the account in Dashboard → Billing, then retry. |
| 403 | The 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. |
| 404 | Unknown id, or an id that belongs to another shop. Also an unknown webhook token. | Check the id and the key's shop. |
| 410 | Gone — the call audio was deleted after its keep period. | Nothing to retry. |
| 413 | An uploaded file is too large. | Keep audio under 25 MB. |
| 422 | The body failed validation — wrong type, too long, too many items. | detail lists each field. |
| 429 | A rate limit or the concurrent-call cap was hit. | Wait Retry-After seconds. Nothing was queued. |
| 5xx | Our problem, not your payload. | Retry with backoff; with an Idempotency-Key a retry is safe. |
- The site collector also returns
409when 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.
| Tool | Costs | Checks |
|---|---|---|
| ?dry_run=1 on POST /v1/orders/{token} and /v1/collect/{site_key} | Nothing | The 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 order | A normal call | The real thing end to end, to your own phone — including the webhook. |
| Idempotency-Key | Nothing | Replaying 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 -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 onhttps://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
limitandoffset. 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
resultorstatusvalue you have not seen. - Tenant isolation. An id from another shop is a plain
404, never a403, 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-Keyon every order, and de-duplicate webhooks onX-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.