You POST us an order. We call the customer, confirm it in Bangla, and POST the result back to you. Your side: one queued job and one route.
Base URL: https://www.sworborno.com/api/v1
Auth: every request carries your API key — keep it in .env, never in git.
Authorization: Bearer vk_live_xxxxxxxxxxxxxxxxxxxx
The key is the account. Every request is attributed to the company the key belongs to — there is no account id to send anywhere, so there is nothing to get wrong. Calls placed for your account always present your own dedicated number as caller ID; no other company on the platform can call from it.
1. Send us an order
POST /integrations/call-requests → 202 Accepted
{
"external_id": "10432",
"phone": "01798XXXXXX",
"context": {
"customer_name": "Rahim Uddin",
"order_id": "10432",
"total": 1450,
"payment_method": "cod",
"address": "House 12, Road 4, Dhanmondi, Dhaka",
"items": [
{ "name": "Cotton Shirt", "qty": 2, "price": 650 },
{ "name": "Delivery", "qty": 1, "price": 150 }
]
}
}
external_id— your order id. Idempotency key: sending it twice returns the first request instead of ringing the customer again, so retries are safe. Scoped to your account; nobody else's10432collides with yours.context— free-form JSON; whatever you put here is what the agent can answer questions from. Send what a support person would want on screen.assistant_id— only needed if your account has more than one assistant.
| Code | Meaning |
|---|---|
| 202 | Accepted (first time or retry — identical either way) |
| 401 | Bad, missing or revoked API key |
| 400 / 404 | assistant_id missing-when-needed / not yours |
| 422 | Bad payload — response names the field (undialable numbers fail here, not silently later) |
| 429 | Rate limited; back off and retry |
Laravel: send from a queued job, never from the checkout controller
// config/services.php
'voice' => [
'url' => env('VOICE_API_URL'),
'key' => env('VOICE_API_KEY'),
'secret' => env('VOICE_WEBHOOK_SECRET'),
],
// app/Jobs/RequestConfirmationCall.php
class RequestConfirmationCall implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 3;
public $backoff = [30, 120];
public function __construct(public Order $order) {}
public function handle(): void
{
Http::withToken(config('services.voice.key'))
->timeout(10)
->post(config('services.voice.url').'/integrations/call-requests', [
'external_id' => (string) $this->order->id,
'phone' => $this->order->phone,
'context' => [
'customer_name' => $this->order->customer_name,
'order_id' => (string) $this->order->id,
'total' => $this->order->total,
'payment_method' => $this->order->payment_method,
'address' => $this->order->full_address,
'items' => $this->order->items->map(fn ($i) => [
'name' => $i->name, 'qty' => $i->quantity, 'price' => $i->price,
])->all(),
],
])->throw();
}
}
// wherever the order is created:
RequestConfirmationCall::dispatch($order);
Two things that catch people out: QUEUE_CONNECTION must not be sync in
production (that puts our network inside your checkout), and ->throw() is
required — without it a failed POST is swallowed and the customer is never
called.
2. Receive the result
When the call settles we POST once to a URL you give us.
Confirmed:
{
"event": "call.result",
"order_id": "10432",
"status": "confirmed",
"reason": null,
"summary": "Order and address confirmed on the call.",
"address": {
"original": "House 12, Road 4, Dhanmondi, Dhaka",
"thana": "ধানমন্ডি",
"zilla": "ঢাকা",
"full": "House 12, Road 4, Dhanmondi, Dhaka, ধানমন্ডি, ঢাকা"
},
"attempt": 1,
"duration_seconds": 61,
"completed_at": "2026-08-21T09:56:57+00:00",
"balance_seconds": 5279,
"available_seconds": 5279
}
Hold:
{
"event": "call.result",
"order_id": "10432",
"status": "hold",
"reason": "cancelled",
"summary": "The customer said they no longer want the order.",
"address": { "original": "House 12, Road 4, Dhanmondi, Dhaka",
"thana": null, "zilla": null,
"full": "House 12, Road 4, Dhanmondi, Dhaka" },
"attempt": 1,
"duration_seconds": 38,
"completed_at": "2026-08-21T10:02:11+00:00",
"balance_seconds": 5241,
"available_seconds": 5241
}
status — the only field your code branches on. Two values, by design.
| Status | What your system should do |
|---|---|
confirmed | Customer confirmed; address collected. Ship it. |
hold | Anything else. Put it in front of a person. Never auto-cancel. |
reason says why it is a hold — display it to the reviewer, don't branch on it:
| Reason | What happened |
|---|---|
cancelled | Customer does not want it |
modified | Wants a change — see summary |
wrong_number | Not the customer |
address_incomplete | Confirmed, but hung up before the address finished |
unclear | Call reached no clear decision — includes “call me back later” |
no_answer | Nobody picked up, across every attempt allowed |
call_failed | The call could not be placed or proceed |
abusive | Caller was abusive — consider not calling this number again |
ran_long | Passed the platform's length limit without settling |
address
original is exactly what you sent — never overwritten. thana/zilla are
what the customer said, district-checked where possible ("চিটাগাং (চট্টগ্রাম)"
= said the first, matched the second; both kept). full is the pre-joined
courier-label string.
duration_seconds, balance_seconds, available_seconds
Added 10 September 2026. Purely additive — every field above is exactly where it was, so an integration that ignores unknown keys needs no change.
duration_seconds— the billed length of the call that produced this result, in whole seconds.nullwhen no attempt connected (call_failed, andno_answerafter every attempt went unanswered), and, rarely, when a call connected but could not be metered — we never guess a length.balance_seconds/available_seconds— your account after this call was charged, with the same meaning asGET /integrations/balance(§4). Gate onavailable_seconds. On the rare result we resend hours later from our own reconciliation sweep, the figures are the balance at the time of that resend.
The result is written down the moment the call reaches a decision, while the customer is still on the line; the POST goes out once the call has ended and been billed, normally ten to twenty seconds later, so all three figures are final by the time you read them. They are frozen with the result: a retried delivery carries the same numbers as the first attempt, not today's balance.
The same duration_seconds is on GET /integrations/call-requests/{id} for
the poll path.
The route — verify, dedupe, answer fast
Put it in routes/api.php (CSRF in web.php rejects our POST with 419).
Every delivery carries three headers:
| Header | What it is |
|---|---|
X-Signature | HMAC-SHA256 of "{timestamp}.{raw body}" with your secret |
X-Webhook-Timestamp | Unix seconds — signed with the body so a captured POST expires |
X-Webhook-Id | Stable across retries of one result — your dedupe key |
public function handle(Request $request)
{
$ts = $request->header('X-Webhook-Timestamp', '');
$expected = hash_hmac('sha256', $ts.'.'.$request->getContent(),
config('services.voice.secret'));
abort_unless(hash_equals($expected, $request->header('X-Signature', '')), 401);
abort_if(abs(time() - (int) $ts) > 300, 401); // no replays
// At-least-once delivery: the same result can arrive twice. Do the work once.
if (WebhookReceipt::where('webhook_id', $request->header('X-Webhook-Id'))->exists()) {
return response()->noContent();
}
WebhookReceipt::create(['webhook_id' => $request->header('X-Webhook-Id')]);
// Two kinds of message arrive here: call results and account events.
if ($request->input('event') === 'balance.low') {
Log::warning('Voice agent balance low', $request->only('balance_minutes'));
// e.g. notify ops; calls stop when the balance is empty.
return response()->noContent();
}
$order = Order::findOrFail($request->input('order_id'));
match ($request->input('status')) {
'confirmed' => $order->update([
'status' => 'confirmed',
'shipping_thana' => $request->input('address.thana'),
'shipping_zilla' => $request->input('address.zilla'),
]),
default => $order->update([
'status' => 'needs_review',
'hold_reason' => $request->input('reason'),
'call_note' => $request->input('summary'),
]),
};
return response()->noContent(); // any non-2xx and we retry
}
Delivery guarantees: the result is written down on our side before the first attempt, then retried on any non-2xx or timeout — immediately, 30s, 2m, 10m, 30m, 2h, 6h (~9 hours). A deploy or restart on your side costs nothing. If every attempt fails we flag it and can resend by hand.
Calls are recorded on our server for dispute resolution — not linked here; ask us with an order id. Disclosing recording to your customers is yours to do.
3. Check, and cancel
GET /integrations/call-requests/{external_id} → status + outcome, by your id
DELETE /integrations/call-requests/{external_id} → withdraw before the call
What GET returns:
{
"id": "5e0b…", "external_id": "10432",
"status": "done",
"outcome": "confirmed",
"outcome_details": { "reason": "", "thana": "ধানমন্ডি", "zilla": "ঢাকা",
"summary": "Order and address confirmed on the call." },
"attempts": 1, "to_number": "01798XXXXXX",
"created_at": "2026-08-21T09:55:40+00:00",
"completed_at": "2026-08-21T09:56:57+00:00",
"duration_seconds": 61,
"error": null
}
status—queued→calling→done, orcancelledafter a DELETE. (failedexists on a few historical rows only; treat it as a hold.)outcome—nulluntildone; thenconfirmedorhold, the same word the webhook carries.outcome_detailsholdsreason,thana,zillaandsummary— the webhook'saddress.fullis yours to rebuild from your own address plusthanaandzilla.attempts— dialling attempts so far. Whilestatusisqueuedwithattempts≥ 1, nobody answered and another attempt is scheduled;errorcarries the carrier's or the balance's own words when a call could not be placed at all.
Cancel when the customer cancels or staff confirm by hand — if the phone is already ringing, it stops. A request that already settled comes back unchanged.
Webhooks are at-least-once, not exactly-once — run this hourly and no order can ever be lost in the gap:
Schedule::call(function () {
Order::where('status', 'awaiting_confirmation')
->where('created_at', '<', now()->subHours(2))
->each(function ($order) {
$r = Http::withToken(config('services.voice.key'))
->get(config('services.voice.url')."/integrations/call-requests/{$order->id}");
if ($r->ok() && $r->json('outcome') !== null) {
ProcessCallResult::dispatch($order, $r->json());
}
});
})->hourly();
4. Balance
Calls stop when the account's minute balance is empty, so watch it from your side — pull or push, both authenticated and signed like everything else:
GET /integrations/balance
→ {"balance_seconds": 5340, "balance_minutes": "89m 0s",
"available_seconds": 5220, "low_balance_threshold_seconds": 600}
available_seconds subtracts what live calls may still spend — gate on it,
not on balance_seconds. The same two figures arrive on every call.result
webhook as the balance after that call (§2), so a system that handles results
already sees the balance move without polling.
And once, at the moment a call drags the balance below the threshold, your webhook receives (same signing, same dedupe id):
{
"event": "balance.low",
"balance_seconds": 540,
"balance_minutes": "9m 0s",
"threshold_seconds": 600,
"at": "2026-08-22T10:15:00+00:00"
}
One event per crossing — a top-up above the threshold re-arms it.
Try it now
curl -X POST $VOICE_API_URL/integrations/call-requests \
-H "Authorization: Bearer $VOICE_API_KEY" -H "Content-Type: application/json" \
-d '{"external_id":"TEST-1","phone":"01798XXXXXX",
"context":{"customer_name":"Test Customer","order_id":"TEST-1","total":1450}}'
curl $VOICE_API_URL/integrations/call-requests/TEST-1 -H "Authorization: Bearer $VOICE_API_KEY"
curl -X DELETE $VOICE_API_URL/integrations/call-requests/TEST-1 -H "Authorization: Bearer $VOICE_API_KEY"
What we need from you
- The HTTPS URL to POST results to.
- 20 real phone numbers straight out of your
orderstable — normalisation is ours, but only if we've seen your data. - Which orders trigger a call — all, COD only, above a value?
- What
confirmedshould do — update automatically, or report only? - (Optional) How your company name should sound. The agent introduces itself with the company name on your account, which we set up — tell us only if the spoken Bangla should differ from the written name (e.g. "Quantizer" spoken as "কোয়ান্টাইজার").
What you get from us
- An API key per environment, revocable independently.
- The webhook signing secret.
- The base URL.
- A dedicated outbound number — a consistent caller ID your customers can save and call back; tell your support staff which it is. A call back to it is answered with a short recorded Bangla notice that a staff member will follow up, then ends — the number never opens a bot conversation.
- A staging assistant pointed at your own phones, so you run the whole loop before a real customer is dialled.