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..."
  }
}
  • type is the category — coarse-grained, useful for branching error-handling logic.
  • code is the specific reason — programmatic, stable across releases (we won't rename a code in a non-breaking way).
  • message is human-readable — show it in logs, don't parse it.

HTTP status codes

StatustypeMeaning
400invalid_requestMalformed body, missing field, unknown lead, validation failure
401authentication_errorMissing, malformed, invalid, or revoked API key
403permission_errorReserved — not currently emitted; tenancy is enforced via 404
404not_foundResource doesn't exist, or it does but isn't yours (we don't distinguish)
409conflictResource state conflicts with the request (e.g. no sending account is currently available for this batch)
429rate_limitedToo many requests — back off and retry (rate limiting is deferred to v1.1; you won't see this in current v1)
5xxapi_errorOur 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

codeWhen
no_sequenceCampaign has no sequence set up in the dashboard. Configure the message template, then retry.
no_sequence_stepsCampaign has a sequence but no steps yet. Ask your BrandRep operator to add a step (message template) in the dashboard.
no_lead_selectorBody has none of lead_ids / influencer_ids / profile_urls.
multiple_lead_selectorsBody has more than one of the three. Pass exactly one.
lead_not_in_campaignOne or more lead_ids don't belong to this campaign. The message lists up to 5 offending IDs.
influencer_not_in_campaignSame, for influencer_ids.
profile_url_not_in_campaignSame, for profile_urls (after server-side URL normalization).
invalid_profile_urlA profile_url couldn't be parsed as Instagram or LinkedIn.
invalid_sequence_stepsequence_step_id exists but doesn't belong to this campaign's sequence.
no_connected_accountsThis brand has no connected sending accounts yet. Ask your BrandRep operator to connect one in the dashboard.
no_available_accountAll of this brand's connected sending accounts are currently busy or cooling down. Wait for batch.completed and retry. (HTTP 409.)

Anywhere

codeWhen
missing_api_keyNo Authorization: Bearer ... header.
invalid_api_keyKey doesn't match any active record (wrong prefix, wrong value, revoked).
invalid_cursorstarting_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_foundThe 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 modeShould you retry?
Network error (no response received)Yes
TimeoutYes, but check state first
5xx responseNo — the server may have committed. Check GET /v1/batches/{id} first.
409 no_available_accountYes, 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 /batchesNo — 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.