Take a test payment.

About ten minutes. You create a sandbox account, succeed one payment, and check a webhook signature. No wallet is called.

  1. Create the account

    Open the dashboard and create a sandbox account. The API key is shown once. It starts with sk_test_. Store it on your server. The dashboard can create another key later, and it can revoke this one.

  2. Create a payment that succeeds

    Create it with POST /v1/payments. The example sets method to sedad, the default. bci_pay and click use that same path. A sandbox phone ending in 0000 succeeds immediately for Sedad and BCI Pay. Idempotency-Key is required. Reuse that same key if you retry this exact request.

    curl https://api-sandbox.kalbe.io/v1/payments \
      -H "Authorization: Bearer sk_test_…" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: quick-1" \
      -d '{"amount":15000,"currency":"MRU","method":"sedad","reference":"order_1842","payer":{"phone":"22246000000"}}'
    {
      "id": "pay_01J",
      "reference": "order_1842",
      "amount": 15000,
      "currency": "MRU",
      "method": "sedad",
      "status": "succeeded",
      "fee_bps": 100,
      "fee_amount": 150,
      "created_at": "2026-10-21T16:04:11Z"
    }

    15000 is 15,000 MRU. The fee is 150 MRU, which is 1%. A final payment has no next_action.

  3. Check the signature

    Save a public HTTPS URL in the dashboard under Settings. Kalbe signs the event and lists it under Webhooks. Replay sends it to your URL. Verify the raw body, not a JSON object you parsed and serialized again.

    const crypto = require("crypto");
    
    function verify(secret, timestamp, rawBody, signature) {
      const age = Math.abs(Date.now() / 1000 - Number(timestamp));
      if (age > 300) return false;
      const expected = crypto.createHmac("sha256", secret)
        .update(timestamp + "." + rawBody)
        .digest("hex");
      const left = Buffer.from(expected);
      const right = Buffer.from(signature);
      return left.length === right.length && crypto.timingSafeEqual(left, right);
    }

    This vector is fixed. Body {"id":"evt_01","type":"payment.succeeded"}, timestamp 1761080000, secret whsec_test. The signature is 9d5ee8ab7377e6dcf6099a5c3c294a485cd84db6a407597aaef402cd5713f300.

Phone endings, Click codes, and the full event body are in the method guides.