Contents
API
Read and write your workspace from your own systems with an API key, and be told when something happens.
aiBalas has a REST API at https://aibalas.com/api/v1, for your own systems: a script that pulls your leads into a spreadsheet, a dashboard that watches your agents' usage, a job that copies every conversation into your own warehouse each night. You call it with an API key instead of signing in, and every response is JSON. It reads and writes your workspace, and webhooks tell your systems the moment something happens.
Getting a key
- 1Open Developers in your workspace (owners only, on a paid plan) and choose "Create key".
- 2Pick the scopes the key needs and, if you want it limited to one agent, which one. A key with no agent chosen can read across every agent in the workspace.
- 3Copy the key. It is shown once, in full, and never again: lose it and you revoke it and create another.
A key belongs to the workspace, not to the person who made it: removing that person from your team does not revoke their keys.
Authentication
curl https://aibalas.com/api/v1/me \
-H "Authorization: Bearer aib_..."Reading data
- Agents: your agents and one by id (each says whether it is switched on, botEnabled), plus how much each has spent this month, and a workspace-wide usage endpoint that totals every agent together (for a key not limited to one agent).
- An agent's remaining figure includes the workspace's shared top-up balance it can draw on, and on a legacy plan its near-limit flag reflects the whole workspace's pooled usage.
- Contacts: everyone across the workspace, or one agent's contacts and a single contact by id.
- Conversations: an agent's conversations, and the messages inside one.
- Bookings: an agent's booking services, its bookings, and a single booking by id.
- Catalogue: an agent's knowledge, products and promotions.
- Templates and broadcasts: an agent's templates, its broadcasts, and a broadcast's recipients.
- Every endpoint, and its exact shape, is written out in the OpenAPI document at /api/v1/openapi.json.
curl "https://aibalas.com/api/v1/agents/{agentId}/contacts?limit=20" \
-H "Authorization: Bearer aib_..."Paging
A list takes a limit (up to a hundred per page) and an opaque cursor: ask for the next page with the cursor the previous response gave you as next_cursor (the same value is also sent as nextCursor), and stop once it comes back null. A broadcast's recipients page differently, with a plain page number instead. A contact id can itself carry a colon (tg:123, for a Telegram contact), so URL-encode it before it goes in a path: tg%3A123.
Writing data
Creates and updates go through the same rules as the console: contact fields are validated the same way, bookings take the same double-booking guard, products and the knowledge base have the same size limits. Every write is recorded in the workspace activity log against the key that made it.
- POST /v1/agents/{agentId}/contacts — create a contact; PATCH …/contacts/{contactId} — profile, lead fields, category, urgency, mode.
- POST /v1/agents/{agentId}/conversations/{contactId}/messages — send a human message to someone who has already written in. The agent pauses for that contact unless you send "pause": false.
- POST …/conversations/{contactId}/mode — hand a conversation to a human ("human") or back to the agent ("bot").
- POST /v1/agents/{agentId}/bookings and POST …/bookings/{id}/cancel.
- PUT /v1/agents/{agentId}/knowledge — replace the knowledge base; POST/PATCH/DELETE products and promotions.
- PATCH /v1/agents/{agentId} with { "botEnabled": false } turns the agent off.
curl -X POST https://aibalas.com/api/v1/agents/AGENT_ID/contacts \
-H "Authorization: Bearer aib_..." -H "Content-Type: application/json" \
-H "Idempotency-Key: crm-lead-8841" \
-d '{"id":"60123456789","displayName":"Ali","email":"[email protected]","tags":["vip"]}'Idempotency
Send an Idempotency-Key header (up to 128 characters, unique per request) on any create. Repeating the same key with the same body within 24 hours returns the first response again, with its Location and ETag headers and Idempotent-Replayed: true, instead of creating a second record. Reusing a key with a DIFFERENT body is refused with 409 conflict, reason idempotency_key_reused: use a new key for a new request. If an earlier request with the key was interrupted before it answered, a retry gets 409 conflict with reason outcome_unknown — check whether it took effect, then use a new key. The key is optional everywhere except sending a message, where it is required — a retry without it could message the customer twice.
curl -X POST https://aibalas.com/api/v1/agents/AGENT_ID/conversations/60123456789/messages \
-H "Authorization: Bearer aib_..." -H "Content-Type: application/json" \
-H "Idempotency-Key: reply-2026-09-23-001" \
-d '{"text":"Hi Ali, your order has shipped.","pause":true}'Creating and updating
A create answers 201 with a Location header naming the new record. GET on one contact, product or promotion returns an ETag header. Send it back as If-Match on a PATCH, and the write is refused with 412 precondition_failed if the record changed since you read it — the response carries the current ETag, so fetch again and retry. A PATCH without If-Match applies as before.
Errors
Every error comes back as JSON: a code, a message meant for a person to read, and sometimes extra detail alongside them. The codes are unauthorized, forbidden, plan_required, scope_required, account_suspended, not_found, validation, rate_limited, payload_too_large, upstream, unavailable, conflict, over_budget, precondition_failed, method_not_allowed and internal. conflict carries a reason — exists, slot_taken, store_owned, already_cancelled, in_progress, idempotency_key_reused or outcome_unknown; rate_limited carries retry_after (also sent as retryAfter) in seconds, as does the Retry-After header; over_budget carries block, used and max; upstream means we could not confirm what happened on our end (most often a message send) — check before you retry, and see Idempotency above. Every response carries an X-Request-Id header: quote it to us if you need help. Send your own X-Request-Id (8 to 128 letters, digits and . _ : -) and we use it, so a request can be traced from your logs to ours. An unexpected error on our side is 500 internal, and its body repeats the id as requestId; a brief outage of something we depend on is 503 unavailable with a Retry-After header, so retry it. An unknown path is a JSON 404 too, and a method an endpoint does not take is 405 method_not_allowed with an Allow header. Every write refuses a field it does not know, naming it under fields, rather than ignoring it.
{
"error": "rate_limited",
"message": "Too many requests. Try again shortly.",
"retry_after": 12,
"retryAfter": 12
}Limits
A key may make 120 requests a minute, with bursts up to 240 after a quiet spell: so X-RateLimit-Remaining can briefly read higher than X-RateLimit-Limit, which is expected, not a fault. Sending messages, once that ships, is capped separately at 30 a minute. Repeated failed authentication from one address is throttled too, so a script retrying a bad key in a loop slows itself down rather than us having to.
Changes
The API is additive-only: a field is added, never renamed or removed, and an endpoint that exists today keeps existing. If we ever need to break that promise, it ships as a new version, /v2, alongside this one, so nothing already built against /v1 stops working out from under you.
Webhooks
A webhook is a URL of yours that aiBalas calls whenever something happens in your workspace: a message arrives, a lead turns hot, a booking is made. Create one on the Developers page (owners only): give it a public https:// URL, choose the events it should receive and, if you like, limit it to one agent. The signing secret is shown once, when you create the webhook; copy it then. The same endpoints can be managed through the API with a key that has the webhooks:manage scope, at /v1/webhooks. A key limited to one agent only sees, and only creates, webhooks for that agent. Turning a disabled webhook back on is done on the Developers page only.
Events
- message.received — a customer wrote to one of your agents.
- message.sent — your agent, a person on your team, or the API sent a message to a customer.
- message.failed — a message could not be delivered. This is the only way to learn that a send the API accepted with a 202 did not go out.
- conversation.handoff — the agent handed a conversation to a human.
- conversation.mode_changed — a conversation moved between the agent and a human.
- lead.updated — a lead's quality, pipeline status, category or urgency changed.
- contact.created — a new contact appeared.
- contact.updated — a contact's profile changed. The event names the fields that changed, never their values.
- booking.created, booking.cancelled, booking.rescheduled — a booking was made, cancelled or moved.
The delivery
POST <your url>
Content-Type: application/json
User-Agent: aiBalas-Webhooks/1
X-aiBalas-Event: lead.updated
X-aiBalas-Delivery: <delivery id>
X-aiBalas-Signature: t=1758600000,v1=<hex>
{ "id": "1042", "type": "lead.updated", "at": "2026-09-23T04:00:00.000Z",
"account": { "id": "...", "slug": "acme" }, "agent": { "id": "...", "slug": "sales" },
"data": { "agentId": "...", "contactId": "60123456789", "changes": { "leadQuality": "hot" }, "source": "agent" } }The event id is a string of digits that only ever grows: compare ids as numbers (or as strings of equal length), never as short text. A message event carries the message text; a handoff event does not carry the handoff summary, and no event carries your private contact notes.
Verifying the signature
Every delivery is signed with your webhook's secret. The X-aiBalas-Signature header holds t, the time of sending in Unix seconds, and v1, the hex HMAC-SHA256 of t, a full stop, and the raw request body, keyed with your secret. Compute it yourself over the body exactly as it arrived (before any JSON parsing), compare in constant time, and reject a t more than 5 minutes old so an old delivery cannot be replayed.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(rawBody, header, secret) {
const [t, v1] = header.split(',').map((p) => p.split('=')[1]);
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return expected.length === v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}
// Answer 2xx quickly; do the work after. Anything else is retried.Retries and ordering
Delivery is at least once. Anything other than a 2xx answer within 10 seconds is retried, a 3xx included (redirects are never followed): the first retry comes after 60 seconds and the wait doubles each time, up to 10 attempts over about 8.5 hours, after which that delivery is marked failed. First attempts arrive in event-id order for each webhook, but a retry can arrive after later events, so key your processing on the event id and treat a repeat as a repeat. Events are sent a couple of seconds after they happen, never instantly. A webhook that accepts nothing for 3 days is disabled and the workspace owners are emailed; turn it back on from the Developers page and delivery resumes where it stopped.
The Test button on the Developers page (or POST /v1/webhooks/{id}/test) sends a ping event to check your receiver, up to 10 a minute. Rotating the secret invalidates the old one immediately, and the new one is shown once: update your receiver first.
Polling instead
If you would rather not run a receiver, GET /v1/events returns the same events, oldest first, to any key with a read scope: pass since=<the last event id you saw> and, optionally, types= and agentId=. Events are kept for 30 days; without since, you get the last 24 hours. Page with next_cursor as usual.
curl "https://aibalas.com/api/v1/events?since=1042&types=message.received,lead.updated" \
-H "Authorization: Bearer aib_..."Still stuck?
Work through the common problems first. If none of them matches, contact us: we can see things you cannot.
Signed in? Open Help in your console to send us a ticket. Sign in