Konbit
Konbit Pay · API

Konbit payment API

Collect a payment from your customers' Konbit wallet. Integrate in a few lines, free sandbox.

How it works

Your server creates a payment intent. The customer pays it in their Konbit app with their PIN. You get a signed webhook and deliver your digital service.

1. Create an intent

One server call with the amount. You get a payment link and an id.

2. The customer pays

They open the link, confirm with their PIN. No fee for them: they pay the shown amount.

3. You get notified

A signed webhook confirms the payment. You deliver the service. The money lands on your Konbit balance.

Amounts are in minor units

amountMinor is an integer in the smallest unit: 10000 = 100.00 DOP. Never use decimals.

1 · Quickstart

5-minute quickstart

Three steps: your server creates an intent, the customer pays it in Konbit, your server confirms the payment before delivering. Here is the full call and the real, annotated response.

curl -X POST https://app.getkonbit.com/api/v1/payment_intents \
  -H "Authorization: Bearer sk_test_..." \
  -H "Idempotency-Key: order-123" \
  -H "Content-Type: application/json" \
  -d '{"amountMinor":10000,"description":"Order 123","metadata":{"orderId":"order-123"},"returnUrl":"https://mon-site.com/merci"}'
Response
{
  "id": "5Qx0u5gexpnqsw6qQhks",       // konbitRef — gardez-le
  "status": "REQUIRES_PAYMENT",
  "amountMinor": 10000,               // 100,00 DOP
  "currency": "DOP",
  "feeMinor": 200,                    // notre commission
  "code": "562183",                   // saisie manuelle / QR
  "checkoutUrl": "https://app.getkonbit.com/pay/5Qx0u5gexpnqsw6qQhks",
  "expiresAtMs": 1783710307539        // millisecondes
}

checkoutUrl: the page where the customer pays. code: manual entry / QR. feeMinor: our commission, already deducted from your balance (the customer pays amountMinor). expiresAtMs: after which the intent turns EXPIRED.

⚠ returnUrl is NOT proof of payment

The redirect to returnUrl happens in the customer's browser and can be forged (just open the URL). NEVER deliver your service on the strength of the return alone. Always confirm server-side: signed webhook or a GET on the status.

2 · Access

Getting your access

Two levels, presented honestly.

Test key — instant

Generate an sk_test_ key above: valid 7 days, sandbox, WITHOUT webhooks. Perfect for exploring the API by polling. No real money moves.

Merchant key — within 24 h

Write to us with your company and use case. We create a dedicated merchant: persistent key + signed webhook. Usually answered within 24 business hours. (Merchant self-service is coming; today we open it for you, for compliance.)

Keep the secret secret

The sk_ key is shown ONCE and lives server-side only: never in a browser, a mobile app, a Git repo or a URL. Store it in a secrets manager. Compromised? Ask for a rotation: the old one is revoked immediately.

Sandbox

Generate a test key

Try it right now, no account needed. A test key valid for 7 days, sandbox mode.

Pay a test intent (sandbox)

No Konbit account needed: call POST /v1/test/pay with the intent id. Konbit simulates a sandbox payer, moves the intent to SUCCEEDED, sends the signed webhook (if you provided a URL) and credits your merchant balance. You test the whole cycle on your own.

curl -X POST https://app.getkonbit.com/api/v1/test/pay \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{"intentId":"<id renvoyé par /payment_intents>"}'
# -> l'intention passe à SUCCEEDED, le webhook signé part (si configuré),
#    et ton solde marchand augmente (testable par GET /balance + payouts).
What the test key does (and its limits)

The key generated here is ephemeral (7 days), sandbox: no real money. It's enough to run create → pay → webhook → payout on your own. For a PERSISTENT key and real production, contact us — a dedicated merchant + compliance come at go-live. Why “test” in the key (sk_test_)? It's the sandbox prefix: no real money can move. In sandbox, the “test card” = POST /v1/test/pay (synthetic payer); every checkout page shows a “SANDBOX” banner. Production keys (sk_live_) are issued by Konbit after validation.

