Panduan · Terbit
Integrasi payment gateway di Flask: csrf.exempt untuk webhook, request.get_data() sebelum apa pun membaca stream, dan requests yang tidak pernah menyerah tanpa timeout
Panduan ini memasang Kasera Pay di aplikasi Flask tanpa SDK: requests untuk memanggil API, hmac dan hashlib dari pustaka standar untuk tanda tangan, dan Flask-SQLAlchemy di atas Postgres untuk basis data. Seluruh kode di bawah dijalankan untuk panduan ini pada Flask 3.1.3 di belakang gunicorn 26.2, termasuk empat puluh pengiriman event yang sama secara serentak yang berakhir dengan satu baris event dan satu pesanan lunas.
Kontraknya sama dengan integrasi payment gateway di Python dan Django dan di FastAPI. Yang berbeda adalah sumber penolakannya. Flask sendiri hampir tidak menolak apa pun; yang menolak adalah ekstensi yang dipasang di sekelilingnya. CSRFProtect dari Flask-WTF, tembok login di before_request, dan aturan garis miring Werkzeug sama-sama menjawab sebelum kode route jalan, sehingga 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
FLASK_SECRET_KEY=...
DATABASE_URL=postgresql+psycopg://toko:...@localhost/toko
# pip install flask flask-sqlalchemy flask-wtf "psycopg[binary]" requests gunicorn2. Aplikasi, dan satu baris yang menyelamatkan webhook
# app/__init__.py
import os
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_wtf.csrf import CSRFProtect
db = SQLAlchemy()
csrf = CSRFProtect()
def create_app() -> Flask:
app = Flask(__name__)
app.config["SECRET_KEY"] = os.environ["FLASK_SECRET_KEY"]
app.config["SQLALCHEMY_DATABASE_URI"] = os.environ["DATABASE_URL"]
db.init_app(app)
csrf.init_app(app)
from .bayar import bp as bayar_bp
from .webhook import bp as webhook_bp
app.register_blueprint(bayar_bp)
app.register_blueprint(webhook_bp)
# Webhook tidak membawa token CSRF dan tidak akan pernah membawanya.
# Tanda tangan HMAC yang menggantikan perannya.
csrf.exempt(webhook_bp)
return appAplikasi Flask yang menerima formulir hampir selalu memasang CSRFProtect, dan memang seharusnya. Perlindungan itu berlaku untuk setiap POST, termasuk POST dari server Kasera Pay yang tidak punya sesi, tidak punya cookie, dan tidak punya token. csrf.exempt(webhook_bp) mengecualikan satu blueprint itu saja; formulir checkout toko tetap terlindungi.
3. Klien API dengan batas waktu
# app/kasera_pay.py
import os
import requests
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:
def __init__(self):
self._http = requests.Session()
self._http.headers["Authorization"] = "Bearer " + os.environ["KASERA_PAY_KEY"]
def _send(self, method: str, path: str, **kwargs) -> dict:
# requests TIDAK punya batas waktu bawaan. (5, 20) = 5 detik untuk
# tersambung, 20 detik menunggu jawaban.
res = self._http.request(method, BASE_URL + path, timeout=(5, 20), **kwargs)
if not res.ok:
detail = res.json().get("error", {}) if res.content else {}
raise KaseraPayError(res.status_code, detail.get("code", "unknown"), detail.get("message", ""))
return res.json()
def create_transaction(self, payload: dict, idempotency_key: str) -> dict:
return self._send(
"POST", "/v1/transactions", json=payload, headers={"Idempotency-Key": idempotency_key}
)
def get_transaction(self, transaction_id: str) -> dict:
return self._send("GET", f"/v1/transactions/{transaction_id}")
kasera = KaseraPay()requests tidak punya batas waktu bawaan. Tanpa argumen timeout, panggilan ke API yang tersambung tetapi tidak menjawab menunggu selamanya, dan akibatnya bergantung pada jenis worker gunicorn:
# API yang menerima koneksi lalu tidak pernah menjawab.
requests tanpa timeout, worker sync -> 500 setelah 30,6 detik (WORKER TIMEOUT)
requests tanpa timeout, worker gthread -> masih menggantung di detik ke-45
requests dengan timeout=(5, 20) -> 504 setelah 20,0 detikDi worker sync, pembeli menerima 500 setelah setengah menit dan worker-nya dibunuh. Di worker gthread, satu thread hilang tanpa suara dan tidak pernah kembali; cukup beberapa kali kejadian sampai seluruh thread habis. Dengan timeout eksplisit, pembeli mendapat 504 yang bisa ditangani, dan percobaan berikutnya aman karena membawa kunci yang sama.
4. Membuat permintaan pembayaran
# app/models.py
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from . import db
class Pesanan(db.Model):
nomor: Mapped[str] = mapped_column(String(64), primary_key=True)
total: Mapped[int]
nama_pembeli: Mapped[str] = mapped_column(String(120))
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(db.Model):
# Primary key atas id event: basis data yang menolak kiriman kedua.
id: Mapped[str] = mapped_column(String(64), primary_key=True)
type: Mapped[str] = mapped_column(String(32))# app/bayar.py
import uuid
import requests
from flask import Blueprint, abort, redirect
from . import db
from .kasera_pay import KaseraPayError, kasera
from .models import Pesanan
bp = Blueprint("bayar", __name__)
@bp.post("/pesanan/<nomor>/bayar")
def bayar(nomor: str):
pesanan = db.get_or_404(Pesanan, nomor)
if pesanan.status != "menunggu_pembayaran":
abort(409)
# Kunci dibuat SEKALI dan di-commit pada pesanan, sebelum API dipanggil.
if not pesanan.idempotency_key:
pesanan.idempotency_key = str(uuid.uuid4())
db.session.commit()
payload = {
"amount": pesanan.total, # rupiah utuh, integer
"description": f"Pesanan {pesanan.nomor}",
"external_id": pesanan.nomor, # label, bukan kunci
"customer": {"name": pesanan.nama_pembeli}, # Virtual Account butuh nama
"payment_methods": ["qris", "va_bca"],
"return_url": f"https://toko.example.com/pesanan/{pesanan.nomor}",
}
try:
hasil = kasera.create_transaction(payload, pesanan.idempotency_key)
except KaseraPayError as e:
abort(502, e.code)
except requests.RequestException:
# Batas waktu habis: hasilnya tidak diketahui. Percobaan berikutnya
# membawa kunci yang sama, jadi aman diulang.
abort(504)
pesanan.transaction_id = hasil["id"]
db.session.commit()
return redirect(hasil["checkout_url"], code=303)Hanya header Idempotency-Key yang mencegah satu pesanan menjadi dua pembayaran. Header itu opsional; tanpa header itu setiap percobaan menjadi permintaan tersendiri, dan external_id hanya label yang disimpan dan dikembalikan. Kuncinya di-commit pada pesanan sebelum API dipanggil. Dalam pengujian panduan ini, create pertama sengaja dijawab 500 dan klik kedua mengirim kunci yang identik, lalu berakhir dengan 303 ke halaman pembayaran. Latar belakangnya ada di idempotency untuk pembayaran.
Dua detail payload yang sering terlewat. customer.name wajib kalau Virtual Account ditawarkan, dan tanpa itu create ditolak 422. amount dikirim sebagai integer rupiah, bukan desimal. Pesanan yang sudah dilepas karena kedaluwarsa dijawab 409 di sini; pembeli yang kembali dibuatkan nomor pesanan baru, supaya tidak ada dua permintaan pembayaran hidup untuk satu pesanan.
5. Route webhook: request.get_data(), bukan json.dumps
# app/webhook.py
import hashlib
import hmac
import json
import os
import time
from flask import Blueprint, request
from sqlalchemy.exc import IntegrityError
from . import db
from .models import KaseraEvent, Pesanan
bp = Blueprint("kasera_webhook", __name__)
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="))
@bp.post("/webhooks/kasera-pay")
def kasera_pay_webhook():
raw = request.get_data() # 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 "invalid signature", 400
event = json.loads(raw)
if not event["type"].startswith("payment."):
return "", 200 # test.ping dari dashboard: cukup dijawab 200
data = event["data"]
try:
db.session.add(KaseraEvent(id=event["id"], type=event["type"]))
db.session.flush() # kiriman kedua gagal di sini, sebelum apa pun berubah
except IntegrityError:
db.session.rollback() # wajib: tanpa ini query berikutnya melempar
return "", 200 # id event sudah pernah dicatat
# Dicocokkan lewat id permintaan pembayaran yang disimpan saat create,
# bukan external_id: label boleh kembar, id tidak.
pesanan = db.session.execute(
db.select(Pesanan)
.filter_by(transaction_id=data["payment_request_id"])
.with_for_update()
).scalar_one_or_none()
if pesanan is not None:
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"
db.session.commit() # kalau gagal: 500, dan Kasera Pay mengulang kirimannya
return "", 200Tanda tangan dihitung atas byte yang benar-benar dikirim, dan di Flask byte itu didapat dengan request.get_data(). Payload yang di-parse lalu disusun ulang dengan json.dumps tidak pernah sama: Python menambahkan spasi setelah koma dan titik dua, dan mengubah huruf non-ASCII menjadi \u. Pada pengujian panduan ini, payload 316 byte dengan nama pembeli beraksen menjadi 343 byte, dan tanda tangannya berbeda seluruhnya.
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.
Empat keputusan di route itu disengaja. Pesanan dicari lewat payment_request_id yang disimpan saat create, bukan lewat external_id: dua permintaan dengan external_id yang sama tetap dua pembayaran, dan pada pengujian panduan ini event dari permintaan kedua tidak menyentuh pesanan yang sudah lunas. payment.paid tetap dicatat walaupun payment.expired datang lebih dulu, karena uang yang diterima sebelum tenggat selalu menang; pengujian panduan ini mengirim expired lalu paid dan pesanan berakhir lunas. Nominal yang tidak cocok ditandai untuk diperiksa, bukan dijawab 500 yang akan diulang tujuh kali. Tagihan 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.
6. Dedupe: primary key, flush, dan rollback
Pengiriman webhook bersifat at-least-once: event yang sama bisa datang lebih dari sekali dengan id yang sama. Pemeriksaan “sudah pernah dicatat?” di kode membaca dulu lalu menulis, dan kiriman yang tiba bersamaan sama-sama membaca kosong. Primary key pada kasera_event.id memindahkan keputusannya ke Postgres, yang menahan insert kedua sampai yang pertama selesai lalu menolaknya:
# 40 kiriman event yang sama, dilepas serentak, gunicorn 2 worker x 16 thread.
primary key + flush (kode di atas) -> 40 x 200, 1 baris event, 1 kali diproses
cek dulu lalu insert -> 40 x 200, 1 baris event, 2 kali diproses
(diulang) -> 40 x 200, 1 baris event, 7 kali diprosesBagian yang khas Flask-SQLAlchemy adalah db.session.rollback() setelah IntegrityError. Sesinya terikat pada konteks request, dan setelah flush yang gagal sesi itu menolak dipakai lagi. Menangkap galatnya tanpa rollback lalu menjalankan query apa pun menghasilkan PendingRollbackError, yaitu 500, yaitu pengiriman yang diulang untuk event yang sebenarnya sudah tercatat.
7. Penolakan yang terjadi sebelum route jalan
Setiap baris di bawah menghasilkan jawaban bukan 2xx tanpa kode route dijalankan:
# Flask 3.1.3, Werkzeug 3.1.9, Flask-WTF 1.3.0, Flask-SQLAlchemy 3.1.1,
# SQLAlchemy 2.1.3, psycopg 3.3.6, gunicorn 26.2.0, Python 3.11, Postgres 16.
CSRFProtect aktif, blueprint webhook tidak di-exempt
-> 400 "The CSRF token is missing."
before_request yang mewajibkan login
-> 302 Location: /login
route "/webhooks/kasera-pay/", POST tanpa garis miring
-> 308 ke alamat dengan garis miring
route "/webhooks/kasera-pay", POST dengan garis miring
-> 404
request.stream.read(), lalu get_data()
-> b"" (0 byte), tanda tangan gagal: 400
json.dumps(request.get_json()) -> 316 byte menjadi 343, tanda tangan gagal: 400
IntegrityError ditangkap tanpa rollback, lalu query
-> PendingRollbackError: 500
# Yang TIDAK merusak apa pun:
request.get_json(), lalu get_data() -> 316 dari 316 byte
request.form, lalu get_data() -> 316 dari 316 byteTembok login. Pola @app.before_request yang mengalihkan setiap pengunjung tanpa sesi ke halaman login ikut mengalihkan Kasera Pay. Klien pengiriman Kasera Pay tidak mengikuti redirect apa pun, jadi 302 itu dihitung gagal. Keluarkan endpoint kasera_webhook.kasera_pay_webhook dari pemeriksaan itu.
Garis miring. Route yang ditulis dengan garis miring di akhir menjawab 308 untuk URL tanpa garis miring; route tanpa garis miring menjawab 404 untuk URL dengan garis miring. Tulis path route dan URL di dashboard persis sama.
Stream yang sudah terbaca. Middleware WSGI atau before_request yang membaca request.stream membuat get_data() di route mengembalikan byte kosong. Membaca request.get_json() atau request.form lebih dulu justru aman, karena Werkzeug menyimpan body-nya.
Sebelum go-live
URL webhook wajib https dan mengarah ke alamat publik. Jalankan Flask di belakang gunicorn atau server WSGI lain, bukan flask run. 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. Cara menerima webhook saat aplikasi masih berjalan di laptop ada di webhook di localhost dan mode tes, dan urutan pemeriksaan lengkapnya di checklist sebelum go-live.
Pertanyaan yang sering muncul
Kenapa webhook dijawab 400 padahal tanda tangannya benar?
Di aplikasi Flask yang memasang CSRFProtect dari Flask-WTF, penyebab paling umum adalah perlindungan CSRF itu sendiri. CSRFProtect memeriksa setiap POST, dan kiriman webhook tidak membawa token CSRF, jadi jawabannya 400 dengan pesan The CSRF token is missing sebelum fungsi route dipanggil. Kecualikan blueprint webhook dengan csrf.exempt. Tanda tangan HMAC yang memastikan kiriman itu memang dari Kasera Pay. Penyebab kedua: ada yang membaca request.stream sebelum route, sehingga request.get_data() mengembalikan byte kosong dan verifikasinya gagal.
Apakah Kasera Pay mengikuti redirect 302 atau 308 dari Flask?
Tidak. Klien pengiriman webhook Kasera Pay menolak mengikuti redirect apa pun, karena alamat tujuan redirect adalah URL kedua yang tidak pernah diperiksa. Jadi 302 dari tembok login dan 308 dari route yang diakhiri garis miring dihitung sebagai pengiriman gagal, lalu diulang sampai maksimal 7 percobaan dalam sekitar 33 jam. Samakan path route dengan URL di dashboard, dan keluarkan route webhook dari pemeriksaan login.
Apakah ada SDK Python resmi untuk Kasera Pay?
Belum. SDK resmi saat ini ada untuk PHP dan JavaScript/TypeScript. Dua endpoint yang dipakai panduan ini cukup dibungkus tangan dengan requests. 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.
Bolehkah event diproses di Celery atau RQ supaya jawabannya cepat?
Untuk pekerjaan yang boleh tertunda, boleh: email konfirmasi, panggilan ke sistem gudang, pembuatan faktur. Untuk perubahan status pesanan dan catatan id event, jangan. Kalau route sudah menjawab 200 lalu tugas di antrean gagal, Kasera Pay tidak akan pernah mengirim ulang. Server penjual punya sepuluh detik untuk menjawab, dan satu transaksi basis data yang wajar muat dengan lega.