Skip to navigation

Credits and usage

What a submission costs you, how you find out you are out, and what /v1/usage is for.

Running out is a 402, not a 400

{
"error": {
"code": "INSUFFICIENT_CREDITS",
"message": "This request needs 1 credit and the account has 0.",
"request_id": "c7a4e2f0-5b6d-4a18-9e3c-2f1d0b8a7c64",
"details": []
}
}

402 INSUFFICIENT_CREDITS on a submission. Nothing was queued, nothing was spent, and retrying the identical call will get the identical answer until the balance changes — so this is not a retryable failure, it is an operational one. Route it to whoever can top up the account, not to your backoff loop.

A submission that is accepted (202) has committed its cost. A submission that is refused has not. The failure modes in between are worth being precise about:

OutcomeCharged?
202 accepted, job later succeededyes
202 accepted, job later failedno — a failed job’s hold is released, not captured
200 idempotent replay of an earlier submissionno — you are charged once
402, 429, 503, or any refusal before acceptanceno
Your client timed out waiting, job finished anywayyes — see Asynchronous jobs

The last row is why an Idempotency-Key on every submission is not optional advice. Without one, a timeout followed by a resubmission is two charges for one image.

When a submission is refused for credits

Credits are tracked per account. Whether a submission without enough units is refused depends on your account:

  • Enforced. A submission that needs more units than the account can spend is refused 402 INSUFFICIENT_CREDITS before anything is queued.
  • Not enforced. Submissions are accepted whatever the balance, and no units are held or charged for them. You will not receive a 402; what you are invoiced is governed by your commercial agreement.

Read enforced from the balance below rather than assuming either. A pk_test_ key never spends credits, in either case — see Test mode.

Reading the balance

GET /v1/credits/balance returns the balance of the account that owns the key, in abstract units, never money. It is enabled per account; where it is not, it answers 404 as an unknown path does.

FieldMeaning
available_unitsSettled units minus units held by accepted, unsettled jobs. May be negative.
held_unitsUnits held by accepted jobs that have not settled yet.
settled_unitsGrants and adjustments minus captured charges.
test_funded_unitsUnits funded by test payments. Not spendable while enforced is true.
spendable_unitsWhat a new submission may spend.
enforcedtrue when a submission without the units is refused 402.
low_balancetrue when the balance is low enough to warrant a top-up.
unit_costsWhat one accepted job of each operation costs, keyed by operation.
const balance = await client.credits.balance();
if (balance.enforced && balance.low_balance) alertBilling(balance.spendable_units);

Polling the balance is a good way to warn before you run out. It does not replace handling 402: another request can spend the last unit between your read and your submission. A balance that cannot be read answers 503, never a zero balance.

What GET /v1/usage gives you

Job counts for your own account, per UTC day and per operation, over a half-open start_date / end_date window: start_date is included and end_date is not. With neither, the window is the last thirty full days plus today. A window longer than 92 days, or one that ends before it starts, is 400 INVALID_USAGE_RANGE.

curl -H "X-API-Key: ${API_KEY}" \
"${BASE_URL}/v1/usage?start_date=2026-09-01&end_date=2026-09-25"
{
"period": { "start_date": "2026-09-01", "end_date": "2026-09-25" },
"as_of": "2026-09-24T10:00:00Z",
"totals": {
"jobs_accepted": 15, "jobs_succeeded": 11, "jobs_failed": 2,
"jobs_cancelled": 1, "jobs_pending": 1, "billable_jobs": 11
},
"products": [
{ "operation": "makeup.apply", "jobs_accepted": 6, "jobs_succeeded": 4,
"jobs_failed": 1, "jobs_cancelled": 0, "jobs_pending": 1, "billable_jobs": 4 },
{ "operation": "skin.analyze", "jobs_accepted": 9, "jobs_succeeded": 7,
"jobs_failed": 1, "jobs_cancelled": 1, "jobs_pending": 0, "billable_jobs": 7 }
],
"days": [
{ "date": "2026-09-21", "operation": "makeup.apply", "jobs_accepted": 4,
"jobs_succeeded": 3, "jobs_failed": 1, "jobs_cancelled": 0,
"jobs_pending": 0, "billable_jobs": 3, "final": true }
]
}

A row exists only for a day and operation with activity, so iterate days; do not index it by day offset. A window with no activity answers zeros in totals and empty products and days, not a 404.

These are operational aggregates, not billing authority. They are the right input for a dashboard, a capacity conversation, or noticing that yesterday’s job count tripled. They are the wrong input for reconciling an invoice — that comes from your commercial agreement, and the two can legitimately differ.

Field meanings:

  • jobs_accepted — submissions accepted, successful or not.
  • jobs_succeeded, jobs_failed, jobs_cancelled, jobs_pending — where those jobs stand as of as_of. A pending job moves into one of the other three later, so a count can change. A cancelled job was cancelled by you before processing started and is never billed.
  • billable_jobs — jobs whose terminal outcome the credit policy captures.
  • final — false for a day within the last 48 hours, whose counts may still move. Treat only a final day as settled.

The endpoint is authenticated and reports the environment of the key you call it with: a pk_test_ key reads test usage, a pk_live_ key live usage. The report names no account, and there is no way to read another account’s usage.

Pricing

Not documented here, and deliberately. Rates, included volumes and overage terms are in your commercial agreement, and a number on a docs page would eventually contradict a contract. Ask your account contact.