3 · Payments

Payment intents

REQUIRES_PAYMENT: pending · SUCCEEDED: paid · EXPIRED: expired · CANCELED: canceled by you · REFUNDED: refunded.

Amounts

amountMinor is an integer in minor units (10000 = 100.00 DOP). Bounds: 1,000 to 5,000,000, i.e. 10 to 50,000 DOP. DOP only in V1 (no HTG on the API yet).

Idempotency-Key (required)

Required on creation. Verified: the SAME key (per merchant) ALWAYS returns the same intent, with no duplicate and no double charge. Use your order id — a network retry is then harmless.

Reconciliation via metadata

Attach your identifiers (orderId, userId…) in metadata (max 20 keys). They come back verbatim in the webhook and the GET, to match a payment to your order. Put NO secrets there: the payer and your webhook can see them.

5 · Error codes

API reference

Authenticate every request with your secret key in the Authorization header.

POST/v1/payment_intents

Create a payment intent

curl -X POST https://app.getkonbit.com/api/v1/payment_intents \
  -H "Authorization: Bearer sk_test_..." \
  -H "Idempotency-Key: order-123" \
  -H "Content-Type: application/json" \
  -d '{"amountMinor":10000,"description":"Order 123","returnUrl":"https://mon-site.com/merci"}'
Response
{
  "id": "5Qx0u5gexpnqsw6qQhks",
  "status": "REQUIRES_PAYMENT",
  "amountMinor": 10000,
  "currency": "DOP",
  "feeMinor": 200,
  "code": "562183",
  "checkoutUrl": "https://app.getkonbit.com/pay/5Qx0u5gexpnqsw6qQhks",
  "expiresAtMs": 1783710307539
}

Redirect your customer to checkoutUrl (or show the QR) so they can pay.

GET/v1/payment_intents/{id}

Get a payment status

Response
{ "id": "5Qx0...", "status": "SUCCEEDED", "amountMinor": 10000, "feeMinor": 200 }
POST/v1/refunds

Refund a payment

curl -X POST https://app.getkonbit.com/api/v1/refunds \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{"payment_intent":"5Qx0u5gexpnqsw6qQhks"}'
GET/v1/balance

Get your balance

Response
{ "object": "balance", "balanceMinor": 9800, "totalFeesMinor": 200, "currency": "DOP" }
POST/v1/payouts

Pay out part of your balance to a driver / beneficiary's wallet, by their Konbit code (9 characters). Idempotency-Key required.

Merchant key required (Konbit contract): portal test keys cannot pay out to a real wallet — their sandbox balance is fictitious.

curl -X POST https://app.getkonbit.com/api/v1/payouts \
  -H "Authorization: Bearer sk_test_..." \
  -H "Idempotency-Key: driver-42-2026-W27" \
  -H "Content-Type: application/json" \
  -d '{"konbitCode":"K7M2P9QRS","amountMinor":85000,"metadata":{"driverId":"drv_42"}}'
Response
{ "id":"p_8Kd...", "object":"payout", "status":"SUCCEEDED", "amountMinor":85000, "feeMinor":0, "currency":"DOP", "konbitCode":"K7M2P9QRS" }
GET/v1/payouts

List your payouts, for reconciliation.

Payment statuses

REQUIRES_PAYMENT: pending · SUCCEEDED: paid · EXPIRED: expired · CANCELED: canceled by you · REFUNDED: refunded.

Error codes

Every error returns the same object: a stable code (to test against) and a readable message.

{ "error": { "code": "amount_out_of_bounds", "message": "..." } }
CodeWhen
401 unauthorizedKey missing, invalid or revoked.
400 idempotency_key_requiredIdempotency-Key header missing on creation.
400 amount_out_of_bounds / invalid_amountAmount outside the configured bounds, or not an integer.
400 invalid_return_urlreturnUrl provided but not https.
404 not_foundUnknown intent, or belonging to another merchant.
404 code_not_foundBeneficiary's Konbit code not found (payout).
409 not_cancelable / not_refundableIncompatible status (already paid, already refunded, not cancelable…).
409 insufficient_balanceMerchant balance too low for the refund or payout.
429 quota_exceededToo many requests.
503 service_unavailableKonbit Pay temporarily disabled.
4 · Webhooks

