Skip to content

How does an order ship for a payment that never arrived?

Sep 15, 2026 12 min read Paymos Tech Paymos Tech
Five orange dotted paths curving in from the left and stopping dead at the face of a tall white panel, a smaller white panel standing untouched behind it

TL;DR

Crypto payment fraud works in the gap between a buyer claiming to have paid and an order going out. Seven attacks live in that gap, and none of them needs a chain exploit: a hash that pays no invoice, a transfer that lands on the address without closing it, a browser trusted for the outcome or the price, a forged callback, a replayed one, a refund aimed at a new address, and the merchant account itself. Each has a control that stops it. Each control also has a place where it stops — the signature check that ignores the timestamp, the step-up challenge a user without a second factor never sees.

Fraud against a crypto payment cannot be a reversal. Once a transfer has finality there is no scheme to appeal to and no issuer to debit the merchant. What is left is the window before the order goes out: the minutes between someone saying they have paid and a warehouse, a licence server or a support agent acting on it.

Seven attacks live in that window. Below, each comes with its control and with the place that control runs out — one described without its edge is the one that gets trusted past it. Most are not blockchain work at all: a forged message, a trusted client, a redirected payout, pointed at a payment instead of at a login form.

The proof of payment that proves nothing

A buyer sends a screenshot and a transaction hash, and the hash checks out.

That is the trap in it. Paste it into a block explorer and a real transfer comes back, with an amount and a token symbol beside it. None of the three ties it to this invoice, and the symbol is the weakest: under EIP-20 name and symbol are optional, and the standard tells interfaces they "MUST NOT expect these values to be present."

That symbol is a string a contract's author chose.

The control is the invoice's own state. A payment is credited when a transfer reaches that invoice's address and confirms to the depth set for its network and its size — two blocks on Ethereum up to $100, thirty-two above $10,000, while six networks settle at network finality rather than a block count.

Every detected transfer is listed on the checkout page while the payer watches, unconfirmed ones included.

Depth is a trade against absolute finality, not a proof of it. A credited transfer that a chain reorganisation orphans later leaves the merchant's credit alone, and the loss books against the platform's accounts.

The invoice answers whether money arrived, never whether this order deserves to ship.

Why does money arrive without the order closing?

Because a transfer can reach the address and still pay no invoice.

Four of the shapes it takes are ones a buyer can produce by hand: a transfer sent after the payment window closed, one aimed at an invoice already paid, expired or cancelled, one in another asset — USDC against an invoice priced in USDT — and one landing on an address that carries no invoice at all. Each is credited to the merchant's balance less the ordinary processing fee.

What none of that touches is the invoice. It stays unpaid, and no webhook fires: a credit of this shape has no event at all, so a system listening to the stream and to nothing else never learns of it. A person hears about it. The notice reaches an inbox and the dashboard bell, and the Balance page carries the credit with the reason it was booked under.

So the buyer's message carries a true sentence. Check your balance, the money is there. It is. An agent who confirms the arrival and releases the order closes something the invoice still shows unpaid.

One wrinkle runs the merchant's way: the same asset from another accepted network pays the invoice one-for-one where that invoice's address takes it. Not every invoice does, and money sent over a chain that address does not cover sits where it landed.

The limit is blunt. The money reached the merchant, and sending any of it back is an outbound transfer somebody has to authorise — there is no payer self-service refund and no return promised at the payment layer.

Who told your shop the order was paid?

The browser, usually — a witness with an interest in the outcome.

Two versions of one defect. In the first, the order is marked paid on the return URL or on a JavaScript event from the checkout overlay — the Low-Code SDK fires five of those, all CustomEvents on window, reporting what the overlay did.

Good for moving a spinner. Not payment state, and a buyer with a console can fire them.

In the second, the amount comes out of the page. The widget resolves one from five sources in priority order and they are not equally safe: a price_id is a price held on the server, so the request carries no amount at all, while amount_selector reads an input element's .value.

On a page the buyer can edit, that is the buyer naming the price. MITRE files the class as CWE-602, client-side enforcement of server-side security: a server that "relies on the client to implement a mechanism that is intended to protect the server."

Moving the price to the server does not make a checkout safe; it moves one decision out of reach. A basket whose total changes per order still has to be priced by your code, and the paid state still has to come from somewhere the buyer cannot write to.

Anyone can POST to your callback URL

A callback URL is an address on the public internet, and it accepts JSON.

