Your first payment
This page walks you through a complete payment flow from the merchant side: creating a customer, opening a checkout session, taking the payment, listening for the webhook, and issuing a refund. It's a deeper version of the Quickstart — if you've already done that, you can pick up here.
By the end you'll understand:
- The lifecycle of a checkout session.
- How payments map to customers.
- How webhooks confirm completion asynchronously.
- How refunds work.
We'll use the Node.js SDK throughout for brevity. Equivalent calls in Python, Go, and raw curl are equivalent — method names just translate to snake_case or PascalCase respectively.
This walkthrough assumes you've connected a payment provider. Managed payments are launching soon — for now, connect your own Xendit, Midtrans, or PayPal account (or manual transfer) under Settings → Providers first. See Providers for the options.
Prerequisites
- A Plugipay account (sign up here)
- A test API key (see Installation)
- Node 18+ and
@forjio/plugipay-nodeinstalled - For webhooks: a way to receive HTTP — ngrok, Cloudflare Tunnel, or a real domain you control
1. Create a customer
A customer is a persistent record of someone who can pay you. You don't strictly need one to take a payment — you can pass payment details inline to a checkout session — but creating customers gives you:
- A reusable identifier across multiple payments
- Storage for default payment methods (subscription billing later)
- Searchable history in the dashboard
Create one:
const customer = await plugipay.customers.create({
email: 'alice@example.com',
name: 'Alice Tan',
metadata: { internalUserId: 'u_42' },
});
console.log(customer.id); // cus_01HXXXXXXXX
The response includes a cus_… ID. Hang onto it — you'll attach it to the checkout session next.
Customer metadata
The metadata object accepts up to 50 key-value pairs of arbitrary strings. Use it to map Plugipay customers back to your own system. Common patterns:
metadata: {
internalUserId: 'u_42',
signupSource: 'mobile_app',
tier: 'pro',
}
Plugipay won't act on metadata — it's there for you to filter and reconcile later. It's returned in webhooks too, so you can route events without an extra lookup.
2. Create a checkout session
A checkout session is a one-time payment intent that comes with a hosted payment page. You give it an amount, attach a customer, point it at success and cancel URLs on your site, and Plugipay does the rest:
const session = await plugipay.checkoutSessions.create({
amount: 250000, // IDR 250,000
currency: 'IDR',
description: 'Pro plan upgrade',
customerId: customer.id,
successUrl: 'https://myapp.com/payment/success',
cancelUrl: 'https://myapp.com/payment/cancel',
metadata: { invoiceId: 'inv_2026_001' },
});
console.log(session.url); // https://pay.plugipay.com/sess_xxx
The session response has:
id—sess_…. Use it to look the session up later.url— the hosted payment page. Redirect your user here.status— starts asopen. Transitions tocomplete(paid),expired(no payment within the TTL), orcanceled(user clicked back).
Sessions are short-lived. They expire 24 hours after creation by default. You can shorten this with the
expiresInSecfield (e.g.{ expiresInSec: 3600 }for one hour). Don't pre-create sessions days in advance.
Currency & amounts
Amounts are integers in the smallest currency unit — cents for USD, sen for IDR (though we typically deal in whole IDR for Indonesian payments, so 250000 = IDR 250,000). For currencies that don't subdivide (JPY, KRW), pass the whole amount.
The supported currencies depend on your payment provider. See Providers for the matrix.
3. Take the payment
Open the session.url in a browser. You're on Plugipay's hosted checkout page, branded for your workspace.
In test mode, use one of these test cards:
| Card | Behavior |
|---|---|
4242 4242 4242 4242 |
Successful payment |
4000 0000 0000 0002 |
Declined — generic |
4000 0000 0000 9995 |
Declined — insufficient funds |
4000 0027 6000 3184 |
Requires 3D Secure step-up |
Any future expiry and any 3-digit CVC will pass validation.
Submit the form. The page redirects to your successUrl with ?session_id=sess_… appended.
4. Handle the webhook
The redirect tells the customer the payment succeeded. To know server-side — for fulfillment, granting access, sending emails — listen for the payment.succeeded webhook.
Create a webhook endpoint
In the dashboard, go to Settings → Webhooks and click Add endpoint:
- URL: where Plugipay should POST events. For local dev, an ngrok tunnel pointing at your machine.
- Events: tick
payment.succeeded,payment.failed,checkout_session.completed,checkout_session.expired. You can subscribe to everything if you prefer.
Plugipay creates a signing secret — save it. You'll use it to verify incoming requests.
Verify and handle
Pseudo-code for an Express handler:
import express from 'express';
import { verifyWebhook, PlugipaySignatureError } from '@forjio/plugipay-node/webhooks';
const app = express();
// Use raw body for signature verification.
app.post('/webhooks/plugipay', express.raw({ type: 'application/json' }), (req, res) => {
try {
const event = verifyWebhook({
body: req.body,
signature: req.headers['x-plugipay-signature'],
secret: process.env.PLUGIPAY_WEBHOOK_SECRET,
});
switch (event.type) {
case 'payment.succeeded':
console.log('Payment succeeded:', event.data.id);
// Mark order paid, send receipt, etc.
break;
case 'payment.failed':
console.warn('Payment failed:', event.data.id);
break;
case 'checkout_session.expired':
console.log('Session expired:', event.data.id);
break;
}
res.json({ received: true });
} catch (err) {
if (err instanceof PlugipaySignatureError) {
return res.status(401).send('Invalid signature');
}
throw err;
}
});
The signature check is mandatory — without it, anyone who knows your webhook URL can spoof events. Plugipay signs every event with the secret you generated; verifyWebhook recomputes the signature and rejects mismatches.
See API → Webhooks for the full event reference and signature scheme.
5. Refund the payment
Sometimes you need to give the money back. A refund can be full or partial:
// Full refund
const refund = await plugipay.refunds.create({
paymentId: 'pay_01HXXXXXXXX',
reason: 'requested_by_customer',
});
// Partial refund
const partial = await plugipay.refunds.create({
paymentId: 'pay_01HXXXXXXXX',
amount: 100000, // refund IDR 100,000 of the original IDR 250,000
reason: 'duplicate',
});
Refunds are async. The initial response has status: "pending". Plugipay returns the funds via the original payment provider, which can take anywhere from seconds (cards) to days (bank transfers).
Listen for the refund.succeeded and refund.failed webhooks to know the final state.
Why refunds need a reason
Plugipay (and the underlying provider) tag refunds with a reason for:
- Reconciliation — matching ledger entries to refund causes.
- Fraud monitoring — spikes in
fraudulentreasons trigger review. - Dispute prevention — refunding before a chargeback is cheaper.
Accepted reasons: requested_by_customer, duplicate, fraudulent, other. If you pass other, supply a free-text description.
6. Verify in the dashboard
Open Dashboard → Payments. You should see:
- The original payment with status Refunded (or Partially refunded).
- The refund as a child record below.
The Activity tab on the payment detail page shows the full lifecycle: created → succeeded → refunded → refund.succeeded.
What's next
You've taken a full round-trip payment. Where to go from here depends on what you're building:
- Recurring billing? Subscriptions covers plans, trial periods, and proration.
- Marketplace splits? Connect-style payouts explain how to split a single payment across multiple destinations.
- Bring your own provider? Providers covers the Xendit, Midtrans, and PayPal modes.
- Test edge cases? API → Errors lists every error code and how to handle each.
Or go to Next steps for the curated learning path.