Sofapay API
Build with Sofapay
Accept payments in minutes. Initialize a transaction from your server, send your customer to the hosted checkout, and confirm the result with a verify call or a signed webhook. All amounts are integers in kobo — ₦25,000.00 is 2500000.
Quickstart
- Generate a test secret key in the dashboard.
- Call
POST /api/v1/transactions/initializefrom your server with an amount and the customer's email. - Redirect the customer to the
authorization_urlin the response. - When they're done, confirm with
GET /api/v1/transactions/verify/:reference— or listen for thecharge.successwebhook.
Authentication
Authenticate every request with your secret key as a bearer token. Secret keys start with sk_test_ in test mode. Keep them on your server — never ship a secret key in browser or mobile code.
Authorization: Bearer sk_test_…Secret keys are shown once, when generated. Sofapay stores only a hash, so a lost key can't be recovered — roll it from the dashboard to get a new one.
Test mode
Test keys never move real money. Open the authorization_url from an initialize call, pick any payment method, and press Pay to simulate a successful payment — or choose Simulate a failed payment to exercise your failure path. Both fire the matching webhook.
Initialize a transaction
POST/api/v1/transactions/initialize
| Field | Type | Description |
|---|---|---|
| amount | integer | Required. Amount in kobo, minimum 100 (₦1.00). |
| string | Required. The customer's email address. | |
| currency | string | Optional. Defaults to NGN — the only currency live today. |
| reference | string | Optional. Your own unique reference. Generated if omitted. |
| metadata | object | Optional. Any JSON you want stored on the transaction. |
| customer | object | Optional. { name, phone } for the payer, saved to your Customers list. |
| callback_url | string | Optional. Where checkout sends the customer afterwards, with ?reference=… appended. Verify before giving value. |
curl -X POST https://sofapay.cresslink.cloud/api/v1/transactions/initialize \
-H "Authorization: Bearer sk_test_…" \
-H "Content-Type: application/json" \
-d '{ "amount": 2500000, "email": "customer@example.com" }'Response
{
"status": true,
"message": "Authorization URL created",
"data": {
"authorization_url": "https://sofapay.cresslink.cloud/checkout/0b1c…",
"access_code": "0b1c…",
"reference": "SPY_8J29DKQ4"
}
}Verify a transaction
GET/api/v1/transactions/verify/:reference
Always verify server-side before giving value. A customer landing on your success page is not proof of payment — the verify response is.
curl https://sofapay.cresslink.cloud/api/v1/transactions/verify/SPY_8J29DKQ4 \
-H "Authorization: Bearer sk_test_…"Response
{
"status": true,
"message": "Verification successful",
"data": {
"id": "0b1c…",
"reference": "SPY_8J29DKQ4",
"amount": 2500000,
"currency": "NGN",
"status": "success",
"channel": "card",
"fees": 47500,
"customer": { "email": "customer@example.com" },
"paid_at": "2026-09-23T14:33:08Z",
"created_at": "2026-09-23T14:32:41Z"
}
}Create a refund
POST/api/v1/refunds
Refund a successful transaction in full, or pass an amount for a partial refund. You can refund several times, up to the original amount. The refund comes out of your balance (pending first, then available). Transaction fees aren't returned. Refunds above ₦500,000 are accepted with status pending_approval and processed after a Sofapay review. You get the refund.processed webhook either way once it goes through.
| Field | Type | Description |
|---|---|---|
| transaction | string | Required. The reference of the transaction to refund. |
| amount | integer | Optional. Amount in kobo. Defaults to everything left to refund. |
| reason | string | Optional. Up to 500 characters, shown in your dashboard. |
curl https://sofapay.cresslink.cloud/api/v1/refunds \
-H "Authorization: Bearer sk_test_…" \
-H "Content-Type: application/json" \
-d '{ "transaction": "SPY_8J29DKQ4", "amount": 500000, "reason": "Item out of stock" }'Response
{
"status": true,
"message": "Refund processed",
"data": {
"id": "7f3e…",
"reference": "RFD_4C1A9E07",
"transaction_reference": "SPY_8J29DKQ4",
"amount": 500000,
"currency": "NGN",
"status": "processed"
}
}List disputes
GET/api/v1/disputes
When a customer's bank disputes a payment, the disputed amount is held from your balance and you have 3 days to respond with evidence from the dashboard. If you win, the amount is released back to you. If you lose or don't respond, it goes back to the customer. Listen for dispute.created to act quickly.
| Field | Type | Description |
|---|---|---|
| status | query | Optional. needs_response, under_review, won or lost. |
curl "https://sofapay.cresslink.cloud/api/v1/disputes?status=needs_response" \
-H "Authorization: Bearer sk_test_…"Errors
Errors return "status": false and a human-readable message. Every response carries an X-Request-Id header — quote it when contacting support.
| Status | Meaning | Typical cause |
|---|---|---|
| 400 | Bad request | Invalid JSON, amount below 100 kobo, bad email, unsupported currency, refund larger than what's left or than your balance. |
| 401 | Unauthorized | Missing Authorization header, or a revoked/unknown secret key. |
| 403 | Forbidden | Your account is suspended. Contact Sofapay support. |
| 404 | Not found | No transaction with that reference on your account. |
| 409 | Conflict | The reference you supplied has already been used. |
| 500 | Server error | Something went wrong on our side. Safe to retry. |
Webhook events
Add an endpoint under Developers → Webhooks. Sofapay sends a POST with a JSON body the moment an event happens. Respond with any 2xx within 5 seconds; failed deliveries are retried automatically after 1 minute, 5 minutes, 30 minutes and 2 hours (five attempts in all), and can be retried by hand from the delivery log.
| Event | Sent when |
|---|---|
| charge.success | A payment succeeded and has been posted to your ledger. |
| charge.failed | A payment attempt failed. No money moved. |
| transfer.success | A payout left your available balance. |
| refund.processed | A refund was posted to your ledger and returned to the customer. |
| dispute.created | A customer's bank disputed a payment. The amount is held; respond within 3 days. |
| dispute.resolved | A dispute closed. data.status is won (funds released) or lost (returned to the customer). |
{
"event": "charge.success",
"data": {
"id": "0b1c…",
"reference": "SPY_8J29DKQ4",
"amount": 2500000,
"currency": "NGN",
"channel": "card",
"status": "success",
"fees": 47500,
"customer": { "email": "customer@example.com" },
"paid_at": "2026-09-23T14:33:08Z"
}
}Verifying signatures
Every delivery includes an X-Sofapay-Signature header: the HMAC-SHA512 of the raw request body, keyed with your endpoint's signing secret (whsec_…), hex-encoded. Compute it over the exact bytes you received — parsing and re-serializing the JSON will change the bytes and break the comparison.
import crypto from "node:crypto";
// Use the raw body — e.g. express.raw({ type: "application/json" })
function isValidSofapaySignature(rawBody, signature, secret) {
const expected = Buffer.from(crypto.createHmac("sha512", secret).update(rawBody).digest("hex"));
const received = Buffer.from(signature ?? "");
// timingSafeEqual throws on length mismatch, so check that first.
return expected.length === received.length && crypto.timingSafeEqual(expected, received);
}
app.post("/webhooks/sofapay", express.raw({ type: "application/json" }), (req, res) => {
const ok = isValidSofapaySignature(
req.body, req.get("X-Sofapay-Signature"), process.env.SOFAPAY_WEBHOOK_SECRET,
);
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body);
// handle event.event / event.data
res.sendStatus(200);
});