JJuma Global logo Developer Docs
Jjuma Pay Documentation

Next.js Integration

A complete step by step guide for integrating JJuma payments into a Next.js application using secure server routes, checkout redirects, webhook verification, database transactions, idempotency, and UGX payment examples.

API base URL: https://api.jjuma.com Guide URL: /integration-guides/nextjs
Jjuma Pay documentation preview

Keep protected credentials on the Next.js server only.

Table of Contents

Jump directly to the parts of the Next.js guide you need.

Introduction

Security Never expose your Secret API Key or Webhook Secret using NEXT_PUBLIC variables, Client Components, browser JavaScript, frontend bundles, public repositories, or API responses. Keep protected credentials on the Next.js server.

JJuma protected payment operations must run on the Next.js server.

The browser must never directly receive the Secret API Key, Webhook Secret, protected authentication headers, or merchant database credentials.

This guide uses the Next.js App Router, Route Handlers, Server Components, server-side environment variables, Node.js crypto, and Prisma with a relational database example.

Merchants using the Pages Router can apply the same security principles through API routes, but the primary examples here use the App Router.

Recommended Project Structure

text
jjuma-nextjs-integration/
├── .env.local
├── .env.example
├── package.json
├── prisma/
│   └── schema.prisma
└── src/
    ├── app/
    │   ├── api/
    │   │   ├── payments/
    │   │   │   └── create/
    │   │   │       └── route.ts
    │   │   └── webhooks/
    │   │       └── jjuma/
    │   │           └── route.ts
    │   ├── payment/
    │   │   ├── success/
    │   │   │   └── page.tsx
    │   │   └── cancelled/
    │   │       └── page.tsx
    │   └── checkout/
    │       └── page.tsx
    ├── lib/
    │   ├── env.ts
    │   ├── jjuma-client.ts
    │   ├── prisma.ts
    │   └── orders.ts
    └── components/
        └── JjumaPaymentButton.tsx
App Router first Route Handlers and Server Components keep the payment logic on the server where the JJuma keys and webhook secret remain protected.

Environment Variables and Configuration

env
# .env.example
JJUMA_API_BASE_URL=https://api.jjuma.com
JJUMA_PUBLIC_API_KEY=bp_live_pub_your_public_key
JJUMA_SECRET_API_KEY=bp_live_sec_your_secret_key
JJUMA_WEBHOOK_SECRET=your_webhook_secret
JJUMA_CHECKOUT_HOST=pay.jjuma.com
DATABASE_URL=postgresql://user:password@localhost:5432/jjuma_pay
APP_URL=https://merchant.example.com
ts
export const env = {
  JJUMA_API_BASE_URL: process.env.JJUMA_API_BASE_URL ?? 'https://api.jjuma.com',
  JJUMA_PUBLIC_API_KEY: process.env.JJUMA_PUBLIC_API_KEY ?? '',
  JJUMA_SECRET_API_KEY: process.env.JJUMA_SECRET_API_KEY ?? '',
  JJUMA_WEBHOOK_SECRET: process.env.JJUMA_WEBHOOK_SECRET ?? '',
  JJUMA_CHECKOUT_HOST: process.env.JJUMA_CHECKOUT_HOST ?? 'pay.jjuma.com',
  DATABASE_URL: process.env.DATABASE_URL ?? '',
  APP_URL: process.env.APP_URL ?? 'https://merchant.example.com',
};

Prisma Schema

prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model Order {
  id                 String   @id @default(cuid())
  orderId            String   @unique
  amount             Decimal  @db.Decimal(15, 2)
  currency           String   @default("UGX")
  paymentStatus      String   @default("pending")
  jjumaTransactionId String?  @unique
  jjumaReference     String?
  paidAmount         Decimal? @db.Decimal(15, 2)
  paidAt             DateTime?
  createdAt          DateTime @default(now())
  updatedAt          DateTime @updatedAt
}

The order table should store the JJuma transaction ID, merchant reference, paid amount, currency, and payment timestamp so the webhook can update the order idempotently.

Reusable JJuma API Client

ts
type JJumaResponse = {
  message?: string;
  data?: { payment_url?: string };
  payment_url?: string;
};

async function request(path: string, init: RequestInit) {
  const response = await fetch(`${env.JJUMA_API_BASE_URL}${path}`, {
    ...init,
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/json',
      ...(init.headers ?? {}),
    },
    cache: 'no-store',
  });

  const data = (await response.json()) as JJumaResponse;
  if (!response.ok) {
    throw new Error(data.message ?? 'The JJuma request failed.');
  }

  return data;
}

