DigiCarl Pay

Dashboard

First sandbox payment in under 60 minutes.

Quickstart

Take a sandbox M-Pesa payment end to end: create an intent, approve the prompt, receive a signed webhook and read the result.

  1. 1Get a sandbox key

    Create a sandbox key under Developers › API keys. Sandbox keys start with dcp_test_ and never move real money.

    Open API keys in the dashboard
    export DCP_KEY="dcp_test_4f9a…c21e"

    Expected

    Nothing to see yet; the key is in your shell.

  2. 2Create an STK intent

    Amounts are integers in minor units: 100 is KSh 1.00. The Idempotency-Key makes retries safe for 24 hours.

    curl http://localhost:3000/api/v1/payment_intents \
      -H "Authorization: Bearer $DCP_KEY" \
      -H "Idempotency-Key: order_1001" \
      -H "Content-Type: application/json" \
      -d '{"amount":100,"currency":"KES","method":"mpesa_stk","phone":"+254708374149","reference":"ORDER-1001"}'
    2b. Paystack test card instead

    Use card 4084 0840 8408 4081, any future expiry, CVV 408.

    curl http://localhost:3000/api/v1/payment_intents \
      -H "Authorization: Bearer $DCP_KEY" \
      -H "Idempotency-Key: order_1001_card" \
      -H "Content-Type: application/json" \
      -d '{"amount":100,"currency":"KES","method":"card","customer_email":"amina@example.co.ke","reference":"ORDER-1001"}'

    Expected

    { "id": "pi_01J…", "status": "pending", "amount": 100 }

  3. 3See the prompt on the sandbox test number

    The Daraja sandbox approves the prompt on +254708374149 by itself within a few seconds.

    curl http://localhost:3000/api/v1/payment_intents/pi_01J… -H "Authorization: Bearer $DCP_KEY"

    Expected

    { "status": "succeeded", "receipt": "TJM4K8P2QX" }

  4. 4Register a webhook with a local tunnel

    Expose your local server, then register the https URL under Developers › Webhooks. Copy the signing secret it shows once.

    npx localtunnel --port 3000
    # your url is: https://quiet-fox-42.loca.lt

    Expected

    A test.ping event arrives at /webhooks/digicarl with a 200 from your server.

  5. 5Verify the DCP-Signature header

    Compute an HMAC-SHA256 of `t.raw_body` with your signing secret and compare in constant time against each v1 in DCP-Signature. Reject a timestamp more than 5 minutes from now.

    import crypto from "node:crypto";
    
    const TOLERANCE_SECONDS = 300; // reject events signed more than 5 minutes ago
    
    // rawBody: the exact bytes received (Buffer or string), before JSON.parse.
    // header: the DCP-Signature request header.
    export function verifyDcpSignature(rawBody, header, secret, now = Date.now()) {
      const parts = String(header ?? "").split(",").map((p) => p.trim().split("="));
      const t = Number(parts.find(([k]) => k === "t")?.[1]);
      if (!Number.isInteger(t) || Math.abs(now / 1000 - t) > TOLERANCE_SECONDS) return false;
      const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
      // During a secret rotation the header carries one v1 per active secret.
      return parts
        .filter(([k]) => k === "v1")
        .some(([, v]) => {
          const given = Buffer.from(v ?? "", "hex");
          return given.length === expected.length && crypto.timingSafeEqual(given, expected);
        });
    }

    Expected

    verify() returns true for real events and false for tampered bodies.

  6. 6Fetch the intent

    Webhooks can arrive late or twice. Read the intent before you ship the order, and deduplicate on DCP-Event-Id.

    curl http://localhost:3000/api/v1/payment_intents/pi_01J… -H "Authorization: Bearer $DCP_KEY"

    Expected

    { "status": "succeeded", "reconciliation_status": "matched_exact" }