→ From Coder to Shipper
From Coder to Shipper · Payments

Payments — API + webhooks

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

Big picture

Real money is somebody else's job

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.

You don't build the bank

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.

You never touch card data

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.

The provider hands you two tools

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.

You build the business logic

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.

Big picture

The cast — four players, one payment

Only you and the provider write code. The customer just pays; the banks just move money.

🧑 Customer

Picks a plan, pays on the provider's page. Sees card or e-wallet options.

① pay ▶ clicks 'Pay'

🐍 Your app

Knows the price and what the money unlocks. Calls the provider's API. Listens for its webhook.

② API ▶ create a payment

💳 Provider

e.g. Sepay — a gateway. Renders the checkout, holds the card data, reports back via webhook.

③ settle ▶ collect the money

🏦 Banks + e-wallets

Card networks, Vietcombank, MoMo, ZaloPay… The licensed side of the business. Nobody writes this.

④ WEBHOOK ◀ …minutes later, the provider POSTs to YOUR url: payment.succeeded. The return trip of the whole story — and the async part.

①–③ 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.
The whole topic in one sentence
01
Part 1

Why a provider — and what one is

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.

The provider · the deal

A provider gives you exactly three things

Every gateway on the planet sells this same package. Endpoint names change; the package does not.

An API — you call it

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 payment page — the customer uses it

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.

Webhooks — it calls you

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 provider · the split

Split the work cleanly

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.

The provider's job

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.

Your job

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.

The provider · the red line

Why 'never touch card data' IS the architecture

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.

Store no card numbers

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.

The provider is the card vault

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.

Your only secrets

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.

02
Part 2

The flow — from 'Pay' to unlocked

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.

The flow · the big picture

The five-beat payment flow

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.

1
Customer picks a plan
e.g. Pro — 200,000đ / month
2
Your app creates the payment — provider API
amount · currency · order_ref · webhook_url → checkout URL
3
Provider shows the checkout — customer pays
card or e-wallet, on the provider's page — not yours
4
⏰ Provider calls your webhook (async, minutes later)
POST /webhooks/pay { "event": "payment.succeeded", ... }
5
Your app unlocks the plan
order → paid, feature flag on, receipt sent — your state machine moves

The unlock (5) is triggered by 4 — never by the customer's browser redirecting back.

The flow · beat 2

Create a payment — ten-ish lines

One HTTPS POST from your app to the provider. The response is a checkout URL — the customer goes there to actually pay.

create_payment.py
# 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 flow · the trap

The 200 is not the money

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'.

200 from create-payment means

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 money is reported by

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.

The flow · the answer

The API answer, live

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'.

ops@vps — zsh
# your app POSTs the create-payment request (snippet above)
HTTP/1.1 200 OK
Content-Type: application/json
{"id": "pay_a1b2c3", "status": "created", "url": "https://pay.sepay.example/p/a1b2c3"}
# status is 'created' — NOT 'paid'. The customer still has to pay.
# you redirect the customer's browser to the 'url' above
03
Part 3

Webhooks — the provider calls you

An API is you calling a URL. A webhook is the reverse: the provider calls YOUR URL, whenever it wants, with a JSON envelope.

Webhooks · what it is

A webhook, precisely

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.

A URL you hand over

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.

A JSON envelope, one event per call

Each POST is one event: what happened (type), which payment (id), the amount, and your order_ref. Small, structured, machine-readable.

Async by definition

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.

Treat it like an untrusted visitor

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.

Webhooks · the mirror

API vs webhook — the same thing, mirrored

One URL pattern, two directions. Most of the confusion in payments comes from forgetting which side you're on.

AspectAPIWebhook
Who initiatesYou — your app calls itThe provider — it calls you
WhenOn a user action, instantlyWhenever the world says so — seconds to minutes later
What it carriesYour request: amount, currency, order_refTheir event: what happened, the amount, your ref
What you buildA 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
Webhooks · the gap

The async gap, visualized

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.

t = 0s
Your app calls the API — sync, done in milliseconds; checkout URL returned
t ≈ 10s
Customer pays — card / e-wallet / 3-D Secure, on the provider's page
t ≈ 90s
Provider settles, then POSTs your webhook — payment.succeeded
t ≈ 90s
Your server reacts — verify signature → find order_ref → unlock the plan
t ≈ 92min? …and maybe the SAME event again: your first 200 never made it to them, so the provider retries. Duplicate events are normal traffic, not an incident.

