GET /v1/credits/balance returns one number: the credits you can spend right now. GET /v1/credits/ledger returns every entry behind that number, so a disputed charge is a lookup rather than an argument.
The ledger is append-only and the balance is the sum of its entries. A verification reserves credits up front, which appears as a negative entry. A refund puts credits back as a positive entry, and so does a grant or a purchase. Nothing is edited after the fact.
What spends a credit is a definitive result on a distinct address: valid, invalid or catch_all. What comes back is every unknown, the whole request when it fails, and anything you sent twice that was collapsed as a duplicate.
curl https://app.zapbounce.com/api/v1/credits/balance \
-H "X-API-Key: $ZB_API_KEY"
curl "https://app.zapbounce.com/api/v1/credits/ledger?page=1&pageSize=20" \
-H "X-API-Key: $ZB_API_KEY"{
"items": [
{
"id": "9140",
"amount": -11480,
"entryType": "reserve",
"source": "purchase",
"reference": "job:0b6f2d3e-6a52-4c58-9d0e-3f6a1c9a7e10:reserve",
"description": null,
"jobId": "0b6f2d3e-6a52-4c58-9d0e-3f6a1c9a7e10",
"paymentId": null,
"createdAt": "2026-10-26T10:04:00.000Z"
}
],
"meta": { "page": 1, "pageSize": 20, "total": 1, "totalPages": 1 },
"balance": 48210
}Fields
| Field | Type | What it carries |
|---|---|---|
| balance | integer | On /credits/balance it is the whole response: { "balance": 48210 }. The ledger response carries it too. |
| amount | integer | Signed. Reservations are negative, and grants and refunds are positive. |
| entryType | string | grant, reserve, refund, expiry or adjustment. |
| source | string | Where the credits came from: subscription, purchase, promo or admin. |
| jobId | string or null | The job a reservation or refund belongs to, when there is one. |
| q (query) | string | Search the ledger, up to 200 characters. |
Matching a job to the ledger, line by line
Say your finance lead asks why a 32,500-row file cost 28,900 credits. The job has the answer. Of those rows, 2,500 were repeats, which leaves totalEmails at 30,000. Another 1,100 came back unknown and were refunded. Take both away and you get 28,900.
Now open the ledger. You should find a reserve entry of minus 30,000 that names the job, and a refund entry of plus 1,100. Your balance before the run was 75,000, so it should now read 46,100. If all the numbers agree, the question is closed in about two minutes.
A mismatch is worth a support ticket, and the ledger entries plus the job id are all the evidence you'll need to send.
Guarding against a 422 in the middle of the night
A balance isn't a promise, so two jobs that start together can both pass your check and one can still be rejected. You have two ways around that. Submit jobs one at a time from a single queue. Or keep a buffer in the balance that covers the largest set of jobs you'd ever run in one day.
Add a preflight to your job. Read the balance, compare it with the row count of the file, and refuse to submit if the balance is lower. The row count is a safe ceiling, because repeats and unknowns only ever bring the real cost down.
A request that fails with CREDITS_INSUFFICIENT has spent nothing, so the fix is adding credits and resubmitting. Send that error somewhere a human will read it. A quiet channel nobody opens is the same as having no alert.
Questions developers ask
Does a catch-all result cost a credit?
Yes. The server was asked twice, once for your address and once for a random one, and the answer tells you something true about the domain.
Do I get credits back for an unknown?
Yes. Unknown results are refunded, and the refund shows in the ledger as a positive entry.
How do I avoid a job failing on balance?
Read /credits/balance before you submit, and compare it with the number of addresses you plan to send.