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

  1. Generate a test secret key in the dashboard.
  2. Call POST /api/v1/transactions/initialize from your server with an amount and the customer's email.
  3. Redirect the customer to the authorization_url in the response.
  4. When they're done, confirm with GET /api/v1/transactions/verify/:reference — or listen for the charge.success webhook.

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

FieldTypeDescription
amountintegerRequired. Amount in kobo, minimum 100 (₦1.00).
emailstringRequired. The customer's email address.
currencystringOptional. Defaults to NGN — the only currency live today.
referencestringOptional. Your own unique reference. Generated if omitted.
metadataobjectOptional. Any JSON you want stored on the transaction.
customerobjectOptional. { name, phone } for the payer, saved to your Customers list.
callback_urlstringOptional. 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.

FieldTypeDescription
transactionstringRequired. The reference of the transaction to refund.
amountintegerOptional. Amount in kobo. Defaults to everything left to refund.
reasonstringOptional. 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.

FieldTypeDescription
statusqueryOptional. 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.

StatusMeaningTypical cause
400Bad requestInvalid JSON, amount below 100 kobo, bad email, unsupported currency, refund larger than what's left or than your balance.
401UnauthorizedMissing Authorization header, or a revoked/unknown secret key.
403ForbiddenYour account is suspended. Contact Sofapay support.
404Not foundNo transaction with that reference on your account.
409ConflictThe reference you supplied has already been used.
500Server errorSomething 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.

EventSent when
charge.successA payment succeeded and has been posted to your ledger.
charge.failedA payment attempt failed. No money moved.
transfer.successA payout left your available balance.
refund.processedA refund was posted to your ledger and returned to the customer.
dispute.createdA customer's bank disputed a payment. The amount is held; respond within 3 days.
dispute.resolvedA 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);
});