Developer guide
Accept bank-transfer payments from your own app. Create a checkout, send your customer to pay, and get a signed webhook the moment the transfer is confirmed.
Get your API key →1. Get an API key
In your dashboard → Developer API keys, create a key. Test keys (vp_test_…) work immediately; live keys (vp_live_…) require you to be signed in. The raw key is shown once — store it securely. We keep only a hash.
Test mode runs the real code path, not a mock. A checkout created with a vp_test_… key is tagged env: "test" and can only ever be confirmed by a synthetic test-alert triggered from your own dev/staging environment — a real bank transfer never confirms it, and the trigger endpoint doesn't exist at all on the production site. So a test checkout created against production simply stays pending forever — that's expected, not a bug. A vp_live_… checkout is the mirror image: it is confirmed only by a real incoming bank transfer VegaPay detects — there is no shortcut to confirm live money. Build and demo your whole flow (create → redirect → webhook/poll) in test mode first, then switch to a live key when you go live.
Signing in and registering are separately rate-limited (10 requests/minute per email address for sign-in, 10/minute per IP address for new registrations) — worth knowing if you're scripting account setup for a demo. A normal one-time sign-in from a browser will never hit this.
2. Create a checkout
Authenticate with Authorization: Bearer <key>. Amounts are integer kobo (₦1 = 100 kobo).reference is your own order id and must be unique among your open checkouts.
POST /api/checkout
Authorization: Bearer vp_test_xxxxxxxx
Content-Type: application/json
{
"amountKobo": 500000, // ₦5,000.00 — integer kobo, always
"reference": "ORDER-1042", // YOUR unique order id
"returnUrl": "https://shop.example/done",
"webhookUrl": "https://shop.example/api/vegapay-webhook"
}201 Created
{
"data": {
"checkoutId": "chk_…",
"checkoutUrl": "/checkout/chk_…", // redirect your customer here
"verifyUrl": "/api/checkout/chk_…", // poll this for status
"reference": "ORDER-1042"
}
}Redirect your customer to checkoutUrl to pay by transfer.
Every endpoint returns { data } on success or { error: { message, details? } } on failure — always check the HTTP status before reading data. A 409 on checkout means your reference is already open; reuse the existing checkoutId rather than retrying with a new one.
400 Bad Request
{
"error": {
"message": "Invalid request",
"details": ["amountKobo must be a positive integer"]
}
}
409 Conflict — you reused an open "reference"
{
"error": {
"message": "a checkout with this reference is already open"
}
}POST /api/checkout is rate-limited to 60 requests/minute per business account — your test and live keys share one limit, not separate ones. On a 429, back off using the Retry-After header (seconds until the window resets) instead of a fixed delay.
429 Too Many Requests
Retry-After: 37
{
"error": {
"message": "Too many requests"
}
}GET /api/checkout/<id> (the poll from step 5) has no rate limit today. That's not an invitation to poll in a tight loop — prefer the webhook, and if you do poll, space out requests by a few seconds.
3. Receive the webhook
When the bank-transfer alert is matched, VegaPay POSTs a signed event to your webhookUrl:
POST https://shop.example/api/vegapay-webhook
X-VegaPay-Signature: t=1782350000,v1=9f86d0818…
X-VegaPay-Event: evt_…
Content-Type: application/json
{
"id": "evt_…",
"type": "payment.matched",
"checkoutId": "chk_…",
"reference": "ORDER-1042",
"amountKobo": 500000,
"status": "matched"
}4. Verify the signature
The header is X-VegaPay-Signature: t=<unix>,v1=<hex> where v1 = HMAC-SHA256(secret, "<t>.<rawBody>"). Recompute it over the exact bytes you received, reject on mismatch, and reject if t is older than ~5 minutes (replay protection). Your signing secret is on your dashboard, under Developer API keys.
import crypto from "node:crypto";
// secret = your dashboard "Webhook signing secret"
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=")),
);
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`) // EXACT received bytes
.digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
const got = Buffer.from(parts.v1 ?? "", "hex");
const exp = Buffer.from(expected, "hex");
// length-check first — timingSafeEqual throws on a length mismatch, and v1 is
// attacker-controlled.
return (
fresh &&
got.length === exp.length &&
crypto.timingSafeEqual(got, exp)
);
}5. Or just poll
No webhook endpoint? Poll GET /api/checkout/<checkoutId> (the verifyUrl) for the status. Customers also get a branded receipt and can independently confirm any payment on /verify.
A confirmed payment is a bank-alert match — VegaPay detected an incoming transfer for your reference. It is not a settlement guarantee or a bank statement; always confirm funds in your own account.
6. Complete example (Node.js + Express)
Copy-paste starting point. Replace db.* and fulfillOrder with your own data layer. Set VEGAPAY_API_KEY and VEGAPAY_WEBHOOK_SECRET from your developer settings.
// server.js — Node.js / Express (ESM)
import express from "express";
import crypto from "node:crypto";
const app = express();
// Capture raw bytes before JSON parsing — required for HMAC verification
app.use(express.json({
verify: (_req, _res, buf) => { _req.rawBody = buf; },
}));
const VEGAPAY_API = "https://vegapay.ng/api"; // dev: https://dev.vegapay.ng/api
const VEGAPAY_KEY = process.env.VEGAPAY_API_KEY;
const WEBHOOK_SECRET = process.env.VEGAPAY_WEBHOOK_SECRET;
// ── 1. Create a checkout when a buyer places an order ─────────────────────
app.post("/orders/:orderId/checkout", async (req, res) => {
const { orderId } = req.params;
const { amountKobo } = req.body; // integer kobo from your cart
const r = await fetch(`${VEGAPAY_API}/checkout`, {
method: "POST",
headers: {
Authorization: `Bearer ${VEGAPAY_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
amountKobo,
reference: orderId, // your unique order id
returnUrl: `https://shop.example/orders/${orderId}`,
webhookUrl: "https://shop.example/webhooks/vegapay",
}),
});
if (!r.ok) return res.status(502).json({ error: "checkout_failed" });
const { data } = await r.json();
await db.orders.update(orderId, {
vegaCheckoutId: data.checkoutId,
status: "awaiting_payment",
});
// Return the URL — the client redirects the customer there to pay
res.json({ checkoutUrl: data.checkoutUrl });
});
// ── 2. Receive the signed webhook when VegaPay matches the transfer ────────
app.post("/webhooks/vegapay", async (req, res) => {
const sig = req.headers["x-vegapay-signature"] ?? "";
const evtId = req.headers["x-vegapay-event"] ?? "";
if (!verifyWebhook(req.rawBody, sig, WEBHOOK_SECRET))
return res.status(401).send("invalid signature");
const { type, reference } = req.body;
if (type === "payment.matched") {
// Guard against duplicate delivery — idempotency on the event id
const seen = await db.fulfillments.has(evtId);
if (!seen) {
await db.orders.update(reference, { status: "paid" });
await fulfillOrder(reference);
await db.fulfillments.insert(evtId);
}
}
res.sendStatus(200); // always acknowledge promptly
});
// ── Signature helper ──────────────────────────────────────────────────────
function verifyWebhook(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`) // exact received bytes
.digest("hex");
const got = Buffer.from(parts.v1 ?? "", "hex");
const exp = Buffer.from(expected, "hex");
return fresh && got.length === exp.length && crypto.timingSafeEqual(got, exp);
}
app.listen(3001);On the client, call your /checkout route and redirect. Handle the return URL as a UX hint only — webhook is the source of truth for fulfilment.
// client.js — redirect customer to the VegaPay checkout
async function startPayment(orderId, amountKobo) {
const res = await fetch(`/orders/${orderId}/checkout`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ amountKobo }),
});
const { checkoutUrl } = await res.json();
window.location.href = checkoutUrl; // customer pays, then returns via returnUrl
}
// ── On your returnUrl page (/orders/:id?status=matched) ──────────────────
// ?status is a UX hint only — trust the signed webhook for fulfilment,
// not the redirect (a browser tab could be closed before returning).
const status = new URLSearchParams(window.location.search).get("status");
if (status === "matched") showSuccess();
else showPending(); // webhook may arrive seconds later