Payments & Webhooks
pending_payment and only becomes confirmed after the customer actually pays.
PromptPay and Omise
PromptPay is Thailand's national instant payment system β a bank transfers money by scanning a QR code. Every Thai banking app supports it. It's the primary payment method in Thailand.
Omise (now OPN Payments) is a payment processor that handles PromptPay charges. We send Omise the amount, Omise generates a QR code image. When the customer scans and pays, Omise notifies us via a webhook.
async def create_promptpay_charge(amount_thb: float, metadata: dict) -> dict:
"""Create a PromptPay charge via Omise. Returns charge_id and QR download URL."""
amount_satang = round(amount_thb * 100) # Omise uses smallest currency unit (satang = 1/100 baht)
async with httpx.AsyncClient() as client:
r = await client.post(
"https://api.omise.co/charges",
auth=(settings.OMISE_SECRET_KEY, ""), # basic auth: key as username, empty password
json={
"amount": amount_satang, # 150 THB = 15000 satang
"currency": "thb",
"source": {"type": "promptpay"},
"metadata": metadata, # {"booking_id": "...", "shop_id": "..."}
},
)
r.raise_for_status()
data = r.json()
return {
"charge_id": data["id"],
"qr_url": data["source"]["scannable_code"]["image"]["download_uri"],
}
The Booking State Machine
Every booking goes through a defined set of states. Understanding this is key to understanding the whole payment flow:
ββββββββββββββββββββ
Customer books ββββΊ β pending_payment β β 15-minute timer starts
ββββββββββ¬βββββββββ
β
βββββββββββββββββββΌββββββββββββββββββββββ
β β β
Timer expires with no Omise webhook: Admin clicks
payment received "charge.complete" "Confirm (Demo)"
β β β
βΌ βΌ β
ββββββββββββ βββββββββββββ β
βcancelled β β confirmed ββββββββββββββββββ
ββββββββββββ βββββββββββββ
β
LINE push notification
sent to customer (if connected)
What Is a Webhook?
The alternative to a webhook is polling: your server repeatedly asks Omise "has the payment completed yet?" every few seconds. This wastes resources and adds latency.
A webhook inverts this: "Don't call us, we'll call you." When a payment completes, Omise makes an HTTP POST request to a URL you registered (https://api.jongyang.xyz/webhooks/omise). Your server processes it immediately.
Polling (bad):
Our server βββΊ Omise: "Did customer pay?" (every 5s)
Omise βββΊ Our server: "Not yet" (t+5s)
Our server βββΊ Omise: "Did customer pay?"
Omise βββΊ Our server: "Not yet"
... 60 requests later ...
Our server βββΊ Omise: "Did customer pay?"
Omise βββΊ Our server: "Yes!" β update DB
Webhook (what we do):
Customer pays
Omise βββΊ Our server: POST /webhooks/omise {"key": "charge.complete", ...}
Our server: update DB immediately
Latency: ~500ms instead of up to 5 minutes
Webhook Signature Verification: HMAC-SHA256
Anyone on the internet could POST to /webhooks/omise and fake a payment confirmation. To prevent this, Omise signs every webhook with a secret key. We verify the signature before trusting the webhook.
def verify_webhook_signature(body: bytes, signature: str, timestamp: str) -> bool:
"""Return True if the Omise webhook HMAC-SHA256 signature is valid."""
if not settings.OMISE_WEBHOOK_SECRET:
return False
key = base64.b64decode(settings.OMISE_WEBHOOK_SECRET) # secret is Base64-encoded
# The signed payload is: timestamp + "." + raw_body
signed_payload = f"{timestamp}.".encode() + body
# Compute HMAC-SHA256
expected = hmac.new(key, signed_payload, hashlib.sha256).hexdigest()
# Compare β any of the comma-separated signatures must match
return any(
hmac.compare_digest(expected, sig.strip())
for sig in signature.split(",")
)
HMAC-SHA256 verification process:
1. Omise sends:
Header: Omise-Signature-Timestamp: 1782122833
Header: Omise-Signature: abc123def456...
Body: {"key": "charge.complete", "data": {...}}
2. We compute:
key = base64_decode(OMISE_WEBHOOK_SECRET)
payload = "1782122833." + body_bytes
expected = HMAC-SHA256(key, payload).hexdigest()
3. We compare:
expected == "abc123def456..."? β True = valid webhook
False = reject (fake webhook)
Why HMAC? Both parties (us + Omise) share the same secret key.
Only Omise can produce the correct signature. An attacker
without the key cannot forge a valid signature.
The Webhook Handler
@router.post("/omise")
async def omise_webhook(request: Request, session: AsyncSession = Depends(get_db)):
body = await request.body() # raw bytes needed for signature verification
signature = request.headers.get("Omise-Signature", "")
timestamp = request.headers.get("Omise-Signature-Timestamp", "")
if not verify_webhook_signature(body, signature, timestamp):
raise HTTPException(status_code=400, detail="Invalid webhook signature")
event = json.loads(body)
# Only process successful payment completions
if event.get("key") != "charge.complete":
return {"received": True}
charge = event.get("data", {})
if charge.get("status") != "successful":
return {"received": True}
# Find the booking linked to this Omise charge
booking = (await session.execute(
select(Booking).where(Booking.omise_charge_id == charge.get("id"))
)).scalar_one_or_none()
if not booking or booking.status != "pending_payment":
return {"received": True} # idempotent β already confirmed, or not our booking
# Confirm the booking!
booking.status = "confirmed"
booking.confirmed_at = datetime.now(timezone.utc)
await session.commit()
# Send LINE push notification if customer connected their LINE account
if settings.LINE_CHANNEL_ACCESS_TOKEN and booking.customer_line_user_id:
await push_text(booking.customer_line_user_id, msg_booking_confirmed(...))
return {"received": True}
The 15-Minute Expiry
When a booking is created, expires_at is set to 15 minutes in the future. APScheduler runs the expiry task every 5 minutes:
async def run_expire_task(session: AsyncSession) -> int:
now = datetime.now(timezone.utc)
result = await session.execute(
update(Booking)
.where(
Booking.status == "pending_payment",
Booking.expires_at < now, # expiry has passed
)
.values(status="cancelled", cancelled_at=now)
)
await session.commit()
return result.rowcount # number of bookings cancelled
def start_scheduler() -> None:
scheduler.add_job(expire_pending_bookings, "interval", minutes=5, id="expire_bookings")
scheduler.start()
This means a time slot is held for 15 minutes after someone starts the payment flow. If they don't pay, the slot is freed and others can book it.
π§ Self-Check Quiz
1. Why does a booking start as pending_payment instead of immediately confirmed?
2. What is a webhook and why is it better than polling for payment confirmation?
3. Why is HMAC signature verification needed for the webhook endpoint?
/webhooks/omise) is public β anyone can send a POST to it. Without signature verification, an attacker could send a fake "charge.complete" event and get bookings confirmed without paying. HMAC-SHA256 with a shared secret proves the request came from Omise (the only party who knows the secret), not an attacker.4. What happens if a customer scans the PromptPay QR but the Omise webhook fails to reach our server?
pending_payment. After 15 minutes, the expiry task cancels it. The customer has paid but has no confirmed booking. In this case, the admin can use the "Confirm (Demo)" button to manually confirm, or check the Omise dashboard to verify payment and manually update the booking. This edge case is why the admin panel shows pending bookings.5. Amount is sent to Omise in "satang" (not baht). Why, and how does the code convert?
amount_satang = round(amount_thb * 100). Using integers prevents any possibility of rounding errors in payment amounts.