Technical

Integrate PayProGlobal with Next.js — Webhooks, Subscriptions, Supabase

An end-to-end technical walkthrough of integrating PayProGlobal into a Next.js 16 app — checkout, IPN signature verification, subscription state, and Supabase + RLS.

· 7 min read

This is a technical walkthrough of a production-grade PayProGlobal integration with Next.js 16 (App Router), TypeScript strict, and Supabase. It covers the four pieces that matter: the checkout URL, the IPN webhook handler, persisting subscription state to the database, and the test-mode flow that lets you verify everything before going live.

This is also what NoStripeKit ships pre-built. If you would rather start with a working codebase than build from scratch, the boilerplate includes all of the below, plus multi-tenancy, RLS policies, an admin panel, and email templates.

Architecture overview

PayProGlobal's integration model is different from Stripe's in one important way: checkout is hosted. You do not embed a payment form in your application. Instead, you redirect the customer to a PayProGlobal-hosted checkout URL that you construct with query parameters. After the customer pays, PayProGlobal sends an IPN (Instant Payment Notification) to a webhook URL you register. Your app verifies the IPN, provisions access, and sends a confirmation email.

The resulting architecture:

User clicks Buy
  → Your server builds a checkout URL
  → Redirect to store.payproglobal.com
  → Customer pays
  → PayProGlobal sends IPN to /api/payproglobal/webhook
  → Your handler verifies signature
  → Update subscriptions table in Supabase
  → Resend confirmation email
  → User gains access on next request (RLS enforced)

Two environment variables drive the integration from the payment side:

PAYPROGLOBAL_SECRET_KEY=...
PAYPROGLOBAL_VALIDATION_KEY=...

Setting up the checkout

A PayProGlobal checkout URL is a GET endpoint at store.payproglobal.com/checkout with your product ID and optional prefill parameters as query strings. You build it on the server and redirect the user.

import { NextResponse } from "next/server";

const CHECKOUT_BASE = "https://store.payproglobal.com/checkout";

export function buildCheckoutUrl(params: {
  productId: string;
  email?: string;
  successUrl?: string;
  secretKey: string;
}): string {
  const url = new URL(CHECKOUT_BASE);
  url.searchParams.set("products[1][id]", params.productId);
  url.searchParams.set("secret-key", params.secretKey);

  if (params.email) {
    url.searchParams.set("billing-email", params.email);
  }

  if (params.successUrl) {
    url.searchParams.set("page-return-url", params.successUrl);
  }

  return url.toString();
}

A Next.js route handler that redirects the authenticated user to checkout:

import { NextResponse } from "next/server";
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import { buildCheckoutUrl } from "@/lib/payproglobal";