Left of the webhook: request/response. Right of the webhook: the world happens, you react.

Webhooks · the payload

A real event — small on purpose

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.

webhook.json
{
  "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.

Webhooks · the handler

Your webhook endpoint, end to end

Five jobs: verify, find, sanity-check, flip, acknowledge. The unlock happens here — not in the browser redirect, which the customer can skip.

webhook.py
@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.

Webhooks · the contract

The webhook contract — four promises

Every provider's docs say the same four things, in different words. Break any of them and your retries pile up.

Respond fast with 200

If you're slow (or return 500), the provider assumes delivery failed and retries. Acknowledge first, do the heavy work after.

Expect retries — always

Retries are the design, not an error state. The same event (same id) is re-POSTed after timeouts and failures, at growing intervals.

Verify before you trust

The signature header proves origin. Without checking it, a stranger who finds your URL can 'pay' your users for free.

The event is truth, not the UI

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.

Webhooks · retries live

Watch a retry happen

This is not an error — it's the system working as designed. Same event, delivered twice, and your database still shows exactly one unlock.

ops@vps — zsh
# your server was busy for 2 seconds; the provider doesn't know why
POST /webhooks/pay -> 503 (will retry in 2 min)
POST /webhooks/pay -> 200 ok
# after BOTH deliveries — one unlock, never two
$ sqlite> select status from orders where ref = 'order_8123';
paid
Webhooks · reconciliation

Reconciliation — their event, your order

The webhook knows nothing about your business. Your job is to translate 'payment X succeeded' into 'order Y is paid' — carefully.

Carry your own reference

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.

Match by ref, not by their id

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.

Double-check the amount

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.

Log every event, even duplicates

Raw payload + event id is your audit trail. When 'the customer says they paid but the plan is locked', the log answers in seconds.

04
Part 4

Making it safe

Two things separate 'a demo that charges a friend' from 'production that never double-charges strangers': idempotency and secrets.

Safety · idempotency

Idempotency — the same event, many times, one effect

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.

The bug

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.

The cure: check before you flip

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.

Bulletproof: remember what you've seen

A processed_events table (event id → processed). Id already there? 200 and exit. Works even when one order legitimately pays multiple times.

200 means 'I understand you'

A duplicate handler returning 200 is polite, correct behaviour. The provider stops retrying only when it hears a 2xx.

Safety · idempotency

The guard, in six lines

Two lines do the whole job: 'already paid?' and 'already seen this event?'. Everything after runs at most once per order.

idempotent.py
@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.

Safety · secrets

Secrets — the key, the URL, the line

You hold exactly two sensitive things: the provider API key and the fact that your webhook URL exists. Treat each accordingly.

The API key stays server-side

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.

The webhook URL is semi-public

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.

Rotate before it leaks

Keys live in dashboards where you can regenerate them in seconds. Suspect a leak? Rotate, then audit recent activity. Fast beats forensics.

Safety · secrets

The key lives in the environment

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.example
# .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.

Recap

The whole payment in one picture

Two arrows, one state flip. If you remember nothing else from this topic, remember this diagram.

🧑 Customer

picks a plan, pays on the provider's checkout

pays ▶

🐍 Your app

knows the price, holds the order, owns the unlock

① API ▶ (sync) ◀ ② WEBHOOK (async)

💳 Provider (e.g. Sepay)

checkout page, card data, banks, e-wallets — the money lives here

🔓 On webhook success: verify signature → match order_ref → check amount → flip order to paid → unlock. Idempotent, logged, at most once.

API = you asking. Webhook = the world telling you. State flip = your product. That's payments.

Recap · go-live

The five checks before real money

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.

Webhook URL is public https

The provider must reach it from the internet — behind a firewall with no route, payments succeed and your app never hears.

Signature is verified

Requests without a valid provider signature get a 401. This is what keeps strangers from 'paying' for your users.

Handler is idempotent

Retries, duplicates and test curls all land safely: check-before-flip or a processed-events table. One effect per order.

order_ref matches, amount checked

Every webhook maps to exactly one of your orders, and the amounts agree. Mismatches get logged and alerted, never guessed.

Keys live in the environment

API key in an env var, git-ignored; test key in dev, live key in prod. The repo has never seen sk_live_....

An API you call. A webhook that calls you.

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.