API quick-start
This guide sends your first message with a test key, then covers the rules every integration needs: authentication, idempotency, rate limits, errors and the sandbox.
1. Authenticate
Send your key as a bearer token on every call.
curl https://api.bubbl3.com/v1/lines \
-H "Authorization: Bearer $BUBBL3_API_KEY"
| Key | Prefix | What it can do |
|---|---|---|
| Live key | b3_live_ | Sends to real contacts through your lines |
| Test key | b3_test_ | Sends only to test numbers, through the device simulator |
A key has one or more scopes, chosen when it's created:
| Scope | Allows |
|---|---|
read | Reading contacts, conversations, messages, lines, outreach, workflows, agents and analytics |
send | Sending messages and replying in conversations |
admin | Managing developer settings, lines, contacts and workflows |
admin doesn't include read; a key that needs both lists both. No key can reach billing, team,
security or the audit log. A key can also be limited to a list of IP addresses.
2. Send a message
curl https://api.bubbl3.com/v1/messages \
-H "Authorization: Bearer $BUBBL3_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042-confirmation" \
-d '{ "to": "+13475550142", "body": "Your order is on its way." }'
The answer is 201 with the message, status: "queued". bubbl3 then picks a line, checks consent,
quiet hours and line capacity, and sends it. Follow its progress with GET /v1/messages/:id,
GET /v1/messages/:id/events, or the message.status webhook.
Use pool_id or line_id to choose where it's sent from, send_at to schedule it,
fallback: false to forbid SMS fallback, and external_id and metadata to link it to your own
records. See Messages for every field.
3. Retry safely with idempotency keys
Network errors happen. Send an Idempotency-Key header on every write so a retry can't send twice.
- The same key with the same request returns the first response, with
idempotent-replayed: true. - The same key with a different request is
409 idempotency_conflict, as is a retry while the first request is still running. - Keys last 24 hours and belong to your workspace. They're 1 to 255 printable characters without spaces.
- A
5xxanswer frees the key, so retrying runs the request again. A4xxanswer is kept, so a retry gets the same answer. - Keys starting with
outreach:,workflow:oragent:are reserved for messages bubbl3 sends itself and are refused with400.
A good key names the thing you're doing once, such as order-1042-confirmation, not a random
value made fresh on each retry.
4. Stay inside the rate limits
| Calls | Limit per key |
|---|---|
Sending (POST /v1/messages, replies, iMessage lookups) | 10 a second, bursts of 20 |
| Everything else | 50 a second |
Over the limit you get 429 rate_limited with a retry-after header in seconds. Wait that long,
then retry with the same idempotency key.
Rate limits protect the API. Separately, every line has a daily and hourly limit on new
contacts, and sends to people who haven't replied yet follow first-message rules. A message that
would break those isn't refused; it's queued until it can go. GET /v1/lines shows each line's
capacity left today.
5. Handle errors
Every error has the same shape:
{
"error": {
"code": "validation_failed",
"message": "The request is invalid",
"request_id": "req_7Hc2LmQ9xT4bWz1R",
"details": {
"issues": [{ "location": "body", "path": "/to", "code": "invalid_format", "message": "Invalid phone number" }]
}
}
}
Branch on code, never on message. Quote request_id (also in the x-request-id header) when
you contact support.
| Status | Common codes | What to do |
|---|---|---|
| 400 | validation_failed, bad_request | Fix the request; don't retry it unchanged |
| 401 | unauthorized | Check the key |
| 403 | forbidden, org_paused | The key's scopes or IP allow list don't cover this call, or the organisation is paused |
| 404 | not_found | The id doesn't exist in this workspace |
| 409 | conflict, idempotency_conflict | Read the current state, or wait for the first request |
| 413, 415 | payload_too_large, unsupported_media_type | Send a smaller JSON body |
| 422 | contact_opted_out | The contact opted out; don't message them |
| 429 | rate_limited | Wait for retry-after |
| 5xx | internal_error, service_unavailable | Retry with backoff and the same idempotency key |
6. Lists and pagination
Lists take ?limit= (up to 100, default 50) and return { data, next_cursor }. Pass
next_cursor back as ?cursor= until it's null.
7. Test with the sandbox
With a test key:
- You can only send to test numbers,
+1 NPA 555 01xx(for example+13475550142). Anything else is400on/to. - Messages go to your workspace's sandbox line and the device simulator. They follow the whole pipeline (status changes, receipts and fallback), so your webhooks fire as they would in production.
- Sandbox messages are marked
is_test: trueand never count towards line limits or health. - In the dashboard, Developers > Sandbox shows sandbox traffic and can send simulated replies.
When it works, swap in a live key. Nothing else changes.