Never integrated a payment provider before? No problem. This guide takes you from zero to a working Poncho Pay payment in a Node.js API and a Flutter app, explaining every term and every line along the way.
What you'll build: a small Node.js server that creates a Poncho Pay payment, receives the "payment done" notification (a webhook), and issues refunds, plus a Flutter screen that lets a user pay.
Who this is for: developers who know basic JavaScript (and a little Flutter) but have never touched payments. No prior payment knowledge needed.
Time needed: about 60–90 minutes.
Table of contents
- What is Poncho Pay, and why use it?
- How it works in plain English
- Words you'll see (mini glossary)
- What you need before starting
- Set up the Node.js project
- Connect to Poncho Pay and check your credentials
- Create your first payment
- Webhooks: how you learn the payment succeeded
- Refunds
- The Flutter app
- How we use Poncho Pay in our product (real-world flows)
- Going live checklist
- Common errors and fixes
- FAQ
1. What is Poncho Pay, and why use it?
Poncho Pay is a UK payment provider. Like other providers, it takes card payments, Apple Pay and Google Pay. What makes it special is that it also supports payment methods that are very common in the UK childcare and activities world:
- Tax-Free Childcare (the UK government scheme)
- Childcare vouchers
- Pay by Bank
I integrated it into a booking platform for children's sports clubs, where parents book classes and camps from a Flutter app. Many parents pay with Tax-Free Childcare, so supporting it mattered. If you're building software for nurseries, after-school clubs, holiday camps or tutors in the UK, this guide is for you.
2. How it works in plain English
Think of paying at a restaurant. The waiter (your app) doesn't handle your card. They send you to the card machine (Poncho Pay's page). When you've paid, the machine tells the restaurant "table 5 paid" (the webhook).
Your app never sees card details. That keeps you safe and simple. Here's the whole journey:
1. User taps "Pay" in your Flutter app
│
▼
2. Your Node.js server asks Poncho Pay: "Create a payment for £48"
│
▼
3. Poncho Pay replies with a checkout link (a URL)
│
▼
4. Flutter opens that link in a WebView. The user pays on Poncho's secure page
│
▼
5. Poncho Pay calls YOUR server: "Payment completed!" ← this is the webhook
│
▼
6. Your server marks the order as paid. The app shows "Booking confirmed 🎉"
The golden rule of this whole guide:
Only trust the webhook (step 5) to decide if someone has paid. Never trust the app or the browser redirect.
Why? A user could close the page, lose signal, or even fake a "success" screen. The webhook comes straight from Poncho's servers to yours, and it's signed so you can prove it's real.
3. Words you'll see (mini glossary)
| Word | Simple meaning |
|---|---|
| Payment provider / gateway | A company (like Poncho Pay) that safely moves money from the customer to you |
| Location URN | Your business's ID inside Poncho Pay. Think of it as your shop number |
| Integration key | A long secret password (64 characters) that lets your server talk to Poncho Pay. Never share it or put it in the app |
| Checkout URL | A web link to Poncho's payment page, created for one specific payment |
| Webhook | Poncho Pay calling your server automatically when something happens (like "payment completed") |
| Signature | A stamp Poncho adds to each webhook so you know it's genuine and not from a hacker |
| Demo / sandbox | A practice version of Poncho Pay where no real money moves |
| Pence | Amounts are sent as whole numbers of pence. £48.00 is 4800. Never use decimals |
| Metadata | Extra info you attach to a payment (like your order ID) that comes back in the webhook |
| Idempotent | Safe to run twice. Important because webhooks can be delivered more than once |
Keep this table handy. You can come back to it anytime.
4. What you need before starting
On your computer
-
Node.js 18 or newer. Check with
node -v. Download from nodejs.org if needed. - A code editor (VS Code is fine).
- A tool to send test requests: Postman, or
curl(already on most machines). - ngrok (free) or Cloudflare Tunnel. It gives your laptop a public web address so Poncho Pay can send you webhooks. We'll set it up in Section 8.
- Flutter installed, if you want to do Section 10.
From Poncho Pay
You need a demo (test) account with:
- A location URN.
- An integration key.
Poncho Pay sets these up for you, so contact them or check their developer documentation to get demo credentials. The demo environment is separate from production, and no real money moves in it.
⚠️ Never paste your real integration key into code, screenshots, GitHub. We'll keep it in a
.envfile that never gets committed.
5. Set up the Node.js project
Open a terminal and run:
mkdir poncho-demo
cd poncho-demo
npm init -y
npm install express dotenv @ponchopay/pp-nodejs
What did those install?
-
expressis a small web server library. -
dotenvloads secrets from a.envfile. -
@ponchopay/pp-nodejsis the official Poncho Pay SDK (a ready-made toolkit so you don't have to write raw API calls).
Tell Node to use modern imports
The Poncho SDK uses modern "ES module" syntax. Open package.json and add this line near the top:
"type": "module",
💡 Using an older CommonJS/TypeScript project? You can't
require()this SDK. Instead use a dynamic import inside a function:const { Client } = await import("@ponchopay/pp-nodejs");. I hit this exact problem in a real project.
Keep secrets in .env
Create a file called .env:
PONCHO_INTEGRATION_KEY=paste_your_demo_integration_key_here
PONCHO_LOCATION_URN=paste_your_demo_location_urn_here
PONCHO_EMAIL=you@example.com
PONCHO_BASE_URL=https://demo.ponchopay.com/api/
PORT=3000
Then create .gitignore so the secrets never reach GitHub:
node_modules
.env
PONCHO_EMAIL is the email tied to your Poncho account. The SDK asks for it when validating and refunding.
6. Connect to Poncho Pay and check your credentials
Create ponchoClient.js:
import "dotenv/config";
import { Client } from "@ponchopay/pp-nodejs";
// A client is your "phone line" to Poncho Pay.
export const poncho = new Client(
process.env.PONCHO_INTEGRATION_KEY,
process.env.PONCHO_BASE_URL
);
export const URN = process.env.PONCHO_LOCATION_URN;
export const EMAIL = process.env.PONCHO_EMAIL;
Now let's test that your credentials work. Create check.js:
import { poncho, URN, EMAIL } from "./ponchoClient.js";
const status = await poncho.validateLocationUrn({ urn: URN, email: EMAIL });
console.log(status);
Run it:
node check.js
You should see something like:
{
verification_status: true,
card_payments_enabled: true,
childcare_voucher_payments_enabled: true,
tax_free_childcare_payments_enabled: true
}
What this tells you:
-
verification_status: truemeans your location is verified. - The other fields tell you which payment methods are switched on.
If you get an error, jump to Common errors. 🎉 If it works, you've made your first successful call to Poncho Pay.
💡 In a real product (like mine, where many clubs each have their own Poncho account), run this check when someone connects their account, and save the results. That lets you show "Tax-Free Childcare: enabled ✅" in your admin screen.
7. Create your first payment
Now the fun part. We'll build a small server with one endpoint: POST /pay.
Create server.js:
import "dotenv/config";
import express from "express";
import { poncho, URN, EMAIL } from "./ponchoClient.js";
const app = express();
// TEMPORARY "database" just for this tutorial.
// In a real app, use PostgreSQL, MongoDB, etc.
const orders = new Map();
// ---- JSON body parser for normal routes (we'll add the webhook route later) ----
app.use("/pay", express.json());
app.post("/pay", async (req, res) => {
try {
const { amountInPence, customerEmail, description } = req.body;
// 1. Create an order in OUR system first
const orderId = "order_" + Date.now();
orders.set(orderId, { status: "PENDING", amountInPence });
// 2. Ask Poncho Pay for a checkout link
const checkoutUrl = await poncho.initiatePayment({
amount: amountInPence, // e.g. 4800 = £48.00
metadata: JSON.stringify({ orderId }), // comes back in the webhook
urn: URN,
email: customerEmail,
note: description,
});
// 3. Send the link back to the app
res.json({ orderId, checkoutUrl });
} catch (err) {
console.error(err);
res.status(500).json({ error: "Could not create payment" });
}
});
app.listen(process.env.PORT, () =>
console.log(`Server running on http://localhost:${process.env.PORT}`)
);
Let's break it down
-
We create our own order first, with status
PENDING. We always want a record on our side. -
initiatePaymentasks Poncho for a checkout page.-
amountis in pence (4800= £48). This is the #1 beginner mistake, so double check it. -
metadatais our "sticky note". We attach ourorderId, and Poncho hands it back in the webhook so we know which order was paid. -
noteis a description that shows up on the payment.
-
- It returns a
checkoutUrl, which is what we send to the app.
Start the server:
node server.js
Try it (without any app yet)
In a second terminal:
curl -X POST http://localhost:3000/pay ^
-H "Content-Type: application/json" ^
-d "{\"amountInPence\":4800,\"customerEmail\":\"test@example.com\",\"description\":\"Swimming term\"}"
(On Mac/Linux, replace ^ with \.) You'll get back:
{ "orderId": "order_1735...", "checkoutUrl": "https://demo.ponchopay.com/..." }
Open that checkoutUrl in your browser. That's the Poncho Pay payment page. Use the test details from Poncho's documentation to complete a test payment. You just created a real (demo) payment. 🎉
But your server doesn't know it was paid yet. For that we need webhooks.
8. Webhooks: how you learn the payment succeeded
What's a webhook again?
After the user pays, Poncho sends an HTTP POST request to a URL on your server. The message says what happened. Poncho Pay sends these events:
| Event | Meaning | What you should do |
|---|---|---|
payment_captured |
A card payment was authorised | Nothing yet (just log it) |
payment_reported_complete |
Payer says they're done (Tax-Free Childcare / vouchers) | Nothing yet |
payment_completed |
Money captured or verified | ✅ Mark the order as paid |
payment_in_bank |
Money reached the bank account | Optional: log it |
payment_refunded |
A refund happened | Record the refund |
payment_cancelled |
The payment was cancelled or abandoned | Release the order or spot |
payment_updated |
Something changed | Just log it |
⚠️ Important: payment_captured is not the same as payment_completed. Only mark an order as paid on payment_completed. Tax-Free Childcare payments can take longer than card payments, so don't be surprised if an order stays pending for a while.
Two rules for safe webhooks
Rule 1: Verify the signature. Anyone on the internet can POST to your URL. The signature proves the message really came from Poncho. We use the SDK's isValidCallback function.
Rule 2: Use the raw body. Signature checking needs the exact bytes that Poncho sent. If Express turns it into a JavaScript object first, the check fails. So for the webhook route, we use express.raw() instead of express.json().
Add the webhook route
Add this to server.js, above app.listen:
import { isValidCallback } from "@ponchopay/pp-nodejs";
// Note: express.raw, NOT express.json, for this route
app.post(
"/webhooks/poncho",
express.raw({ type: "*/*" }),
async (req, res) => {
const rawBody = req.body.toString("utf8");
const signature = req.headers["signature"];
if (!signature) return res.status(400).send("Missing signature");
// The SDK expects a web-standard Request object, so we build one
const request = new Request("https://localhost/webhook", {
method: "POST",
headers: { "content-type": "application/json", signature },
body: rawBody,
});
const valid = await isValidCallback(process.env.PONCHO_INTEGRATION_KEY, request);
if (!valid) return res.status(400).send("Invalid signature");
const payload = JSON.parse(rawBody);
console.log("Webhook received:", JSON.stringify(payload, null, 2));
try {
await handlePonchoEvent(payload);
} catch (err) {
// Log it, but still answer 200 so Poncho doesn't retry forever
console.error("Webhook handling failed:", err);
}
res.status(200).json({ received: true });
}
);
// Read the values we care about from the payload
function handlePonchoEvent(payload) {
const eventType = payload.event ?? payload.type;
const payment = payload.payment ?? {};
// metadata may arrive as a JSON string or as an object
let metadata = payload.metadata ?? payment.metadata ?? {};
if (typeof metadata === "string") metadata = JSON.parse(metadata);
const order = orders.get(metadata.orderId);
switch (eventType) {
case "payment_completed":
if (order) {
order.status = "PAID";
// Save this. You need it for refunds (Section 9)
order.paymentMethodId = payment.payment_methods?.[0]?.id;
console.log(`✅ ${metadata.orderId} is PAID`);
}
break;
case "payment_cancelled":
if (order) order.status = "CANCELLED";
break;
case "payment_refunded":
if (order) order.status = "REFUNDED";
break;
default:
console.log("Ignoring event:", eventType);
}
}
📌 About the payload shape. Field names like
payload.event,payment.payment_methodsandmetadataare what I saw in my own integration. The very first time, print the payload (theconsole.logabove does this) and check the real field names for your account. Poncho's docs are the source of truth.
Also add a tiny route so the app can check an order's status (we'll use it in Flutter):
app.get("/orders/:id", (req, res) => {
const order = orders.get(req.params.id);
if (!order) return res.status(404).json({ error: "Not found" });
res.json(order);
});
Make your laptop reachable (ngrok)
Poncho Pay can't call localhost. Use a tunnel:
ngrok http 3000
ngrok prints a public address like https://abcd-1234.ngrok-free.app. Your webhook URL is that address plus the path:
https://abcd-1234.ngrok-free.app/webhooks/poncho
Register that URL in the Poncho Pay admin dashboard (the webhook or callback settings for your location). If you can't find where, ask Poncho support.
Test the whole loop
- Restart your server and keep ngrok running.
- Call
/payagain and open thecheckoutUrl. - Complete the test payment.
- Watch your server terminal. You should see the webhook printed and
✅ order_... is PAID. - Call
GET /orders/<orderId>and you'll see"status": "PAID".
You just completed the full payment loop. 🎉
Make it production-safe (idempotency)
Webhooks can arrive more than once. If the same webhook is processed twice, you might send two confirmation emails or double-count a booking. The fix is to store the ID of every webhook you've handled and skip repeats:
const processedEvents = new Set();
// inside the webhook route, after verifying the signature:
const eventId = payload.id ?? payload.event_id;
if (eventId && processedEvents.has(eventId)) {
return res.status(200).json({ received: true }); // already handled
}
// ...handle the event...
if (eventId) processedEvents.add(eventId);
(In a real app, store these in a database table with a unique constraint on the event ID.)
9. Refunds
Refunds use the payment method ID we saved in the webhook (order.paymentMethodId). Add this route:
app.post("/orders/:id/refund", express.json(), async (req, res) => {
const order = orders.get(req.params.id);
if (!order || order.status !== "PAID") {
return res.status(400).json({ error: "Order is not refundable" });
}
const amount = req.body.amountInPence ?? order.amountInPence; // full or partial
try {
await poncho.refundPaymentMethod(order.paymentMethodId, {
urn: URN,
email: EMAIL, // the person issuing the refund
amount, // always required, even for a full refund
});
res.json({ message: "Refund requested" });
} catch (err) {
console.error(err);
res.status(500).json({ error: "Refund failed" });
}
});
Things to remember:
- You must always send an amount, even for a full refund. Send less than the total for a partial refund.
- The response only means "refund requested". The real confirmation is the
payment_refundedwebhook. Update your records there, not here. - Refund from your server only. Never expose refunds to the app without authentication.
10. The Flutter app
Poncho Pay uses a hosted checkout page, so the Flutter flow is simple:
- Call your server's
/payand get thecheckoutUrl. - Open the URL in a WebView.
- When the user finishes, close the WebView.
- Ask your server whether the order is paid. Don't trust what the WebView showed.
Add packages
In pubspec.yaml:
dependencies:
http: ^1.6.0
webview_flutter: ^4.14.1
Run flutter pub get.
The payment screen
Create pay_screen.dart:
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:webview_flutter/webview_flutter.dart';
// Android emulator can't see "localhost". Use 10.0.2.2 for your computer.
// For real devices, use your ngrok/public URL.
const apiBase = 'http://10.0.2.2:3000';
class PayScreen extends StatefulWidget {
const PayScreen({super.key});
@override
State<PayScreen> createState() => _PayScreenState();
}
class _PayScreenState extends State<PayScreen> {
bool loading = false;
String message = '';
Future<void> startPayment() async {
setState(() { loading = true; message = ''; });
// 1. Ask our server to create the payment
final res = await http.post(
Uri.parse('$apiBase/pay'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'amountInPence': 4800,
'customerEmail': 'test@example.com',
'description': 'Swimming term',
}),
);
final data = jsonDecode(res.body);
final orderId = data['orderId'] as String;
final checkoutUrl = data['checkoutUrl'] as String;
// 2. Open the checkout page and wait until the user closes it
if (!mounted) return;
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => CheckoutWebView(url: checkoutUrl)),
);
// 3. Ask OUR server if it was really paid
await waitForPayment(orderId);
}
Future<void> waitForPayment(String orderId) async {
setState(() => message = 'Confirming your payment…');
// Poll for up to ~60 seconds, because the webhook may arrive a moment late
for (var i = 0; i < 20; i++) {
final res = await http.get(Uri.parse('$apiBase/orders/$orderId'));
final status = jsonDecode(res.body)['status'];
if (status == 'PAID') {
setState(() { loading = false; message = 'Payment successful 🎉'; });
return;
}
if (status == 'CANCELLED') {
setState(() { loading = false; message = 'Payment cancelled'; });
return;
}
await Future.delayed(const Duration(seconds: 3));
}
setState(() {
loading = false;
message = 'Still processing. We will confirm shortly.';
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Swimming term: £48.00')),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (loading) const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(message),
const SizedBox(height: 16),
ElevatedButton(
onPressed: loading ? null : startPayment,
child: const Text('Pay securely'),
),
],
),
),
);
}
}
class CheckoutWebView extends StatefulWidget {
final String url;
const CheckoutWebView({super.key, required this.url});
@override
State<CheckoutWebView> createState() => _CheckoutWebViewState();
}
class _CheckoutWebViewState extends State<CheckoutWebView> {
late final WebViewController controller;
@override
void initState() {
super.initState();
controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(NavigationDelegate(
onNavigationRequest: (request) {
// Poncho sends the user to your "success" page when done.
// Replace this text with YOUR success page path.
if (request.url.contains('payment-success')) {
Navigator.pop(context); // close the WebView
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
},
))
..loadRequest(Uri.parse(widget.url));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Secure payment')),
body: WebViewWidget(controller: controller),
);
}
}
How the "user finished" detection works
When payment finishes, Poncho Pay sends the user to a success page URL (a page on your own server, for example https://yourapi.com/payment-success). The WebView watches for that URL and closes itself. Where you configure the redirect depends on your Poncho setup, so check Poncho's documentation or support for the right place.
A very common mistake: showing "Success!" as soon as the WebView closes. Don't. The user might have pressed Back. Always confirm with your server like waitForPayment does above.
Android and iOS notes
-
Android: make sure
android/app/src/main/AndroidManifest.xmlhas<uses-permission android:name="android.permission.INTERNET" />. Debug builds add it for you, but release builds don't. -
Plain
http://addresses are blocked by default on modern Android and iOS. Usehttps(ngrok gives you one), or enable cleartext traffic for local testing only.
11. How we use Poncho Pay in our product (real-world flows)
The tutorial above is deliberately small. So you can see how it fits into a real product, here is how it works in ours: a booking platform where sports clubs run classes and camps, and parents book and pay from a Flutter app. No code here, just the journey.
There are two kinds of users, so there are two flows.
Flow A: A club connects Poncho Pay (one-time setup)
Club signs up on the admin portal
│
▼
Club verifies its email address
│
▼
"Payment setup" step: club chooses Poncho Pay
│
▼
Club pastes its Location URN and Integration Key
(both come from the club's own Poncho Pay account)
│
▼
We ask Poncho Pay: "Is this location real and ready?"
│
┌────┴─────┐
No ▼ ▼ Yes
Show a clear Save the details safely (key stored encrypted)
message and unlock the club's dashboard
A few decisions worth copying:
- We check with Poncho before saving anything. If the location isn't verified, or has no payment method switched on, the club sees a plain message telling it what to fix in Poncho. That prevents clubs going live with a setup that can't take money.
- The club owns its Poncho account. Each club connects its own, so payments go straight to the club.
- A club can't create classes or camps until payments work. No payment setup, no bookings. This avoids parents trying to book from a club that can't get paid.
- We show the club which methods are on (card, Tax-Free Childcare, childcare vouchers), so it knows what parents will see.
Flow B: A parent books and pays
Parent picks a class or camp in the app
│
▼
We create the booking as "Pending" (the spot is held)
│
▼
Parent taps "Pay"
│
▼
We check the club is ready to take payments
│
▼
We ask Poncho Pay for a checkout page → booking becomes "Processing"
│
▼
The app shows Poncho's secure page inside the app
│
▼
Parent pays (card, Tax-Free Childcare or vouchers)
│
▼
Poncho tells our server "payment completed" ← the webhook
│
▼
Booking becomes "Confirmed" and the parent sees "You're booked 🎉"
What this taught us:
- The booking exists before the money does. "Pending → Processing → Confirmed" gives every booking a clear status, and it holds the child's spot while the parent pays.
- The app never decides a payment worked. When the payment page closes, the app shows "Confirming your payment…" and asks our server for the booking status. Only the webhook can turn a booking to Confirmed.
- Parents can pay for several things at once. In our cart, one payment can cover several classes or camps, and we show each as a line on the checkout.
- Tax-Free Childcare takes longer than cards. A booking can stay "Processing" for a while, so we tell parents what to expect instead of leaving them wondering.
Flow C: A parent leaves without paying
This was our nastiest bug, so it's worth its own flow.
Parent opens checkout, then closes it or hits Back
│
▼
Poncho sends "payment cancelled"
│
▼
We release the booking and free the spot
│
▼
Parent can book again straight away
At first we ignored the "cancelled" message. Bookings then stayed in "Processing" forever, which stopped parents rebooking the same child and quietly used up class places. Handling cancellation fixed both.
Flow D: A club issues a refund
Club admin opens an order in the dashboard
│
▼
Chooses full or partial refund, enters amount and reason
│
▼
We send the refund request to Poncho Pay
(using the same provider that took the payment)
│
▼
Poncho confirms with a "refunded" message
│
▼
Only now do we update our records and reverse any coupon used
Key points:
- Only club admins and platform staff can refund, never parents from the app.
- Full and partial refunds are both supported, with a reason recorded for a clear history.
- We wait for Poncho's confirmation before updating anything, because the refund request only means "asked", not "done".
- Every booking remembers which provider took its payment, so a refund always goes back through the same one, even if the club later changes its setup.
Flow E: A club changes its payment setup later
Rare, but it happens. When it does, new bookings pause briefly during the switch. Bookings that already exist keep using the provider they were paid with, so nothing gets lost or refunded through the wrong place.
The big picture
| Who | What they experience |
|---|---|
| Club | Connects Poncho once, sees which payment methods are on, issues refunds from the dashboard |
| Parent | Books, pays on a secure page, sees "confirming…", then "booked" |
| Our server | Creates the payment, listens for Poncho's messages, and is the only one that decides "paid" |
If you're planning your own integration, sketch these same flows first. Once they're clear, the code in the earlier sections is the easy part.
12. Going live checklist
Before real money moves, tick these off:
- [ ] Switch
PONCHO_BASE_URLto the production URL and use the production URN and key - [ ] Register the production webhook URL (HTTPS, on your real domain)
- [ ] Store the integration key in an environment variable or secrets manager (or encrypted in your database if you serve many merchants), never in code
- [ ] Replace the in-memory
orderswith a real database - [ ] Store processed webhook IDs in the database (idempotency)
- [ ] Handle
payment_cancelled(release the order or class spot). I forgot this at first and orders got stuck as "processing" forever - [ ] Only confirm orders on
payment_completed - [ ] Log every webhook and set up alerts for failures
- [ ] Add authentication to your refund endpoint
- [ ] Run a small real payment and refund yourself first
Bonus for platforms with many merchants (like mine): every merchant has their own integration key, so you can't verify webhooks against one global key. Either use one webhook URL per merchant (e.g. /webhooks/poncho/:merchantId) so you know which key to use, or try each merchant's key until one validates. I did the second, which works but gets slower as you grow.
13. Common errors and fixes
| Problem | Likely cause | Fix |
|---|---|---|
ERR_REQUIRE_ESM or "cannot use import" |
SDK is ESM-only | Add "type": "module" to package.json, or use dynamic import()
|
validateLocationUrn fails |
Wrong URN, key, email, or mixing demo and production | Check all three, and make sure the base URL matches the environment |
| Amount looks wrong (£0.48 instead of £48) | Sent pounds instead of pence | Multiply by 100 and send an integer |
| Webhook never arrives | ngrok stopped, URL not registered, or wrong path | Restart ngrok, re-register the new URL (free ngrok URLs change!) |
| "Invalid signature" every time | Body was parsed before verifying, or wrong key | Use express.raw() for the webhook route, and check the key is the right account's |
Order stuck on PENDING
|
You're waiting on the wrong event, or the webhook isn't reaching you | Log every webhook. Use payment_completed
|
| Webhook arrives twice | Normal! Retries happen | Add idempotency (Section 8) |
| Flutter can't reach the server |
localhost doesn't work in an emulator |
Use 10.0.2.2 on Android emulator, or your ngrok URL |
| WebView blank on release build | Missing INTERNET permission | Add it to the Android manifest |
| Refund says "requested" but nothing changed | Refunds are confirmed later | Wait for the payment_refunded webhook |
14. FAQ
Does Poncho Pay have an official Node.js SDK?
Yes: @ponchopay/pp-nodejs. It's ESM-only.
Is there a Flutter SDK?
Not that I'm aware of. That's why we use a WebView with the hosted checkout.
How do I test without real money?
Use the demo environment (https://demo.ponchopay.com/api/) with demo credentials.
Which event means "paid"?
payment_completed. Not payment_captured.
Can I do partial refunds?
Yes. Send a smaller amount when calling refundPaymentMethod.
Do I need TypeScript?
No. Everything above is plain JavaScript. The same code works in TypeScript.
Is my server ever handling card numbers?
No. Customers pay on Poncho's hosted page, which keeps your app much simpler and safer.
Wrap-up
That's the whole integration:
- Create a payment on your server → get a checkout URL.
- Open the URL in a Flutter WebView.
-
Receive the signed webhook → mark as paid (only on
payment_completed). - Confirm with your server, never the browser.
- Refund via the SDK and trust the refund webhook.
If you remember only one thing: the webhook is the truth. Everything else is decoration.
If this helped, please like and share it with someone building for the UK childcare and activities market. Questions or stuck on a step? Leave a comment and I'll help.
#PonchoPay #NodeJS #Flutter #Payments #Webhooks #API #FinTech #BackendDevelopment #MobileDev #UKTech
Top comments (0)