Collect payments with the DojaPay API
Give each customer or order its own bank account number. When they transfer into it from any Nigerian bank app, we credit your DojaPay wallet and notify your server.
- Sign in with your public and secret key to get an access token.
- Create a virtual account for a customer (static, reusable) or for one order (dynamic, expires).
- Your customer transfers to that account number.
- We send an
inflow.receivedwebhook to your server and credit your wallet. - Confirm the payment by its reference, then fulfil.
POST https://api.dojapay.com/api/v1/virtual-account/create
Authorization: Bearer <access token>
Accept: application/json
Content-Type: application/json
Prefer to explore in a tool?
Import the whole API into Postman, or point any OpenAPI tool at the spec.
Getting started
Quickstart
From nothing to a working integration in a few minutes. Everything here uses sandbox keys, so it only ever touches test data and no real money moves.
- Create a DojaPay account at dojapay.com. Your sandbox keys are under Developer on your dashboard; set your webhook URL on the same page.
- Put the keys in your environment, never in source code.
- Sign in and keep the
access_token. It lasts 24 hours; reuse it. - Create a test account with create-test. You get back an account number starting 99.
- Pay it with Simulate a payment. Your webhook URL receives
inflow.received, just as it will for a real transfer. - Handle the webhook and confirm each payment before you fulfil. See Handling notifications for a working receiver.
- Go live. When your integration works, email support@dojapay.com. Live keys are issued once your business verification is approved; then sign in with them and call create instead of create-test.
export DOJAPAY_PUBLIC_KEY="your sandbox public key"
export DOJAPAY_SECRET_KEY="your sandbox secret key"
curl -X POST "https://api.dojapay.com/api/v1/auth/login" \
-u "$DOJAPAY_PUBLIC_KEY:$DOJAPAY_SECRET_KEY" \
-H "Accept: application/json"
export DOJAPAY_ACCESS_TOKEN="the data.access_token you got back"
curl -X POST "https://api.dojapay.com/api/v1/virtual-account/create-test" \
-H "Authorization: Bearer $DOJAPAY_ACCESS_TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"accountType": "STATIC",
"bank": "9psb",
"firstName": "Ada",
"lastName": "Obi",
"email": "ada@example.com",
"bvn": "22123456789"
}'
curl -X POST "https://api.dojapay.com/api/v1/sandbox/simulate-payment" \
-H "Authorization: Bearer $DOJAPAY_ACCESS_TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{ "accountNumber": "the data.account.number you got back", "amount": 15000 }'
Getting started
Keys and environments
There are two kinds of key, sandbox and live, and one base URL for both. The key you sign in with decides which environment every request runs in.
- Sandbox keys come with every new account and only ever touch test data: test accounts, simulated payments, test balances. No bank is involved and no money moves. See Testing in the sandbox.
- Live keys are issued once your business verification is approved. Only they can call create.
- Using the wrong kind of key for an endpoint returns HTTP
403with code"11". - Keep your secret key on your server. Never put it in a mobile app, a web page or a public repository.
Check your credentials tells you which environment a token belongs to.
Getting started
Response format
Every response has the same envelope. Check success and code, not the message text, which can change. Values in the examples on this page are illustrative.
A validation failure puts the reason for each field in errors.
One exception. An error raised before your request reaches an endpoint (HTTP 429 rate limit, 404 unknown path, 405 wrong method) uses the same envelope, but code is "08" and message is an object, not a list. For these, act on the HTTP status.
{
"success": true,
"code": "00",
"message": [
"Transaction status retrieved"
],
"data": {
"...": "..."
},
"errors": null,
"reference": "DJP-20261029101500K7Q2M9XA",
"timestamp": "2026-10-29T10:15:00+01:00"
}
{
"success": false,
"code": "04",
"message": [
"Validation failed"
],
"data": null,
"errors": {
"email": [
"The email field must be a valid email address."
]
},
"reference": "DJP-20261029101500K7Q2M9XA",
"timestamp": "2026-10-29T10:15:00+01:00"
}
{
"success": false,
"code": "08",
"message": {
"code": 429,
"message": "Too Many Requests."
},
"data": null,
"errors": null,
"reference": "DJP-20261029101500K7Q2M9XA",
"timestamp": "2026-10-29T10:15:00+01:00"
}
Authentication
Sign in
/auth/login
Exchange your public and secret key for an access token. Send the keys as HTTP Basic credentials and no body.
Send the access token on every other request as Authorization: Bearer <access token>. It lasts 24 hours; expires_in is in seconds. Signing in again while it is still valid returns the same token, so it is safe to call on start-up.
The limit here is 5 a minute. Keep the token and reuse it; do not sign in per request.
Errors
- 401 · 02 The keys are wrong.
- 403 · 11 The key or the merchant account is not active.
curl -X POST "https://api.dojapay.com/api/v1/auth/login" \
-u "$DOJAPAY_PUBLIC_KEY:$DOJAPAY_SECRET_KEY" \
-H "Accept: application/json"
const credentials = Buffer.from(
`${process.env.DOJAPAY_PUBLIC_KEY}:${process.env.DOJAPAY_SECRET_KEY}`
).toString("base64");
const res = await fetch("https://api.dojapay.com/api/v1/auth/login", {
method: "POST",
headers: {
Authorization: `Basic ${credentials}`,
Accept: "application/json",
},
});
const body = await res.json();
$ch = curl_init('https://api.dojapay.com/api/v1/auth/login');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_USERPWD => getenv('DOJAPAY_PUBLIC_KEY') . ':' . getenv('DOJAPAY_SECRET_KEY'),
CURLOPT_HTTPHEADER => [
'Accept: application/json',
],
CURLOPT_POSTFIELDS => '',
]);
$body = json_decode(curl_exec($ch), true);
import os
import requests
res = requests.post(
"https://api.dojapay.com/api/v1/auth/login",
auth=(os.environ["DOJAPAY_PUBLIC_KEY"], os.environ["DOJAPAY_SECRET_KEY"]),
headers={"Accept": "application/json"},
timeout=30,
)
body = res.json()
{
"success": true,
"code": "00",
"message": [
"Authentication successful"
],
"data": {
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOi...",
"refresh_token": "8b1f0c4e9a2d...",
"token_type": "Bearer",
"expires_in": 86400
},
"errors": null,
"reference": "DJP-20261029101500K7Q2M9XA",
"timestamp": "2026-10-29T10:15:00+01:00"
}
Authentication
Refresh a token
/auth/refresh
Get a new access token without sending your secret key. Needs no access token, so it works after the old one has expired.
You get a new access token and a new refresh token. The old refresh token stops working at once, so store the new one. Refresh tokens last 30 days.
Body
-
refresh_tokenstring requiredThe refresh token from your last sign-in or refresh.
Errors
- 401 · 02 The refresh token is unknown, already used, or older than 30 days.
- 403 · 11 The key or the merchant account is not active.
- 422 · 04 refresh_token is missing.
curl -X POST "https://api.dojapay.com/api/v1/auth/refresh" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"refresh_token": "8b1f0c4e9a2d..."
}'
const res = await fetch("https://api.dojapay.com/api/v1/auth/refresh", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
refresh_token: "8b1f0c4e9a2d...",
}),
});
const body = await res.json();
$ch = curl_init('https://api.dojapay.com/api/v1/auth/refresh');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'refresh_token' => '8b1f0c4e9a2d...',
]),
]);
$body = json_decode(curl_exec($ch), true);
import os
import requests
res = requests.post(
"https://api.dojapay.com/api/v1/auth/refresh",
headers={"Accept": "application/json"},
json={
"refresh_token": "8b1f0c4e9a2d...",
},
timeout=30,
)
body = res.json()
{
"success": true,
"code": "00",
"message": [
"Token refreshed successfully"
],
"data": {
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOi...",
"refresh_token": "5d0e7a91c3f4...",
"token_type": "Bearer",
"expires_in": 86400
},
"errors": null,
"reference": "DJP-20261029101500K7Q2M9XA",
"timestamp": "2026-10-29T10:15:00+01:00"
}
Authentication
Check your credentials
/auth/me
The merchant and key behind the current access token.
curl "https://api.dojapay.com/api/v1/auth/me" \
-H "Authorization: Bearer $DOJAPAY_ACCESS_TOKEN" \
-H "Accept: application/json"
const res = await fetch("https://api.dojapay.com/api/v1/auth/me", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.DOJAPAY_ACCESS_TOKEN}`,
Accept: "application/json",
},
});
const body = await res.json();
$ch = curl_init('https://api.dojapay.com/api/v1/auth/me');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DOJAPAY_ACCESS_TOKEN'),
'Accept: application/json',
],
]);
$body = json_decode(curl_exec($ch), true);
import os
import requests
res = requests.get(
"https://api.dojapay.com/api/v1/auth/me",
headers={"Authorization": f"Bearer {os.environ['DOJAPAY_ACCESS_TOKEN']}", "Accept": "application/json"},
timeout=30,
)
body = res.json()
{
"success": true,
"code": "00",
"message": [
"Authenticated"
],
"data": {
"merchant": {
"business_name": "Acme Ltd",
"contact_email": "dev@acme.ng",
"status": "active"
},
"credential": {
"environment": "sandbox",
"status": "active",
"ip_restricted": false,
"token_expires_at": "2026-10-30T10:15:00+01:00"
}
},
"errors": null,
"reference": "DJP-20261029101500K7Q2M9XA",
"timestamp": "2026-10-29T10:15:00+01:00"
}
Authentication
Sign out
/auth/logout
Invalidate the current access token immediately. Sign in again to get a new one.
curl -X POST "https://api.dojapay.com/api/v1/auth/logout" \
-H "Authorization: Bearer $DOJAPAY_ACCESS_TOKEN" \
-H "Accept: application/json"
const res = await fetch("https://api.dojapay.com/api/v1/auth/logout", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.DOJAPAY_ACCESS_TOKEN}`,
Accept: "application/json",
},
});
const body = await res.json();
$ch = curl_init('https://api.dojapay.com/api/v1/auth/logout');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DOJAPAY_ACCESS_TOKEN'),
'Accept: application/json',
],
CURLOPT_POSTFIELDS => '',
]);
$body = json_decode(curl_exec($ch), true);
import os
import requests
res = requests.post(
"https://api.dojapay.com/api/v1/auth/logout",
headers={"Authorization": f"Bearer {os.environ['DOJAPAY_ACCESS_TOKEN']}", "Accept": "application/json"},
timeout=30,
)
body = res.json()
{
"success": true,
"code": "00",
"message": [
"Token invalidated"
],
"data": [],
"errors": null,
"reference": "DJP-20261029101500K7Q2M9XA",
"timestamp": "2026-10-29T10:15:00+01:00"
}
Virtual accounts
Create a virtual account
/virtual-account/create
Give a customer or an order its own bank account number. Payments into it are credited to your wallet and notified to your webhook URL.
Show your customer data.account.number and the bank, and keep the number against the order or customer in your own system: it is how you will recognise the payment when the notification arrives. An account.created webhook is also sent; you can ignore it.
The example is a dynamic account for one order of exactly ₦15,000, valid for 3 hours. For a static account, send "accountType": "STATIC" and leave out amountType, amount and expiryHours.
Body
-
accountTypestring requiredSTATIC belongs to one customer and can be paid any number of times. DYNAMIC is for one order and expires.
STATICDYNAMIC -
bankstring requiredThe issuing bank. Lowercase, exactly: "9PSB" is rejected.
9psb -
firstNamestring requiredCustomer first name.
-
lastNamestring requiredCustomer last name.
-
emailstring requiredCustomer email address.
-
bvnstring bvn or nin requiredCustomer BVN, 11 digits. Send bvn or nin.
-
ninstring bvn or nin requiredCustomer NIN, 11 digits. Required when bvn is absent.
-
amountTypestring required for DYNAMICWhat the account accepts. Required for DYNAMIC accounts.
EXACTANYHIGHEROREXACTLOWEROREXACT -
amountnumber required for DYNAMICIn naira. Required for DYNAMIC accounts unless amountType is ANY.
-
expiryHoursinteger required for DYNAMICHow long the account accepts payment, in whole hours from 1 to 24. Required for DYNAMIC accounts.
Errors
- 403 · 11 Signed in with a sandbox key. Use create-test.
- 422 · 04 A field is missing or invalid; see errors.
curl -X POST "https://api.dojapay.com/api/v1/virtual-account/create" \
-H "Authorization: Bearer $DOJAPAY_ACCESS_TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"accountType": "DYNAMIC",
"amountType": "EXACT",
"amount": 15000,
"expiryHours": 3,
"bank": "9psb",
"firstName": "Ada",
"lastName": "Obi",
"email": "ada@example.com",
"nin": "12345678901"
}'
const res = await fetch("https://api.dojapay.com/api/v1/virtual-account/create", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.DOJAPAY_ACCESS_TOKEN}`,
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
accountType: "DYNAMIC",
amountType: "EXACT",
amount: 15000,
expiryHours: 3,
bank: "9psb",
firstName: "Ada",
lastName: "Obi",
email: "ada@example.com",
nin: "12345678901",
}),
});
const body = await res.json();
$ch = curl_init('https://api.dojapay.com/api/v1/virtual-account/create');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DOJAPAY_ACCESS_TOKEN'),
'Accept: application/json',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'accountType' => 'DYNAMIC',
'amountType' => 'EXACT',
'amount' => 15000,
'expiryHours' => 3,
'bank' => '9psb',
'firstName' => 'Ada',
'lastName' => 'Obi',
'email' => 'ada@example.com',
'nin' => '12345678901',
]),
]);
$body = json_decode(curl_exec($ch), true);
import os
import requests
res = requests.post(
"https://api.dojapay.com/api/v1/virtual-account/create",
headers={"Authorization": f"Bearer {os.environ['DOJAPAY_ACCESS_TOKEN']}", "Accept": "application/json"},
json={
"accountType": "DYNAMIC",
"amountType": "EXACT",
"amount": 15000,
"expiryHours": 3,
"bank": "9psb",
"firstName": "Ada",
"lastName": "Obi",
"email": "ada@example.com",
"nin": "12345678901",
},
timeout=30,
)
body = res.json()
{
"success": true,
"code": "00",
"message": [
"Virtual account created/updated successfully"
],
"data": {
"event_type": "account.created",
"account": {
"name": "Ada Obi",
"number": "5030012345",
"type": "DYNAMIC",
"bank": "9PSB",
"expiry": {
"date": "2026-10-29T13:15:00"
}
},
"order": {
"amount": 15000,
"currency": "NGN",
"description": "Acme Ltd virtual account request",
"country": "NGA",
"amounttype": "EXACT"
},
"customer": {
"email": "ada@example.com",
"nin": "12345678901"
}
},
"errors": null,
"reference": "DJP-20261029101500K7Q2M9XA",
"timestamp": "2026-10-29T10:15:00+01:00"
}
Virtual accounts
Create a test account
/virtual-account/create-test
The same request and response as create, with a sandbox key. You get a test account number starting 99: no bank is involved and no money can be paid into it. Pay it with Simulate a payment.
Body
-
accountTypestring requiredSTATIC belongs to one customer and can be paid any number of times. DYNAMIC is for one order and expires.
STATICDYNAMIC -
bankstring requiredThe issuing bank. Lowercase, exactly: "9PSB" is rejected.
9psb -
firstNamestring requiredCustomer first name.
-
lastNamestring requiredCustomer last name.
-
emailstring requiredCustomer email address.
-
bvnstring bvn or nin requiredCustomer BVN, 11 digits. Send bvn or nin.
-
ninstring bvn or nin requiredCustomer NIN, 11 digits. Required when bvn is absent.
-
amountTypestring required for DYNAMICWhat the account accepts. Required for DYNAMIC accounts.
EXACTANYHIGHEROREXACTLOWEROREXACT -
amountnumber required for DYNAMICIn naira. Required for DYNAMIC accounts unless amountType is ANY.
-
expiryHoursinteger required for DYNAMICHow long the account accepts payment, in whole hours from 1 to 24. Required for DYNAMIC accounts.
Errors
- 403 · 11 Signed in with a live key. Use create.
- 422 · 04 A field is missing or invalid; see errors.
curl -X POST "https://api.dojapay.com/api/v1/virtual-account/create-test" \
-H "Authorization: Bearer $DOJAPAY_ACCESS_TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"accountType": "DYNAMIC",
"amountType": "EXACT",
"amount": 15000,
"expiryHours": 3,
"bank": "9psb",
"firstName": "Ada",
"lastName": "Obi",
"email": "ada@example.com",
"nin": "12345678901"
}'
const res = await fetch("https://api.dojapay.com/api/v1/virtual-account/create-test", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.DOJAPAY_ACCESS_TOKEN}`,
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
accountType: "DYNAMIC",
amountType: "EXACT",
amount: 15000,
expiryHours: 3,
bank: "9psb",
firstName: "Ada",
lastName: "Obi",
email: "ada@example.com",
nin: "12345678901",
}),
});
const body = await res.json();
$ch = curl_init('https://api.dojapay.com/api/v1/virtual-account/create-test');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DOJAPAY_ACCESS_TOKEN'),
'Accept: application/json',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'accountType' => 'DYNAMIC',
'amountType' => 'EXACT',
'amount' => 15000,
'expiryHours' => 3,
'bank' => '9psb',
'firstName' => 'Ada',
'lastName' => 'Obi',
'email' => 'ada@example.com',
'nin' => '12345678901',
]),
]);
$body = json_decode(curl_exec($ch), true);
import os
import requests
res = requests.post(
"https://api.dojapay.com/api/v1/virtual-account/create-test",
headers={"Authorization": f"Bearer {os.environ['DOJAPAY_ACCESS_TOKEN']}", "Accept": "application/json"},
json={
"accountType": "DYNAMIC",
"amountType": "EXACT",
"amount": 15000,
"expiryHours": 3,
"bank": "9psb",
"firstName": "Ada",
"lastName": "Obi",
"email": "ada@example.com",
"nin": "12345678901",
},
timeout=30,
)
body = res.json()
{
"success": true,
"code": "00",
"message": [
"Virtual account created/updated successfully"
],
"data": {
"event_type": "account.created",
"account": {
"name": "Ada Obi",
"number": "5030012345",
"type": "DYNAMIC",
"bank": "9PSB",
"expiry": {
"date": "2026-10-29T13:15:00"
}
},
"order": {
"amount": 15000,
"currency": "NGN",
"description": "Acme Ltd virtual account request",
"country": "NGA",
"amounttype": "EXACT"
},
"customer": {
"email": "ada@example.com",
"nin": "12345678901"
}
},
"errors": null,
"reference": "DJP-20261029101500K7Q2M9XA",
"timestamp": "2026-10-29T10:15:00+01:00"
}
Virtual accounts
Get an account
/virtual-account/details/{accountNumber}
One of your virtual accounts, by its account number.
Path parameters
-
accountNumberstring requiredThe 10-digit account number.
Errors
- 404 · 26 No account with that number belongs to you.
- 422 · 04 The account number is not 10 characters.
curl "https://api.dojapay.com/api/v1/virtual-account/details/5030012345" \
-H "Authorization: Bearer $DOJAPAY_ACCESS_TOKEN" \
-H "Accept: application/json"
const res = await fetch("https://api.dojapay.com/api/v1/virtual-account/details/5030012345", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.DOJAPAY_ACCESS_TOKEN}`,
Accept: "application/json",
},
});
const body = await res.json();
$ch = curl_init('https://api.dojapay.com/api/v1/virtual-account/details/5030012345');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DOJAPAY_ACCESS_TOKEN'),
'Accept: application/json',
],
]);
$body = json_decode(curl_exec($ch), true);
import os
import requests
res = requests.get(
"https://api.dojapay.com/api/v1/virtual-account/details/5030012345",
headers={"Authorization": f"Bearer {os.environ['DOJAPAY_ACCESS_TOKEN']}", "Accept": "application/json"},
timeout=30,
)
body = res.json()
{
"success": true,
"code": "00",
"message": [
"Account details retrieved successfully"
],
"data": {
"account_number": "5030012345",
"account_name": "Ada Obi",
"bank_name": "9PSB",
"status": "active",
"created_at": "2026-10-29T10:15:00+01:00"
},
"errors": null,
"reference": "DJP-20261029101500K7Q2M9XA",
"timestamp": "2026-10-29T10:15:00+01:00"
}
Virtual accounts
List accounts
/virtual-account/list
Your virtual accounts, newest first.
Query parameters
-
statusstring optionalOnly accounts in this state.
activeinactiveexpired -
bankstring optionalOnly accounts at this bank, for example 9psb.
-
per_pageinteger optionalResults per page, 1 to 100. Default 20.
-
pageinteger optionalPage number. Default 1.
curl "https://api.dojapay.com/api/v1/virtual-account/list?status=active&per_page=20" \
-H "Authorization: Bearer $DOJAPAY_ACCESS_TOKEN" \
-H "Accept: application/json"
const res = await fetch("https://api.dojapay.com/api/v1/virtual-account/list?status=active&per_page=20", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.DOJAPAY_ACCESS_TOKEN}`,
Accept: "application/json",
},
});
const body = await res.json();
$ch = curl_init('https://api.dojapay.com/api/v1/virtual-account/list?status=active&per_page=20');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DOJAPAY_ACCESS_TOKEN'),
'Accept: application/json',
],
]);
$body = json_decode(curl_exec($ch), true);
import os
import requests
res = requests.get(
"https://api.dojapay.com/api/v1/virtual-account/list",
headers={"Authorization": f"Bearer {os.environ['DOJAPAY_ACCESS_TOKEN']}", "Accept": "application/json"},
params={
"status": "active",
"per_page": 20,
},
timeout=30,
)
body = res.json()
{
"success": true,
"code": "00",
"message": [
"Virtual accounts retrieved successfully"
],
"data": {
"data": [
{
"account_number": "5030012345",
"account_name": "Ada Obi",
"account_type": "DYNAMIC",
"bank_code": "9psb",
"provider": "9PSB",
"status": "active",
"reference_code": "...",
"expires_at": "2026-10-29T13:15:00+01:00",
"assigned_at": null,
"created_at": "2026-10-29T10:15:00+01:00"
}
],
"meta": {
"current_page": 1,
"per_page": 20,
"total": 1,
"last_page": 1
}
},
"errors": null,
"reference": "DJP-20261029101500K7Q2M9XA",
"timestamp": "2026-10-29T10:15:00+01:00"
}
Webhooks
Payment received
When a customer pays into one of your accounts, we send a POST with this body to the webhook URL in your developer settings.
| Field | Meaning |
|---|---|
| reference | The payment's reference. Use it to confirm the payment, and to recognise a notification you have already handled. |
| data.event_type | inflow.received for a payment. Ignore types you do not handle. |
| data.account_number | Which of your virtual accounts was paid. Match it to your customer or order. |
| data.amount | What the customer sent, as a string with two decimals. |
| data.settlement_amount | What is credited to your wallet after fees. |
| data.charge | Total fee taken: fee_amount plus vat. |
| data.sender_* | Who paid, as reported by their bank. |
| data.fee_rule, data.fee_breakdown | How the fee was worked out. |
| data.environment | Present, as sandbox, only on a simulated payment. Real payments do not carry it. |
{
"success": true,
"code": "00",
"message": [
"Inflow received and processed successfully."
],
"data": {
"event_type": "inflow.received",
"session_id": "100004261029101500123456789012",
"account_name": "Ada Obi",
"account_number": "5030012345",
"bank_name": "9PSB",
"bank_code": "9PSB",
"sender_bank_code": "000014",
"sender_bank_name": "Access Bank",
"sender_account_number": "0123456789",
"sender_account_name": "ADA OBI",
"amount": "15000.00",
"settlement_amount": 14919.37,
"gross_amount": 15000,
"fee_amount": 75,
"charge": 80.63,
"vat": 5.63,
"fee_rule": {
"transaction_type": "inflow_virtual_account",
"charge_type": "percentage",
"value": "0.50000000",
"cap": null
},
"fee_breakdown": {
"fee": 75,
"vat": 5.63,
"vat_rate": 0.075
}
},
"reference": "DPY-20261029101500482913",
"timestamp": "2026-10-29T10:15:02+01:00"
}
Webhooks
Handling notifications
- Reply with any 2xx within 10 seconds. Do slow work after you have replied.
- With no reply in time, or a 5xx, we try again: up to 5 attempts in total, about 30 seconds, then 1, 2 and 5 minutes apart. A 4xx is treated as final and not retried, so do not return one for a temporary problem on your side.
- Because of retries, the same notification can arrive more than once. Record each
referenceyou process and ignore repeats, or you may fulfil an order twice. - Ignore event types and fields you do not use; both may be added to.
- Always confirm the payment before fulfilling. Anyone who learns your webhook URL can post to it, and confirming is what proves the payment is real.
By the time a notification reaches you, the payment's status already reads success. Compare its gross_amount with what the order expects.
import express from "express";
const app = express();
app.use(express.json());
app.post("/webhooks/dojapay", async (req, res) => {
// Reply first: we wait 10 seconds at most.
res.sendStatus(200);
const event = req.body;
if (event?.data?.event_type !== "inflow.received") return;
// Never trust the notification itself. Confirm it.
const check = await fetch(
"https://api.dojapay.com/api/v1/transaction/status/" + encodeURIComponent(event.reference),
{
headers: {
Authorization: `Bearer ${process.env.DOJAPAY_ACCESS_TOKEN}`,
Accept: "application/json",
},
}
);
const payment = await check.json();
if (payment.success && payment.data.status === "success") {
// Records the reference; does nothing if it has seen it before.
await fulfilOnce(
event.reference,
event.data.account_number,
payment.data.gross_amount
);
}
});
// webhook.php
$event = json_decode(file_get_contents('php://input'), true);
// Reply first: we wait 10 seconds at most.
http_response_code(200);
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
if (($event['data']['event_type'] ?? null) !== 'inflow.received') {
exit;
}
// Never trust the notification itself. Confirm it.
$ch = curl_init('https://api.dojapay.com/api/v1/transaction/status/' . rawurlencode($event['reference']));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DOJAPAY_ACCESS_TOKEN'),
'Accept: application/json',
],
]);
$payment = json_decode(curl_exec($ch), true);
if (($payment['success'] ?? false) && $payment['data']['status'] === 'success') {
// Records the reference; does nothing if it has seen it before.
fulfil_once(
$event['reference'],
$event['data']['account_number'],
$payment['data']['gross_amount']
);
}
import os
import requests
from flask import Flask, request
app = Flask(__name__)
@app.post("/webhooks/dojapay")
def dojapay_webhook():
event = request.get_json(silent=True) or {}
if event.get("data", {}).get("event_type") != "inflow.received":
return "", 200
# Never trust the notification itself. Confirm it.
payment = requests.get(
"https://api.dojapay.com/api/v1/transaction/status/" + requests.utils.quote(event["reference"], safe=""),
headers={
"Authorization": f"Bearer {os.environ['DOJAPAY_ACCESS_TOKEN']}",
"Accept": "application/json",
},
timeout=8,
).json()
if payment.get("success") and payment["data"]["status"] == "success":
# Records the reference; does nothing if it has seen it before.
fulfil_once(
event["reference"],
event["data"]["account_number"],
payment["data"]["gross_amount"],
)
# Reply within 10 seconds. Move slow work to a queue.
return "", 200
Transactions
Confirm a payment
/transaction/status/{reference}
Look a payment up by its reference before you fulfil. It is confirmed when status is success.
Here amount is what you received after fees and gross_amount is what the customer sent.
Path parameters
-
referencestring requiredThe reference from the payment notification.
Errors
- 404 · 06 Not one of your payments. Do not fulfil.
curl "https://api.dojapay.com/api/v1/transaction/status/DPY-20261029101500482913" \
-H "Authorization: Bearer $DOJAPAY_ACCESS_TOKEN" \
-H "Accept: application/json"
const res = await fetch("https://api.dojapay.com/api/v1/transaction/status/DPY-20261029101500482913", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.DOJAPAY_ACCESS_TOKEN}`,
Accept: "application/json",
},
});
const body = await res.json();
$ch = curl_init('https://api.dojapay.com/api/v1/transaction/status/DPY-20261029101500482913');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DOJAPAY_ACCESS_TOKEN'),
'Accept: application/json',
],
]);
$body = json_decode(curl_exec($ch), true);
import os
import requests
res = requests.get(
"https://api.dojapay.com/api/v1/transaction/status/DPY-20261029101500482913",
headers={"Authorization": f"Bearer {os.environ['DOJAPAY_ACCESS_TOKEN']}", "Accept": "application/json"},
timeout=30,
)
body = res.json()
{
"success": true,
"code": "00",
"message": [
"Transaction status retrieved"
],
"data": {
"reference": "DPY-20261029101500482913",
"status": "success",
"settlement_status": "pending",
"amount": 14919.37,
"gross_amount": 15000,
"currency": "NGN",
"type": "credit",
"channel": "virtual_account",
"narration": "Virtual Account Inflow",
"initiated_at": null,
"completed_at": null
},
"errors": null,
"reference": "DJP-20261029101500K7Q2M9XA",
"timestamp": "2026-10-29T10:15:00+01:00"
}
Transactions
Get a transaction
/transaction/{reference}
Everything status returns, plus the environment and when the record was created.
Path parameters
-
referencestring requiredThe transaction reference.
Errors
- 404 · 06 Not one of your transactions.
curl "https://api.dojapay.com/api/v1/transaction/DPY-20261029101500482913" \
-H "Authorization: Bearer $DOJAPAY_ACCESS_TOKEN" \
-H "Accept: application/json"
const res = await fetch("https://api.dojapay.com/api/v1/transaction/DPY-20261029101500482913", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.DOJAPAY_ACCESS_TOKEN}`,
Accept: "application/json",
},
});
const body = await res.json();
$ch = curl_init('https://api.dojapay.com/api/v1/transaction/DPY-20261029101500482913');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DOJAPAY_ACCESS_TOKEN'),
'Accept: application/json',
],
]);
$body = json_decode(curl_exec($ch), true);
import os
import requests
res = requests.get(
"https://api.dojapay.com/api/v1/transaction/DPY-20261029101500482913",
headers={"Authorization": f"Bearer {os.environ['DOJAPAY_ACCESS_TOKEN']}", "Accept": "application/json"},
timeout=30,
)
body = res.json()
{
"success": true,
"code": "00",
"message": [
"Transaction retrieved successfully"
],
"data": {
"reference": "DPY-20261029101500482913",
"status": "success",
"settlement_status": "pending",
"amount": 14919.37,
"gross_amount": 15000,
"currency": "NGN",
"type": "credit",
"channel": "virtual_account",
"narration": "Virtual Account Inflow",
"environment": "live",
"initiated_at": null,
"completed_at": null,
"created_at": "2026-10-29T10:15:01+01:00"
},
"errors": null,
"reference": "DJP-20261029101500K7Q2M9XA",
"timestamp": "2026-10-29T10:15:00+01:00"
}
Transactions
List transactions
/transaction/list
Your transactions, newest first. Each payment is followed by its fee as a separate fee entry referenced <reference>-FEE; filter type=credit for payments only.
Query parameters
-
statusstring optionalOnly transactions in this state.
successpendingfailed -
typestring optionalcredit for payments in, fee for the fee taken on each.
creditdebitfee -
channelstring optionalOnly transactions through this channel.
virtual_accounttransfercardussd -
fromstring optionalCreated on or after this date, YYYY-MM-DD.
-
tostring optionalCreated on or before this date, YYYY-MM-DD.
-
referencestring optionalMatches any part of a reference.
-
per_pageinteger optionalResults per page, 1 to 100. Default 20.
-
pageinteger optionalPage number. Default 1.
Errors
- 422 · 04 A filter is invalid, for example to before from.
curl "https://api.dojapay.com/api/v1/transaction/list?type=credit&from=2026-10-01&per_page=20" \
-H "Authorization: Bearer $DOJAPAY_ACCESS_TOKEN" \
-H "Accept: application/json"
const res = await fetch("https://api.dojapay.com/api/v1/transaction/list?type=credit&from=2026-10-01&per_page=20", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.DOJAPAY_ACCESS_TOKEN}`,
Accept: "application/json",
},
});
const body = await res.json();
$ch = curl_init('https://api.dojapay.com/api/v1/transaction/list?type=credit&from=2026-10-01&per_page=20');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DOJAPAY_ACCESS_TOKEN'),
'Accept: application/json',
],
]);
$body = json_decode(curl_exec($ch), true);
import os
import requests
res = requests.get(
"https://api.dojapay.com/api/v1/transaction/list",
headers={"Authorization": f"Bearer {os.environ['DOJAPAY_ACCESS_TOKEN']}", "Accept": "application/json"},
params={
"type": "credit",
"from": "2026-10-01",
"per_page": 20,
},
timeout=30,
)
body = res.json()
{
"success": true,
"code": "00",
"message": [
"Transactions retrieved successfully"
],
"data": {
"data": [
{
"reference": "DPY-20261029101500482913",
"status": "success",
"settlement_status": "pending",
"amount": 14919.37,
"gross_amount": 15000,
"currency": "NGN",
"type": "credit",
"channel": "virtual_account",
"narration": "Virtual Account Inflow",
"initiated_at": null,
"completed_at": null,
"created_at": "2026-10-29T10:15:01+01:00"
}
],
"meta": {
"current_page": 1,
"per_page": 20,
"total": 1,
"last_page": 1
}
},
"errors": null,
"reference": "DJP-20261029101500K7Q2M9XA",
"timestamp": "2026-10-29T10:15:00+01:00"
}
Transactions
Re-verify with the bank
/transaction/verify
Ask the bank to re-confirm a virtual account payment. On success, returns the payment details as the bank reported them. Confirm a payment is enough for most integrations.
Body
-
referencestring requiredThe payment reference.
Errors
- 404 · 06 Not one of your payments, or the bank did not confirm it.
- 422 · 04 The reference is missing or unknown.
- 422 · 17 This payment cannot be re-verified with its bank.
curl -X POST "https://api.dojapay.com/api/v1/transaction/verify" \
-H "Authorization: Bearer $DOJAPAY_ACCESS_TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"reference": "DPY-20261029101500482913"
}'
const res = await fetch("https://api.dojapay.com/api/v1/transaction/verify", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.DOJAPAY_ACCESS_TOKEN}`,
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
reference: "DPY-20261029101500482913",
}),
});
const body = await res.json();
$ch = curl_init('https://api.dojapay.com/api/v1/transaction/verify');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DOJAPAY_ACCESS_TOKEN'),
'Accept: application/json',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'reference' => 'DPY-20261029101500482913',
]),
]);
$body = json_decode(curl_exec($ch), true);
import os
import requests
res = requests.post(
"https://api.dojapay.com/api/v1/transaction/verify",
headers={"Authorization": f"Bearer {os.environ['DOJAPAY_ACCESS_TOKEN']}", "Accept": "application/json"},
json={
"reference": "DPY-20261029101500482913",
},
timeout=30,
)
body = res.json()
Wallet
Get your balance
/wallet/balance
Your DojaPay wallet balance, in naira. With a sandbox key, the total of your simulated payments after fees, marked "environment": "sandbox"; your real balance is never shown or touched.
curl "https://api.dojapay.com/api/v1/wallet/balance" \
-H "Authorization: Bearer $DOJAPAY_ACCESS_TOKEN" \
-H "Accept: application/json"
const res = await fetch("https://api.dojapay.com/api/v1/wallet/balance", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.DOJAPAY_ACCESS_TOKEN}`,
Accept: "application/json",
},
});
const body = await res.json();
$ch = curl_init('https://api.dojapay.com/api/v1/wallet/balance');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DOJAPAY_ACCESS_TOKEN'),
'Accept: application/json',
],
]);
$body = json_decode(curl_exec($ch), true);
import os
import requests
res = requests.get(
"https://api.dojapay.com/api/v1/wallet/balance",
headers={"Authorization": f"Bearer {os.environ['DOJAPAY_ACCESS_TOKEN']}", "Accept": "application/json"},
timeout=30,
)
body = res.json()
{
"success": true,
"code": "00",
"message": [
"Wallet balance retrieved successfully"
],
"data": {
"balance": 254330.75,
"currency": "NGN",
"status": "active"
},
"errors": null,
"reference": "DJP-20261029101500K7Q2M9XA",
"timestamp": "2026-10-29T10:15:00+01:00"
}
Sandbox
Testing in the sandbox
Sign in with your sandbox key and everything runs against test data. Nothing reaches a bank, nothing touches your real wallet, and nothing you do in the sandbox appears in your real transactions or reports.
- create-test gives you a test account. Its number starts with
99, so it can never be a real account. - Simulate a payment pays it. Your real fee rule is applied, so the amounts you see are the amounts you will get.
- Status, the transaction list, verify and your balance all show the test results. Dynamic accounts enforce their amount rule and expiry, as the bank does.
- Endpoints the sandbox does not simulate refuse a sandbox key with HTTP
403; they never fall through to a real operation.
Sandbox webhooks go to the same webhook URL as live ones. Tell them apart by data.environment, which is "sandbox", and by the reference, which starts SBX- instead of DPY-. Confirming a sandbox payment with a live token returns 404, which is one more reason to confirm every payment before fulfilling.
When your integration works, switch to your live key and call create instead of create-test. Everything else stays the same.
Sandbox
Simulate a payment
/sandbox/simulate-payment
Pay one of your test accounts as if a customer had transferred to it. You get an inflow.received webhook, and the payment shows in transaction status, the transaction list and your sandbox balance, exactly as a real one would.
Body
-
accountNumberstring requiredA test account number from create-test.
-
amountnumber requiredWhat the customer sends, in naira. Your real fee rule is applied to it.
-
senderNamestring optionalWho paid, as the bank would report it. Default SANDBOX SENDER.
-
senderAccountNumberstring optionalThe payer's account number, 10 digits.
-
senderBankNamestring optionalThe payer's bank. Default Sandbox Bank.
Errors
- 403 · 11 Signed in with a live key. Real payments come from real transfers.
- 404 · 26 No test account with that number belongs to you.
- 422 · 25 The account has expired.
- 422 · 28 The amount breaks the account's rule, for example not exactly the amount an EXACT account expects.
- 422 · 04 A field is missing or invalid; see errors.
curl -X POST "https://api.dojapay.com/api/v1/sandbox/simulate-payment" \
-H "Authorization: Bearer $DOJAPAY_ACCESS_TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"accountNumber": "9904790966",
"amount": 15000,
"senderName": "ADA OBI"
}'
const res = await fetch("https://api.dojapay.com/api/v1/sandbox/simulate-payment", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.DOJAPAY_ACCESS_TOKEN}`,
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
accountNumber: "9904790966",
amount: 15000,
senderName: "ADA OBI",
}),
});
const body = await res.json();
$ch = curl_init('https://api.dojapay.com/api/v1/sandbox/simulate-payment');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DOJAPAY_ACCESS_TOKEN'),
'Accept: application/json',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'accountNumber' => '9904790966',
'amount' => 15000,
'senderName' => 'ADA OBI',
]),
]);
$body = json_decode(curl_exec($ch), true);
import os
import requests
res = requests.post(
"https://api.dojapay.com/api/v1/sandbox/simulate-payment",
headers={"Authorization": f"Bearer {os.environ['DOJAPAY_ACCESS_TOKEN']}", "Accept": "application/json"},
json={
"accountNumber": "9904790966",
"amount": 15000,
"senderName": "ADA OBI",
},
timeout=30,
)
body = res.json()
{
"success": true,
"code": "00",
"message": [
"Sandbox payment simulated. An inflow.received webhook has been queued to your webhook URL."
],
"data": {
"event_type": "inflow.received",
"session_id": "999999261029101500482913",
"account_name": "Ada Obi",
"account_number": "9904790966",
"bank_name": "9PSB",
"bank_code": "9PSB",
"sender_bank_code": "000000",
"sender_bank_name": "Sandbox Bank",
"sender_account_number": "0000000000",
"sender_account_name": "ADA OBI",
"amount": "15000.00",
"settlement_amount": 14919.37,
"gross_amount": 15000,
"fee_amount": 75,
"charge": 80.63,
"vat": 5.63,
"fee_rule": {
"transaction_type": "inflow_virtual_account",
"charge_type": "percentage",
"value": "0.50000000",
"cap": null
},
"fee_breakdown": {
"fee": 75,
"vat": 5.63,
"vat_rate": 0.075
},
"environment": "sandbox",
"reference": "SBX-20261029101500731846"
},
"errors": null,
"reference": "DJP-20261029101500K7Q2M9XA",
"timestamp": "2026-10-29T10:15:00+01:00"
}
Reference
Error codes
The codes you are most likely to meet. code is a string; compare it as one.
| Code | HTTP | Meaning | What to do |
|---|---|---|---|
| 00 | 200 | Success | |
| 01 | 401 | Authorization header missing or not a bearer token | Send Authorization: Bearer <access token>. |
| 02 | 401 | Invalid credentials or token | Check your keys, then sign in again. |
| 03 | 401 | Access token has expired | Refresh it, or sign in again. |
| 04 | 422 | Validation failed | Read the errors field. |
| 06 | 404 | Not found | Check the reference belongs to you. |
| 08 | 500 | System error | Retry later. Quote the response reference to support. |
| 11 | 403 | Permission denied | Wrong key for this endpoint (live or sandbox), an IP not on your allow list, or an inactive account. |
| 17 | 422 | Request not usable for this operation | Read the message. |
| 25 | 422 | Virtual account has expired | Create a new account for the order. |
| 26 | 404 | Virtual account not found | Check the account number. |
| 28 | 422 | Payment breaks the account amount rule | Send the amount the account expects. |
Reference
Rate limits
| Endpoints | Requests per minute |
|---|---|
| auth/login, auth/refresh | 5 |
| auth/me, auth/logout | 30 |
| Virtual accounts, transactions, wallet | 60 |
Over the limit you get HTTP 429 (with code "08", as described under Response format). Wait a minute and retry.
Reference
OpenAPI and Postman
The same API, machine-readable. Both are generated from the definitions behind this page, so they always match it.
- Postman. Import the collection, set
publicKeyandsecretKeyin its variables, and send Sign in. It stores the tokens for every other request. - OpenAPI 3.0. For Swagger, Insomnia, or generating a client in your language.
Ready to integrate?
Create an account, take your sandbox keys, and build against test accounts first.
© 2026 DojaPay · support@dojapay.com