Webhooks

Every state change fires a signed JSON POST to your webhookUrl (https required). Header Konbit-Signature: t=,v1=. Here is the exact envelope:

POST (votre webhookUrl)
Konbit-Signature: t=1720000000000,v1=<hmac_sha256>

{
  "id": "evt_9f2c...",                 // id de l'ÉVÉNEMENT — dédup
  "type": "payment_intent.succeeded",  // .refunded · .canceled · .expired · .payment_failed (data.failureCode)
  "createdAtMs": 1720000000000,        // millisecondes
  "data": {
    "id": "5Qx0...",                   // id de l'intention (= konbitRef)
    "status": "SUCCEEDED",
    "amountMinor": 10000,
    "currency": "DOP",
    "feeMinor": 200,
    "metadata": { "orderId": "order-123" }
  }
}
⚠ t is in MILLISECONDS

The signature's t AND createdAtMs are in milliseconds since the epoch (13 digits). Pitfall #1: treating them as seconds fails the 5-minute window and you reject valid webhooks.

Verify the signature, step by step

1. Read the RAW body (unparsed). 2. Extract t and v1 from the header. 3. Reject if |now − t| > 5 min. 4. Compute HMAC-SHA256(webhook_secret, "."). 5. Compare to v1 in constant time. 6. Deduplicate on id (the EVENT id, not data.id).

Verification — copy-paste snippets

Node.js / Express
const crypto = require("crypto");
// IMPORTANT: raw body, pas express.json() sur cette route
app.post("/konbit/webhook",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const raw = req.body;                       // Buffer brut
    const sig = req.get("Konbit-Signature") || "";
    const parts = Object.fromEntries(sig.split(",").map(p => p.split("=")));
    const t = Number(parts.t);                  // MILLISECONDES
    if (!t || Math.abs(Date.now() - t) > 5 * 60 * 1000) return res.sendStatus(401);
    const expected = crypto.createHmac("sha256", process.env.KONBIT_WEBHOOK_SECRET)
      .update(t + "." + raw.toString("utf8")).digest("hex");
    const a = Buffer.from(expected), b = Buffer.from(parts.v1 || "");
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);
    const event = JSON.parse(raw.toString("utf8"));
    if (alreadyProcessed(event.id)) return res.sendStatus(200);   // dédup sur event.id
    // re-vérifiez event.data.amountMinor + currency contre VOTRE commande
    markProcessed(event.id);
    fulfill(event.data.metadata.orderId);
    res.sendStatus(200);
  });
PHP
<?php
$raw = file_get_contents("php://input");        // corps brut
$sig = $_SERVER["HTTP_KONBIT_SIGNATURE"] ?? "";
parse_str(str_replace(",", "&", $sig), $p);      // t=...&v1=...
$t = (int)($p["t"] ?? 0);                        // MILLISECONDES
if (!$t || abs(round(microtime(true)*1000) - $t) > 300000) { http_response_code(401); exit; }
$expected = hash_hmac("sha256", $t . "." . $raw, getenv("KONBIT_WEBHOOK_SECRET"));
if (!hash_equals($expected, $p["v1"] ?? "")) { http_response_code(401); exit; }
$event = json_decode($raw, true);
if (already_processed($event["id"])) { http_response_code(200); exit; }  // dédup
// re-vérifiez amountMinor + currency contre votre commande
mark_processed($event["id"]);
fulfill($event["data"]["metadata"]["orderId"]);
http_response_code(200);
Python / Flask
import hmac, hashlib, time, os
from flask import request, abort

