Panduan · Terbit
Integrasi payment gateway di FastAPI: await request.body() dan bukan model Pydantic, redirect garis miring yang tidak diikuti, dan time.sleep yang menahan seluruh server
Panduan ini memasang Kasera Pay di aplikasi FastAPI tanpa SDK. Yang dipakai hanya httpx untuk memanggil API, hmac dan hashlib dari pustaka standar untuk tanda tangan, dan SQLAlchemy untuk basis data. Seluruh kode di bawah dijalankan untuk panduan ini pada FastAPI 0.141.1, Starlette 1.7.0, Pydantic 2.13.5, dan Python 3.11, termasuk tiga puluh pengiriman event yang sama secara serentak yang berakhir dengan satu baris event dan satu pesanan lunas.
Kontraknya sama persis dengan integrasi payment gateway di Python dan Django. Yang berbeda adalah tempat kegagalannya. Di Django, penolakan terjadi di middleware CSRF dan APPEND_SLASH. Di FastAPI, penolakan justru datang dari fitur yang membuat framework ini disukai: validasi parameter oleh Pydantic, injeksi header, dan redirect garis miring otomatis. Ketiganya menjawab sebelum fungsi route dipanggil, jadi log aplikasi kosong sementara Kasera Pay mencatat pengiriman yang gagal.
1. Kredensial
API key dibawa sebagai bearer token dan berawalan kp_test_ selama membangun, kp_live_ setelah go-live. Signing secret webhook berbeda per mode dan diambil dari dashboard, menu Developer. Secret mode tes tidak pernah bisa memverifikasi payload live.
# .env: kp_test_ selama membangun, kp_live_ setelah go-live.
KASERA_PAY_KEY=kp_test_...
KASERA_PAY_WEBHOOK_SECRET=whsec_...
KASERA_PAY_BASE_URL=https://pay.kasera.id
# pip install fastapi httpx sqlalchemy uvicorn2. Satu AsyncClient untuk seluruh umur aplikasi
Route FastAPI yang ditulis async def berjalan di event loop. Memanggil urllib atau requests dari dalamnya menahan loop itu sampai jawabannya datang, dan selama itu tidak ada request lain yang dilayani. Klien di bawah memakai httpx.AsyncClient, dibuka sekali saat aplikasi mulai dan ditutup saat aplikasi berhenti, sehingga koneksi ke API dipakai ulang antar request.
# app/kasera_pay.py
import os
import httpx
BASE_URL = os.environ.get("KASERA_PAY_BASE_URL", "https://pay.kasera.id")
class KaseraPayError(Exception):
def __init__(self, status: int, code: str, message: str):
super().__init__(f"Kasera Pay {status} {code}")
self.status = status
self.code = code
self.message = message
class KaseraPay:
"""Satu AsyncClient untuk seluruh umur aplikasi, bukan satu per request."""
def __init__(self, transport: httpx.AsyncBaseTransport | None = None):
self._http = httpx.AsyncClient(
base_url=BASE_URL,
headers={"Authorization": "Bearer " + os.environ["KASERA_PAY_KEY"]},
timeout=20.0,
transport=transport,
)
async def aclose(self) -> None:
await self._http.aclose()
async def _send(self, method: str, path: str, **kwargs) -> dict:
res = await self._http.request(method, path, **kwargs)
if res.is_error:
detail = res.json().get("error", {}) if res.content else {}
raise KaseraPayError(res.status_code, detail.get("code", "unknown"), detail.get("message", ""))
return res.json()
async def create_transaction(self, payload: dict, idempotency_key: str) -> dict:
# Idempotency-Key opsional di API, dan satu-satunya yang mencegah satu
# pesanan menjadi dua pembayaran. Nilainya dibawa dari pesanan.
return await self._send(
"POST", "/v1/transactions", json=payload, headers={"Idempotency-Key": idempotency_key}
)
async def get_transaction(self, transaction_id: str) -> dict:
return await self._send("GET", f"/v1/transactions/{transaction_id}")3. Membuat permintaan pembayaran
Hanya header Idempotency-Key yang mencegah satu pesanan menjadi dua pembayaran. Header itu opsional; tanpa header itu setiap percobaan menjadi permintaan tersendiri. external_id hanya label yang disimpan dan dikembalikan, tidak pernah menyatakan dua permintaan sebagai satu pembayaran. Latar belakangnya ada di idempotency untuk pembayaran.
# app/main.py
import uuid
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from fastapi.responses import RedirectResponse
from .kasera_pay import KaseraPay, KaseraPayError
from .models import Pesanan, SessionLocal
from .webhook import router as webhook_router
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.kasera = KaseraPay()
yield
await app.state.kasera.aclose()
app = FastAPI(lifespan=lifespan)
app.include_router(webhook_router)
@app.post("/pesanan/{nomor}/bayar")
async def bayar(nomor: str):
with SessionLocal.begin() as db:
pesanan = db.get(Pesanan, nomor)
if pesanan is None:
raise HTTPException(404)
# Kunci dibuat SEKALI dan disimpan pada pesanan. Kunci baru di setiap
# percobaan adalah cara satu pesanan menjadi dua pembayaran.
if not pesanan.idempotency_key:
pesanan.idempotency_key = str(uuid.uuid4())
payload = {
"amount": pesanan.total, # rupiah utuh
"description": f"Pesanan {pesanan.nomor}",
"external_id": pesanan.nomor, # label, bukan kunci
"payment_methods": ["qris", "va_bca"],
"return_url": f"https://toko.example.com/pesanan/{pesanan.nomor}",
}
kunci = pesanan.idempotency_key
try:
hasil = await app.state.kasera.create_transaction(payload, kunci)
except KaseraPayError as e:
raise HTTPException(502, e.code) from e
with SessionLocal.begin() as db:
db.get(Pesanan, nomor).transaction_id = hasil["id"]
return RedirectResponse(hasil["checkout_url"], status_code=303)Kuncinya dibuat sekali lalu disimpan pada pesanan, jadi percobaan kedua setelah galat jaringan atau 500 membawa kunci yang sama. Dalam pengujian panduan ini, create pertama sengaja dijawab 500 dan create kedua membawa Idempotency-Key yang identik. Pembuatan kunci di dalam fungsi pemanggil API, bukan pada pesanan, terlihat sama benarnya di kode dan diam-diam menghapus seluruh perlindungannya.
4. Model data, dan id event sebagai primary key
# app/models.py
from sqlalchemy import String, create_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker
class Base(DeclarativeBase):
pass
class Pesanan(Base):
__tablename__ = "pesanan"
nomor: Mapped[str] = mapped_column(String(64), primary_key=True)
total: Mapped[int]
idempotency_key: Mapped[str | None] = mapped_column(String(64))
transaction_id: Mapped[str | None] = mapped_column(String(64), unique=True)
status: Mapped[str] = mapped_column(String(32), default="menunggu_pembayaran")
class KaseraEvent(Base):
# Primary key atas id event: basis data yang menolak kiriman kedua.
__tablename__ = "kasera_event"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
type: Mapped[str] = mapped_column(String(32))
engine = create_engine("sqlite:///toko.db")
SessionLocal = sessionmaker(engine)Pengiriman webhook bersifat at-least-once: event yang sama bisa datang lebih dari sekali dengan id yang sama. Pemeriksaan di kode membaca dulu lalu menulis, dan dua kiriman yang tiba bersamaan sama-sama membaca kosong. Primary key pada kasera_event.id memindahkan keputusannya ke basis data, yang hanya menerima satu.
5. Route webhook: Request, bukan model Pydantic
Tanda tangan dihitung atas byte yang benar-benar dikirim, dan di FastAPI byte itu didapat dengan await request.body(). Payload yang sudah di-parse lalu disusun ulang dengan json.dumps tidak sama: pada pengujian panduan ini payload 123 byte menjadi 127 byte dan tanda tangannya berbeda seluruhnya.
# app/webhook.py
import hashlib
import hmac
import json
import os
import time
from fastapi import APIRouter, Request, Response
from starlette.concurrency import run_in_threadpool
from .events import handle_event
router = APIRouter()
TOLERANCE_SECONDS = 300
def verify(raw_body: bytes, header: str, secret: str) -> bool:
"""Kasera-Signature-V1: t=<unix>,v1=<hex>[,v1=<hex>]"""
parts = header.split(",")
if not parts[0].startswith("t="):
return False
try:
t = int(parts[0][2:])
except ValueError:
return False
if abs(time.time() - t) > TOLERANCE_SECONDS:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
# Dua entri v1 selama 24 jam setelah rotasi secret; cukup satu yang cocok.
return any(
hmac.compare_digest(p[3:], expected) for p in parts[1:] if p.startswith("v1=")
)
# Tanpa model Pydantic, tanpa Header(): keduanya menjawab 422 sebelum fungsi
# ini jalan, dan 422 dihitung Kasera Pay sebagai pengiriman yang gagal.
@router.post("/webhooks/kasera-pay")
async def kasera_pay_webhook(request: Request) -> Response:
raw = await request.body() # bytes persis seperti yang ditandatangani
header = request.headers.get("kasera-signature-v1", "")
if not header or not verify(raw, header, os.environ["KASERA_PAY_WEBHOOK_SECRET"]):
return Response("invalid signature", status_code=400)
event = json.loads(raw)
# Kerja basis data sinkron dipindah ke threadpool supaya event loop tidak
# tertahan. Kalau gagal, pengecualiannya menjadi 500 dan kiriman diulang.
await run_in_threadpool(handle_event, event)
return Response(status_code=200)Setiap pengiriman membawa header Kasera-Signature-V1 berbentuk t=<unix>,v1=<hex>, dengan v1 adalah HMAC-SHA256 atas t, satu titik, lalu body mentah. Timestamp yang melenceng lebih dari lima menit ditolak, dan itu yang mencegah kiriman lama yang tersadap diputar ulang. Setelah rotasi secret, header membawa dua entri v1 selama 24 jam. Rinciannya ada di referensi webhook.
6. Pemroses event di threadpool
# app/events.py
from sqlalchemy.exc import IntegrityError
from .models import KaseraEvent, Pesanan, SessionLocal
def handle_event(event: dict) -> None:
"""Sinkron, dan sengaja: dijalankan di threadpool oleh route webhook."""
data = event["data"]
try:
with SessionLocal.begin() as db:
db.add(KaseraEvent(id=event["id"], type=event["type"]))
db.flush() # kiriman kedua gagal di sini, sebelum apa pun berubah
pesanan = db.get(Pesanan, data.get("external_id"), with_for_update=True)
if pesanan is None:
return # tagihan dari dasbor, bukan dari toko ini
if event["type"] == "payment.paid":
# Uang menang: dicatat walaupun payment.expired datang lebih dulu.
cocok = data["amount"] == pesanan.total
pesanan.status = "lunas" if cocok else "perlu_diperiksa"
elif event["type"] in ("payment.expired", "payment.failed"):
if pesanan.status == "menunggu_pembayaran":
pesanan.status = "dilepas"
except IntegrityError:
pass # id event sudah pernah dicatat; route tetap menjawab 200SQLAlchemy dengan sesi sinkron adalah bentuk yang paling umum di proyek FastAPI, dan memanggilnya langsung dari async def menahan event loop. run_in_threadpool dari Starlette memindahkannya ke thread lain tanpa mengubah kodenya. Pengukurannya jelas:
# Lima request serentak ke route yang tidur satu detik.
async def + time.sleep(1) -> 5,01 detik (antre satu per satu)
def + time.sleep(1) -> 1,02 detik (threadpool)Tiga keputusan di pemroses itu disengaja. payment.paid tetap dicatat walaupun payment.expired sudah datang lebih dulu, karena uang pembeli yang diterima sebelum tenggat selalu menang, dan pengujian panduan ini mengembalikan pesanan yang sudah dilepas menjadi lunas. Nominal yang tidak cocok ditandai untuk diperiksa, bukan dijawab 500 yang akan diulang tujuh kali. Tagihan yang dibuat dari dashboard, yang tidak punya pesanan di toko ini, tetap dicatat id event-nya dan dijawab 200.
payment.expired dan payment.failed hanya dikirim ke endpoint yang mencentangnya di dashboard. Endpoint yang dibuat sebelum pilihan event tersedia hanya menerima payment.paid sampai keduanya dicentang.
7. Empat jawaban yang dibuat FastAPI sebelum route jalan
Bagian inilah yang khas FastAPI. Setiap baris di bawah menghasilkan jawaban bukan 2xx tanpa satu pun baris kode route dijalankan:
# FastAPI 0.141.1, Starlette 1.7.0, Pydantic 2.13.5, Python 3.11.
# Tidak satu pun sampai ke badan fungsi route.
parameter event: Event (model Pydantic, paid_at: str)
payment.paid -> 200
payment.expired (paid_at: null) -> 422 string_type di body.data.paid_at
parameter kasera_signature_v1 = Header()
header tidak ada -> 422
route "/webhooks/kasera-pay", POST ke ".../kasera-pay/"
-> 307 Location tanpa garis miring
request.stream() dibaca, lalu body() -> RuntimeError: Stream consumed (500)
# Yang TIDAK merusak apa pun:
await request.json() lalu body() -> 200, byte utuh (di route)
BaseHTTPMiddleware yang membaca body -> 200, byte utuhModel Pydantic sebagai parameter. Menulis event: Event di tanda tangan fungsi terasa paling rapi, dan tetap lolos selama hanya payment.paid yang diuji. Begitu payment.expired dicentang, paid_at datang sebagai null dan validasinya menjawab 422. Model yang sama menolak setiap kiriman yang bentuknya tidak persis cocok dengannya. Validasi dengan model boleh dilakukan di dalam route, setelah tanda tangan terbukti, dengan skema yang menerima event yang belum dikenal.
Header() tanpa nilai bawaan. Header yang hilang menjadi 422, bukan 400. Akibatnya bagi pengiriman sama saja, tetapi galatnya menyesatkan saat dibaca di log pengiriman, karena terlihat seperti body yang salah bentuk.
redirect_slashes. FastAPI menjawab 307 ke alamat tanpa garis miring ketika URL berbeda satu garis miring dari route. Klien pengiriman Kasera Pay tidak mengikuti redirect apa pun, karena tujuan redirect adalah URL kedua yang tidak pernah diperiksa. Tulis path route dan URL di dashboard persis sama.
Stream yang sudah terbaca. Middleware yang mengiterasi request.stream() membuat request.body() di route melempar RuntimeError. Membaca request.body() di middleware justru aman, karena hasilnya disimpan dan dipakai ulang oleh route.
8. Yang boleh dipindah ke BackgroundTasks
Server penjual punya waktu sepuluh detik untuk menjawab setiap kiriman. Email konfirmasi, panggilan ke gudang, dan pembuatan faktur tidak perlu selesai dalam jendela itu dan boleh dipindah ke BackgroundTasks atau antrean. Status pesanan dan catatan id event tidak boleh, karena BackgroundTasks berjalan setelah jawaban 200 terkirim, dan kegagalan sesudah 200 tidak akan pernah diulang oleh Kasera Pay.
Sebelum go-live
URL webhook wajib https dan mengarah ke alamat publik. Uji empat hal di mode tes: kiriman sah dijawab 200, event yang sama dua kali menghasilkan satu perubahan, tanda tangan yang dirusak dijawab 400, dan kegagalan basis data menghasilkan 500 supaya kiriman diulang. Kalau banyak endpoint lain juga dipakai, client bertipe bisa dibuat dari spesifikasi OpenAPI Kasera Pay. Urutan pemeriksaan lengkapnya ada di checklist sebelum go-live.
Pertanyaan yang sering muncul
Kenapa webhook dijawab 422 padahal tanda tangannya benar?
Karena FastAPI memvalidasi parameter route sebelum fungsi dipanggil. Model Pydantic yang mewajibkan paid_at bertipe str menerima payment.paid, lalu menolak payment.expired yang membawa paid_at: null dengan 422, dan fungsi route tidak pernah jalan. Parameter Header() tanpa nilai bawaan juga menjawab 422 kalau header-nya tidak ada. Kasera Pay menghitung setiap jawaban bukan 2xx sebagai pengiriman gagal dan mengulangnya sampai maksimal 7 percobaan dalam sekitar 33 jam. Route webhook sebaiknya hanya menerima Request, membaca body mentahnya, dan mem-parse JSON sendiri setelah tanda tangan terbukti.
Apakah Kasera Pay mengikuti redirect 307 dari FastAPI?
Tidak. Klien pengiriman webhook Kasera Pay menolak mengikuti redirect apa pun, karena alamat tujuan redirect adalah URL kedua yang tidak pernah diperiksa. Jadi 307 yang dibuat redirect_slashes untuk URL yang berbeda satu garis miring dihitung sebagai pengiriman gagal. Samakan persis path di route dengan URL yang didaftarkan di dashboard.
Bolehkah pemrosesan event dipindah ke BackgroundTasks supaya jawabannya cepat?
Untuk pekerjaan yang boleh tertunda, boleh: email konfirmasi, panggilan ke sistem gudang, pembuatan faktur. Untuk perubahan status pesanan, jangan. BackgroundTasks berjalan setelah jawaban 200 terkirim, dan kegagalan sesudah 200 tidak akan pernah diulang oleh Kasera Pay. Status pesanan dan catatan id event harus selesai sebelum route menjawab. Batas waktu jawabannya sepuluh detik, jadi pekerjaan basis data yang wajar muat dengan lega.
Apakah perlu SDK Python atau client dari spesifikasi OpenAPI?
Tidak ada SDK Python resmi, dan dua endpoint yang dipakai panduan ini cukup dibungkus tangan dengan httpx. Kalau integrasinya memakai banyak endpoint, client bertipe bisa dibuat dari spesifikasi OpenAPI yang diterbitkan Kasera Pay. Verifikasi webhook tetap ditulis sendiri seperti di bagian 5.