Duplicate Detection
Prevent double charges. Patterns using idempotency keys, transaction search, and the customer vault to make retries safe in the face of timeouts, network hiccups, and impatient users.
Double-charging a customer is one of the worst bugs a payments integration can have. It happens when a transaction succeeds on moat's side but your server loses the response — a timeout, a crash, a deployment mid-flight. The user, uncertain, clicks "Pay" again. Now they are charged twice.
There are two independent mechanisms moat provides to prevent this. Use both.
Mechanism 1: Idempotency keys
Every transaction call accepts an optional idempotency_key. If you call the same endpoint with the same key within the TTL window (24 hours), moat returns the result of the original call instead of processing the request a second time.
Picking a key
The key should be unique to the intent — one checkout, one invoice payment, one invoice renewal — not unique to each call. Typical sources:
- Your internal order ID:
"order_ORD-00042" - A UUID generated per checkout session:
"chk_" + uuid() - An invoice ID + attempt count:
"inv_INV-2026-042_attempt_1"
Do not use random values generated on each retry — that defeats the purpose. Generate the key once, persist it with the order, and pass the same key on every retry.
Example
async function chargeOrder(order) {
const idempotencyKey = "order_" + order.id;
try {
const resp = await fetch("https://sandbox.fluidpay.com/api/transaction", {
method: "POST",
headers: {
"Authorization": process.env.API_KEY,
"Content-Type": "application/json"
},
body: JSON.stringify({
type: "sale",
amount: order.amount,
payment_method: { customer: { id: order.customer_id } },
order_id: order.id,
idempotency_key: idempotencyKey
})
});
return await resp.json();
} catch (e) {
// Network error. Retry — same idempotency_key, so moat will not
// double-charge if the first attempt succeeded before the connection broke.
return chargeOrder(order);
}
}
What idempotency gets you
- Safe retries on timeouts and network errors.
- Safe page reloads — if the user hits F5 during a long authorization, your server can retry and get the same answer rather than charging twice.
- Safe rolling deploys — if a request is in-flight when your server restarts and gets replayed, moat deduplicates.
What idempotency does NOT get you
Idempotency protects against repeats of a specific call within 24 hours. It does not protect against:
- The user filling out the checkout form, submitting successfully, navigating back, and filling it out again. That is two different intents — two different keys — and moat will charge them both.
- Retries after 24 hours — the key has expired.
For those cases you need mechanism 2.
Mechanism 2: Search before you charge
For especially critical flows, you can check whether a recent transaction already exists for this customer and order before starting a new one:
async function chargeOrder(order) {
// Look for an existing approved transaction for this order in the last 24h
const recent = await fetch("https://sandbox.fluidpay.com/api/transaction/search", {
method: "POST",
headers: {
"Authorization": process.env.API_KEY,
"Content-Type": "application/json"
},
body: JSON.stringify({
order_id: { operator: "equals", value: order.id },
status: { operator: "equals", value: "approved" },
created_at: {
operator: "gte",
value: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
}
})
}).then(r => r.json());
if (recent.data.total > 0) {
// Already charged. Use the existing transaction.
return recent.data.transactions[0];
}
return processCharge(order);
}
Combined with idempotency, this handles the "back button + re-submit" scenario: the second submit finds the first transaction and skips.
Mechanism 3: Vault before you charge
For customer-facing "Pay" buttons, a common pattern is:
- On form submit, disable the button immediately (client-side).
- Tokenize the card.
- Vault the token on a customer record (this is essentially free and has no charge side-effects).
- Only then initiate the transaction — with an idempotency key derived from the order.
This means re-submissions of the form only ever result in one attempted charge, because steps 1 and 2 short-circuit if already completed, and step 4 is protected by idempotency.
Recommended defaults
- Always set
idempotency_keyon server-originated transaction calls. - Derive the key from a stable intent ID (order, invoice, renewal number).
- Disable "Pay" buttons on click. Keep them disabled until a response lands.
- For recurring renewals, derive the key from the subscription ID + billing cycle number.
- Log the
idempotency_keyalongside the transaction so you can correlate retries in your own logs.