@app.post("/konbit/webhook")
def konbit_webhook():
    raw = request.get_data()                     # bytes bruts
    parts = dict(p.split("=", 1) for p in request.headers.get("Konbit-Signature", "").split(","))
    t = int(parts.get("t", 0))                   # MILLISECONDES
    if not t or abs(time.time() * 1000 - t) > 300000:
        abort(401)
    expected = hmac.new(os.environ["KONBIT_WEBHOOK_SECRET"].encode(),
                        f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, parts.get("v1", "")):
        abort(401)
    event = request.get_json()
    if already_processed(event["id"]):           # dédup sur event.id
        return "", 200
    # re-vérifiez amountMinor + currency contre votre commande
    mark_processed(event["id"])
    fulfill(event["data"]["metadata"]["orderId"])
    return "", 200

Retry policy

Immediate delivery on the event. On failure (non-2xx response, or the 10 s timeout exceeded), Konbit retries 5 times, with a backoff of 1, 5, 30, 120 then 720 minutes — 6 attempts total over about 14.5 hours before giving up ("dead" status, visible for diagnostics). Each attempt is RE-SIGNED with a current t: your 5-minute window therefore accepts retries. The event id stays identical across retries — deduplicate on it.

Or by polling

Two ways to know a payment succeeded: receive the webhook, or poll the intent until its status is SUCCEEDED.

curl https://app.getkonbit.com/api/v1/payment_intents/{id} \
  -H "Authorization: Bearer sk_test_..."
# répétez jusqu'à "status": "SUCCEEDED"
The golden rule

Webhook AND reconciliation polling, never one without the other. The webhook notifies you fast; a periodic GET of recent intents catches any missed delivery (endpoint briefly down, "dead" event). Never depend on a single channel.

Pay a test intent

In sandbox the simplest is POST /v1/test/pay (above): no Konbit account required. Otherwise, open the checkoutUrl signed into a funded test Konbit account. Once card collection is enabled for your account (see below), the same checkoutUrl also offers “Pay by card” (no Konbit account): the intent is then marked paidViaCard, credited to your USD balance, and you receive card_payment.succeeded + payment_intent.succeeded (dedupe by id).

6 · Go-live

Go-live checklist

Eleven points before you switch. The three in bold are the ones 90% of integrators forget — and that cost money or open a hole.

  • Merchant sk_ key obtained and stored server-side (secrets manager), never client-side.
  • Idempotency-Key set on every creation (your orderId).
  • Amounts in minor units, within bounds; DOP currency.
  • RAW body preserved for the HMAC check (no parsing before the signature).
  • Webhook endpoint on https, replies 2xx in under 10 s, heavy work done asynchronously.
  • Signature verified in constant time, t handled as milliseconds, requests older than 5 min rejected.
  • Reconciliation polling as a safety net, on top of the webhook.
  • EXPIRED / CANCELED / REFUNDED statuses and error codes handled cleanly.
  • Deduplication on the EVENT id (at-least-once delivery).
  • Re-verify the webhook's amount AND currency against your order — don't trust the event type alone.
  • No client-side path marks "paid": returnUrl is not proof, only server confirmation is.

USD rail: collect by card, pay out by Zelle

For platforms operating in dollars (investment, payroll, marketplaces): your customers pay by card on a Konbit-hosted checkout — no Konbit account needed — and you distribute USD by Zelle, in batches, with per-line webhook confirmation.

1. Card collection

Your server creates the intent (amount locked server-side), the payer enters their card on /cpay. Konbit's fee is added on top; your USD balance receives the net.

2. Funds secured

Every payment goes through an anti-fraud settlement hold before it becomes distributable. Three-state balance: pending, available, reserved.

3. Zelle payouts

You submit the list (up to 200 recipients per batch). Konbit executes each transfer and notifies you by signed webhook: paid or failed, line by line.

Card collection: per-merchant activation

Card collection (USD rail) is a PER-MERCHANT activation, approved by Konbit (compliance, chargeback clause) — never on a test key. Once enabled, your DOP payment links also offer the card option to payers, no Konbit account needed. Contact us to enable it.