İçeriğe geç
wedevit

September 1, 2026 · 9 min read · software

İlhan Buğra Aslan

The card was charged but no order exists: the hard parts of payment integration


The hard part of a payment integration is not charging the card. Pasting the provider's sample code and watching a test card succeed takes half a day. The hard part is keeping the state of the money and the state of your order record in agreement afterwards. Nearly every "the payment went through but there is no order" and "there is an order but the money never arrived" case comes from the same three places: creating the order on the page the browser lands on, having no protection against processing the same request twice, and never reconciling. All three are cheap to fix during the first week of an integration. Fixing them later comes bundled with a data cleanup project.

The browser coming back is not the payment result

The common flow looks like this. The user is sent to a 3D Secure page, completes the challenge at their bank, gets redirected to /checkout/success, and the order is created on that page. It works flawlessly in staging, because nobody in staging closes the tab. In production the user backgrounds the browser while waiting for the one-time code, switches to their banking app, and comes back to a tab the OS has already reloaded. Or the mobile connection drops exactly on the way out of the 3D page. The bank has taken the money. Your database has nothing.

The fix is a separation of duties. The return page only tells the user what happened; it never writes the record. The only things allowed to change the record are the server-to-server notification (the webhook) and the verification call you make to the provider yourself. If the result is not in hand when the user lands, show a "confirming your payment" state and poll for a few seconds. Making someone wait three seconds beats telling them something false.

Webhooks arrive at least once, and not in order

Payment providers deliver notifications at least once. The same event can arrive twice, and a later event can arrive before an earlier one. Stripe's documentation says plainly that it does not guarantee events are delivered in the order they were generated, and it tells you to track event IDs rather than the created timestamp to spot duplicates. Undelivered events are retried with exponential backoff for up to three days in live mode. If your endpoint is down for five minutes, nothing is lost, it all shows up at once later. Your handler has to be ready for that shape of traffic.

Three rules follow. First, verify the signature. The provider signs the body with a shared secret (Stripe uses the Stripe-Signature header with HMAC-SHA256) and verification runs against the raw body, not a version that has been parsed to JSON and re-serialised. If your framework touches the body, the signature will never match. Check the timestamp inside the signature too; the official libraries default to a five minute tolerance, which is what stops a captured request from being replayed days later. Second, return a 2xx quickly and push the work onto a queue. Third, enforce deduplication in the database, not in application code.

That third one is where most teams are still exposed. A "check whether this event was handled, and handle it if not" block does not help, because the two copies of an event almost always land at the same moment and both pass the check. The fix is one line of schema: a UNIQUE constraint on the provider's event ID in a payment_events table, with the insert and the processing inside the same transaction. If the insert conflicts, the event has already been handled, so return 200 quietly. Return an error and the provider will keep retrying for days.

Idempotency matters on the way out too

You sent a capture request to the provider and got a timeout. Did it go through? You have no idea. A timeout does not mean the request failed to arrive, it means the answer failed to come back. Retry blindly and you may charge the customer twice. The answer is a client-generated idempotency key: a second request carrying the same key does not start a new transaction, it returns the result of the first. The header is going through standardisation in the IETF HTTPAPI working group as Idempotency-Key. It is still an internet draft rather than a published RFC, but most providers already use that name.

How you generate the key is the part that gets done wrong. Minting a fresh UUID on every attempt achieves nothing, because each retry carries a different key and the provider treats them as separate charges. Tie the key to business meaning instead: the order ID combined with an attempt counter is a reasonable starting point. A retry after a network failure then carries the same key, while a customer genuinely paying a second time produces a different one.

A payment is a state machine, not a boolean

An orders.is_paid column survives about three months and then meets its first partial refund. The real states of a card payment are at least: initiated, awaiting authentication, authorised, captured, partially captured, voided, refunded, partially refunded, disputed. The gap between authorisation and capture earns its keep in any business holding stock. You authorise the card, capture when the goods actually ship, and void if the item turns out to be unavailable. A void, unlike a refund, leaves no trace on the customer's statement and usually leaves the fees cleaner as well.

Model the payment as its own entity, separate from the order, and only allow the transitions that make sense. One order can have several payments: a partial capture, a line added later, a retry on a different card after a decline. One payment can have several refunds. Wire those as one-to-one relationships and the first partial refund will force a schema change.

Never store money in a float

Store amounts as integers in the currency's minor unit. In the database, 149.90 is 14990. Store the currency code next to it. Most projects that start with a single currency add a second one within a couple of years, and at that point the historical rows cannot answer the question of what currency they were in. If you convert, record which moment's rate you used. There is always a gap between the rate at order time and the rate at capture time, and finance will ask about it.

