The real cause of “double charges” in internal billing
In internal billing automations, “double charge” incidents rarely come from one blatant bug. They usually come from a distributed system behaving exactly as designed: retries happen, jobs get re-queued, workers restart mid-flight, and webhook deliveries arrive more than once. If the automation isn’t built around a durable notion of “this business action has already been applied,” you can end up with exactly-once intent but at-least-once execution.
The fix isn’t a single flag. The practical pattern is to combine (1) distributed idempotency keys, (2) a dedupe store with the right uniqueness guarantees, and (3) side effects that are applied exactly once—or made safe to apply multiple times.
A practical pattern: intent key, dedupe store, and exactly-once side effects
Think of each billable event as an intent: “Charge account A $X for invoice Y.” Your system must be able to answer, durably and quickly: Have we already applied this intent? The pattern below is widely applicable whether you bill via Stripe, an internal ledger, or a downstream ERP connector.
1) Define a stable idempotency key at the business level
Start by deciding what “the same charge” means. Good keys are stable across retries and uniquely represent the business intent. Examples:
- Invoice charge:
invoice:{invoice_id}:finalize - Usage billing:
usage:{account_id}:{period_start}:{period_end}:{metric_version} - One-off internal recharge:
recharge:{cost_center_id}:{ticket_id}
Avoid keys derived from timestamps, random UUIDs generated at processing time, or payload hashes that can change due to field ordering or non-semantic differences. The key should be computed where the intent is created (often the service that emits the event), not where it’s consumed.
2) Persist the key in a dedupe store before performing side effects
The dedupe store is your “gate.” It must support an atomic uniqueness constraint so two workers racing to process the same intent cannot both win. Common choices:
- PostgreSQL with a unique index on
(idempotency_key) - Redis with
SET NX(often paired with careful TTL strategy) - DynamoDB conditional writes
For many internal tools stacks, Postgres is the simplest because it doubles as your system-of-record and can store response metadata for later reconciliation.
3) Model dedupe records with states, not just “seen/not seen”
A common trap is writing a dedupe record and marking it “done” too early. Use a small state machine:
- processing: intent claimed; worker is executing
- succeeded: side effects applied; response stored
- failed: attempt ended; may be retried depending on policy
Store fields like created_at, updated_at, attempt_count, last_error, and a result_reference (e.g., charge ID, ledger entry ID). This turns “idempotency” into an operational tool: you can audit, reconcile, and re-run safely.
4) Apply side effects in an exactly-once friendly order
The ordering matters. A pragmatic sequence looks like:
- Acquire dedupe gate (insert row / conditional write). If it already exists and is
succeeded, return the stored result. If it exists and isprocessing, treat it as in-flight and decide whether to wait, retry later, or take over based on a lease timeout. - Perform the side effect (create a charge, post a journal entry, call ERP API).
- Finalize dedupe record to
succeededand store the external reference.
If you crash after the side effect but before marking success, your system should be able to recover. That’s why storing the external reference is critical: it enables safe replays and allows you to detect “already charged” even when your internal state update didn’t complete.
Handling race conditions, retries, and worker restarts
Use a lease to avoid stuck “processing” records
If a worker dies mid-run, the dedupe record may remain processing. Add a lease concept such as locked_until. A new worker can take over when locked_until is in the past. This prevents a single crash from blocking an intent forever.
Design retry policies that match failure types
Not all failures are equal:
- Transient (timeouts, 503s): retry with backoff; keep the same idempotency key.
- Permanent (validation error, missing mapping): mark
failedand alert; do not hot-loop retries. - Ambiguous (timeout after request sent): rely on idempotency at the downstream provider if supported, and store provider request IDs for reconciliation.
This is where dead-letter queues and “manual replay” buttons become operationally valuable. If your frontends are also event-driven, the same discipline applies; a useful companion pattern is described in reliable event-driven frontends with idempotency keys, retries, and dead-letter queues.
Exactly-once side effects without pretending the world is perfect
In distributed systems, “exactly once” is usually achieved by combining at-least-once delivery with idempotent consumers. For billing, you often need stronger guarantees around the side effect. Use one or more of these techniques:
Prefer downstream idempotency keys when available
Payment processors and some ERPs support idempotency keys on create operations. Use your business idempotency key (or a deterministic derivative) when calling them. Then store the returned charge/payment ID in your dedupe record.
Make ledger writes naturally idempotent
If you operate an internal ledger, design it so posting the same journal entry twice is impossible. For example, enforce a unique constraint on (idempotency_key) in the ledger table, not only in the workflow dedupe store. This provides a second layer of defense: even if orchestration logic goes wrong, the financial system remains correct.
Use an outbox pattern for durable event emission
If your billing automation emits follow-up events (e.g., “invoice_charged”), write those events to an outbox table in the same transaction as your internal state update, then publish asynchronously. This prevents “charged but no event” and “event but not charged” splits. It’s also a good fit when you’re tracking downstream attribution from first-party events; see cookie-free conversion attribution using first-party events for the broader event discipline.
Operationalizing the pattern in internal tooling
Teams often implement the above once, then re-implement it differently in every automation. The better approach is to standardize the components: a shared library for key generation, a single dedupe table schema, and consistent logging fields (idempotency_key, run_id, external_reference).
This is where a code-first internal platform can help. With windmill.dev, teams can centralize workflows that orchestrate billing scripts, database writes, and provider calls, while keeping the idempotency logic in real code (TypeScript/Python/Go/SQL) and making executions observable with logs, retries, and alerts. The key is not the tool—it’s that your organization treats idempotency as a reusable primitive rather than an afterthought per workflow.
Checklist: what to implement before you trust internal billing automations
- Business-level idempotency key defined and documented for each billing intent.
- Dedupe store with an atomic uniqueness guarantee and stored results.
- State machine with lease timeouts for
processingrecords. - Downstream idempotency keys (or equivalent) used wherever supported.
- Ledger-level uniqueness constraints to prevent duplicate financial postings.
- Replay and reconciliation workflows for ambiguous failures.