export async function createPayment(payload: Record) {
  return request('/api/v1/payments/create', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${env.JJUMA_PUBLIC_API_KEY}`,
    },
    body: JSON.stringify(payload),
  });
}

export async function verifyPayment(transactionId: string) {
  return request(`/api/v1/payments/verify/${encodeURIComponent(transactionId)}`, {
    method: 'GET',
    headers: {
      Authorization: `Bearer ${env.JJUMA_SECRET_API_KEY}`,
    },
  });
}

Load Orders From the Merchant Database

ts
import { prisma } from './prisma';

export async function getOrder(orderId: string) {
  return prisma.order.findUnique({ where: { orderId } });
}

export async function markOrderPaid(args: {
  orderId: string;
  transactionId: string;
  reference: string;
  amount: string;
  currency: string;
}) {
  return prisma.$transaction(async (tx) => {
    const order = await tx.order.findUnique({
      where: { orderId: args.orderId },
    });

    if (!order || order.paymentStatus === 'paid') {
      return null;
    }

    return tx.order.update({
      where: { orderId: args.orderId },
      data: {
        paymentStatus: 'paid',
        jjumaTransactionId: args.transactionId,
        jjumaReference: args.reference,
        paidAmount: args.amount,
        paidAt: new Date(),
      },
    });
  });
}
Server-side pricing The browser should only submit the order ID. Load amount, currency, and payment status from the database.

Create Payment Route Handler

ts
import { NextResponse } from 'next/server';
import { createPayment } from '@/lib/jjuma-client';
import { getOrder } from '@/lib/orders';
import { env } from '@/lib/env';

export async function POST(request: Request) {
  const formData = await request.formData();
  const orderId = String(formData.get('orderId') ?? '').trim();

  if (!orderId) {
    return NextResponse.json({ message: 'Missing order ID.' }, { status: 400 });
  }

  const order = await getOrder(orderId);
  if (!order) {
    return NextResponse.json({ message: 'Order not found.' }, { status: 404 });
  }

  if (order.paymentStatus === 'paid') {
    return NextResponse.json({ message: 'This order has already been paid.' }, { status: 409 });
  }

  const result = await createPayment({
    amount: String(order.amount),
    currency: String(order.currency).toUpperCase(),
    description: `Payment for order ${orderId}`,
    redirect_url: `${env.APP_URL}/payment/success?orderId=${encodeURIComponent(orderId)}`,
    cancel_redirect_url: `${env.APP_URL}/payment/cancelled?orderId=${encodeURIComponent(orderId)}`,
    webhook_url: `${env.APP_URL}/api/webhooks/jjuma`,
    external_order_id: orderId,
    idempotency_key: `order-${orderId}`,
  });

  const paymentUrl = result.data?.payment_url ?? result.payment_url ?? '';
  if (!paymentUrl) {
    return NextResponse.json({ message: 'JJuma did not return a payment URL.' }, { status: 502 });
  }

  const checkoutHost = new URL(paymentUrl).host;
  if (checkoutHost !== env.JJUMA_CHECKOUT_HOST) {
    return NextResponse.json({ message: 'Untrusted checkout destination.' }, { status: 502 });
  }

  return NextResponse.redirect(paymentUrl, 303);
}
tsx
export function JjumaPaymentButton({ orderId }: { orderId: string }) {
  return (
    
); }

Checkout Page and Return Pages

tsx
import { JjumaPaymentButton } from '@/components/JjumaPaymentButton';
import { getOrder } from '@/lib/orders';

export default async function CheckoutPage({
  searchParams,
}: {
  searchParams: Promise<{ orderId?: string }>;
}) {
  const { orderId = '' } = await searchParams;
  const order = orderId ? await getOrder(orderId) : null;

  if (!order) {
    return <p>Order not found.</p>;
  }

  return (
    <main>
      <h1>Checkout</h1>
      <p>Order reference: {order.orderId}</p>
      <JjumaPaymentButton orderId={order.orderId} />
    </main>
  );
}
tsx
export default function PaymentSuccessPage() {
  return <main><h1>Payment received</h1><p>Your payment is being confirmed.</p></main>;
}
tsx
export default function PaymentCancelledPage() {
  return <main><h1>Payment not completed</h1><p>You can return and try again.</p></main>;
}
Redirects are not confirmation The return pages only show browser state. The webhook must perform the real database update.

Webhook Handler

ts
import crypto from 'node:crypto';
import { NextResponse } from 'next/server';
import { env } from '@/lib/env';
import { markOrderPaid } from '@/lib/orders';

function isValidSignature(rawBody: string, timestamp: string, signature: string) {
  const expected = crypto
    .createHmac('sha256', env.JJUMA_WEBHOOK_SECRET)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');
  const received = signature.replace(/^sha256=/i, '').trim();
  const expectedBuffer = Buffer.from(expected, 'hex');
  const receivedBuffer = Buffer.from(received, 'hex');
  return expectedBuffer.length === receivedBuffer.length && crypto.timingSafeEqual(expectedBuffer, receivedBuffer);
}

export async function POST(request: Request) {
  const rawBody = await request.text();
  const signature = request.headers.get('X-Jjuma-Signature')?.trim() ?? '';
  const timestamp = request.headers.get('X-Jjuma-Timestamp')?.trim() ?? '';

  if (!rawBody || !signature || !timestamp) {
    return NextResponse.json({ message: 'Missing webhook data.' }, { status: 400 });
  }

  if (Number.isNaN(Date.parse(timestamp))) {
    return NextResponse.json({ message: 'Invalid webhook timestamp.' }, { status: 401 });
  }

  if (!isValidSignature(rawBody, timestamp, signature)) {
    return NextResponse.json({ message: 'Invalid webhook signature.' }, { status: 401 });
  }

  const event = JSON.parse(rawBody) as {
    event?: string;
    data?: {
      order_id?: string;
      transaction_id?: string;
      reference?: string;
      amount?: string | number;
      currency?: string;
    };
  };

  const eventName = String(event.event ?? '').trim();
  const payment = event.data ?? {};

  if (!['payment.completed', 'payment.failed', 'payment.cancelled'].includes(eventName)) {
    return NextResponse.json({ status: 'ignored' });
  }

  if (eventName !== 'payment.completed') {
    return NextResponse.json({ status: 'ok' });
  }

  if (!payment.order_id || !payment.transaction_id || !payment.reference || !payment.amount || !payment.currency) {
    return NextResponse.json({ message: 'Completed payment is missing required fields.' }, { status: 400 });
  }

  await markOrderPaid({
    orderId: payment.order_id,
    transactionId: payment.transaction_id,
    reference: payment.reference,
    amount: String(payment.amount),
    currency: String(payment.currency).toUpperCase(),
  });

  return NextResponse.json({ status: 'ok' });
}
HTTP status Meaning
200 OKEvent verified and processed, ignored safely, or already processed
400 Bad RequestInvalid JSON or missing payment data
401 UnauthorizedInvalid signature or timestamp
405 Method Not AllowedRequest method is not POST
500 Internal Server ErrorTemporary server or database error
Confirmed JJuma webhook rules Headers: X-Jjuma-Signature, X-Jjuma-Timestamp, X-Jjuma-Event, and X-Jjuma-Delivery-Id. Signing format: HMAC SHA256 over timestamp.raw_body with the webhook secret. Confirmed payment events: payment.completed, payment.failed, and payment.cancelled.

Payment Validation

  • Validate the signature and timestamp before parsing any payment data.
  • Process only the completed event for successful payments.
  • Confirm the order exists and is still unpaid.
  • Compare the amount, currency, merchant reference, and transaction ID against the stored order.
  • Never trust browser-submitted pricing or status values.

Duplicate Protection

ts
if (order.paymentStatus === 'paid') {
  return NextResponse.json({ status: 'already_processed' }, { status: 200 });
}
  • Store the JJuma transaction ID.
  • Use a database transaction or row lock when applying the paid update.
  • Reject repeated webhook deliveries safely.
  • Never update the same order twice.

Optional Verification

ts
import { verifyPayment } from '@/lib/jjuma-client';

const verification = await verifyPayment(transactionId);
if (!verification.data?.payment_url && !verification.payment_url) {
  throw new Error('Payment verification failed.');
}

The signed webhook remains the primary source of truth. Use the verification endpoint as a secondary check after the webhook arrives.

Testing Locally

text
1. Create a test order in the database.
2. Open the Next.js checkout page in a browser.
3. Submit the payment form and confirm the server route redirects to the JJuma checkout URL.
4. Use a signed webhook payload to hit /api/webhooks/jjuma.
5. Confirm the order is updated once.
6. Send the same webhook again and confirm duplicate protection.
7. Check invalid signatures, invalid JSON, and missing fields return the documented errors.

Troubleshooting Common Problems

  • Webhook signature invalid: check the raw body, webhook secret, timestamp format, and any proxy that rewrites the request body.
  • Payment creation unauthorized: check the Bearer header, public key, and JJuma base URL.
  • Checkout redirects to the wrong host: validate the returned URL before redirecting.
  • Customer returns but order stays pending: the redirect is not confirmation; check the webhook route and selected events.
  • Payment processed twice: check duplicate protection and repeated webhook handling.
  • Prisma cannot connect: check the DATABASE_URL, SSL settings, and production database network rules.

Secure Deployment

  • Deploy the App Router routes on the Next.js server.
  • Keep credentials in server-side environment variables only.
  • Use HTTPS.
  • Keep the raw webhook body unchanged.
  • Forward webhook headers unchanged.
  • Set the production webhook URL to the deployed route handler.
  • Monitor application logs and database writes after launch.

Security Checklist

Keep secrets server-side

Never expose the Secret API Key or Webhook Secret in Client Components, browser code, or API responses.

Verify raw webhook body

Use the original request body, verify the timestamp, and compare the signature with timing-safe logic.

Validate payments

Load the amount and currency from the database and reject browser-submitted pricing.

Prevent duplicates

Store the JJuma transaction ID and use transactions or row locks before updating the order.

Final Integration Checklist

Final pass Next.js app created, server environment variables configured, Prisma schema added, JJuma client added, payment route handler added, checkout page added, success and cancel pages added, webhook route handler added, raw body verification added, required events selected, signature verification confirmed, amount and currency validation added, duplicate protection added, HTTPS enabled, and production webhook tested.

Full Payment Flow

The customer opens the Next.js checkout page, the server loads the order, creates a JJuma payment, redirects to the hosted checkout page, receives a signed webhook, verifies the raw body and timestamp, validates the amount and currency, updates the database once, and then shows the success or cancelled page.