API documentation

Voice order confirmation — integration guide

অর্ডার পাঠান, কল হবে, ফলাফল আপনার সিস্টেমে ফিরে আসবে।

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's 10432 collides 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.
CodeMeaning
202Accepted (first time or retry — identical either way)
401Bad, missing or revoked API key
400 / 404assistant_id missing-when-needed / not yours
422Bad payload — response names the field (undialable numbers fail here, not silently later)
429Rate 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.

StatusWhat your system should do
confirmedCustomer confirmed; address collected. Ship it.
holdAnything 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:

ReasonWhat happened
cancelledCustomer does not want it
modifiedWants a change — see summary
wrong_numberNot the customer
address_incompleteConfirmed, but hung up before the address finished
unclearCall reached no clear decision — includes “call me back later”
no_answerNobody picked up, across every attempt allowed
call_failedThe call could not be placed or proceed
abusiveCaller was abusive — consider not calling this number again
ran_longPassed 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. null when no attempt connected (call_failed, and no_answer after 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 as GET /integrations/balance (§4). Gate on available_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:

HeaderWhat it is
X-SignatureHMAC-SHA256 of "{timestamp}.{raw body}" with your secret
X-Webhook-TimestampUnix seconds — signed with the body so a captured POST expires
X-Webhook-IdStable 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
}
  • statusqueuedcallingdone, or cancelled after a DELETE. (failed exists on a few historical rows only; treat it as a hold.)
  • outcomenull until done; then confirmed or hold, the same word the webhook carries. outcome_details holds reason, thana, zilla and summary — the webhook's address.full is yours to rebuild from your own address plus thana and zilla.
  • attempts — dialling attempts so far. While status is queued with attempts ≥ 1, nobody answered and another attempt is scheduled; error carries 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

  1. The HTTPS URL to POST results to.
  2. 20 real phone numbers straight out of your orders table — normalisation is ours, but only if we've seen your data.
  3. Which orders trigger a call — all, COD only, above a value?
  4. What confirmed should do — update automatically, or report only?
  5. (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

  1. An API key per environment, revocable independently.
  2. The webhook signing secret.
  3. The base URL.
  4. 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.
  5. A staging assistant pointed at your own phones, so you run the whole loop before a real customer is dialled.