← Authentication 07 / 18 Next: LINE API β†’

Payments & Webhooks

Module 3 · External Services · ⏱ ~30 min
Why this matters: The core revenue mechanism of the platform is PromptPay QR payment via Omise. Understanding the webhook flow explains why a booking starts as 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.

python β€” backend/app/services/omise.py
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.

python β€” backend/app/services/omise.py
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

python β€” backend/app/routers/webhooks/omise.py
@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:

python β€” backend/app/tasks/scheduler.py
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?

Because payment hasn't been received yet. Creating the booking and receiving payment are two separate steps. The booking is created when the customer submits the form, then the QR is shown. Payment might never come (customer changes mind, QR expires). Only when Omise confirms the payment (via webhook) does the booking become confirmed.

2. What is a webhook and why is it better than polling for payment confirmation?

A webhook is a push notification from an external service β€” Omise POSTs to our URL when payment completes. Polling would mean our server repeatedly asking Omise "did it complete?" every few seconds, wasting network resources and adding latency. Webhooks are instant (sub-second), efficient, and don't require our server to remember to check.

3. Why is HMAC signature verification needed for the webhook endpoint?

The webhook URL (/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?

The booking stays 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?

Payment processors use the smallest currency unit (satang = 1/100th of a baht) to avoid floating-point arithmetic entirely β€” all amounts are integers. 150 baht = 15,000 satang. The conversion is amount_satang = round(amount_thb * 100). Using integers prevents any possibility of rounding errors in payment amounts.