Retrying a failed network call is reflexive. The request timed out, you try again, nobody thinks twice. Retrying a charge feels different. If the first attempt actually went through and you just didn't hear back, retrying might bill the customer a second time, and now you have an angry subscriber and a refund to process.
So a lot of billing code never retries charges at all, and eats the failures instead. In the carrier-billing work I did for years, we retried them on purpose. It's safe, but for a reason that took me a while to actually internalize: the safety isn't in my code being careful. It's in where the idempotency key lives.
The key isn't a lock you hold
When you charge a subscriber, you pass a reference: your own unique id for that charge. The carrier dedupes on it. Send the same reference twice and the second one is not a second charge, it's the carrier recognizing "I've seen this" and returning the original result. The idempotency guarantee lives on their side, keyed by a value you control.
Once you believe that, retrying transient failures becomes ordinary again. You wrap the HTTP layer in something that retries with backoff, and you stop worrying:
namespace Gardi\DcbKit\Transport;
use Gardi\DcbKit\Contracts\Transport;
use Throwable;
final class RetryingTransport implements Transport
{
public function request(string $method, string $url, array $payload, array $headers = []): array
{
$attempt = 0;
while (true) {
try {
return $this->inner->request($method, $url, $payload, $headers);
} catch (Throwable $e) {
$attempt++;
if ($attempt >= $this->maxAttempts || ! ($this->retryOn)($e)) {
throw $e;
}
($this->sleeper)($this->backoffMicroseconds($attempt));
}
}
}
}
A retried charge here is harmless because the reference rides along in the payload, and the carrier sorts out the duplicate.
Two failure modes people merge into one
This is where it gets subtle, because there are two different "I'm charging the same thing twice" situations and they need different answers.
The first is in-flight: you sent the charge, it succeeded on the carrier's side, and the response got lost on the way back to you. You retry. That's the case above, and it's covered by the carrier's reference dedup. Your retry logic doesn't need to be smart about it.
The second is your own app repeating completed work: a queued job gets re-run, or a webhook fires twice, and your code tries to charge a reference it already finished days ago. You don't want to even make that call. That one you guard on your side, by remembering charges you've completed:
public function charge(string $msisdn, Money $amount, string $reference): ChargeResult
{
$seen = $this->store->get($reference);
if ($seen !== null) {
return $seen;
}
$result = $this->inner->charge($msisdn, $amount, $reference);
if ($result->successful) {
$this->store->put($reference, $result);
}
return $result;
}
Back the store with whatever you trust, Redis or a unique-indexed table:
interface IdempotencyStore
{
public function get(string $reference): ?ChargeResult;
public function put(string $reference, ChargeResult $result): void;
}
Never remember a failure
Look again at that if ($result->successful). It's the most important line, and it's easy to get wrong by trying to be thorough.
The instinct is to cache the outcome of every charge, success or failure, so you never repeat work. In billing that's a bug. Imagine you cache a failed charge for a subscriber who was out of balance. An hour later they top up. Now they can be charged, but your store remembers "this reference failed" and refuses to try again. You've turned a temporary, recoverable state into a permanent one, and the subscriber never gets billed for a service they're using.
So only successes are remembered. A failure is not a terminal state in payments, it's a "not yet." The situation can change, and the next attempt should be allowed to find out.
It's a division of labor, not a mechanism
The thing I'd tell a younger version of myself: idempotency in payments isn't one feature you bolt on. It's a division of labor between two parties.
The carrier owns "the same reference is the same charge." That's what makes retrying a possibly-completed call safe. Your store owns "don't re-run work I already finished and recorded." That's what stops a re-queued job from doing real damage. They look like the same concern and they are not, and most of the double-charge bugs I've seen came from someone using one to do the other's job.
Keep them straight, only ever remember the charges that actually went through, and retrying stops being the scary part of the system. This is the part of those years I eventually pulled out into a small package, dcb-kit, so I'd stop rewriting it.