Skip to content
SkanPay Docs

Quickstart

From a new account to an order that marks itself paid. Most merchants finish this in an afternoon.

  1. Create your account and get approved

    Sign up at app.skanpay.website and upload your business documents under Verification. SkanPay reviews them and switches on live payments for your account. Until that happens, API keys cannot be created and any key returns live_mode_not_enabled.

  2. Connect your app and generate keys

    In the dashboard open Connected apps, add your website or app (a name and its domain), then choose Generate keys. You get a publishable key (pk_live_…) and a secret key (sk_live_…). The secret key is shown once; SkanPay stores only a hash of it. Put it in your server's environment:

    .env (on your server, never in the browser)
    SKANPAY_SECRET_KEY=sk_live_...
  3. Set your webhook URL

    On the same app, enter the https:// URL on your server that should receive payment outcomes, for example https://shop.example.com/webhooks/skanpay. Saving it shows the webhook signing secret (whsec_…). Store it next to your secret key:

    .env
    SKANPAY_WEBHOOK_SECRET=whsec_...
  4. Charge a customer

    When the customer checks out, call POST /v1/charges from your server. Use your order id as both the reference and the Idempotency-Key, so a retry can never charge twice.

    curl -X POST https://api.skanpay.website/v1/charges \
      -H "Authorization: Bearer $SKANPAY_SECRET_KEY" \
      -H "Idempotency-Key: order-9001" \
      -H "Content-Type: application/json" \
      -d '{
        "amount": "50000",
        "currency": "UGX",
        "msisdn": "+256772123456",
        "reference": "order-9001",
        "description": "2 x T-shirt",
        "metadata": { "cart_id": "c_42" }
      }'

    The response comes back immediately with the prompt already on the customer's phone:

    201 Created
    {
      "id": "chg_01J9Z3K8Q4W6XK2M7N5P0R3T8V",
      "object": "charge",
      "status": "AWAITING_CUSTOMER",
      "amount": "50000",
      "currency": "UGX",
      "country": "UG",
      "network": "MTN",
      "msisdn_masked": "+2567****456",
      "reference": "order-9001",
      "fee": "0",
      "net": "50000",
      "failure_code": null,
      ...
    }

    Tell the customer to check their phone and enter their PIN. Don't mark the order paid yet, and don't mark it failed either: AWAITING_CUSTOMER just means “waiting”.

  5. Receive the webhook and fulfil the order

    When the customer approves, SkanPay POSTs a charge.successful event to your webhook URL. Verify the signature over the raw request body, then mark the order paid, keyed on data.object.reference.

    // server.mjs  (npm install express)
    // Run: SKANPAY_WEBHOOK_SECRET=whsec_... node server.mjs
    import express from "express";
    import { createHmac, timingSafeEqual } from "node:crypto";
    
    const TOLERANCE_SECONDS = 300;
    
    function verifySkanPaySignature(rawBody, header, secret) {
      const parts = Object.fromEntries(
        String(header ?? "").split(",").map((pair) => pair.trim().split("=", 2)),
      );
      const t = Number(parts.t);
      if (!Number.isInteger(t)) return false;
      if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
    
      const expected = createHmac("sha256", secret)
        .update(`${t}.`)
        .update(rawBody) // the raw bytes, exactly as received
        .digest();
      const presented = Buffer.from(parts.v1 ?? "", "hex");
      return presented.length === expected.length && timingSafeEqual(presented, expected);
    }
    
    const app = express();
    
    // express.raw, NOT express.json: the signature covers the raw bytes.
    app.post("/webhooks/skanpay", express.raw({ type: "application/json" }), async (req, res) => {
      const ok = verifySkanPaySignature(
        req.body,
        req.get("SkanPay-Signature"),
        process.env.SKANPAY_WEBHOOK_SECRET,
      );
      if (!ok) return res.sendStatus(400);
    
      const event = JSON.parse(req.body.toString("utf8"));
    
      // At-least-once delivery: ignore an event id you have already handled.
      // (Use a unique column in your database; this is where you check it.)
    
      const object = event.data.object;
      switch (event.type) {
        case "charge.successful":
          console.log(`mark order ${object.reference} paid (${object.amount} ${object.currency})`);
          break;
        case "charge.failed":
          console.log(`order ${object.reference} not paid: ${object.failure_code}`);
          break;
      }
    
      res.sendStatus(200); // acknowledge quickly; do slow work after responding
    });
    
    app.listen(8080, () => console.log("listening on :8080"));
  6. Try it with a real, small amount

    There is no sandbox: charge your own phone for the minimum amount (USh 500 in Uganda), approve it, and watch the webhook arrive. The transaction, its full status trail and the webhook delivery all show in the dashboard, and you can replay the delivery from there while you debug your handler.

Next