Taking real money without ever touching a card number: the provider's API, the provider's webhook, and the async flow that flips your app's state. (Sepay is one good example of such a provider — the shape is the same everywhere.)
← / → to navigate · EN/VI switch below
The moment your app takes money, you're up against card networks, 3-D Secure, fraud rules and a dozen local payment methods. That's an industry — rent it, don't build it.
Payment processing is licensed and regulated (Visa/Mastercard rules, local banks, e-wallets). You rent it from a payment provider (gateway) — Sepay, Stripe, Paystack and co. all sell this.
The customer types card details into the provider's page, not yours. Your server never sees or stores a card number — that one line keeps you out of PCI scope.
An API you call (create a payment, check a status) and a webhook it calls back (payment succeeded / failed). The entire integration is built around those two.
What the payment unlocks, the price, the order records, surviving retries. That's your code — and it's all you'll write in this topic.
Only you and the provider write code. The customer just pays; the banks just move money.
Picks a plan, pays on the provider's page. Sees card or e-wallet options.
Knows the price and what the money unlocks. Calls the provider's API. Listens for its webhook.
e.g. Sepay — a gateway. Renders the checkout, holds the card data, reports back via webhook.
Card networks, Vietcombank, MoMo, ZaloPay… The licensed side of the business. Nobody writes this.
①–③ are synchronous (seconds). ④ is asynchronous (minutes) — and ④ is what you must build to survive.
You never see the money. You only see the webhook. Build your app to trust the webhook — and to handle it exactly once.
A payment provider is a licensed middleman between you and the money. Learn the standard shape once; Sepay (common in Vietnam: cards + e-wallets) is just one instance of it.
Every gateway on the planet sells this same package. Endpoint names change; the package does not.
A small set of HTTPS endpoints: create a payment, check a status, (later) cancel or refund. You call with your secret key; you get JSON back.
A hosted checkout (card form or e-wallet choice) the provider renders. Card data goes straight to the provider — past your app, never through it.
A URL you register. When something happens (paid, failed, expired), the provider POSTs a JSON event to it. This is the async half of everything.
The line between the two columns is the whole architecture. Cross it (store a card number, call a bank API yourself) and you've rebuilt the bank.
Hold the card data, talk to banks and e-wallets, apply fraud checks and 3-D Secure, collect the money, report the outcome.
Their product is reliability at scale: retries, disputes, uptime. Licensed, regulated — rent it.
Know what the payment is for (your order), tell the provider the price, listen for its webhook, flip your app's state — unlock the thing.
All business logic — all yours. You build a state machine, not a bank.
PCI DSS is the compliance regime for anyone who stores or transmits card data. The provider's hosted checkout moves that burden off you — for free.
Not in your database, not in your logs, not in your code. If a card number reaches your server, you've inherited a compliance problem.
They're the PCI-scoped system. Your server isn't. 'Card data never crosses into my app' is worth more than any feature you'll ship.
What you actually hold: your provider API key (server-side, env var) and your own orders. Everything else stays on their side of the line.
The whole dance in one line: the customer picks a plan, your app asks the provider to collect the money, and the provider's webhook tells you it worked.
Beats 1–3 are synchronous and take seconds. Beats 4–5 are asynchronous and take minutes — beat 4 is the one you must build to survive.
The unlock (5) is triggered by 4 — never by the customer's browser redirecting back.
One HTTPS POST from your app to the provider. The response is a checkout URL — the customer goes there to actually pay.
# your app: customer clicked "Pay 200,000đ / month"
import os
import requests
r = requests.post(
"https://api.sepay.example/v1/payments",
headers={"Authorization": "Bearer " + os.environ["SEPAY_API_KEY"]},
json={
"amount": 200000, # VND, integer
"currency": "VND",
"order_ref": "order_8123", # YOUR id — you'll match on it later
"webhook_url": "https://app.example.com/webhooks/pay",
},
)
checkout = r.json() # -> {"url": "https://pay.sepay.example/p/a1b2c3"}
No card data in sight. You send what you're selling, and where to report back. The provider answers with a checkout URL — not with 'paid'.
The most common beginner bug: seeing HTTP 200 from 'create payment' and unlocking the product right away. The 200 means 'checkout page created', not 'customer paid'.
The provider accepted your request and built a checkout page. The customer hasn't even seen it yet. Money moved so far: 0đ.
The customer can abandon it, fail 3-D Secure, or close the tab — your create call still says 200 OK.
The webhook — minutes later, asynchronously. payment.succeeded (or failed / expired) is the only signal that money actually moved.
Rule: the API call creates the possibility of payment; the webhook confirms the fact of it.
What the provider actually returns when you create a payment: a 200, and a checkout URL. Read the status word carefully — it says 'created', not 'paid'.
An API is you calling a URL. A webhook is the reverse: the provider calls YOUR URL, whenever it wants, with a JSON envelope.
No magic: it's an HTTPS POST arriving at one of your routes — just like a form submission, except the sender is the provider and the timing is theirs.
At integration you register https://app.example.com/webhooks/pay. From then on the provider can POST to it. It must be public (https) — the internet has to reach it.
Each POST is one event: what happened (type), which payment (id), the amount, and your order_ref. Small, structured, machine-readable.
The call arrives whenever the real world says so: 30 seconds or 30 minutes later. Your server must be up and ready at arbitrary moments.
Anyone who finds your URL can POST to it. So verify the signature first, then act. The webhook is the source of truth — but you check it at the door.
One URL pattern, two directions. Most of the confusion in payments comes from forgetting which side you're on.
| Aspect | API | Webhook |
|---|---|---|
| Who initiates | You — your app calls it | The provider — it calls you |
| When | On a user action, instantly | Whenever the world says so — seconds to minutes later |
| What it carries | Your request: amount, currency, order_ref | Their event: what happened, the amount, your ref |
| What you build | A requests call (a few lines) | A route that listens: POST /webhooks/pay |
| Mindset | "Ask", then read the answer | "Report" — answer fast with 200, expect retries, stay idempotent |
Between 'customer clicks pay' and 'your server hears about it' sit real humans and real banks. Design for the gap, not for the happy path.
Left of the webhook: request/response. Right of the webhook: the world happens, you react.
The request header also carries a signature (e.g. X-Signature) — your proof it really came from the provider. The body carries your own order_ref — your proof it's YOUR order.
{
"event": "payment.succeeded",
"id": "evt_9f8e7d6c",
"created_at": "2026-10-27T03:14:09Z",
"payment": {
"id": "pay_a1b2c3",
"amount": 200000,
"currency": "VND",
"order_ref": "order_8123",
"method": "card"
}
}
Every field exists so your handler can verify, match, and act — or safely ignore.
Five jobs: verify, find, sanity-check, flip, acknowledge. The unlock happens here — not in the browser redirect, which the customer can skip.
@app.post("/webhooks/pay")
def webhook():
if not verify_signature(request): # 1. is it really the provider?
return "not you", 401
data = request.json
order = Order.get(data["payment"]["order_ref"]) # 2. find YOUR order
if order is None or order.total != data["payment"]["amount"]:
return "mismatch", 400 # 3. sanity-check the match
mark_paid(order) # 4. flip state: unlock plan
return "ok", 200 # 5. fast 200 = "I got it"
Short on purpose: it checks, flips, and gets out of the way. Heavy work (emails, provisioning) goes to a background job.
Every provider's docs say the same four things, in different words. Break any of them and your retries pile up.
If you're slow (or return 500), the provider assumes delivery failed and retries. Acknowledge first, do the heavy work after.
Retries are the design, not an error state. The same event (same id) is re-POSTed after timeouts and failures, at growing intervals.
The signature header proves origin. Without checking it, a stranger who finds your URL can 'pay' your users for free.
The customer's browser may never redirect back. If the webhook says succeeded, it succeeded — even if the user's tab still shows a spinner.
This is not an error — it's the system working as designed. Same event, delivered twice, and your database still shows exactly one unlock.
The webhook knows nothing about your business. Your job is to translate 'payment X succeeded' into 'order Y is paid' — carefully.
When creating the payment you attach order_ref (your DB id). The webhook echoes it back. That's your join key between their world and yours.
The provider's pay_... id is theirs; your order_ref is yours. A webhook for a ref you don't have? Log it and alert — never guess.
The webhook says which payment succeeded; your DB says what that order is worth. Amounts disagree → don't unlock, investigate. The cheapest fraud defense you'll ever buy.
Raw payload + event id is your audit trail. When 'the customer says they paid but the plan is locked', the log answers in seconds.
Two things separate 'a demo that charges a friend' from 'production that never double-charges strangers': idempotency and secrets.
Webhooks arrive twice: a retry after a timeout, a duplicate delivery, your own curl while testing. The handler must be safe to run any number of times.
Unguarded: the first webhook unlocks the plan; the second (same event) re-runs the handler. If it also creates an invoice, adds credits or emails a receipt — the customer sees two of everything.
If the order is already paid, return 200 and do nothing. 'Check state, then flip' is the whole pattern — a transition that applies only once.
A processed_events table (event id → processed). Id already there? 200 and exit. Works even when one order legitimately pays multiple times.
A duplicate handler returning 200 is polite, correct behaviour. The provider stops retrying only when it hears a 2xx.
Two lines do the whole job: 'already paid?' and 'already seen this event?'. Everything after runs at most once per order.
@app.post("/webhooks/pay")
def webhook():
if not verify_signature(request):
return "not you", 401
data = request.json
if Event.seen(data["id"]): # already processed this event
return "ok", 200
order = Order.get(data["payment"]["order_ref"])
if order and order.status == "paid": # already unlocked this order
return "ok", 200
mark_paid(order)
Event.remember(data["id"])
return "ok", 200
Now retries, duplicates and your own test curls are all harmless — the effect is 'at most once' no matter how often the call arrives.
You hold exactly two sensitive things: the provider API key and the fact that your webhook URL exists. Treat each accordingly.
Env var on the server, never in the repo, never in frontend code or a public config. Anyone holding it can create and (often) refund payments.
Anyone with it can POST to it — that's why the signature check is non-optional. The URL being public is a feature, not a leak.
Keys live in dashboards where you can regenerate them in seconds. Suspect a leak? Rotate, then audit recent activity. Fast beats forensics.
Same pattern on every deploy target: a .env file that is git-ignored, and code that reads it at runtime. The key appears in exactly one place — the server's environment.
# .env — git-ignored, lives only on the server
SEPAY_API_KEY=sk_live_9f8e7d6c5b4a3210
SEPAY_WEBHOOK_SECRET=whsec_1a2b3c4d5e6f
# app.py reads it at runtime — never hard-coded
API_KEY = os.environ["SEPAY_API_KEY"]
The _live_ prefix is convention: test keys and live keys coexist per environment. A commit containing sk_live_... is an incident.
Two arrows, one state flip. If you remember nothing else from this topic, remember this diagram.
picks a plan, pays on the provider's checkout
knows the price, holds the order, owns the unlock
checkout page, card data, banks, e-wallets — the money lives here
API = you asking. Webhook = the world telling you. State flip = your product. That's payments.
Run this list the day you switch from test keys to live ones. Each check is a bug that costs you or your customers money otherwise.
The provider must reach it from the internet — behind a firewall with no route, payments succeed and your app never hears.
Requests without a valid provider signature get a 401. This is what keeps strangers from 'paying' for your users.
Retries, duplicates and test curls all land safely: check-before-flip or a processed-events table. One effect per order.
Every webhook maps to exactly one of your orders, and the amounts agree. Mismatches get logged and alerted, never guessed.
API key in an env var, git-ignored; test key in dev, live key in prod. The repo has never seen sk_live_....
Get the async flow and idempotency right, keep card data on their side and your key in the environment — and you've shipped real money.