Skip to main content
Home

Developer API

Integrate the Payment Integration API into your application. Authentication, payments, plans, subscriptions, and webhooks.

Introduction#

The Payment Integration API is a RESTful, JSON-only service that aggregates Paystack, Flutterwave, and Hubtel behind a single tenant-scoped surface. Every endpoint expects and returns JSON; every protected endpoint is scoped to your client (tenant).

The host shown in the snippets below is https://api.example.com. Replace it with your deployment URL (typically http://localhost:8080 when running locally).

Already authenticated?

The interactive Swagger reference at https://api.example.com/swagger/ covers every endpoint with parameter docs and live "Try it out" support.

Authentication#

Two auth modes are accepted on protected routes. JWT (Bearer token) is the recommended default; API keys exist for server-to-server and demo flows.

JWT (recommended)#

Exchange email + password for an access JWT and a longer-lived refresh JWT. Send the access token as a Bearer header on subsequent calls.

login.sh
curl -X POST 'https://api.example.com/api/v1/auth/login/client' \
  -H 'Content-Type: application/json' \
  -d '{"email":"you@example.com","password":"********"}'
200 OK
{
  "token": "eyJhbGciOi...",        // 24h access token
  "refresh_token": "eyJhbGciOi...",// 30-day refresh token
  "client": {
    "id": "0e3c0c5a-...",
    "name": "Acme Corp",
    "email": "you@example.com",
    "slug": "acme-corp",
    "password_changed": true
  }
}
authenticated.sh
curl -X GET 'https://api.example.com/api/v1/me' \
  -H 'Authorization: Bearer eyJhbGciOi...'

API key#

API keys are issued from the dashboard (/api-keys) or programmatically. Send them as X-API-Key:

api-key-call.sh
curl -X GET 'https://api.example.com/api/v1/transactions' \
  -H 'X-API-Key: pi_live_abc123...'

Where to use which

Prefer JWT for browser sessions (refresh works, logout revokes server-side). Prefer API keys for backend integrations where you can store the key in a vault. Never ship an API key in client-side code that runs in a browser.

Refreshing tokens#

Access tokens expire (default 24h). When you get a 401, exchange your refresh token for a fresh pair. The previous refresh token is revoked atomically.

refresh.sh
curl -X POST 'https://api.example.com/api/v1/auth/refresh' \
  -H 'Content-Type: application/json' \
  -d '{"refresh_token":"eyJhbGciOi..."}'

The web client does this automatically on 401 — see src/services/api/client.ts.

Logging out#

Logout revokes the refresh token server-side so it cannot be used again, even if it leaks. Idempotent — unknown tokens still return 200.

bash
curl -X POST 'https://api.example.com/api/v1/auth/logout' \
  -H 'Content-Type: application/json' \
  -d '{"refresh_token":"eyJhbGciOi..."}'

Quick start#

Three calls: log in, initiate a one-time payment, redirect the user to the gateway.

quickstart.js
// 1. Log in (browser or server)
const login = await fetch('https://api.example.com/api/v1/auth/login/client', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email: 'you@example.com', password: process.env.PWD }),
}).then(r => r.json());

const token = login.token;

// 2. Initiate a payment
const payment = await fetch('https://api.example.com/api/v1/payments/initiate', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${token}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    email: 'customer@example.com',
    amount: 5000,           // major units (e.g. 5000 NGN)
    currency: 'NGN',
    provider: 'paystack',
    callback: 'https://yourapp.com/payment/callback',
  }),
}).then(r => r.json());

// 3. Redirect the customer to the gateway page
window.location.href = payment.redirect_url;
// On callback, you'll get ?reference=trx_xxx&provider=paystack
//   GET /api/v1/payments/verify/{reference}?provider=paystack

Reference handling

You can pass your own reference in the body and the gateway will use it. Otherwise the API generates one (trx_-prefixed UUID).

Payments#

Initiate a payment#

http
POST /api/v1/payments/initiate
Authorization: Bearer <token>
Content-Type: application/json

{
  "email": "customer@example.com",
  "amount": 5000,
  "currency": "NGN",
  "provider": "paystack",                    // paystack | flutterwave | hubtel
  "callback": "https://yourapp.com/return",
  "reference": "order_123"                   // optional; auto-generated if absent
}
200 OK
{
  "is_redirect": true,
  "redirect_url": "https://checkout.paystack.com/...",
  "reference": "order_123"
}

Verify a payment#

Call this on your callback URL with the reference the gateway returns. The API checks with the gateway, updates the local transaction status, and returns the canonical state.

http
GET /api/v1/payments/verify/{reference}?provider=paystack
Authorization: Bearer <token>
200 OK
{
  "status": "success",        // success | failed | pending
  "internal_ref": "order_123",
  "gateway_ref": "T123456",
  "amount": 5000,
  "currency": "NGN"
}
Webhooks update the same transaction asynchronously when the gateway confirms, so a verify is idempotent — calling it twice on a successful payment doesn't change anything.

