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

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:

  • idsess_…. Use it to look the session up later.
  • url — the hosted payment page. Redirect your user here.
  • status — starts as open. Transitions to complete (paid), expired (no payment within the TTL), or canceled (user clicked back).

Sessions are short-lived. They expire 24 hours after creation by default. You can shorten this with the expiresInSec field (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 fraudulent reasons 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.

Plugipay — Payments that don't tax your success