DEV Community

Cover image for Poncho Pay Integration Tutorial: Node.js API, Webhooks and Flutter
Chirag Boghara
Chirag Boghara

Posted on AI-assisted

Poncho Pay Integration Tutorial: Node.js API, Webhooks and Flutter

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

  1. What is Poncho Pay, and why use it?
  2. How it works in plain English
  3. Words you'll see (mini glossary)
  4. What you need before starting
  5. Set up the Node.js project
  6. Connect to Poncho Pay and check your credentials
  7. Create your first payment
  8. Webhooks: how you learn the payment succeeded
  9. Refunds
  10. The Flutter app
  11. How we use Poncho Pay in our product (real-world flows)
  12. Going live checklist
  13. Common errors and fixes
  14. 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 🎉"
Enter fullscreen mode Exit fullscreen mode

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:

  1. A location URN.
  2. 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 .env file 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
Enter fullscreen mode Exit fullscreen mode

What did those install?

  • express is a small web server library.
  • dotenv loads secrets from a .env file.
  • @ponchopay/pp-nodejs is 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",
Enter fullscreen mode Exit fullscreen mode

💡 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
Enter fullscreen mode Exit fullscreen mode

Then create .gitignore so the secrets never reach GitHub:

node_modules
.env
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Run it:

node check.js
Enter fullscreen mode Exit fullscreen mode

You should see something like:

{
  verification_status: true,
  card_payments_enabled: true,
  childcare_voucher_payments_enabled: true,
  tax_free_childcare_payments_enabled: true
}
Enter fullscreen mode Exit fullscreen mode

What this tells you:

  • verification_status: true means 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}`)
);
Enter fullscreen mode Exit fullscreen mode

Let's break it down

  1. We create our own order first, with status PENDING. We always want a record on our side.
  2. initiatePayment asks Poncho for a checkout page.
    • amount is in pence (4800 = £48). This is the #1 beginner mistake, so double check it.
    • metadata is our "sticky note". We attach our orderId, and Poncho hands it back in the webhook so we know which order was paid.
    • note is a description that shows up on the payment.
  3. It returns a checkoutUrl, which is what we send to the app.

Start the server:

node server.js
Enter fullscreen mode Exit fullscreen mode

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\"}"
Enter fullscreen mode Exit fullscreen mode

(On Mac/Linux, replace ^ with \.) You'll get back:

{ "orderId": "order_1735...", "checkoutUrl": "https://demo.ponchopay.com/..." }
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

📌 About the payload shape. Field names like payload.event, payment.payment_methods and metadata are what I saw in my own integration. The very first time, print the payload (the console.log above 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);
});
Enter fullscreen mode Exit fullscreen mode

Make your laptop reachable (ngrok)

Poncho Pay can't call localhost. Use a tunnel:

ngrok http 3000
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

  1. Restart your server and keep ngrok running.
  2. Call /pay again and open the checkoutUrl.
  3. Complete the test payment.
  4. Watch your server terminal. You should see the webhook printed and ✅ order_... is PAID.
  5. 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);
Enter fullscreen mode Exit fullscreen mode

(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" });
  }
});
Enter fullscreen mode Exit fullscreen mode

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_refunded webhook. 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:

  1. Call your server's /pay and get the checkoutUrl.
  2. Open the URL in a WebView.
  3. When the user finishes, close the WebView.
  4. 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
Enter fullscreen mode Exit fullscreen mode

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),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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.xml has <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. Use https (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
Enter fullscreen mode Exit fullscreen mode

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 🎉"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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_URL to 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 orders with 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:

  1. Create a payment on your server → get a checkout URL.
  2. Open the URL in a Flutter WebView.
  3. Receive the signed webhook → mark as paid (only on payment_completed).
  4. Confirm with your server, never the browser.
  5. 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)