Errors, retries, and limits
Error envelope
Every non-2xx response uses the same shape:
{
"error": {
"type": "invalid_request",
"code": "lead_not_in_campaign",
"message": "The following lead IDs do not belong to this campaign: d6a8..., e7b9..."
}
}
typeis the category — coarse-grained, useful for branching error-handling logic.codeis the specific reason — programmatic, stable across releases (we won't rename a code in a non-breaking way).messageis human-readable — show it in logs, don't parse it.
HTTP status codes
| Status | type | Meaning |
|---|---|---|
| 400 | invalid_request | Malformed body, missing field, unknown lead, validation failure |
| 401 | authentication_error | Missing, malformed, invalid, or revoked API key |
| 403 | permission_error | Reserved — not currently emitted; tenancy is enforced via 404 |
| 404 | not_found | Resource doesn't exist, or it does but isn't yours (we don't distinguish) |
| 409 | conflict | Resource state conflicts with the request (e.g. no sending account is currently available for this batch) |
| 429 | rate_limited | Too many requests — back off and retry (rate limiting is deferred to v1.1; you won't see this in current v1) |
| 5xx | api_error | Our problem — usually transient |
422 — schema validation (different envelope)
FastAPI emits HTTP 422 with a different body shape when a request fails schema validation — e.g. you POST /v1/campaigns/{id}/batches without the required message_body field, or pass the wrong type for lead_ids. The body looks like:
{
"detail": [
{
"type": "missing",
"loc": ["body", "message_body"],
"msg": "Field required",
"input": { ... }
}
]
}
This is not the { "error": { "type": "...", "code": "...", "message": "..." } } envelope you'll see for every other 4xx. If your client parses error.code and crashes on missing key, branch on status === 422 first. The 422 will never be retried-into-success — it's a client-side schema bug, fix the payload.
Specific error codes you'll encounter
POST /v1/campaigns/{id}/batches
code | When |
|---|---|
no_sequence | Campaign has no sequence set up in the dashboard. Configure the message template, then retry. |
no_sequence_steps | Campaign has a sequence but no steps yet. Ask your BrandRep operator to add a step (message template) in the dashboard. |
no_lead_selector | Body has none of lead_ids / influencer_ids / profile_urls. |
multiple_lead_selectors | Body has more than one of the three. Pass exactly one. |
lead_not_in_campaign | One or more lead_ids don't belong to this campaign. The message lists up to 5 offending IDs. |
influencer_not_in_campaign | Same, for influencer_ids. |
profile_url_not_in_campaign | Same, for profile_urls (after server-side URL normalization). |
invalid_profile_url | A profile_url couldn't be parsed as Instagram or LinkedIn. |
invalid_sequence_step | sequence_step_id exists but doesn't belong to this campaign's sequence. |
no_connected_accounts | This brand has no connected sending accounts yet. Ask your BrandRep operator to connect one in the dashboard. |
no_available_account | All of this brand's connected sending accounts are currently busy or cooling down. Wait for batch.completed and retry. (HTTP 409.) |
Anywhere
code | When |
|---|---|
missing_api_key | No Authorization: Bearer ... header. |
invalid_api_key | Key doesn't match any active record (wrong prefix, wrong value, revoked). |
invalid_cursor | starting_after couldn't be decoded — usually means you passed something other than the literal value of next_cursor from a previous page. |
brand_not_found, campaign_not_found, batch_not_found, webhook_endpoint_not_found | The resource doesn't exist or isn't yours. We return 404 in both cases — we don't leak existence. |
Retry guidance
Idempotency: not supported in v1
POST /v1/campaigns/{id}/batches is not idempotent. Retrying the same request body may create a duplicate batch.
| Failure mode | Should you retry? |
|---|---|
| Network error (no response received) | Yes |
| Timeout | Yes, but check state first |
| 5xx response | No — the server may have committed. Check GET /v1/batches/{id} first. |
409 no_available_account | Yes, after waiting for batch.completed on the in-flight batch |
| 4xx (other) | No — your request is wrong, fix it |
No batch.started event within 30s of POST /batches | No — wait. The operator worker polls for new batches on a ~30s cadence; the delay is cadence, not failure. |
Stripe-style Idempotency-Key is on the v1.1 roadmap.
Recommended retry policy on transient failures
Exponential backoff with jitter, max 3 attempts:
import time, random
def with_retries(call, max_attempts=3):
for attempt in range(max_attempts):
try:
resp = call()
if resp.status_code < 500 and resp.status_code != 408:
return resp
except (ConnectionError, Timeout):
pass
if attempt < max_attempts - 1:
time.sleep((2 ** attempt) + random.random())
raise Exception("retries exhausted")
Rate limits
Not enforced in v1. We may add a per-key token bucket before onboarding a second customer; if so, the response will be 429 with a Retry-After header. Build your retry logic to handle it now — it's cheap insurance.
Pagination
Cursor-based on (created_at, id). Responses include:
{
"data": [ ... ],
"has_more": true,
"next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0w..."
}
Pass the next_cursor verbatim as starting_after on the next call. The cursor is opaque — don't parse it. We may change the format without notice.
has_more is false on the final page; next_cursor will be null.
limit defaults to 50, max 200. Larger pages are not currently supported.
Versioning
The API is versioned via URL prefix (/v1). Within /v1 we make only additive changes:
- New optional request fields
- New response fields
- New error codes
- New event types
We will not:
- Remove or rename fields
- Tighten validation in a way that breaks previously-accepted requests
- Change response field types
If we need to make a breaking change, it ships under /v2 and /v1 continues operating in parallel for a deprecation window.
Build your receiver to ignore unknown fields — that's the contract that keeps additive evolution non-breaking.