export async function GET() {
  const cookieStore = await cookies();
  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    { cookies: { getAll: () => cookieStore.getAll() } }
  );

  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) {
    return NextResponse.redirect(new URL("/login", process.env.NEXT_PUBLIC_APP_URL!));
  }

  const checkoutUrl = buildCheckoutUrl({
    productId: process.env.PAYPROGLOBAL_PRODUCT_ID!,
    email: user.email,
    successUrl: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?checkout=success`,
    secretKey: process.env.PAYPROGLOBAL_SECRET_KEY!,
  });

  return NextResponse.redirect(checkoutUrl);
}

Custom checkout fields (like a GitHub username) are passed via the template parameters configured in your PayProGlobal vendor panel. You can also append them as query parameters in the URL construction above.

Verifying IPN webhook signatures

PayProGlobal sends IPN notifications as POST requests to the URL you configure in the vendor panel. The body is application/x-www-form-urlencoded. Every IPN includes a signature parameter that you must verify before trusting the payload.

The verification algorithm is straightforward: concatenate your validation key with a subset of the IPN parameters in a defined order, hash the result, and compare with the signature in the payload.

import crypto from "crypto";

export function verifyIpnSignature(
  params: Record<string, string>,
  validationKey: string
): boolean {
  const orderId = params["order-id"] ?? "";
  const orderTotal = params["order-total"] ?? "";
  const productId = params["product-id"] ?? "";
  const receivedSignature = params["ipn-signature"] ?? "";

  const raw = `${validationKey}${orderId}${orderTotal}${productId}`;
  const expected = crypto
    .createHash("md5")
    .update(raw)
    .digest("hex")
    .toUpperCase();

  return expected === receivedSignature.toUpperCase();
}

Note: confirm the exact parameter order and hash algorithm in your PayProGlobal integration documentation — the specific parameters included in the signature vary by IPN type and vendor configuration.

The webhook handler

The IPN handler sits at app/api/payproglobal/webhook/route.ts. It parses the body, verifies the signature, and dispatches based on the notification type.

import { NextRequest, NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";
import { verifyIpnSignature } from "@/lib/payproglobal";
import { sendConfirmationEmail } from "@/lib/email";

const supabaseAdmin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
);

export async function POST(req: NextRequest) {
  const body = await req.text();
  const params = Object.fromEntries(new URLSearchParams(body));

  const valid = verifyIpnSignature(
    params,
    process.env.PAYPROGLOBAL_VALIDATION_KEY!
  );

  if (!valid) {
    return NextResponse.json({ error: "invalid signature" }, { status: 400 });
  }

  const notificationType = params["notification-type"];

  if (notificationType === "ORDER_COMPLETED") {
    await handleOrderCompleted(params);
  }

  return NextResponse.json({ ok: true });
}

async function handleOrderCompleted(params: Record<string, string>) {
  const email = params["customer-email"];
  const orderId = params["order-id"];
  const productId = params["product-id"];

  const { data: user } = await supabaseAdmin
    .from("profiles")
    .select("id")
    .eq("email", email)
    .single();

  if (!user) return;

  await supabaseAdmin.from("subscriptions").upsert({
    user_id: user.id,
    order_id: orderId,
    product_id: productId,
    status: "active",
    created_at: new Date().toISOString(),
  });

  await sendConfirmationEmail({ email, orderId });
}

For the one-time purchase model (which is how NoStripeKit itself is sold), ORDER_COMPLETED is the primary event. For subscription products, you also handle SUBSCRIPTION_RENEWED, SUBSCRIPTION_CANCELLED, and SUBSCRIPTION_EXPIRED in the same dispatch pattern.

Persisting subscription state in Supabase

The subscriptions table that the webhook writes to drives access control via RLS. A minimal schema:

create table subscriptions (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null references auth.users(id) on delete cascade,
  order_id text not null unique,
  product_id text not null,
  status text not null check (status in ('active', 'cancelled', 'expired')),
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

alter table subscriptions enable row level security;

create policy "Users can read own subscriptions"
  on subscriptions for select
  using (auth.uid() = user_id);

The webhook handler uses the service-role client (which bypasses RLS) to write. Authenticated users read their own rows via the anon client. Feature access checks in your server components read this table:

async function hasActiveSubscription(userId: string): Promise<boolean> {
  const { data } = await supabaseAdmin
    .from("subscriptions")
    .select("status")
    .eq("user_id", userId)
    .eq("status", "active")
    .single();

  return data?.status === "active";
}

Test mode and the IPN simulator

PayProGlobal provides a test mode and an IPN simulator in the vendor panel. Before going live, use the simulator to send a test ORDER_COMPLETED notification to your webhook endpoint and verify the full flow: signature passes, database updates, email sends.

For local development, expose your local server with ngrok or cloudflared and configure the IPN URL in the simulator to point at your local tunnel. The exact IPN parameter values the simulator sends may differ slightly from live IPNs — test with real low-value transactions before launch.

cloudflared tunnel --url http://localhost:3000

Then set your IPN URL in the PayProGlobal vendor panel to the tunnel URL + /api/payproglobal/webhook.

Doing all of this in one command

The integration above — checkout URL builder, IPN signature verification, webhook handler, Supabase schema, RLS policies, service-role client, confirmation email — is what NoStripeKit ships pre-configured. You clone, run the setup wizard (npm run setup), add your PayProGlobal product ID, and the billing stack is live. The rest of your time goes to your actual product.

FAQ

Does PayProGlobal support webhooks for subscription events?

Yes. Beyond ORDER_COMPLETED, PayProGlobal sends IPN notifications for subscription renewals, cancellations, failed payments, and expirations. The handler structure above dispatches on notification-type, so adding new event types is a matter of adding new handler functions.

How do I handle the case where the webhook fires before the user exists in my database?

This can happen if a user purchases before completing account creation, or if the email on the PayProGlobal order differs from the one used for signup. The safest approach: on ORDER_COMPLETED, store the order in a pending_orders table keyed by email. On user signup, check pending_orders and provision access retroactively. NoStripeKit includes this reconciliation logic.

Should I use the service-role key in the webhook handler?

Yes — the webhook handler must write to the database without being constrained by the user's RLS policies (because there is no authenticated session at webhook time). The service-role key bypasses RLS. Keep it server-side only, never in client-accessible code, and never commit it to the repository.

Vlad Vityuk

Vlad Vityuk — full-stack engineer based in Kyiv, Ukraine. Builds B2B SaaS where Stripe does not work. Author of NoStripeKit, the Next.js + PayProGlobal boilerplate.