Your API credit system bills before it knows the request worked
Credits are deducted at the gate, before your handler runs. That one structural fact drives every refund ticket, double-charge and reconciliation gap in a metered API. Here is the policy, the code and the honest trade-offs.

Sorower
Co-founder

In this article
- The three-step shape of an API credit system
- "Why not just deduct after the response?"
- What the gate can know, and what it cannot
- Which failures should cost a credit
- The retry multiplier
- Reconciliation: charged versus served
- "What's an acceptable amount of drift?"
- Reversing a charge, and why "refund" is the wrong word
- Publish the contract, then stop arguing
- Key takeaways
- Try the reconciliation on your own traffic
Picture the support ticket. A customer writes in: "Your API returned 500s for about twenty minutes last night. Why did our credit balance still go down?"
You go looking, and the answer turns out to be worse than a bug. It is the design. Your API credit system deducted those credits at the front door, before your service ever attempted the work, because the front door is the only place a limit can actually be enforced. The failure happened later, in a part of the stack your metering never heard from.
Almost every metered API has this shape, and almost none of them document what it means. There is a pile of good writing on how to price a credit. There is very little on what a credit actually buys when things go sideways, which is the part your customers file tickets about.
The three-step shape of an API credit system
Strip any metered API down and you get the same three beats:
Authorize. Check the key, check the balance, deduct.
Serve. Run the actual handler.
Record. Log what happened.
The money moves in step 1. The truth arrives in step 3. Everything difficult about metering lives in the gap between them.

Here is the first thing worth saying plainly: a credit system is a prepayment system. You take payment at the door and find out afterwards whether you delivered. Every refund conversation, every reconciliation job, every angry ticket traces back to that one structural fact. It is not a flaw you can code around. It is the shape of the problem.
"Why not just deduct after the response?"
This is the first question every engineer asks, and it deserves a real answer rather than a shrug.
Deduct-after is unenforceable under concurrency. If the balance only drops when a request finishes, then a client sitting at zero credits can open fifty connections simultaneously, and all fifty pass the balance check before any of them completes. You have just given away fifty free requests. Make it five hundred connections and you have given away five hundred. The limit becomes a suggestion that scales with your customer's willingness to open sockets.
Deduct-at-the-gate is not a convenience. It is the only version that holds under load. The cost of that correctness is that you charge before you know the outcome, and you own the cleanup.
What the gate can know, and what it cannot
The gate is not blind. It knows a surprising amount before your handler runs, and that determines which failures are free by construction rather than by policy.
ReqKey runs its checks in a fixed order, and the credit deduction sits at the end of the list: key exists, key belongs to the project, key is active, consumer is active, key has not expired, key has access to the requested API, and only then, sufficient credits. Anything that fails earlier never reaches the deduction.
That ordering is doing real work for you. A revoked key, a key pointed at an API it cannot touch, an expired key: all rejected before a single credit moves. You do not need a refund policy for those, because nothing was ever charged.
curl -X POST "https://api.reqkey.com/key/validate" \
-H "Authorization: Bearer reqkey_xxx..." \
-H "Content-Type: application/json" \
-d '{
"key": "prod_A1B2C3D4E5F6G7H8I9J0K1L2",
"apiId": "api_payment",
"credits": 5,
"resource": "/api/v1/exports"
}'A successful validation hands back both the new balance and, importantly, an identifier:
{
"valid": true,
"requestId": "abc123xyz",
"apiId": "api_payment",
"apiName": "PaymentAPI",
"creditsRemaining": 9995,
"creditsLimit": 10000,
"allowedApis": ["api_payment", "api_analytics"],
"resource": "/api/v1/exports"
}Hold on to that requestId. It is the only thread connecting what you charged to what you delivered, and section six is entirely about pulling on it.
A few mechanical details that change how you design the caller, all of them easy to get wrong:
creditsis a number, not a flag. It defaults to 1, so a cheap read can cost 1 and a bulk export can cost 25 from the same pool.credits: 0performs a real validation with no deduction. This is the free preflight check, and it is more useful than it sounds.Non-integer values are rounded down. Sending
2.5charges 2. If you were planning to price fractional credits by passing decimals, that plan quietly charges less than you think.An unknown key returns HTTP 200 with
{"valid": false}, not a 4xx. Branch on thevalidfield, not the status code, or you will treat every bad key as a success.
That last one catches people constantly. The full status table lives in the error reference, and it is worth ten minutes before you write your integration.
Which failures should cost a credit
Now the actual policy question. Once a credit has left the pool, which outcomes justify keeping it?
The principle that survives contact with customers: charge for work you performed, not for requests you received. Simple to state, genuinely annoying to apply, because "performed work" is a spectrum and the boundary cases are where the tickets come from.

