DigiCarl Pay
DashboardFirst sandbox payment in under 60 minutes.
Take a sandbox M-Pesa payment end to end: create an intent, approve the prompt, receive a signed webhook and read the result.
Create a sandbox key under Developers › API keys. Sandbox keys start with dcp_test_ and never move real money.
Open API keys in the dashboardexport DCP_KEY="dcp_test_4f9a…c21e"
Expected
Nothing to see yet; the key is in your shell.
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"}'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 }
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" }
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.
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.
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" }
Step 1: Get a sandbox key
export DCP_KEY="dcp_test_4f9a…c21e"