Docs/Workflows/Duplicate Detection

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:

  1. On form submit, disable the button immediately (client-side).
  2. Tokenize the card.
  3. Vault the token on a customer record (this is essentially free and has no charge side-effects).
  4. 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_key on 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_key alongside the transaction so you can correlate retries in your own logs.

See also