Documentation is currently in beta. Report issues →

Payments API

Endpoints for processing payments via Paystack.

Payment Flow Overview

1. User creates booking
        ↓
2. Frontend receives payment URL
        ↓
3. User redirected to Paystack checkout
        ↓
4. User completes payment
        ↓
5. Paystack sends webhook
        ↓
6. Backend confirms booking
        ↓
7. User redirected to confirmation page

Initialize Payment

Initialize a Paystack payment for a booking.

POST /api/payments/initialize

Request Body

{
  "bookingId": "clx789xyz...",
  "method": "MOBILE_MONEY",
  "callbackUrl": "https://y3nko.travel/dashboard/bookings/Y3K-ABC123"
}

Request Schema

FieldTypeRequiredDescription
bookingIdstringYesBooking CUID
methodstringNoPayment method hint
callbackUrlstringNoRedirect URL after payment

Payment Methods

ValueDescription
CARDDebit/Credit card
MOBILE_MONEYMobile Money (MTN, Vodafone, AirtelTigo)
BANK_TRANSFERBank transfer

Response

{
  "data": {
    "authorizationUrl": "https://checkout.paystack.com/abc123xyz",
    "accessCode": "abc123xyz",
    "reference": "PAY-XYZ789"
  }
}

Errors

StatusCodeDescription
400ALREADY_PAIDBooking already has successful payment
404NOT_FOUNDBooking not found
403FORBIDDENNot your booking

Payment Status

Check the status of a payment.

GET /api/payments/{reference}/status

Path Parameters

ParameterTypeDescription
referencestringPayment reference

Response

{
  "data": {
    "payment": {
      "id": "clx999...",
      "status": "COMPLETED",
      "amount": "3123.75",
      "currency": "GHS",
      "method": "MOBILE_MONEY",
      "paystackRef": "PAY-XYZ789",
      "paidAt": "2024-02-20T10:35:00Z"
    },
    "booking": {
      "id": "clx789xyz...",
      "reference": "Y3K-ABC123",
      "status": "CONFIRMED"
    }
  }
}

Payment Statuses

StatusDescription
PENDINGPayment initialized, awaiting completion
PROCESSINGPayment being processed
COMPLETEDPayment successful
FAILEDPayment failed
REFUNDEDPayment refunded

Paystack Webhook

Endpoint for receiving Paystack webhook events.

POST /api/webhooks/paystack
⚠️

This endpoint is called by Paystack servers. It verifies the webhook signature before processing.

Headers

HeaderDescription
x-paystack-signatureHMAC SHA512 signature

Webhook Events Handled

EventAction
charge.successConfirm booking, send confirmation email
charge.failedMark payment as failed
refund.processedUpdate payment status

Response

{
  "received": true
}

Webhook Implementation

// app/api/webhooks/paystack/route.ts
import { NextRequest, NextResponse } from 'next/server'
import crypto from 'crypto'
import { prisma } from '@/lib/db'
import { sendBookingConfirmation } from '@/lib/email'
 
export async function POST(req: NextRequest) {
  const body = await req.text()
  const signature = req.headers.get('x-paystack-signature')
 
  // Verify signature
  const hash = crypto
    .createHmac('sha512', process.env.PAYSTACK_WEBHOOK_SECRET!)
    .update(body)
    .digest('hex')
 
  if (hash !== signature) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
  }
 
  const event = JSON.parse(body)
 
  switch (event.event) {
    case 'charge.success':
      await handleSuccessfulPayment(event.data)
      break
    case 'charge.failed':
      await handleFailedPayment(event.data)
      break
  }
 
  return NextResponse.json({ received: true })
}
 
async function handleSuccessfulPayment(data: any) {
  const { reference, metadata, channel, paid_at } = data
 
  // Update payment
  await prisma.payment.update({
    where: { paystackRef: reference },
    data: {
      status: 'COMPLETED',
      method: mapPaystackChannel(channel),
      paidAt: new Date(paid_at)
    }
  })
 
  // Update booking
  const booking = await prisma.booking.update({
    where: { id: metadata.bookingId },
    data: {
      status: 'CONFIRMED',
      confirmedAt: new Date()
    },
    include: { listing: true, user: true }
  })
 
  // Block dates
  await blockDates(booking.listingId, booking.checkIn, booking.checkOut)
 
  // Send email
  await sendBookingConfirmation(booking)
}

Pricing Calculation

Pricing is calculated server-side to prevent manipulation.

// lib/pricing.ts
const SERVICE_FEE_RATE = 0.10  // 10%
const TAX_RATE = 0.125         // 12.5% (VAT + Tourism Levy)
 
export function calculatePricing(input: {
  pricePerNight: number
  nights: number
  currency: 'GHS' | 'USD'
}) {
  const subtotal = input.pricePerNight * input.nights
  const serviceFee = Math.round(subtotal * SERVICE_FEE_RATE * 100) / 100
  const taxes = Math.round(subtotal * TAX_RATE * 100) / 100
  const total = Math.round((subtotal + serviceFee + taxes) * 100) / 100
 
  return {
    pricePerNight: input.pricePerNight,
    nights: input.nights,
    subtotal,
    serviceFee,
    taxes,
    total,
    currency: input.currency
  }
}

Pricing Breakdown Example

For a 3-night stay at GHS 850/night:

ItemAmount
Subtotal (3 × 850)GHS 2,550.00
Service Fee (10%)GHS 255.00
Taxes (12.5%)GHS 318.75
TotalGHS 3,123.75

Testing Payments

Test Cards

Card NumberResult
4084 0840 8408 4081Success
4084 0840 8408 4092Failure

Additional Test Card Details:

  • CVV: 408
  • Expiry: Any future date
  • PIN: 0000
  • OTP: 123456

Test Mobile Money

NetworkPhone NumberOTP
MTN0551234987123456

Local Webhook Testing

Use ngrok to receive webhooks locally:

ngrok http 3000

Then configure the webhook URL in Paystack dashboard:

https://abc123.ngrok.io/api/webhooks/paystack

Refund Processing

// lib/refunds.ts
import Paystack from 'paystack-node'
 
const paystack = new Paystack(process.env.PAYSTACK_SECRET_KEY!)
 
export async function processRefund(bookingReference: string, reason: string) {
  const booking = await prisma.booking.findUnique({
    where: { reference: bookingReference },
    include: { payments: { where: { status: 'COMPLETED' } } }
  })
 
  const payment = booking.payments[0]
  if (!payment) throw new Error('No completed payment found')
 
  // Calculate refund amount based on policy
  const refundAmount = calculateRefundAmount(booking)
  if (refundAmount <= 0) {
    return { success: false, message: 'No refund applicable' }
  }
 
  // Process refund via Paystack
  const response = await paystack.refund.create({
    transaction: payment.paystackRef,
    amount: Math.round(refundAmount * 100) // pesewas
  })
 
  if (response.data.status === 'processed') {
    await prisma.payment.update({
      where: { id: payment.id },
      data: { status: 'REFUNDED' }
    })
 
    await prisma.booking.update({
      where: { id: booking.id },
      data: {
        status: 'CANCELLED',
        cancelledAt: new Date(),
        cancelReason: reason
      }
    })
 
    return { success: true, refundAmount }
  }
 
  return { success: false, message: response.data.message }
}

Paystack amounts are in pesewas (1 GHS = 100 pesewas). Always multiply GHS amounts by 100.