DEVELOPMENT ENVIRONMENT — test data, not live
VegaPay

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
// 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
// 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