A defensible default policy for a metered API | |||
Outcome | Whose fault | Charge? | Why |
|---|---|---|---|
Invalid, revoked or expired key | Client | No | Rejected before deduction. Nothing to refund. |
Rate limited (429) | Client | No | You rejected it at the gate and did no work. |
Out of credits (402) | Client | No | There is nothing left to charge, by definition. |
Malformed request (400) | Client | Usually no | Parsing is cheap. Charging for typos reads as petty. |
Not found (404) | Ambiguous | Judgement call | A lookup that found nothing still cost you a lookup. Pick one, then document it. |
Validation failed after real work (422) | Client | Yes | You rendered, processed or called upstream before failing. |
Your unhandled error (500) | You | No, reverse it | Charging for your own bug is the fastest way to lose the account. |
Upstream vendor failed | You | No, reverse it | Your customer did not choose your vendor. Absorb it. |
Client disconnected mid-response | Client | Yes | The work happened. The bytes just went nowhere. |
Partial or truncated result | You | Pro-rate or reverse | Charging full price for half an answer is the one customers escalate. |
Two rows deserve a note. 404 is genuinely ambiguous, and reasonable providers land on both sides. An enrichment API that searched three databases to conclude "no match" did the work, and charging is defensible. A REST API returning 404 for a typo'd path did not. The mistake is not picking the wrong side. It is picking neither, and letting the answer vary by endpoint depending on who wrote it.
Client disconnects surprise people, but the logic holds: you burned the CPU, you called the vendor, you paid the bill. That the client hung up is not your problem. Say so in your docs, once, and the argument never happens.
On the 429 row, ReqKey makes this structural rather than optional. Consumer rate limits are enforced independently of credits, and a rate-limited validation consumes neither a credit nor rate-limit quota. A throttled client recovers by slowing down, with no billing residue. That separation matters more than it first appears: credits meter how much and rate limits meter how fast, and a consumer with unlimited credits can still be rate limited. We pulled that thread properly in where the limit actually belongs.
The retry multiplier
Here is where a tidy policy quietly falls apart.
Your API returns a 500. Your client library, following the retry guidance you published, backs off and retries. Twice. The third attempt succeeds. Your customer sees one successful call. Your metering saw three validations and charged three credits.
Which leads to the second uncomfortable truth: your retry guidance is a pricing decision. Publishing "retry 5xx with exponential backoff" without saying what happens to credits means you have told your customers to triple their own bill during your outage. They will notice. They will do the arithmetic. It will be in the ticket.
The fix is not to ban retries, which are correct and necessary. It is to make the retried request recognisably the same request. Stripe's write-up on designing robust APIs with idempotency is still the clearest treatment: the client generates a key, the server recognises the repeat and returns the stored outcome instead of re-executing. Their framing of the underlying problem is hard to improve on, and it is just three words: "Networks are unreliable."
Applied to metering, a wrapper that charges once per logical operation rather than once per TCP attempt:
// One logical operation = one charge, however many attempts it takes.
const seen = new Map(); // use Redis in production; see the TTL note below
async function meteredCall({ apiKey, idempotencyKey, cost, run }) {
const cached = seen.get(idempotencyKey);
if (cached) return cached; // retry: replay, do not re-charge
const res = await fetch("https://api.reqkey.com/key/validate", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.REQKEY_ROOT_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ key: apiKey, credits: cost }),
});
if (res.status === 402) throw new OutOfCredits(await res.json());
if (res.status === 429) throw new RateLimited(await res.json());
if (!res.ok) throw new Error(`validate failed: ${res.status}`);
const auth = await res.json();
if (!auth.valid) throw new InvalidKey(auth.message); // 200 + valid:false
try {
const out = await run(auth.requestId);
seen.set(idempotencyKey, out);
return out;
} catch (err) {
// We charged at the gate and then failed. Mark it for reversal.
await queueReversal({ requestId: auth.requestId, apiKey, cost });
throw err;
}
}Expected behaviour: three retries of one logical operation produce one validation call and one credit, and a failure inside run() leaves a reversal on the queue rather than a silent overcharge.
Two things people get wrong here. Give the idempotency cache a TTL long enough to outlive your published retry window, otherwise a slow retry sails past an expired entry and charges again. And key it on the client's operation, not on the request body hash, or two legitimately identical requests collapse into one charge and you undercount.
Reconciliation: charged versus served
A policy you cannot verify is a wish. The question worth answering every day is blunt: how many credits did we charge for requests that never produced a response?

