BFSI Calling Client API
The bulk counterpart of the single-call API you already integrate: same token, same lead fields, same call.ended webhook — with queueing, the calling window, consent checks and retries handled by the platform.
Overview
Every dial passes a compliance gate (calling window, suppression list, consent evidence) before it is placed. Retries follow a disposition-based policy — you never re-push a lead that was busy or did not answer.
Authentication
Send your API key as a bearer token on every request. Keys are issued per account by Bheemverse and delivered out of band; keep them server-side.
Authorization: Bearer ss_bfsi_…
Content-Type: application/json
Check the key and the account state with GET /me:
{
"workspace": "Muthoot Microfin",
"default_pipeline_id": "ef56fbbb-…",
"webhook_configured": false,
"ready_to_dial": true,
"readiness_failures": [],
"limits": {"max_concurrent_calls": 10, "daily_call_limit": 2000}
}
Lead record
| Field | Required | Notes |
|---|---|---|
phone_number | yes | E.164 or 10-digit Indian mobile, normalised to +91… (phone also accepted). Invalid numbers are counted, not dialed. |
id | recommended | Your CRM's lead id — the same lead.id as in /client/initiate; echoed as lead.id / lead_id in every event (external_id also accepted). |
name | — | Used by the agent to confirm the person. |
language | — | Preferred language hint (ml, ta, hi, en…). The agent still follows the caller's spoken language. |
product, branch, district | — | Stored on the lead; returned in exports. |
consent_source | for promotional calls | Where consent was obtained (e.g. crm_optin, branch_form). Recording it marks the lead as consented; without it a promotional campaign skips the lead at dial time (gate_blocked). |
consented_at | — | Date or datetime; DD/MM/YYYY accepted. Defaults to now. |
Duplicates within one push (same last-10 digits) are dropped and counted. A phone already known to the account reuses the existing lead.
Endpoints
| Method · path | Purpose | Body / response |
|---|---|---|
GET /me | Account, readiness, limits | see above |
PUT /webhook | Set the hand-back URL (https only) | {"url": "https://…", "secret": "optional new secret"} |
POST / | Create a campaign from leads | {"name", "auto_start"?, "webhooks": {"on_call_end"}?, "metadata"?, "pipeline_id"?, "leads": [...]} → campaign_id, state, report, brand_readiness, without_consent. Max 20,000 leads per call. |
GET / | List campaigns with counts | campaigns[] with counts {total, pending, calling, completed, failed, skipped} |
GET /{id} | Live status | state, statuses, dispositions, retry_backlog {parked, next_eligible} |
POST /{id}/start · /pause · /resume | Control | pause accepts {"reason"}; start/resume are refused (422) if the account's agent identity is incomplete |
GET /{id}/outcomes | Pull all results | one record per lead — the same shape as the webhook payload |
Example — push and start
curl -X POST https://socialselling.ai/v1/ai-call/client/campaigns \
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{
"name": "IL leads — Ernakulam — 1 Sep",
"auto_start": true,
"webhooks": {"on_call_end": "https://your-crm.com/webhooks/ai-call-complete"},
"metadata": {"campaign_id": "IL-2026-09-A"},
"leads": [
{"phone_number": "+919876543210", "id": "CRM-88121", "name": "Sreeja P",
"language": "ml", "product": "Individual Loan", "branch": "Kakkanad",
"district": "Ernakulam", "consent_source": "branch_form", "consented_at": "28/08/2026"}
]
}'
{
"campaign_id": "63b3f83e-…", "name": "IL leads — Ernakulam — 1 Sep", "state": "running",
"report": {"total_rows": 1, "queued": 1, "invalid_phone": 0, "duplicates_in_file": 0,
"leads_created": 1, "existing_leads_reused": 0, "consent_recorded": 1, "suppressed": 0},
"brand_readiness": {"ok": true, "failures": []},
"without_consent": 0
}
Bulk send & queue control
Send big files in chunks and steer the queue from your CRM. Three rules always hold, whatever you set: a live call is never cut, the calling window can be narrowed but never widened, and concurrency never exceeds your plan.
| Method · path | Purpose |
|---|---|
POST / + Idempotency-Key header | Create once; a replay with the same key returns the existing campaign. |
POST /{id}/leads | Append a chunk (≤ 20,000). Phones already in the campaign are skipped (already_in_campaign) — a chunk can be safely resent after a network error. |
GET /{id}/leads?status=&disposition=&cursor= | Every queue row: position, priority, status, disposition, attempts, next attempt. Paginate with next_cursor. |
PATCH /{id} | Tune live: priority (higher campaigns dial first), concurrency_cap, max_dials_per_minute, daily_cap_per_phone, retry_policy per disposition, window {start, end, days} (narrow only), webhooks.on_call_end, metadata. |
POST /{id}/leads/{ref}/cancel · reschedule · prioritize · retry | One lead; ref = your lead id, our lead_id, or the phone. |
POST /{id}/leads/bulk | {"action", "refs": [...], "at"?, "priority"?, "reset_attempts"?} — up to 5,000 refs. |
POST /{id}/stop | Stop and drain: no new dials, waiting leads cancelled, live calls finish. |
GET /queue | Account-wide live view: calls in flight vs cap, DID pool, window open / next open, per-campaign eligible · parked · calling · done. |
POST · DELETE /dnc | Your do-not-call list — never dialed again in any campaign; queued rows cancelled at once. |
PATCH /v1/ai-call/client/campaigns/63b3f83e-…
{"priority": 20, "max_dials_per_minute": 30, "window": {"start": "10:00", "end": "17:00"},
"retry_policy": {"no_answer": {"gap_min": 180, "max_attempts": 2}}}
POST /v1/ai-call/client/campaigns/63b3f83e-…/leads/bulk
{"action": "reschedule", "refs": ["CRM-88121", "CRM-88122"], "at": "2026-09-02T11:00:00+05:30"}
Minutes & usage
Your plan is a prepaid pack of AI minutes. Billing is per connected minute in 6-second pulses; ring time and unanswered calls are free. When the pack is exhausted or its validity ends, campaigns pause themselves (a live call is never cut) and resume after a top-up.
| Method · path | Purpose |
|---|---|
GET /me | Now includes plan: sku, status, minutes_purchased, minutes_consumed, minutes_remaining, percent_used, validity_end, days_to_expiry, concurrency_cap. ready_to_dial is false when the plan is not active. |
GET /usage?days=30 | Full balance with burn rate and projected exhaustion, plus per-day calls / minutes. |
| Start / resume | Refused with 422 {"error": "plan_blocked", "reason": "plan_exhausted" | "plan_expired"} when the pack cannot fund more calls. |
{
"plan": {"sku": "pilot_100k", "status": "active", "minutes_purchased": 100000,
"minutes_consumed": 8.9, "minutes_remaining": 99991.1, "percent_used": 0.0,
"validity_end": "2026-11-06T14:49:28+00:00", "days_to_expiry": 60.0,
"burn_per_day_7d": 1.3, "projected_exhaustion": null},
"daily": [{"day": "2026-09-07", "calls": 3, "minutes": 8.9}]
}
Hand-back webhook
One POST per finished call — a completed conversation, or a lead whose retries are exhausted. Retried attempts (busy, no answer) do not produce a webhook; the final result does. Delivery goes to the campaign's webhooks.on_call_end, else the account default set with PUT /webhook; retried three times (immediately, +30 s, +2 min) on any non-2xx response. The event name and envelope are identical to single calls — an existing call.ended handler keeps working; campaign fields are additive.
POST {webhooks.on_call_end}
Content-Type: application/json
X-Timestamp: 1756700000
X-Signature: sha256=<hex HMAC-SHA256(secret, "{X-Timestamp}.{raw body}")>
{
"event": "call.ended", ← the envelope you already parse
"call_id": "…", "occurred_at": "2026-09-01T05:12:40+00:00",
"lead": {"id": "CRM-88121", "name": "Sreeja P", "phone_number": "+919876543210"},
"lead_id": "CRM-88121", "engine": "premium", "duration_s": 214,
"classification": {"intent": "hot", "score": 82},
"transcript_summary": "…", "transcript": "…", "recording_url": "https://…",
"metadata": {"campaign_id": "IL-2026-09-A"},
"campaign": {"id": "63b3f83e-…", "name": "IL leads — Ernakulam — 1 Sep"}, ← campaign extension
"status": "completed", "disposition": "connected", "attempts": 1,
"captures": {
"person_confirmed": "yes", "interested": "yes", "income_source": "tailoring business",
"monthly_income": 18000, "marital_status": "married", "spouse_monthly_income": 12000,
"other_earning_members": 1, "household_incomes": [9000]
},
"household_income_total": 39000,
"sent_at": "2026-09-01T05:12:52+00:00"
}
Verifying the signature
# Python
import hmac, hashlib
expected = hmac.new(SECRET.encode(), f"{ts}.{raw_body}".encode(), hashlib.sha256).hexdigest()
ok = hmac.compare_digest(signature_header, f"sha256={expected}")
// Node
const expected = crypto.createHmac('sha256', SECRET).update(`${ts}.${rawBody}`).digest('hex');
const ok = crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(`sha256=${expected}`));
Use the raw request body, not a re-serialised object. Respond 2xx quickly and process asynchronously; deliveries are idempotent per lead_id + campaign_id — store the latest.
Dispositions & captures
| Disposition | Meaning | Retried? |
|---|---|---|
connected | conversation took place; see captures | — |
no_answer · busy · switched_off | carrier outcome | yes — 2 h / 30 min / next day, up to 3 attempts |
callback_requested | the person named a time | at that time |
not_interested | after the one permitted persuasion loop; objection in captures | no |
wrong_number · not_available | identity step outcome | wrong number: no · not available: later |
do_not_call | asked never to be called — suppressed immediately | never |
gate_blocked · dnd_rejected | skipped before dialing (no consent / suppression / carrier DND) | never |
originate_error | carrier could not place the call | 1 / 5 / 15 min |
Captures (Individual Loan flow v3): person_confirmed, interested, income_source, monthly_income, marital_status, spouse_monthly_income (married only), other_earning_members, household_incomes[] (when > 0), plus household_income_total computed. A field the person could not answer clearly arrives as null with a <field>__unclear note — never a guessed value.
Calling rules
- Window: 09:30–18:00 IST, Monday–Saturday. Leads pushed outside the window queue and dial when it opens.
- Consent: promotional campaigns dial only leads with consent evidence; others are reported as
gate_blocked. Suppressed numbers (do-not-call, STOP) are never dialed. - Frequency: at most 2 attempts per number per day and 5 per lead per campaign.
- Concurrency: per the account plan; the queue drains inside it automatically.
- Circuit breaker: if the carrier fails more than 20% of originates in 5 minutes the campaign pauses itself (
paused_reasonexplains); resume when clear.
Errors
| Status | When | Body |
|---|---|---|
| 401 | missing/invalid/inactive key | {"detail": "Invalid or inactive API key"} |
| 403 | module not enabled for the account | {"detail": {"error": "bfsi_not_enabled"}} |
| 404 | campaign not in this account | — |
| 422 | validation; start refused | {"detail": {"error": "brand_validation_failed", "failures": [...]}} or a message |
| 413 | > 20,000 leads in one push | split the file |
Go-live checklist
GET /mereturnsready_to_dial: true.PUT /webhookwith your https endpoint; verify a signature with the shared secret.- Push a 5-lead test campaign with your own team's numbers,
auto_start: false; check the report. - Start it inside the window; watch
GET /{id}drain; confirm the webhooks arrive and match byexternal_id. - Then the first real file.