Skip to main content
ariefrse.my
HomeSkillsProjectsAI WorkWritingContact
Resume
All writing

Article · Practice

Money-safe webhooks: idempotency, exact decimals, and a balance invariant

The bugs that hurt in invoicing are never crashes. They are the ones where the system is confidently wrong.

September 10, 2026 · 7 min read

Invoicing software has one job that no amount of interface polish can rescue: the numbers have to be right, every time.

The bugs that hurt are never the crashes. They are the ones where the system is confidently wrong. A total off by one sen. An invoice issued twice. A payment that was received but never recorded.

Building SnapInvois, a WhatsApp-native invoicing platform, I stopped treating those as edge cases to test and started treating them as invariants to enforce. Four of them carry most of the weight.

The shape of the problem

Every one of these failures is the same story. Something the network does on purpose, deliver twice, retry, reorder, replay, meets code that assumed it would happen exactly once.

1. Money is an integer, never a float

JavaScript numbers are binary floating point. 0.1 + 0.2 is not 0.3. That is fine for graphics and deadly for invoices, because the error stays invisible until it lands on a customer's document.

So money is stored and computed as an integer number of sen, and parsing is the only place a decimal string becomes a number.

// src/lib/money.ts
// Money is stored and computed as integer sen, never as a float.
export type Sen = number;

export function parseAmount(input: string): Sen {
  // "12.30" -> 1230, with no binary floating point anywhere in between.
  const match = /^(-?)(\d+)(?:\.(\d{1,2}))?$/.exec(input.trim());
  if (!match) throw new Error(`Invalid amount: ${input}`);
  const [, sign, whole, cents = '0'] = match;
  const sen = Number(whole) * 100 + Number(cents.padEnd(2, '0'));
  return sign === '-' ? -sen : sen;
}

The database column is a Decimal, the API speaks integer sen, and formatting into RM 12.30 happens only at the edge, for display.

2. The balance invariant, enforced at the boundary

Representing money safely is not the same as using it correctly.

The stronger rule is that a document has to agree with itself: subtotal minus discount plus tax must equal what the buyer is asked to pay. That is not a test I run sometimes. It is a function called before anything is persisted or sent.

export function assertInvoiceBalances(invoice: {
  subtotal: Sen;
  discount: Sen;
  taxTotal: Sen;
  payable: Sen;
}): void {
  const expected = invoice.subtotal - invoice.discount + invoice.taxTotal;
  if (expected !== invoice.payable) {
    // Refuse to persist or submit a document whose own arithmetic disagrees.
    throw new Error(
      `Invoice does not balance: expected ${expected} sen, payable ${invoice.payable} sen`,
    );
  }
}

A thrown error on a bad document is a feature. The alternative is a plausible-looking invoice that is quietly wrong, discovered weeks later by the person who has to explain it to a customer.

3. Webhooks are delivered more than once

Messaging and payment providers retry. Meta's WhatsApp Cloud API will re-deliver a webhook if your endpoint is slow or returns a non-200, and it makes no promise that delivery happens once. If your handler creates a record per delivery, a retry becomes a duplicate invoice.

The fix is to make the message id the unit of work. The first thing the handler does is claim the inbound message id. A unique constraint makes the second delivery fail on purpose, and that failure is treated as success.

// app/api/v1/whatsapp/webhook/route.ts
// Claim the inbound message id first. A replay hits the unique
// constraint, and a duplicate delivery becomes a no-op.
try {
  await db.waMessage.create({ data: { waMessageId } });
} catch (error) {
  if (isUniqueViolation(error)) {
    return new Response('ok', { status: 200 }); // already processed
  }
  throw error;
}

// Only now do the work: parse -> draft -> PDF -> deliver.

The same idea applies one layer down, in the queue. Background jobs are retried too, so anything a job does has to be safe to run twice. That is far easier to guarantee when every step is keyed by a unique id rather than by "the thing that arrived last."

4. Verify the signature before you trust the body

An endpoint that accepts unsigned input is an open door. Anyone who finds the URL can invent messages.

// src/lib/whatsapp/signature.ts
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifySignature(rawBody: string, header: string, secret: string): boolean {
  const expected = createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex');
  const provided = header.replace(/^sha256=/, '');
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(provided, 'hex');
  // Compare in constant time, and never let a length mismatch throw.
  return a.length === b.length && timingSafeEqual(a, b);
}

Two details matter. Verify against the raw request body, not a re-serialised object, because JSON key order will betray you. And compare with timingSafeEqual, because ordinary string comparison leaks how much of a guess was correct.

What ties it together

Individually these are small. Together they change the failure mode: instead of silently producing wrong numbers, the system either produces the right ones or refuses to act.

The assumptionThe invariant
ArithmeticFloats are close enoughInteger sen, balanced before persist
DeliveryThe webhook arrives onceMessage id claimed once, replays are no-ops
TrustThe request came from the providerSignature verified in constant time, on raw bytes
RetriesJobs run exactly onceEvery step is safe to run again

What I learned

Never store money as a float. Assert the document balances before you persist it. Claim a unique id before doing work in any webhook. Verify the signature over the raw body, rejecting before parsing.

And assume every queued job runs twice. If a retry could double an effect, the step is not idempotent yet.

paymentswebhooksidempotencysnapinvois

Share

XLinkedInWhatsAppThreads

Related

  • Everything fails silentlyGreen is not evidence. It is the absence of evidence. A running gallery of production failures that passed every check that existed.
  • The sweep that found managers could pay other teams' claimsThe scariest findings were not crashes or wrong numbers. They were correct-looking actions performed by the wrong person.

© 2026 Muhammad Arief Roseli

GitHubLinkedInSnapInvois