Handle errors and retries safely

Every AskBiz API error response is JSON with at least an error field. This page covers what each status code means per endpoint, the guarantee that a failed call is never billed, and the full Idempotency-Key contract for retrying /scan and /whatsapp/send without double-charging or double-sending.

Every error response from the AskBiz API is JSON with at least an error string field — there is no single universal schema beyond that; some endpoints attach extra context fields on top. A failed or rejected call is never billed, on any endpoint that charges per call. And on POST /api/v1/scan and POST /api/v1/whatsapp/send specifically, sending an Idempotency-Key header means a retry after a timeout returns the original result instead of re-running (and re-billing) the underlying action.

The universal error shape

Every 4xx or 5xx response from any /api/v1/* endpoint is a JSON object with at least this field:

{
  "error": "A human-readable description of what went wrong"
}

Some status codes add fields on top of error — insufficient credits (402) includes required_cents and a topup link, a monthly-quota 429 includes plan, limit, and used, and a disabled-key 403 tells you to re-enable the key from your dashboard settings. There is no one shared schema for these extras — they're documented per status code below, not invented as a generic envelope.

What each status code means

StatusMeaningApplies to
400Malformed JSON body, or a required field missing or invalid — e.g. ask’s question over 2000 characters, whatsapp/send’s phone not in international format, connections’s invalid email or scopes array, charges’s amount_cents out of range or missing description.ask, scan, whatsapp/send, connections, charges
401Missing or invalid x-api-key header.ask, scan, whatsapp/send, connections, charges
402Wallet balance is too low to cover the call’s price. Body includes required_cents.scan, whatsapp/send
403Two different causes: your key is disabled account-wide (fix: re-enable it from dashboard settings — this applies regardless of endpoint or request body), or this specific call isn’t authorized — whatsapp/send rejects a generic-mode key outright, and scan rejects a merchant_id with no active connection granting read_inventory.ask, scan, whatsapp/send (any endpoint, for a disabled key)
422The vision model couldn’t identify a product in the image.scan
429Per-minute rate limit or monthly quota exceeded. A quota 429 body includes plan, limit, and used. Check the X-RateLimit-Remaining response header — every endpoint returns it, backed by a durable per-key counter.ask, scan, whatsapp/send
502The upstream call AskBiz depends on failed — the Groq vision pipeline for scan, Meta’s WhatsApp API for whatsapp/send. Safe to retry.scan, whatsapp/send
500The AI request itself failed. Safe to retry.ask

One more code worth knowing outside this table: POST /api/v1/connections returns 409 if an active connection to that merchant_email already exists for your key — that’s a duplicate-request conflict, not a retry-safety concern, since a second identical POST won’t create a second connection row.

402 — insufficient credits (scan, whatsapp/send)

required_cents tells you the exact price of the call you attempted — 3 for scan, 2 for whatsapp/send. topup is a link to top up your wallet.

{
  "error": "Insufficient credits",
  "required_cents": 2,
  "topup": "<your wallet top-up link>"
}

429 — quota exceeded (ask, scan, whatsapp/send)

plan, limit, and used tell you exactly which ceiling you hit. Plan limits are per month: free is 100, growth is 10,000, business is unlimited (-1). Per-minute limits are separate: free is 5/min, growth is 60/min, business is 120/min. See GET /api/v1/pricing for the full table, no key required to check it.

{
  "error": "Monthly quota exceeded",
  "plan": "free",
  "limit": 100,
  "used": 100
}

You’re never charged for a failed call

On every endpoint that has a price, billing happens only after the underlying action actually succeeds — never on the attempt itself:

  • POST /api/v1/scan debits 3 cents only on a 200 response — the vision model returned an identification, whether or not it matched your inventory. A 400, 401, 402, 403, 422, 429, or 502 is never billed.
  • POST /api/v1/whatsapp/send debits 2 cents only after Meta confirms the message actually sent (200, success: true). A 400, 401, 402, 403, 429, or 502 — including the case where Meta itself rejects the send — is never billed.
  • POST /api/v1/ask isn’t credit-billed at all, success or failure — it’s free within your plan’s quota.
  • POST /api/v1/connections and POST /api/v1/charges aren’t credit-billed operations either — creating a connection request or a charge request costs you nothing; charges collects money from the merchant, it doesn’t debit your wallet.

Retrying safely with Idempotency-Key

POST /api/v1/scan and POST /api/v1/whatsapp/send accept an Idempotency-Key header — any client-generated string, a UUID works well. Send the same key on a retry of the same logical operation (e.g. after a timeout where you don’t know if the first attempt landed), and the API returns the exact original response instead of re-running the action. That means a retried scan never runs the vision model twice, a retried whatsapp/send never sends a second real message, and neither ever double-charges. Without the header, every request is independent — a retry is a brand-new, separately billable call. This is the same convention Stripe uses for the same header name.

Without Idempotency-Key — risky

async function scanProduct(base64Jpeg) {
  const res = await fetch('https://askbiz.co/api/v1/scan', {
    method: 'POST',
    headers: {
      'x-api-key': process.env.ASKBIZ_API_KEY,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ image: base64Jpeg }),
  })
  return res.json()
}

// If this call times out, you don't know whether the scan actually ran
// server-side. Calling scanProduct() again is a brand-new, independent
// request — if the first attempt actually succeeded after your client
// gave up on it, you now pay 3 cents twice for the same photo.
try {
  return await scanProduct(photo)
} catch (err) {
  return await scanProduct(photo) // no protection against a double charge
}

With Idempotency-Key — safe

async function scanProduct(base64Jpeg, idempotencyKey) {
  const res = await fetch('https://askbiz.co/api/v1/scan', {
    method: 'POST',
    headers: {
      'x-api-key': process.env.ASKBIZ_API_KEY,
      'Content-Type': 'application/json',
      'Idempotency-Key': idempotencyKey,
    },
    body: JSON.stringify({ image: base64Jpeg }),
  })
  return res.json()
}

// Generate the key once, per logical scan — not per attempt.
const idempotencyKey = crypto.randomUUID()

try {
  return await scanProduct(photo, idempotencyKey)
} catch (err) {
  // Same key on retry: if the first attempt already succeeded server-side,
  // you get back the exact original response instead of a second charge.
  return await scanProduct(photo, idempotencyKey)
}

One caveat: the key alone is what the API matches on. Reusing the same key for two genuinely different scans returns the first scan’s stored result for the second one too — generate a fresh key per logical operation, and only reuse it across retries of that same operation.

Endpoints that don’t use Idempotency-Key

POST /api/v1/ask, POST /api/v1/connections, and POST /api/v1/charges don’t check for or store this header. For ask, that’s low-stakes — it isn’t billed, so a redundant retry just costs latency, not money. For connections, a duplicate POST to the same merchant_email already returns a 409 instead of creating a second pending connection, so accidental double-submission is handled without an opt-in key. charges has no built-in de-duplication described here — each successful POST creates a new charge request, so avoid blindly retrying a charges call you’re not sure landed; check GET /api/v1/charges for existing charges to that merchant first.

What’s next

For full parameter and response details per endpoint, see POST /api/v1/scan, POST /api/v1/whatsapp/send, POST /api/v1/ask, POST /api/v1/connections, and POST /api/v1/charges. Webhook deliveries (from the dashboard, not an x-api-key call) have their own retry and signing model — see Subscribe to real-time webhooks.

Errors and retries FAQ

Do I get billed for a 429 rate-limit or quota error?+

No. Like every other error status, a 429 is never billed — /api/v1/scan debits 3 cents and /api/v1/whatsapp/send debits 2 cents only on a successful 200 response, never on a rejected attempt.

Does /api/v1/ask support Idempotency-Key?+

No. Idempotency-Key is only recognized on POST /api/v1/scan and POST /api/v1/whatsapp/send. /api/v1/ask doesn’t bill per call at all — it’s free within your plan’s quota — and doesn’t check for or store this header, so sending it has no effect.

What happens if I retry a POST to /api/v1/connections with the same merchant email?+

You get a 409, not a duplicate connection. /api/v1/connections already rejects a second active connection request to the same merchant_email for your key, so this specific case is protected without needing an Idempotency-Key.

If I reuse the same Idempotency-Key for two genuinely different scans, will the second one run?+

No — the API matches on the key alone and can’t tell your two photos apart from it. It returns the first request’s stored response for any repeat of that key, regardless of what the body says the second time. Generate a new key per logical operation, and only reuse it across retries of that same operation.

Why did I get a 403 telling me to re-enable my key, when my request body looks correct?+

That means the key is disabled at the account level, independent of the endpoint or request body — every x-api-key-authenticated endpoint returns the same 403 in that case. Re-enabling it from your dashboard settings is the fix, not changing the request.