List transactions#

http
GET /api/v1/transactions?page=1&page_size=20&status=completed&gateway=paystack
Authorization: Bearer <token>

Filters: status, gateway, currency_code, start_date, end_date, internal_ref, gateway_ref, customer_id, subscription_id. Sort: sort_by{created_at|amount|status} + sort_order.

Plans & Subscriptions#

Create a plan#

CreatePlan is supported on Paystack and Flutterwave. Hubtel doesn't expose a plan API.
http
POST /api/v1/plans
Authorization: Bearer <token>
Content-Type: application/json

{
  "name": "Pro Monthly",
  "amount": 10000,
  "currency_code": "NGN",
  "interval": "monthly",      // daily | weekly | monthly | annually
  "provider": "paystack"
}

List plans#

http
GET /api/v1/plans
Authorization: Bearer <token>

Create a subscription#

Subscription creation is currently Paystack-only. The plan must already have a Paystack provider link.
http
POST /api/v1/subscriptions/create
Authorization: Bearer <token>
Content-Type: application/json

{
  "plan_id": "0a3...uuid",
  "email": "customer@example.com",
  "name": "Customer Name",   // optional
  "user_id": "your-user-uuid", // optional; for your own user mapping
  "customer_id": "existing-customer-uuid" // optional; reuses an existing Customer
}

The response includes an authorization_url when the gateway needs the customer to authorize a card on file. Redirect them there to complete the subscription setup.

Cancel a subscription#

http
DELETE /api/v1/gateways/{gateway}/subscriptions/{id}
Authorization: Bearer <token>

Cancels at the gateway when possible (Paystack requires email_token, stored by the API in the subscription metadata) and soft-deletes the local row.

BYO gateway credentials#

Each tenant can supply their own Paystack/Flutterwave/Hubtel credentials, encrypted at rest with AES-GCM. When set, the API uses these instead of the global keys.

http
PUT /api/v1/client/gateways/paystack/config
Authorization: Bearer <token>
Content-Type: application/json

{
  "secret_key": "sk_live_...",
  "public_key": "pk_live_...",
  "is_enabled": true
}

The GET response is masked: it returns flags (has_secret_key, has_api_key, …) but never the plaintext.

GET response
{
  "gateway": "paystack",
  "configured": true,
  "is_enabled": true,
  "public_key": "pk_live_...",
  "has_secret_key": true,
  "has_api_key": false,
  "has_api_secret": false,
  "has_webhook_secret_hash": false
}

Demo accounts

BYO config writes are blocked for demo tenants. Claim your account first. GETs still work so you can see "Not configured" state.

Webhooks#

URLs#

Configure these URLs in the corresponding gateway dashboards:

bash
Paystack    POST https://api.example.com/api/v1/webhooks/paystack
Flutterwave POST https://api.example.com/api/v1/webhooks/flutterwave
Hubtel      POST https://api.example.com/api/v1/webhooks/hubtel

The dashboard at /account/gateways shows your specific URLs (with the right host) and a copy button.

Event types we react to#

  • charge.success → updates the matching one-time transaction to completed
  • subscription.created → flips the subscription to active
  • subscription.cancelled / subscription.disable → flips to cancelled
  • invoice.payment_succeeded → records a transaction row + bumps next_billing_date

Other events are accepted (200 OK) but ignored.

Signatures#

Each gateway is verified before processing — the API will return 401 if the signature header is missing or wrong.

  • PaystackX-Paystack-Signature (HMAC-SHA512 of body with the secret key)
  • Flutterwaveverif-hash header must match the dashboard secret
  • Hubtel — IP allowlist (gateway-side); body signed via API credentials

Events are deduplicated by gateway + event ID with a 48-hour Redis TTL. Re-deliveries return 200 with "status":"duplicate".

Errors & rate limits#

All error responses are JSON of the form {"error": "human-readable message"}. Status codes follow standard conventions:

  • 400 — invalid input (validation, malformed body)
  • 401 — missing or invalid auth (try refresh)
  • 403 — authenticated but not allowed (e.g. demo account hitting BYO config write)
  • 404 — resource not found, or not yours
  • 409 — conflict (duplicate plan name, email already in use, plan still has subscriptions)
  • 429 — rate limit exceeded (see X-RateLimit-* headers)
  • 5xx — bug or upstream gateway issue; safe to retry idempotent calls

Authenticated routes are rate-limited at 100 req/min per client by default. The public demo endpoint is capped at 5 req/hour per IP.

Full API reference#

Every endpoint, schema, and error code is documented in the interactive Swagger spec. Run the API with SWAGGER_ENABLED=true (default outside production) and point at:

bash
https://api.example.com/swagger/

The raw OpenAPI 2.0 JSON lives at https://api.example.com/swagger/doc.json — drop it into Postman, Insomnia, or any code generator (openapi-generator, oapi-codegen) for typed client SDKs.