Guide
Take payment and confirm it
Send the customer to Paystack, show a processing state when they come back, and mark the invoice paid only when Flux says so.
Scopes: payments:write to start checkouts (or none, if you use payUrl), and a webhook endpoint for confirmation.
1. Start the payment#
Show Pay only for approved invoices that aren't paid. Then either start a checkout with POST /invoices/{id}/checkout and redirect to checkoutUrl, or link to a fresh payUrl. Handle the 409 codes with a clear message: “already paid”, “cancelled”, “pay by bank transfer”.
2. When the payer comes back#
Paystack sends the payer back after paying. That redirect isn't proof of payment. Show “Payment processing. We'll confirm shortly.” and wait for Flux.
3. Confirm from Flux#
- Webhooks (recommended):
invoice.paidorreceipt.issuedmeans it's paid.payment.succeededcomes with them. - Polling, if you can't receive webhooks:
GET /invoices/{id}every 30 to 60 seconds for a few minutes, untilstatusispaid. Then less often.
// 1. Pay button -> your server starts the checkout and redirects.
app.post("/billing/invoices/:id/pay", requireLogin, async (req, res) => {
const r = await flux(`/invoices/${req.params.id}/checkout`, { method: "POST" });
if (r.ok) return res.redirect(303, (await r.json()).checkoutUrl);
const error = await r.json();
// already_paid, invoice_voided, not_online, payment_not_available, gateway_error
return res.redirect(`/billing?notice=${encodeURIComponent(error.code)}`);
});
// 2. Back from Paystack: show "processing" until Flux confirms. Never mark it paid here.
// 3. Webhook handler (after verifying the signature and deduplicating):
async function onFluxEvent(event) {
const obj = event.data.object;
switch (event.type) {
case "invoice.paid":
await markPaid(obj.id, obj.paidAt);
break;
case "invoice.payment_review":
await flagForReview(obj.id, obj.paymentStatus); // short payment or wrong currency
break;
case "payment.failed":
await notePaymentFailed(obj.invoiceId, obj.problem); // problem is null when Paystack declined
break;
}
}# 1. Pay button -> your server starts the checkout and redirects.
@app.post("/billing/invoices/<int:invoice_id>/pay")
@login_required
def pay(invoice_id):
r = requests.post(f"{BASE}/invoices/{invoice_id}/checkout", headers=HEADERS, timeout=30)
if r.ok:
return redirect(r.json()["checkoutUrl"], code=303)
# already_paid, invoice_voided, not_online, payment_not_available, gateway_error
return redirect(f"/billing?notice={r.json()['code']}")
# 2. Back from Paystack: show "processing" until Flux confirms. Never mark it paid here.
# 3. Webhook handler (after verifying the signature and deduplicating):
def on_flux_event(event):
obj = event["data"]["object"]
if event["type"] == "invoice.paid":
mark_paid(obj["id"], obj["paidAt"])
elif event["type"] == "invoice.payment_review":
flag_for_review(obj["id"], obj["paymentStatus"]) # short payment or wrong currency
elif event["type"] == "payment.failed":
note_payment_failed(obj["invoiceId"], obj["problem"]) # None when Paystack declined4. When it doesn't work out#
invoice.payment_review: the money didn't cover the invoice, or came in another currency. The invoice stays unpaid and the organization deals with it in Flux. Tell the customer their payment is being reviewed; don't ask them to pay again.payment.failedwithproblem: null: Paystack declined the payment. The customer can try again.- Refunds are made in Paystack and arrive as
payment.refunded.