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 pageInitialize Payment
Initialize a Paystack payment for a booking.
POST /api/payments/initializeRequest Body
{
"bookingId": "clx789xyz...",
"method": "MOBILE_MONEY",
"callbackUrl": "https://y3nko.travel/dashboard/bookings/Y3K-ABC123"
}Request Schema
| Field | Type | Required | Description |
|---|---|---|---|
bookingId | string | Yes | Booking CUID |
method | string | No | Payment method hint |
callbackUrl | string | No | Redirect URL after payment |
Payment Methods
| Value | Description |
|---|---|
CARD | Debit/Credit card |
MOBILE_MONEY | Mobile Money (MTN, Vodafone, AirtelTigo) |
BANK_TRANSFER | Bank transfer |
Response
{
"data": {
"authorizationUrl": "https://checkout.paystack.com/abc123xyz",
"accessCode": "abc123xyz",
"reference": "PAY-XYZ789"
}
}Errors
| Status | Code | Description |
|---|---|---|
| 400 | ALREADY_PAID | Booking already has successful payment |
| 404 | NOT_FOUND | Booking not found |
| 403 | FORBIDDEN | Not your booking |
Payment Status
Check the status of a payment.
GET /api/payments/{reference}/statusPath Parameters
| Parameter | Type | Description |
|---|---|---|
reference | string | Payment 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
| Status | Description |
|---|---|
PENDING | Payment initialized, awaiting completion |
PROCESSING | Payment being processed |
COMPLETED | Payment successful |
FAILED | Payment failed |
REFUNDED | Payment 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
| Header | Description |
|---|---|
x-paystack-signature | HMAC SHA512 signature |
Webhook Events Handled
| Event | Action |
|---|---|
charge.success | Confirm booking, send confirmation email |
charge.failed | Mark payment as failed |
refund.processed | Update 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:
| Item | Amount |
|---|---|
| Subtotal (3 × 850) | GHS 2,550.00 |
| Service Fee (10%) | GHS 255.00 |
| Taxes (12.5%) | GHS 318.75 |
| Total | GHS 3,123.75 |
Testing Payments
Test Cards
| Card Number | Result |
|---|---|
4084 0840 8408 4081 | Success |
4084 0840 8408 4092 | Failure |
Additional Test Card Details:
- CVV:
408 - Expiry: Any future date
- PIN:
0000 - OTP:
123456
Test Mobile Money
| Network | Phone Number | OTP |
|---|---|---|
| MTN | 0551234987 | 123456 |
Local Webhook Testing
Use ngrok to receive webhooks locally:
ngrok http 3000Then configure the webhook URL in Paystack dashboard:
https://abc123.ngrok.io/api/webhooks/paystackRefund 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.