A forged invoice.paid needs no access to anything: the right shape, the right invoice id, and a handler that reads the body. CWE-345 is the entry for it — a product that "does not sufficiently verify the origin or authenticity of data, in a way that causes it to accept invalid data."

Every delivery is signed with HMAC-SHA256, and the X-Webhook-Signature header is composite: a timestamp and one or more v1 values. During a secret rotation it carries two for 24 hours and either may match, which is why a receiver parses v1 as a list rather than a field.

The signature page has what gets hashed and the timing-safe comparison for each language; this article owns the trust decision, not the code.

Then there is replay, which is where this one surprises people. X-Webhook-Timestamp is stamped on every delivery, and no window of its own is enforced on the way out — the tolerance belongs to whoever verifies.

The docs ask for a rejection past 300 seconds and the official SDKs default to five minutes, so an integration built on one inherits the check.

A verifier hand-written against the HMAC alone accepts a captured delivery a week later, and the signature on it is genuine.

One event, two deliveries, two shipments

Most duplicates are not an attack, which is what makes the handler easy to get wrong.

An attempt that hits its ten-second timeout is retried; one cycle is eleven attempts from a minute out to eight hours, roughly sixteen hours end to end, and a failed event can be replayed by hand.

Fan-out adds more: two endpoints on the same category get two deliveries of one business fact.

An attacker only has to resend one captured delivery.

Two ids travel in one request; the control is giving each its own job. X-Webhook-Id — the evt_… — is the delivery identity, so deduplicate on it. The inv_… inside data is the business identity, identical across endpoints and across every status transition, so book the order against that.

Swapping them fails both ways: key the order table on the delivery id and two endpoints book one payment twice; dedupe on the invoice id and the paid that follows confirming is thrown away. Why payment webhooks repeat works the handler through.

Dedupe stops the second run, not the second arrival. Deliveries keep coming for as long as the cycle lasts, and what an exhausted cycle fails is the event rather than the endpoint.

A confirming that spent hours on the ladder can also land after the paid that followed it, so a handler applying whatever arrived last walks a paid order backwards. The docs say it outright for deposit events.

Where does the refund end up?

Into whichever address the person handling the request pasted. This one costs the attacker nothing and never touches your systems: a refund request arrives carrying a destination — from a customer mailbox somebody else is reading, or from a colleague who has been talked into it.

The control is the withdrawal whitelist. A payout can only name an address already on it, and an empty whitelist disables withdrawals outright. Entries are keyed by address plus network group rather than by single network: whitelist an EVM address once and that one row answers for every EVM payout network. Comparison runs lower-cased, so a checksum-cased spelling is not a second destination. Removing an entry is a revoke rather than a delete — the row survives, which keeps the audit trail whole.

Adding an address is a privileged action and lands in that trail.

It stops short in two places. The step-up challenge on a whitelist change is not unconditional: it is skipped for a merchant user with no second factor, because step-up strengthens a factor that already exists.

Read as a defence you already have, it is not one. It arrives when somebody enrols TOTP or a passkey.

The other is the size of a single approval. Up to a thousand addresses go on in one request under one step-up, and a CSV import puts that to use with one notification for the whole file rather than one per address.

Convenience and blast radius are the same number, so the list is the thing to read. Nothing in it says who owns the address at the other end either: a merchant may whitelist a customer's address on purpose.

Eligibility, deadlines and who approves belong to a refund policy.

When the attacker is already signed in

Every control above answers to the account that configured it.

From inside the dashboard the callback URL is editable, the webhook secret is rotatable, and the whitelist is a form. None of the six attacks above is needed once the session belongs to somebody else.

There is no password on the account to phish. Sign-in is an email magic link, Google, Telegram OIDC where it is enabled, or a passkey, with TOTP available as a second factor.

For this attack the passkey is the strongest of the four, because it is bound to where it was made: WebAuthn scopes a credential to the relying party it was registered with, and the authenticator "ensures that all operations are scoped to a particular origin, and cannot be replayed against a different origin, by incorporating the origin in its responses."

A convincing lookalike domain collects nothing it can use.

Step-up sits in front of three operations — turning the second factor off, changing the whitelist, editing the IP allowlist on an API key — and each scope is its own ticket, so a code captured for one authorises nothing else.

Payout credentials carry a requirement payment credentials cannot even opt into: a payout key with an empty IP allowlist is refused at authentication rather than left unrestricted.