Write your rounding rule down as well. Split 100.00 into three instalments and you get 33.33 + 33.33 + 33.34. Without an explicit rule about where the leftover cent goes, the totals stop matching the first time a partial refund lands, and tracking down the difference will cost someone an afternoon.

Three things specific to the Turkish market

Instalments cause more integration bugs than anything else, because the amount the customer pays and the amount that reaches your account are not the same number. Available instalment counts depend on the card's BIN and issuing bank, the basket total changes if an instalment surcharge applies, and the commission rate climbs with the number of instalments. If the instalment table on your basket page is not pulled live from the provider's BIN lookup, the user sees a different figure on the payment screen and abandons there.

Second, multi-acquirer routing. Merchants working with several banks route each transaction to a different virtual POS depending on the card's issuer. That makes "which acquirer processed this" a mandatory field on the transaction record. Without it you cannot find the transaction to refund it, and you cannot match it during reconciliation.

Third, a legal boundary worth settling before you design anything. Selling your own products is straightforward. Building a marketplace that collects money on behalf of sellers and pays them out later is different: collecting funds for third parties is a regulated payment service under Turkish Law 6493, and the collection has to run through a licensed payment institution. Pooling the money in your own account and distributing it by hand is technically easy and not a legal option.

Keep card data out, but do not assume an iframe finishes the job

Collecting the card number in your own form pushes you into the heaviest PCI DSS scope there is. Use the provider's hosted fields or iframe instead. One thing changed here in 2025: the PCI DSS v4.0.1 SAQ A form (revised February 2025) asks merchants using an embedded payment page or iframe to confirm that their site is not susceptible to script attacks that could affect their e-commerce system. According to the Council's FAQ on the criterion, it does not apply to merchants who redirect the customer away to the provider's own page; it applies specifically to embedded solutions. Using an iframe does not exempt you from knowing what runs on your payment page.

The leak most teams miss is card fields reaching third-party tooling. Session replay tools, error monitoring SDKs and marketing tags can capture form contents on default settings. List every third-party script running on the payment page, delete the ones that do not need to be there, run the rest with masking on, and add a CI check that greps your log output for card number patterns. You write that test once and it earns its place for a decade.

How to test the flow

The happy path of payment code always works. What breaks in production is the error paths nobody exercised. Put these scenarios into automated tests against the provider's sandbox: the user cancelling the 3D Secure challenge, an insufficient funds decline, a retry with the same idempotency key after a capture timeout, the same webhook event delivered twice, events arriving out of order (a refund notification before the capture notification), and a request with an invalid signature being rejected.

That last one is the easiest to forget. Send a plain unsigned POST to your webhook endpoint. If it does not come back 400 or 401, that endpoint is a publicly reachable "mark this order as paid" button. Have someone other than the person who wrote the integration run that test.

Without reconciliation you cannot know any of this works

Even with everything above done correctly, the only thing that proves it is a daily reconciliation. Compare three sources: your own payments table, the provider's transaction report, and the settlement file from the bank. Differences land in three buckets. Transactions successful on your side with no match at the provider. Transactions captured at the provider with no record on your side, which are the cases where you took the customer's money and never shipped anything, and those are the expensive ones. And transactions where the amounts disagree, usually because of commission, an instalment surcharge or a partial refund.

Do not run this by hand in a spreadsheet. Make it a nightly job that raises an alert whenever the difference is not zero. Add two business alerts next to it: "authorised but not captured for 30 minutes" and "payment succeeded but no order created". Those two queries surface problems far earlier than any error rate chart, because both conditions can occur while every service is technically healthy.

Have the evidence ready before a dispute arrives

A chargeback is not a software problem, but it is a process your software has to be ready for. The cardholder's window to open a dispute varies by reason code and commonly runs up to 120 days. The merchant's window to respond is measured in weeks: typically 30 days on Visa and 45 on Mastercard, and missing it loses the case automatically with no appeal. Run the clock from the date on the notice your acquirer or provider sends you, not from your own calculation.

The engineering job is to capture the evidence at transaction time: the 3D Secure authentication result and its reference, order and fulfilment timestamps, IP and device details, the shipping tracking record, and correspondence with the customer. Reconstructing that from logs six months later usually fails. Write it to one record while the transaction is happening.

Where to start

If you already have an integration in production, an audit takes a day. Ask four questions. Over the last 30 days, are there transactions the provider shows as successful with no counterpart in your database? Has the same event ID been processed more than once? Does your webhook endpoint actually verify signatures? And is the order record created on the page the browser is redirected to? If even one answer comes back wrong, what you need is not a new payment provider, it is the items above this paragraph. Checking takes an afternoon. Fixing usually takes a few days.


Need help with this topic?

get in touchall posts