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.