No password is not no session. A magic link lands in an inbox, and the inbox is what gets taken over first; the second factor closes that path and nobody is enrolled in it by default. What a merchant reads afterwards is its own recorded actions and login history — the complete platform log is operator-only.

Which half of this is yours?

The seven controls do not all sit on the same side of the API.

Three hold whatever anyone does: the invoice state, the whitelist in front of a payout, and the refusal to close an invoice on a transfer that did not pay it. The other four exist only if your code does them — verify the signature and its timestamp, dedupe on the delivery id, price on the server, enrol the second factor that switches step-up on.

One case sits outside the list. A signature says a key authorised the transfer, never that its owner meant to, and nothing on the merchant's side tells a stolen wallet from a good customer. That is a support process and a shipping policy, not a control, and no security page will close it.

Seven attacks before fulfilment, and where each control runs out (September 2026)
The attackWhat stops itWhat it still leaves open
Proof of payment supplied by the buyerThe invoice's own state, credited at the configured depthWhether the order should go out is still a commercial call
A transfer that reaches the address and pays nothingThe invoice does not move; the credit lands with its reasonNo webhook fires, so an event-only integration never hears it
Fulfilment or pricing decided in the browserPaid state read from the server, price held on the serverA basket that varies still has to be priced by your own code
A forged callbackHMAC-SHA256 signature, compared timing-safe, v1 read as a listReplay — the timestamp window is the receiver's to choose
One event delivered more than onceDedupe on X-Webhook-Id, attribute on the id inside dataArrivals keep coming; only the second run is stopped
A refund aimed at a supplied addressPayouts only to whitelisted addresses, empty list disables themOne step-up can approve a thousand addresses at once
An attacker who is already signed inNo password to phish; passkeys, TOTP, per-scope step-upThe second factor is opt-in, and step-up is skipped without one

Frequently asked questions

What is crypto payment fraud?

Attempts to get goods, credit or a payout released for money that never settled against the order. On a blockchain rail the reversal is gone, so the attempt has to land before fulfilment rather than weeks after it.

Is a transaction hash proof that an invoice was paid?

No. A hash proves a transfer exists on a chain. Whether it reached this invoice's address, in an accepted asset, before the window closed, and to the required confirmation depth is what the invoice state answers.

Why did money arrive without the order being marked paid?

Because it reached the address without paying the invoice — after the window closed, against an invoice already paid or cancelled, or in another asset. The balance takes it less the processing fee, the invoice stays where it was, and no webhook fires for it.

Can someone fake a payment webhook?

Anyone can POST to a callback URL, which is why every delivery carries an HMAC-SHA256 signature over the raw body. Verify it with a timing-safe comparison, read the header's v1 values as a list, and check the timestamp — the signature alone does not stop a replay.

Does Paymos require 2FA before a withdrawal address is added?

Only where the merchant user has TOTP or a passkey enrolled. Step-up strengthens a second factor that already exists, so a user with none is not challenged. The whitelist itself always applies, and an empty whitelist disables withdrawals outright.

How do I stop a duplicate webhook shipping an order twice?

Keep the two ids apart. X-Webhook-Id names this delivery, so deduplicate on it; the invoice id inside data names the order, so key the order table on that. Swapped round, two endpoints book one payment twice.

When NOT to use a fraud screen of your own

  • If the money landed in the wrong asset or after the window closed, that is a reconciliation job and not a fraud case. The Balance page already names the reason; a scoring model adds nothing to a fact you can read.
  • If you sell to named accounts with terms, identity is the control you already have. A screen in front of it mostly produces a queue for orders you were going to fill anyway.
  • If your prices are held server-side and fulfilment waits for a verified final invoice state, the seven attacks here are already closed. Scoring a closed door buys you a false-positive rate.
  • If the worry is a stolen wallet paying you, nothing on your side sees it. A valid signature proves the key signed, not that its owner chose to.

Sources

  1. 1. EIP-20 — Token Standard (accessed 2026-09-15)
  2. 2. CWE-602 — Client-Side Enforcement of Server-Side Security (accessed 2026-09-15)
  3. 3. CWE-345 — Insufficient Verification of Data Authenticity (accessed 2026-09-15)
  4. 4. W3C — Web Authentication: An API for accessing Public Key Credentials Level 2 (accessed 2026-09-15)
  5. 5. Paymos — Verify webhook signatures (accessed 2026-09-15)
  6. 6. Paymos — Security (accessed 2026-09-15)

Last reviewed Sep 15, 2026

#fraud#webhooks#security#payment-risk
Share