This is what the requestId is for. It comes back from validation, and it is the required field when you log the outcome, which makes it the join key between the two halves of the story.
curl -X POST "https://api.reqkey.com/ingest" \
-H "Authorization: Bearer reqkey_xxx..." \
-H "Content-Type: application/json" \
-d '{
"requestId": "abc123xyz",
"method": "POST",
"endpoint": "/api/v1/exports",
"statusCode": 500,
"latencyMs": 812
}'{
"success": true,
"requestId": "abc123xyz",
"timestamp": "2026-01-30T12:34:56Z"
}Log the outcome for every validated request, including the ones that failed. The instinct is to log successes and let errors fall through to your exception tracker. That instinct destroys exactly the records you need, because a validation with no matching outcome is indistinguishable from a validation whose outcome you forgot to send. The ingestion reference has the full field list, and statusCode plus latencyMs carry most of the weight.
Once both halves land, the reconciliation is an outer join: charged validations with no recorded outcome, and recorded 5xx responses that were charged. The first set is your instrumentation gap. The second is your refund queue.
"What's an acceptable amount of drift?"
Honest answer: nobody can hand you that number, and anyone who does is selling something. It depends on your failure rate, your retry policy and how reliably your logging survives the incidents that matter. What you can do is measure it and watch the trend, because the shape tells you more than the level. Drift that holds steady is instrumentation you can fix. Drift that spikes with your error rate is customers being charged for your bad night, and that one has a deadline.
Worth planning for the case where the recording path itself is down. If your outcome log is unavailable during an incident, you lose the evidence for exactly the window you will later be asked to refund. We wrote about that class of decision in fail open or fail closed.
Reversing a charge, and why "refund" is the wrong word
Time for the part where I criticise our own API.
ReqKey has no "void this requestId" primitive. Credits are consumer-scoped, and there are two ways to put one back, which behave differently in a way that matters for your reporting.
POST /key/recharge is additive to the limit. It is addressed by key but resolves to the consumer, and it raises the ceiling:
{
"success": true,
"consumerId": "cons_A1B2C3D4",
"previousCredits": 10000,
"addedCredits": 40,
"newTotalCredits": 10040,
"availableCredits": 8564,
"message": "Credits updated instantly"
}Notice what happened. The customer's limit is now 10,040. Reverse forty charges a month this way and by December their "10,000 credit plan" reads 10,480 in your own data. The balance the customer sees is right, and every limit-based report you build on top is subtly wrong.
The truer reversal is to write down usage instead, via POST /consumer/update:
curl -X POST "https://api.reqkey.com/consumer/update" \
-H "Authorization: Bearer reqkey_xxx..." \
-H "Content-Type: application/json" \
-d '{
"consumerId": "cons_A1B2C3D4",
"credits": {"limit": 10000, "used": 1476}
}'The limit stays 10,000 and consumption reflects work actually delivered. One caveat, and it is a real one: used is an absolute value, not a delta. You read the current usage, subtract, and write it back, which is a read-modify-write race against live traffic. Batch your reversals, run them from a single worker, and do not fire one per failed request from inside your error handler.
So: use recharge for genuine top-ups, which is what it is built for, and consumer/update for corrections. Between them you can implement any of the policies in the table above, but you are implementing it, not configuring it. If you want a provider that reverses a specific charge for you atomically, that is a fair thing to want, and today we do not do it.
Publish the contract, then stop arguing
Every disagreement in this post evaporates if the answer is written down before the incident. Almost nobody writes it down.
Your credit documentation should answer, in plain language:
What one credit buys, per endpoint, in a table.
Whether failed requests are charged, split by whose fault it was.
What happens on a retry, and whether you honour an idempotency key.
Whether credits expire, and whether unused ones roll over.
How a customer disputes a charge, and how quickly you respond.
Five bullets. They take an afternoon and they pre-empt the entire category of ticket that starts with "we were surprised by".
If you want to hear this argued from the billing side rather than the API side, this talk is a good hour on how metering goes wrong in practice:
And if you are still deciding what a credit should cost before worrying about when to refund it, Zuplo's breakdown of credit pricing math covers that side well. This post deliberately picks up where that one stops.
Key takeaways
Deduct at the gate, and accept that you own the cleanup. Deduct-after cannot be enforced, because concurrent requests all pass the balance check before any of them completes. The prepayment is the price of a limit that actually holds.
Charge for work performed, not requests received. Write the policy as a table covering 400, 404, 422, 429, 500, upstream failures and client disconnects. The ambiguous rows are fine. Silent, per-endpoint inconsistency is not.
Your published retry advice is a pricing decision. Telling clients to retry 5xx without idempotency tells them to multiply their bill during your outage. Key the charge to the logical operation, with a TTL longer than your retry window.
Log the outcome of every validated request, especially failures. Without the request id on both sides you cannot tell a lost response from a lost log, and you will be guessing during the one conversation where guessing is expensive.
Reversals are not refunds. Adding credits raises the limit and corrupts your plan reporting. Correcting usage keeps the limit honest. Know which one your API does before you build monthly reports on it.
Try the reconciliation on your own traffic
The useful exercise here is not reading a policy table. It is running the outer join against real traffic and seeing what falls out, because the gap between charged and served is always more interesting than anyone expects.
ReqKey gives you the pieces that makes that possible: a credits number so different endpoints cost different amounts, credits: 0 for a check that deducts nothing, and a request id that ties every charge to its recorded outcome. The free tier includes 100,000 requests a month, which is enough to point it at production traffic and find out how honest your metering actually is. The plans and credits page covers how limits, refills and overage fit together once you know what you are charging for.



