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'Source:https://dev.to/chirag_boghara_/poncho-pay-integration-tutorial-nodejs-api-webhooks-and-flutter-2jolRelated posts
