What AI Gets Wrong When It Writes Your Payment Code
A developer builds a checkout in an afternoon now. They describe what they want, an AI writes the integration, the test card goes through, and the feature ships. I’m not going to pretend that’s a bad thing — it genuinely is faster, and a lot of the code is good.
But payment code fails differently from other code, and I don’t think that’s widely appreciated yet.
When AI writes a broken sorting function, your tests go red. When AI writes a broken payment integration, the test card goes through, the demo works, the PR gets approved, and the failure shows up six weeks later as a chargeback, a double-charged customer, or a PCI questionnaire you can no longer honestly answer. The code doesn’t crash. It just quietly does the wrong thing with money.
We see the same handful of mistakes over and over. Here they are, in roughly the order they cost people money.
AI-directed engineering is a real discipline
I want to name the thing before I criticise it, because I think the framing matters.
What’s happening isn’t “AI writes the code now.” It’s that the engineer’s job has moved up a level: from typing the implementation to directing it — specifying the contract, reviewing the output against a source of truth, and knowing which parts to distrust. That’s AI-directed engineering, and the people who are good at it ship faster than anyone did before with fewer defects, not more.
The ones who get burned are the ones who skipped the directing part. They treated a confident answer as a reviewed answer. In most domains that costs you a bug. In payments it costs you money, a compliance category, or a customer’s trust — and you find out late.
So read the rest of this as a spec for the review half of the job. Every failure below is cheap to catch if you know to look, and expensive if you don’t.
1. Money units
This is the most common and the most expensive. Some payment APIs take amounts in minor units — cents, so $19.99 is 1999. Others take a decimal, where the same charge is 19.99. A few take a string to dodge floating-point issues entirely.
An AI writing against one convention while your gateway expects the other produces an error of exactly 100×, in one direction or the other. Charge 19.99 where 1999 was expected and you bill nineteen cents. Charge 1999 where a decimal was expected and you bill $1,999 for a $19.99 order.
Neither throws. Both pass a smoke test if the tester doesn’t check the amount on the receipt. The nineteen-cent version can run for weeks before anyone reconciles a statement and notices revenue is missing.
// order total built in dollars, sent to an API
// that expects minor units
const subtotal = 19.99;
const tax = subtotal * 0.0825;
const total = subtotal + tax; // 21.639175
await gateway.charge({ amount: total });
// bills $0.21 — and nothing throwsFloat arithmetic, then a unit mismatch. Two bugs stacked, one silent result.
// integer cents end to end; round once, explicitly
const subtotalCents = 1999;
const taxCents = Math.round(subtotalCents * 0.0825);
const totalCents = subtotalCents + taxCents; // 2164
await gateway.charge({ amount: totalCents });
// bills $21.64Confirm the unit against the provider’s reference before you trust either version.
This is worth stating plainly because it is the single check I’d run first on any integration: find the line where an amount crosses into the payment API, and confirm the unit against the provider’s own reference — not against what the model asserted. Then confirm nothing upstream is doing floating-point arithmetic on it. 0.1 + 0.2 is not 0.3 in most languages, and that error compounds through tax and discount logic.
2. Idempotency, and the double charge
Ask an AI to make a payment call resilient and you will usually get a retry loop. Wrap it in exponential backoff, retry on timeout, log the failure. It looks like careful engineering.
It is also how you charge a customer twice.
A network timeout does not mean the charge failed. It means you don’t know whether it failed. The request may have reached the processor, been authorised, and had the response lost on the way back. Retrying blindly sends a second authorisation for the same order.
for (let i = 0; i < 3; i++) {
try {
return await gateway.charge({ amount, card });
} catch (err) {
if (i === 2) throw err;
await sleep(2 ** i * 1000);
}
}
// a timeout is not a failure — it's an unknownLooks like careful engineering. Authorises the customer up to three times.
// stable across attempts, unique per transaction
const key = `order-${order.id}-attempt`;
for (let i = 0; i < 3; i++) {
try {
return await gateway.charge(
{ amount, card },
{ idempotencyKey: key }
);
} catch (err) { /* …same backoff… */ }
}Derive the key from your order ID. Never from a timestamp or a random value per attempt.
The correct pattern is an idempotency key: a unique identifier you generate per logical transaction and send with every attempt, so the processor recognises the retry and returns the original result instead of creating a new charge. AI-written retry logic frequently omits it, because the tutorials the pattern was learned from were demonstrating retries, not payments.
If your integration retries anything that moves money, that retry needs a key that stays stable across attempts and changes between genuinely different transactions. Deriving it from your own order ID is usually right. Deriving it from a timestamp is usually wrong.
3. Webhook verification that isn’t
Webhooks are how a processor tells your system that something happened — a payment settled, a chargeback opened, an account status changed. They are also an unauthenticated HTTP endpoint on your server that acts on whatever it receives, unless you verify the signature.
Two failure modes, and the second is worse.
The first: verification is skipped entirely. The handler parses the body and acts on it. Anyone who discovers the URL can post a fabricated “payment succeeded” event and receive goods.
The second, and this one is genuinely insidious: verification is written to fail open. The code looks like this, and I have seen versions of it in a lot of codebases:
if (!WEBHOOK_SECRET) return true; // allow through if not configured yet
Every reviewer reads that and thinks reasonable, that’s for local development. Then the service deploys to an environment where the secret was never added, the check silently returns true for every request, and the endpoint is wide open — while the code still contains a signature verification function that everyone assumes is protecting them. The warning it logs scrolls past in a log nobody reads.
function verify(headers, body) {
if (!SECRET) {
console.warn('no secret — skipping');
return true; // ← ships to prod like this
}
const expected = sha512(SECRET + body.id);
return expected === headers['webhook-signature'];
}Two bugs: fails open when the env var is missing, and === exits at the first differing byte.
function verify(headers, body) {
// bypass is explicit, never implied by a missing secret
if (!SECRET) {
if (process.env.NODE_ENV === 'production') {
throw new Error('webhook secret not configured');
}
return process.env.ALLOW_UNSIGNED === '1';
}
const sig = headers['webhook-signature'] || '';
const a = Buffer.from(sha512(SECRET + body.id));
const b = Buffer.from(sig);
// timingSafeEqual throws on length mismatch
return a.length === b.length && crypto.timingSafeEqual(a, b);
}A missing secret in production is a startup failure, not a silent allow.
Fail closed. If the secret is missing in production, the correct behaviour is to reject the request and make noise, not to wave it through. A dev-mode bypass should be gated on an explicit environment check, never on the mere absence of a secret.
4. Comparing signatures with ==
Say the verification is there and it fails closed. There’s still a subtler bug underneath it.
AI-generated signature checks almost always compare the computed hash to the received one with an ordinary equality operator. That comparison exits at the first differing byte, so it takes measurably longer to reject a signature that matches the first ten characters than one that fails immediately. With enough requests, that timing difference is enough to reconstruct a valid signature byte by byte.
The fix is a constant-time comparison — crypto.timingSafeEqual in Node, hmac.compare_digest in Python. Note that timingSafeEqual throws if the two buffers differ in length, so check length first and return false, rather than letting the exception escape into your error handler.
For reference, our own webhooks are SHA-512 signed over the secret plus the event identifiers, and the comparison is length-checked and constant-time. That is the shape you want, whoever you process with.
5. PCI scope creep
This is the one that changes your legal obligations rather than your bank balance, and it is the one developers are least likely to catch, because the code works perfectly.
If a raw card number ever touches a server you control — even in memory, even for a millisecond, even if you never log it — you are in a materially harder PCI compliance category. The easy path (SAQ-A) is available specifically because the card data never reaches your infrastructure: it goes from the customer’s browser directly to the payment provider, which returns a token that your server handles instead.
Ask an AI to “build a checkout form” without saying more and there is a real chance you get a form that posts the card number to your own backend, which then forwards it to the gateway. It works. The test transaction succeeds. Nothing in the code review flags it, because nothing is broken. What’s broken is that your compliance burden just expanded enormously, and you won’t find out until you fill in a self-assessment questionnaire or, worse, until you have an incident.
// the form posts the PAN to your own backend
app.post('/checkout', async (req, res) => {
const { cardNumber, cvv, exp } = req.body;
await gateway.charge({ cardNumber, cvv, exp, amount });
});
// works perfectly. nothing is broken.
// your compliance category just changed.// card fields are hosted by the provider, in an iframe
// your JS never reads them — it receives a token
const { token } = await hostedFields.tokenize();
await fetch('/checkout', {
method: 'POST',
body: JSON.stringify({ token, amount })
});
// your server only ever sees the tokenThe instruction that prevents this is specific: card data must be tokenised in the browser, by the provider’s hosted fields or equivalent, and your server should never see a PAN. Our own integrations work this way — card data is tokenised browser-side via Hosted Fields, which is what keeps merchants using our Medusa plugin in SAQ-A scope rather than dragging them into SAQ-D.
6. Test and live credentials in the same code path
A very common generated pattern: one config object, one isProduction or sandbox: true flag, and a ternary that picks the key.
The problem isn’t that it can’t work. It’s that a single mutable boolean now stands between test mode and real money, and that boolean is exactly the kind of thing that gets flipped during debugging and committed by accident. The failure is symmetrical and both directions are bad: sandbox keys in production means every customer payment silently fails, and live keys in a test environment means your integration test suite starts charging real cards.
Separate them at the environment level so the wrong key isn’t reachable from the wrong environment, and make the failure loud — if a production process starts up holding a key that doesn’t look like a production key, it should refuse to start rather than run.
7. Declines treated as errors
A declined card is not an exception. It is a completely normal, expected response that carries information: insufficient funds, expired card, suspected fraud, do-not-honour, or a soft decline the customer can retry their way out of.
AI-written integrations habitually wrap the charge in a try/catch and collapse everything into “payment failed, please try again.” That is a conversion problem — a customer whose card expired needs to be told to use a different card, not invited to retry the same one until they give up — and it’s a support problem, because now nobody can tell you why anything failed.
Read the response codes. Handle the categories differently. Log enough to answer “why did this decline” three weeks later without the customer on the phone.
Why AI produces these specifically
None of this is a case against writing payment code with AI. It’s worth understanding the pattern, though, because it predicts what to check.
Models learn from the enormous volume of tutorial code, blog posts and quickstarts on the internet. That corpus is optimised for one thing: showing the shortest path to a working example. Quickstarts deliberately omit idempotency keys, they use == because the point of the snippet was the hash and not the comparison, they skip the environment separation, they show the happy path and stop.
So AI is not making things up here. It is faithfully reproducing the simplified version of a pattern — and the simplifications that make a tutorial readable are precisely the ones that make production payment code dangerous. The model has learned what payment code looks like, and payment code that looks right is the whole problem.
The second factor: a model has no way to know your provider’s actual contract unless you give it one. Field names, amount units, response shapes and signature schemes vary between processors. Asked to integrate, a model will produce something with the right general structure using the conventions most common in its training data — which may be a different provider’s conventions entirely. It will state those field names with complete confidence, because it has no mechanism for doubt.
A review checklist that actually catches these
If you take one thing from this, take this list. It is short and it is ordered by how much money the mistake costs.
- Amount units — trace every value that becomes a charge back to its source. Minor units or decimal? Any floating-point arithmetic on the way?
- Idempotency — does anything retry? Does the retry carry a stable key?
- Webhook verification — present, fails closed, constant-time comparison, length checked.
- PCI scope — does a raw card number reach any server you operate? It should not.
- Credentials — can a test key reach production, or a live key reach test? Does startup fail loudly if so?
- Declines — are response codes read and categorised, or collapsed into one message?
- Logs — is a full PAN, CVV or token anywhere in your logging? Check your error handlers especially, since they tend to dump whole request objects.
The one habit I’d add: verify claims against the provider’s reference, not against the model’s explanation. When AI tells you a field is called amount and takes cents, that is a hypothesis. It is cheap to check and expensive to assume.
That habit is formalised in how we work. When our own team builds against our gateway, integration claims get checked against the canonical API reference before they ship — field names, request shapes, money units, response handling — and anything that doesn’t match, or isn’t documented, gets flagged rather than assumed. It is not glamorous. It catches real bugs, including several of the ones above, before they reach a merchant.
If you’re building this now
Payments are a good candidate for AI-assisted development. The integration surface is well-defined, the patterns are established, and a competent model will get you most of the way in a fraction of the time it used to take.
What it won’t do is know which of its confident assertions happen to be wrong for your processor. That part is still yours — and it’s the part that makes someone an AI-directed engineer rather than someone who pasted an answer. Or it’s ours, if you’d rather have people who do this every day look at it before it goes live.
If you want the other half of this — the same integration done right, step by step against our API — I wrote it up in embedding payments with Claude Code. And if you want a second set of eyes on what your AI wrote, tell us what you’re building. We’ll go through it with you. And if you’d rather not assemble it yourself at all, our Medusa plugin is the same integration done once, properly, and published.
Let’s move forward.
Building payments into your product?
Tell us what you’re building and we’ll look at the integration with you — amount handling, idempotency, webhook verification, PCI scope. The API reference is public — read it first.