Business integration · API v1

Take BOONKA payments on your own site.

Create a payment session from your server, send the shopper to BOONKA Checkout, and get a signed webhook the moment they pay with their BOONKA credit. Three calls, no card data on your infrastructure.

Document rédigé en anglais.

Life of a payment session

every session is a state machine with a 30-minute clock

  1. createdyour server
  2. pendingawaiting shopper
  3. processingcredit debited
  4. succeededwebhook fires
  5. paid outafter each sale
Or ends atexpired30 min passedcancelledyou or the shopperfailedpayment declined

Ship the order on payment.succeeded, not on the redirect.Get paid out after each sale, minus the 2% fee.

Get started

How BOONKA Checkout works

Your shopper pays with their prepaid BOONKA credit, in Algerian dinars, so there are no card numbers, no PANs, and nothing sensitive ever touches your servers. You never handle credentials; you hand off to a hosted checkout page and wait to be told what happened.

An integration is built from four moving parts:

  • Your server creates a payment session: an amount, your order reference, and where to send the shopper afterwards.
  • BOONKA Checkout is the hosted page the shopper opens. They sign in to their BOONKA account, review your order, and confirm.
  • Your webhook endpoint receives a signed payment.succeeded event. This is the only trustworthy signal that you have been paid.
  • Your business dashboard is where you create API keys, set the webhook URL, and read the payment log.

Everything below the quickstart is detail. If you only read one paragraph, read this one: create the session server-side, redirect, and confirm by webhook.

Base URLs

WhatURL
APIhttps://api.boonka.com/api/v1
Hosted checkouthttps://checkout.boonka.io/{session_id}

All requests are HTTPS and all bodies are JSON. Never build the checkout URL yourself; use the checkout_url returned when you create the session.

The whole flow
# 1. Your server creates the session
POST /api/v1/payment-sessions/store
   → checkout_url, id, expires_at

# 2. You redirect the shopper
302 https://checkout.boonka.io/9d4b1c2e-…

# 3. The shopper pays with their BOONKA credit
#     BOONKA POSTs to your endpoint
POST https://shop.dz/webhooks/boonka
   X-Gateway-Timestamp: 1785412200
   X-Gateway-Signature: sha256=a3f1…

   {"type": "payment.succeeded", …}

# 4. The shopper lands back on your site
302 https://shop.dz/orders/1042/paid

Quickstart

Your first payment

This gets you a working test payment end to end. Pick your language in any code panel; the choice follows you down the whole page.

Create a test API key

In your BOONKA business dashboard, open Developers → API keys and create a key in Test mode. You get a pair:

pk_test_… is the public key, sent in the X-API-Key header. sk_test_… is the secret key, sent as a Bearer token. The secret is shown once and never again. Store it in your server’s environment, never in client code or a repository.

Create a payment session

From your server, when the shopper clicks "Pay with BOONKA". Send the amount in minor units: 4 500,00 DZD is 450000.

Redirect to checkout_url

Send the shopper to the checkout_url in the response. Store the session id against your order first; you will need it to reconcile the webhook.

Confirm with a webhook

Set your endpoint under Developers → Webhooks, verify the signature on every request, and fulfil the order when you receive payment.succeeded. Use Send test event to check your receiver before you take a real payment.

Create a session
curl https://api.boonka.com/api/v1/payment-sessions/store \
  -H "X-API-Key: $BOONKA_PUBLIC_KEY" \
  -H "Authorization: Bearer $BOONKA_SECRET_KEY" \
  -H "Idempotency-Key: order_1042.v1" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 450000,
    "reference": "order_1042",
    "success_url": "https://shop.dz/orders/1042/paid",
    "cancel_url": "https://shop.dz/orders/1042/cancelled",
    "metadata": { "order_id": "1042" }
  }'
201Created
{
  "success": true,
  "message": "Payment session created successfully",
  "data": {
    "id": "9d4b1c2e-3f5a-4b6c-8d7e-9f0a1b2c3d4e",
    "status": "pending",
    "amount": 450000,
    "currency": "DZD",
    "reference": "order_1042",
    "checkout_url": "https://checkout.boonka.io/9d4b1c2e-3f5a-4b6c-8d7e-9f0a1b2c3d4e",
    "expires_at": "2026-07-29T12:00:00+00:00",
    "created_at": "2026-07-29T11:30:00+00:00"
  }
}

Authentication

API keys

Server-to-server calls are authenticated with a key pair, sent as two headers on every request:

HeaderValue
X-API-KeyYour public key: pk_test_… or pk_live_…
AuthorizationBearer followed by your secret key: sk_test_… or sk_live_…

Both are required. Missing either one returns 401 Missing API Key.; a mismatched or revoked pair returns 401 Invalid API credentials.

One active key per environment

You hold at most one active key per environment. Creating a new key immediately revokes the previous one for that environment. This is how rotation works, and it is not reversible. Deploy the new secret before you create it, or plan for the brief window where in-flight requests fail with 401.

Keeping keys safe

  • Keep the secret key on your server, in environment variables or a secret manager. It must never reach a browser, a mobile app, or a public repository.
  • The public key is not a secret in the cryptographic sense, but it identifies you, so there is no reason to publish it either.
  • Rotate immediately if a secret is exposed. Creating a replacement key revokes the leaked one.
  • Use separate keys for staging and production by using test mode for staging.
Authenticated request
curl https://api.boonka.com/api/v1/payment-sessions/9d4b1c2e-… \
  -H "X-API-Key: pk_live_RVLyODnGa7FRXewlPasom6a7RDpBqTOP" \
  -H "Authorization: Bearer sk_live_…"
401Unauthorized
{
  "success": false,
  "message": "Invalid API credentials."
}

Get started

Test and live mode

BOONKA gives you two complete, separate environments. You build and prove your integration in test mode, then switch to live mode to take real money. The API is identical in both: same endpoints, same payloads, same signed webhooks.

The environment is carried by the key itself; there is no mode parameter to forget. A pk_test_ / sk_test_ pair creates test sessions. A pk_live_ / sk_live_ pair creates real ones.

Test modeLive mode
Key prefixpk_test_ · sk_test_pk_live_ · sk_live_
MoneyNone. Test credit only. No real money moves.Real dinars, real payouts.
ShopperA second BOONKA test account.Your real customers.
WebhooksDelivered and signed identically.Delivered and signed identically.
environment"test" on sessions and events."live" on sessions and events.

The two are sealed off from each other

A session created with a test key is invisible to a live key, and retrieving it returns 404. That is deliberate: a stray test key in production fails loudly instead of quietly reading nothing.

Both environments deliver to the same webhook URL, so your handler must read environment on every event and refuse to act on the wrong one. This is the single most important line of code in the switch to production.

Moving from test to live

Finish the testing phase, then work through the production checklist.

Fail fast on a mismatched key
// Run this at boot, before the app serves traffic.
const isProduction = process.env.NODE_ENV === "production";
const expected = isProduction ? "pk_live_" : "pk_test_";

if (!process.env.BOONKA_PUBLIC_KEY?.startsWith(expected)) {
  throw new Error(
    `BOONKA key is wrong for this deploy; expected ${expected}`
  );
}

Core concepts

Amounts and currency

Every amount in the API is an integer in minor units, that is, centimes. BOONKA works in Algerian dinars and currency is always "DZD"; there is no currency parameter to send.

One dinar is 100 centimes, so 450000 means 4 500,00 DZD. Sending 4500.00 is rejected: amount is an integer.

Convert once, at the boundary, and keep integers everywhere else. Never hold an amount in a floating-point value.

Itemised checkout

You can pass items, service_fee and transaction_tax so the checkout page shows a breakdown instead of a single figure. These fields are display only: the shopper is charged exactly amount, whatever the lines add up to.

If you send a breakdown, make it reconcile with amount yourself. BOONKA renders what you send without checking the arithmetic.

Metadata

metadata is key-value data you attach to a session and get back on every read and every webhook. Use it to carry your own identifiers so you never have to guess which order an event belongs to.

Itemised session
{
  "amount": 262500,          // charged: 2 625,00 DZD
  "service_fee": 5000,        // shown: 50,00 DZD
  "transaction_tax": 7500,    // shown: 75,00 DZD
  "items": [
    {
      "name": "Herbal tea blend, 250 g",
      "quantity": 2,
      "amount": 125000
    }
  ],
  "reference": "order_1042",
  "success_url": "https://shop.dz/orders/1042/paid",
  "cancel_url": "https://shop.dz/orders/1042/cancelled"
}
Metadata
"metadata": {
  "order_id": "1042",
  "customer_id": "cust_8821",
  "is_gift": true,
  "table": 14
}

// Comes back unchanged on every read
// and on every webhook for this session.

Core concepts

Session lifecycle

A payment session is created pending and lives for 30 minutes. Within that window the shopper can open the checkout and pay. After it, the session is dead and cannot be revived. Create a new one.

The expiry is fixed and not configurable. Create the session when the shopper is ready to pay, not when they add something to their basket.

StatusMeaningTerminal
pendingCreated and waiting. The shopper can pay.no
processingThe charge is in flight. Lasts moments.no
succeededPaid. Fulfil the order.yes
failedThe charge was attempted and declined.yes
cancelledCancelled by you or by the shopper.yes
expiredThe 30-minute window closed unpaid.yes
refundedFully refunded after a dispute.yes
partially_refundedPartly refunded after a dispute.yes

What can go wrong at checkout

These are resolved on BOONKA’s side and reach you as a status, not as an API error. Your job is to handle the resulting webhook or read the session.

  • Not enough credit. The shopper’s BOONKA credit cannot cover the amount. Nothing is charged and the session stays pending, so they can recharge and try again inside the window.
  • The shopper is you. A business cannot pay their own session. Use a second test account.
  • Session already claimed. The first shopper to open a checkout link claims it. A second, different shopper is refused, so treat checkout_url as private to one buyer.
  • Business suspended. A business carrying a severe reputation penalty cannot receive payments, and shoppers are told so at checkout.
Reconciling an order
// A webhook arrived, or a shopper came back
// to your success page. Same handling either way:

const session = await boonka.retrieve(order.boonka_session_id);

switch (session.status) {
  case "succeeded":
    await orders.fulfilOnce(order.id, session.id);
    break;

  case "pending":
  case "processing":
    await orders.markAwaitingPayment(order.id);
    break;

  case "expired":
  case "cancelled":
  case "failed":
    await orders.restockOrder(order.id);
    break;
}

Core concepts

Settlement

When a payment succeeds, the shopper’s BOONKA credit is debited and you are paid out after each sale, minus BOONKA’s 2% fee. That 2% on what you receive is BOONKA’s only fee.

So payment.succeeded means the shopper has paid and their BOONKA credit has been debited. The shopper is always charged exactly amount: the fee comes out of what you receive, never on top of the shopper’s price.

What this means for your integration

  • Fulfil on payment.succeeded. The shopper has been debited and is entitled to their order.
  • Do not reconcile your bank account against this API. Payouts live in your dashboard, not in the session object.
  • Expect disputes to matter. If a shopper opens a dispute, the payment can be refunded to them.

Refunds

Refunds are handled through the BOONKA dashboard and the dispute process. There is no business-facing refund endpoint in v1. A refund after a dispute goes back to the shopper as BOONKA credit, and the session reflects it: status becomes refunded or partially_refunded, and refunded_amount carries the amount returned in minor units.

Poll or read the session if you need to reflect refunds in your own records; refunds do not currently emit a webhook event.

A refunded session
{
  "id": "9d4b1c2e-3f5a-4b6c-8d7e-9f0a1b2c3d4e",
  "status": "partially_refunded",
  "amount": 450000,
  "refunded_amount": 120000,
  "currency": "DZD",
  "paid_at": "2026-07-22T11:45:00+00:00"
}

Core concepts

Idempotency

Send an Idempotency-Key header when you create a payment session. BOONKA does not reject a request without one, but without it a retry creates a second session for the same order.

The key makes retries safe. If your request times out and you send it again with the same key, BOONKA replays the original response instead of creating a second session, so a flaky network cannot produce two checkout links for one order.

RuleDetail
ReplaySame key, same body → the original session, not a second one
ConflictSame key, different body → 409, nothing is created

Choosing a key

Derive it from the thing you are trying to do exactly once. An order identifier plus a version counter works well: order_1042.v1. If the shopper abandons checkout and comes back with a changed basket, bump to order_1042.v2 so you get a fresh session for the new amount.

Do not use a random UUID generated at the top of your retry loop; a new key on each attempt defeats the entire mechanism. Generate the key once, then retry with it.

Safe retries
# Same key, same body. Run it twice.
# The second call returns the FIRST session.

curl -i https://api.boonka.com/api/v1/payment-sessions/store \
  -H "X-API-Key: $BOONKA_PUBLIC_KEY" \
  -H "Authorization: Bearer $BOONKA_SECRET_KEY" \
  -H "Idempotency-Key: order_1042.v1" \
  -H "Content-Type: application/json" \
  -d '{"amount":450000,"success_url":"…","cancel_url":"…"}'

# → HTTP/1.1 201 Created   id 9d4b1c2e-…
# → HTTP/1.1 201 Created   id 9d4b1c2e-…  (same session)
409Conflict: key reused with a different body
{
  "success": false,
  "message": "Idempotency key conflict. This key was already used for a different request."
}

Core concepts

Redirect URLs

Every session carries two URLs you provide. Both must be absolute and reachable by a browser.

FieldWhen the shopper lands there
success_urlAfter a successful payment
cancel_urlAfter cancelling, or when the payment fails

A redirect is a courtesy, not a receipt

Landing on success_url tells you the shopper’s browser reached a URL. It does not tell you money moved. Anyone can type that URL. Browsers close, connections drop, and phones die between the payment and the redirect.

So treat the success page as a place to show the outcome, never to decide it. When the shopper arrives, look up the order and either read the session from the API or check whether the webhook has already marked it paid.

Make the URLs identify the order

BOONKA appends nothing to your URLs: no session id, no status. Put your own identifier in the path or query string so the page knows which order it is looking at:

  • https://shop.dz/orders/1042/paid, clean and unguessable if 1042 is paired with a session lookup.
  • https://shop.dz/checkout/return?order=1042&t=b7f3…, add a short signed token if the page must work without a session cookie.
Handling the return page
// GET /orders/:id/paid
app.get("/orders/:id/paid", async (req, res) => {
  const order = await orders.find(req.params.id);

  // The webhook usually beats the browser back.
  if (order.status === "paid") {
    return res.render("thanks", { order });
  }

  // It hasn't yet. Ask BOONKA directly.
  const session = await boonka.retrieve(order.boonka_session_id);

  if (session.status === "succeeded") {
    await orders.fulfilOnce(order.id, session.id);
    return res.render("thanks", { order });
  }

  // Still settling. Show a waiting state, not a failure.
  return res.render("confirming", { order });
});

API reference

Create a payment session

POST/api/v1/payment-sessions/store

Creates a session and returns the checkout_url to send the shopper to. The environment comes from your key pair.

Headers

HeaderDescription
X-API-KeyrequiredYour public key.
AuthorizationrequiredBearer plus your secret key.
Idempotency-KeyrecommendedMakes the call safe to retry. See Idempotency.
Content-Typerequiredapplication/json

Body

FieldTypeDescription
amountintegerrequiredAmount to charge, in minor units.
success_urlstringrequiredWhere to send the shopper after a successful payment. Absolute URL.
cancel_urlstringrequiredWhere to send the shopper after a cancelled or failed payment.
referencestringoptionalYour own order identifier. Max 255 characters. Searchable in the payment log and returned on every event.
metadataobjectoptionalKey-value data returned with the session and every webhook.
service_feeintegeroptionalFee line shown on the checkout page, in minor units. Display only: it does not change what is charged.
transaction_taxintegeroptionalTax line shown on the checkout page, in minor units. Display only.
itemsarrayoptionalLine items rendered on the checkout page. Display only. Each item: name (string, max 255, required), quantity (integer ≥ 1, required), amount (integer ≥ 0, minor units, required).

Returns

201 with the new session. Store data.id against your order before you redirect. It is the only handle you have on the payment afterwards.

Request
curl https://api.boonka.com/api/v1/payment-sessions/store \
  -H "X-API-Key: $BOONKA_PUBLIC_KEY" \
  -H "Authorization: Bearer $BOONKA_SECRET_KEY" \
  -H "Idempotency-Key: order_1042.v1" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 450000,
    "reference": "order_1042",
    "success_url": "https://shop.dz/orders/1042/paid",
    "cancel_url": "https://shop.dz/orders/1042/cancelled",
    "metadata": {
      "order_id": "1042",
      "customer_id": "cust_8821"
    },
    "service_fee": 5000,
    "items": [
      { "name": "Herbal tea blend, 250 g", "quantity": 2, "amount": 125000 }
    ]
  }'
201Created
{
  "success": true,
  "message": "Payment session created successfully",
  "data": {
    "id": "9d4b1c2e-3f5a-4b6c-8d7e-9f0a1b2c3d4e",
    "status": "pending",
    "amount": 450000,
    "currency": "DZD",
    "reference": "order_1042",
    "checkout_url": "https://checkout.boonka.io/9d4b1c2e-3f5a-4b6c-8d7e-9f0a1b2c3d4e",
    "expires_at": "2026-07-29T12:00:00+00:00",
    "created_at": "2026-07-29T11:30:00+00:00"
  }
}
422Unprocessable: validation failed
{
  "success": false,
  "message": "The success url field must be a valid URL.",
  "errors": {
    "success_url": ["The success url field must be a valid URL."]
  }
}

API reference

Retrieve a payment session

GET/api/v1/payment-sessions/{id}

Reads the current state of a session. This is your source of truth whenever you need to know where a payment stands: on the return page, in a reconciliation job, or when a customer asks.

ParameterTypeDescription
idpathrequiredThe session id returned at creation, e.g. 9d4b1c2e-3f5a-4b6c-8d7e-9f0a1b2c3d4e.

Scoping

A key can only read its own business’s sessions, in its own environment. Anything else returns 404, including a live key asking for a test session. A 404 here means "not yours or not in this environment", not necessarily "does not exist".

Reading, not polling

Webhooks are the intended way to learn that a payment happened; this endpoint is for checking a specific session you already care about. If you do run a sweeper for missed events, keep it to sessions you know are outstanding and back it off once they reach a terminal status. Do not poll every session on a timer.

Returns

200 with the full session object.

Request
curl https://api.boonka.com/api/v1/payment-sessions/9d4b1c2e-3f5a-4b6c-8d7e-9f0a1b2c3d4e \
  -H "X-API-Key: $BOONKA_PUBLIC_KEY" \
  -H "Authorization: Bearer $BOONKA_SECRET_KEY"
200OK
{
  "success": true,
  "message": "Payment session retrieved successfully",
  "data": {
    "id": "9d4b1c2e-3f5a-4b6c-8d7e-9f0a1b2c3d4e",
    "status": "succeeded",
    "environment": "live",
    "merchant_name": "Noqta",
    "amount": 450000,
    "total": 450000,
    "service_fee": 5000,
    "transaction_tax": 0,
    "items": [
      { "name": "Herbal tea blend, 250 g", "quantity": 2, "amount": 125000 }
    ],
    "currency": "DZD",
    "reference": "order_1042",
    "metadata": { "order_id": "1042" },
    "checkout_url": "https://checkout.boonka.io/9d4b1c2e-3f5a-4b6c-8d7e-9f0a1b2c3d4e",
    "transaction_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
    "refunded_amount": 0,
    "failure_reason": null,
    "expires_at": "2026-07-29T12:00:00+00:00",
    "paid_at": "2026-07-29T11:45:00+00:00",
    "created_at": "2026-07-29T11:30:00+00:00",
    "updated_at": "2026-07-29T11:45:00+00:00"
  }
}

API reference

Cancel a payment session

POST/api/v1/payment-sessions/{id}/cancel

Cancels a session that has not been paid yet. Use it when the shopper abandons checkout on your side, when stock runs out, or when your order is voided before payment.

Only a pending session can be cancelled. It moves to cancelled, its checkout link stops working, and payment.cancelled fires.

OutcomeStatusResponse
Was pending200Cancelled. Webhook fires.
Already cancelled200No change, no new webhook. Safe to call twice.
Paid, failed or expired409Nothing changes. The body carries the current status.
Not yours404Wrong business or wrong environment.

No Idempotency-Key is needed, because cancelling twice is already harmless.

You rarely need to cancel just to clean up. An unpaid session expires by itself after 30 minutes.

Request
curl -X POST \
  https://api.boonka.com/api/v1/payment-sessions/9d4b1c2e-…/cancel \
  -H "X-API-Key: $BOONKA_PUBLIC_KEY" \
  -H "Authorization: Bearer $BOONKA_SECRET_KEY"
200OK: cancelled
{
  "success": true,
  "message": "Payment session cancelled.",
  "data": {
    "id": "9d4b1c2e-3f5a-4b6c-8d7e-9f0a1b2c3d4e",
    "status": "cancelled",
    "reference": "order_1042",
    "redirect_url": "https://shop.dz/orders/1042/cancelled"
  }
}
409Conflict: already paid
{
  "success": false,
  "message": "This payment session can no longer be cancelled.",
  "data": {
    "id": "9d4b1c2e-3f5a-4b6c-8d7e-9f0a1b2c3d4e",
    "status": "succeeded",
    "reference": "order_1042",
    "redirect_url": "https://shop.dz/orders/1042/cancelled"
  }
}

API reference

The payment session object

Returned in full by retrieve. Creation returns a shorter form containing id, status, amount, currency, reference, checkout_url, expires_at and created_at.

All timestamps are ISO 8601 with an offset. All amounts are integers in minor units.

FieldTypeDescription
idstringUUID of the session. Your handle on the payment; store it against the order.
statusstringOne of the lifecycle statuses.
environmentstringtest or live, inherited from the key that created it.
merchant_namestringYour display name as the shopper sees it at checkout.
amountintegerWhat the shopper is charged, in minor units.
totalintegerThe charged total. Equal to amount today; a separate field so itemised sessions can diverge later.
service_feeintegernullableDisplay-only fee line, if you sent one.
transaction_taxintegernullableDisplay-only tax line, if you sent one.
itemsarraynullableDisplay-only line items, if you sent them.
currencystringAlways "DZD".
referencestringnullableYour order identifier, as supplied at creation.
metadataobjectnullableThe metadata you attached, returned unchanged.
checkout_urlstringThe hosted checkout page for this session.
transaction_idstringnullableThe transaction record, once the payment has succeeded. null before that.
refunded_amountintegerAmount refunded so far, in minor units. 0 unless the session was refunded.
failure_reasonstringnullableWhy the payment failed or was cancelled, e.g. cancelled_by_merchant. null otherwise.
expires_atstringWhen the session stops being payable, 30 minutes after creation.
paid_atstringnullableWhen the payment succeeded. null until then.
created_atstringWhen the session was created.
updated_atstringWhen the session last changed.
Session object
{
  "id": "9d4b1c2e-3f5a-4b6c-8d7e-9f0a1b2c3d4e",
  "status": "succeeded",
  "environment": "live",
  "merchant_name": "Noqta",
  "amount": 450000,
  "total": 450000,
  "service_fee": 5000,
  "transaction_tax": 0,
  "items": [
    { "name": "Herbal tea blend, 250 g", "quantity": 2, "amount": 125000 }
  ],
  "currency": "DZD",
  "reference": "order_1042",
  "metadata": { "order_id": "1042" },
  "checkout_url": "https://checkout.boonka.io/9d4b1c2e-…",
  "transaction_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "refunded_amount": 0,
  "failure_reason": null,
  "expires_at": "2026-07-29T12:00:00+00:00",
  "paid_at": "2026-07-29T11:45:00+00:00",
  "created_at": "2026-07-29T11:30:00+00:00",
  "updated_at": "2026-07-29T11:45:00+00:00"
}

Webhooks

Receiving events

Webhooks are how you find out that a payment happened. BOONKA POSTs a JSON event to a URL you own, signed so you can prove it came from BOONKA.

You configure one endpoint per business account in Developers → Webhooks. It receives both test and live events; the environment field on every event tells you which is which.

Requirements for your endpoint

  • HTTPS only. Plain HTTP is rejected when you save the URL.
  • Publicly reachable. localhost, .local, .internal and private IP ranges are refused. For local development, use a tunnel that gives you a public HTTPS hostname.
  • Fast. BOONKA waits 10 seconds for a response. Acknowledge first, do the work afterwards.
  • Unauthenticated. Exclude the route from login middleware and CSRF protection. The signature is the authentication.

Answer quickly, work later

Return 200 as soon as you have verified the signature and safely recorded the event. Anything slower than 10 seconds is recorded as a failed delivery and retried, which means you will process the same payment several times for no reason.

Verify, write the event to a queue or a table, respond, and let a worker do the fulfilment.

Expect duplicates and out-of-order arrivals

Delivery is at least once. A slow response, a network blip, or a retry after a transient failure can all produce the same event twice. Ordering is not guaranteed either: under retry, payment.succeeded can arrive after a later event.

Make your handler idempotent by keying on the event id. Insert it with a unique constraint; if the insert conflicts, you have seen the event and can return 200 without doing the work again.

A delivery, in full
POST /webhooks/boonka HTTP/1.1
Host: shop.dz
Content-Type: application/json
X-Gateway-Timestamp: 1785412200
X-Gateway-Signature: sha256=a3f1c9e0b74d…

{
  "id": "7c1e4a8f-0d33-4b2e-9a71-5f1c0c2a7b4e",
  "type": "payment.succeeded",
  "environment": "live",
  "created_at": "2026-07-29T11:45:00+00:00",
  "data": {
    "id": "9d4b1c2e-3f5a-4b6c-8d7e-9f0a1b2c3d4e",
    "status": "succeeded",
    "amount": 450000,
    "currency": "DZD",
    "reference": "order_1042",
    "metadata": { "order_id": "1042" },
    "paid_at": "2026-07-29T11:45:00+00:00"
  }
}
Deduplicating
-- One row per event. The constraint does the work.
CREATE TABLE boonka_events (
  event_id   text NOT NULL,
  event_type text NOT NULL,
  payload    jsonb NOT NULL,
  received_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (event_id)
);

-- In the handler: if nothing was inserted,
-- it's a duplicate. Return 200 and stop.
INSERT INTO boonka_events (event_id, event_type, payload)
VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING
RETURNING event_id;

Webhooks

Verifying signatures

Your webhook URL is public, so anyone can POST to it. The signature is what separates a real BOONKA event from someone claiming an order was paid. Verify every request before you read the body.

The two headers

HeaderValue
X-Gateway-TimestampUnix time in seconds when the delivery was signed.
X-Gateway-Signaturesha256= followed by the hex HMAC.

How to verify

  1. Read the raw request body, exactly as received. Do not parse and re-serialise it first, because any change in whitespace or key order breaks the signature.
  2. Build the signed string: the timestamp, a full stop, then the raw body: {timestamp}.{body}.
  3. Compute HMAC-SHA256 over it using your endpoint’s signing secret (whsec_…), hex-encoded.
  4. Compare "sha256=" + your_hmac with the header using a constant-time comparison. A plain == leaks timing information.
  5. Reject anything whose timestamp is more than about five minutes from your clock, so a captured request cannot be replayed later.

Getting the secret

The signing secret is shown in full when you first save your webhook and whenever you rotate it. After that the dashboard shows only a hint like whsec_…a91f. Store it as an environment variable next to your API secret.

Rotating invalidates the old secret immediately. Deploy the new one first, then rotate, or accept a short window of rejected deliveries, which will be retried.

Test it before you trust it

Developers → Webhooks → Send test event delivers a webhook.test event signed exactly like a real one. If your verification passes for that, it will pass for a payment.

Verify and acknowledge
import crypto from "node:crypto";
import express from "express";

const SECRET = process.env.BOONKA_WEBHOOK_SECRET;
const TOLERANCE = 300; // seconds

app.post(
  "/webhooks/boonka",
  express.raw({ type: "application/json" }),  // raw bytes
  async (req, res) => {
    const timestamp = req.get("X-Gateway-Timestamp");
    const signature = req.get("X-Gateway-Signature");
    if (!timestamp || !signature) return res.sendStatus(400);

    // Replay guard
    const age = Math.abs(Date.now() / 1000 - Number(timestamp));
    if (!Number.isFinite(age) || age > TOLERANCE) {
      return res.sendStatus(400);
    }

    const body = req.body.toString("utf8");
    const expected =
      "sha256=" +
      crypto
        .createHmac("sha256", SECRET)
        .update(`${timestamp}.${body}`)
        .digest("hex");

    // Constant-time compare
    const a = Buffer.from(signature);
    const b = Buffer.from(expected);
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.sendStatus(400);
    }

    const event = JSON.parse(body);

    // Record it, then answer. Fulfil in a worker.
    const isNew = await events.record(event.id, event.type, event);
    res.sendStatus(200);

    if (isNew) await queue.push("boonka.event", event);
  }
);

Webhooks

Event reference

Every event has the same envelope. data is a compact view of the payment session. For the full object, retrieve the session by data.id.

FieldTypeDescription
idstringUnique identifier for this event. Deduplicate on it.
typestringThe event name, listed below.
environmentstringtest or live. Always branch on this before touching real orders.
created_atstringWhen the event was generated, ISO 8601.
data.idstringThe payment session UUID.
data.statusstringThe session status at the time of the event.
data.amountintegerAmount in minor units.
data.currencystringAlways "DZD".
data.referencestringnullableYour order identifier.
data.metadataobjectnullableThe metadata you attached at creation.
data.paid_atstringnullableSet on payment.succeeded; null otherwise.

Events

TypeFires whenWhat to do
payment.succeededThe shopper paid.Fulfil the order. This is the one that matters.
payment.failedThe charge was attempted and declined.Put the stock back, tell the shopper, offer another attempt.
payment.cancelledYou or the shopper cancelled a pending session.Put the stock back. Mark the order abandoned.
webhook.testYou pressed Send test event.Nothing. Use it to prove your verification works.

Handling the environment

One endpoint receives both test and live events. If your staging and production systems share a webhook URL, branch on environment and never let a test event fulfil a real order. If they do not share a URL, still assert it. A mistaken key in production is much easier to spot when the handler rejects the event loudly.

Routing an event
async function handle(event) {
  // Refuse to act on the wrong environment.
  const expected = process.env.NODE_ENV === "production" ? "live" : "test";
  if (event.environment !== expected) {
    logger.warn({ event }, "boonka: environment mismatch, ignoring");
    return;
  }

  const order = await orders.findBySessionId(event.data.id);
  if (!order) return;   // not ours; nothing to do

  switch (event.type) {
    case "payment.succeeded":
      // Trust but check: the amount must match.
      if (event.data.amount !== order.totalMinor) {
        await orders.flagForReview(order.id, "amount mismatch");
        return;
      }
      return orders.fulfilOnce(order.id, event.data.id);

    case "payment.failed":
    case "payment.cancelled":
      return orders.restockOrder(order.id, event.type);

    case "webhook.test":
      return;
  }
}
webhook.test payload
{
  "id": "5f1c0c2a-7b4e-4e1a-9b2d-7c1e4a8f0d33",
  "type": "webhook.test",
  "environment": "test",
  "created_at": "2026-07-29T11:30:00+00:00",
  "data": {
    "message": "This is a test event from Boonka. If you received it, your endpoint is configured correctly."
  }
}

Webhooks

Retries and delivery

A delivery counts as successful when your endpoint answers with any 2xx inside 10 seconds. Anything else, whether a 4xx, a 5xx, a timeout or a TLS failure, is a failure and will be retried.

SettingValue
Timeout10 seconds per attempt
Attempts5 in total
Backoff1 min → 5 min → 15 min → 30 min → 60 min
SuccessAny 2xx
GuaranteeAt least once. Duplicates are normal; order is not guaranteed.

After the fifth failure the delivery is abandoned. The payment still happened; only the notification was lost. This is why a reconciliation sweep matters: for orders still awaiting payment after their session expired, read the session and reconcile the difference.

Reading the delivery log

Developers → Webhooks → Deliveries shows every attempt with its HTTP status, response time, and error text. When an integration goes quiet, look here first. It usually says exactly what your endpoint returned.

Filter it by status, event type and environment to narrow down a problem.

If deliveries are failing

  • 401 or 403 in the log. Your route is behind authentication. Exclude it from login middleware and CSRF checks.
  • 419 or a CSRF error. Laravel’s CSRF middleware. Add the path to the exclusion list.
  • 400 that you returned yourself. Signature verification failed. Check you are hashing the raw body, and that the secret matches the current one.
  • Timeouts. You are doing the work before responding. Acknowledge first.
  • Nothing in the log at all. No webhook is configured, or yours is paused. Check it is saved and active in the dashboard.
A reconciliation sweep
// Runs every 15 minutes. Catches anything the
// webhook missed: a dropped delivery, an outage.

async function reconcile() {
  const stale = await orders.find({
    status: "awaiting_payment",
    createdBefore: minutesAgo(35),   // past the 30-min window
    limit: 200,
  });

  for (const order of stale) {
    const session = await boonka.retrieve(order.boonka_session_id);
    if (!session) continue;

    if (session.status === "succeeded") {
      // The webhook never landed. Fulfil now.
      await orders.fulfilOnce(order.id, session.id);
      metrics.increment("boonka.recovered_by_sweep");
    } else if (["expired", "cancelled", "failed"].includes(session.status)) {
      await orders.restockOrder(order.id, session.status);
    }
  }
}

Operate

Errors

Every response carries a success boolean and a human-readable message. Failures may add errors (a field-by-field map on validation failures) and code (a stable machine-readable string on some endpoints).

Branch on the HTTP status and, where present, code. Never branch on message, because the wording can change.

StatusMeaningWhat to do
401Missing, malformed or revoked API credentials.Check both headers and that the key is still active.
403The action is not allowed for this actor.Do not retry.
404Not found, not yours, or in the other environment.Check the id and that the key matches the environment.
409State conflict: an idempotency clash, or a session that cannot be cancelled.Read the body. Usually a real outcome, not a transient error.
422Validation failed.Read errors and fix the payload.
5xxSomething failed on BOONKA’s side.Retry with exponential backoff and the same idempotency key.

Retrying safely

Retry 5xx responses, connection failures and timeouts. Do not retry 4xx: those describe your request, and sending it again produces the same answer.

A timeout is the dangerous case: you do not know whether the session was created. Retry it with the same idempotency key and you will either create it or receive the one that already exists.

Error shapes
// 422: validation, with a field map
{
  "success": false,
  "message": "The success url field must be a valid URL.",
  "errors": {
    "success_url": ["The success url field must be a valid URL."]
  }
}

// 401: credentials
{
  "success": false,
  "message": "Missing API Key."
}

// 409: idempotency conflict
{
  "success": false,
  "message": "Idempotency key conflict. This key was already used for a different request."
}
A retry policy that is safe
async function callBoonka(path, init, idempotencyKey) {
  let delay = 500;

  for (let attempt = 1; attempt <= 4; attempt++) {
    try {
      const res = await fetch(`${boonka.base}${path}`, {
        ...init,
        headers: { ...boonka.headers(), "Idempotency-Key": idempotencyKey },
        signal: AbortSignal.timeout(15_000),
      });

      // 4xx describes the request. Don't repeat it.
      if (res.status < 500) return res;
    } catch (err) {
      // Timeout or network failure: safe to retry,
      // because the key makes creation idempotent.
      if (attempt === 4) throw err;
    }

    await sleep(delay + Math.random() * 250);   // jitter
    delay *= 2;
  }

  throw new Error("boonka: exhausted retries");
}

Operate

Testing

Test mode is a full parallel environment. Sessions behave identically, webhooks are signed identically, and no real money moves. Build everything against it before you request live keys.

What you need

  • A pk_test_ / sk_test_ key pair.
  • A second BOONKA account to act as the shopper. A business cannot pay their own session, so you cannot test end to end with one account.
  • A publicly reachable HTTPS URL for webhooks. A tunnelling tool works; localhost and private IPs are rejected.

A test plan worth running

ScenarioHowExpect
Happy pathPay with a funded test shopper.payment.succeeded, order fulfilled once.
Not enough creditShopper with less BOONKA credit than the amount.Nothing charged, session stays pending.
ExpiryCreate a session, wait 30 minutes, then read it.expired. No webhook arrives.
Business cancelCancel a pending session.200, then payment.cancelled.
Cancel a paid sessionPay, then cancel.409 with "status": "succeeded".
Duplicate webhookReplay a delivery from your own logs.Handled once. Second is a no-op.
Bad signaturePOST to your endpoint with a wrong signature.Your endpoint returns 400, nothing is fulfilled.
Idempotent createSend the same create twice with one key.One session, not two.
Wrong environmentRead a test session with a live key.404.

Get a green delivery here before you take your first test payment. If this fails, every payment webhook will fail the same way.

Forge a request and confirm it is rejected
# Your endpoint must answer 400, not 200.

curl -i -X POST https://shop.dz/webhooks/boonka \
  -H "Content-Type: application/json" \
  -H "X-Gateway-Timestamp: $(date +%s)" \
  -H "X-Gateway-Signature: sha256=deadbeef" \
  -d '{"id":"forged","type":"payment.succeeded",
       "environment":"live","created_at":"2026-07-29T11:45:00+00:00",
       "data":{"id":"forged","status":"succeeded",
               "amount":450000,"currency":"DZD"}}'

# → HTTP/1.1 400 Bad Request     ✓ correct
# → HTTP/1.1 200 OK              ✗ you are giving away goods

Operate

Go-live checklist

Work through this before you switch to pk_live_. Most incidents on a new integration come from one of these lines being skipped.

Security

  • The secret key lives in the server environment, never in client code, a repository, or a log line.
  • Webhook signatures are verified on every request, against the raw body, with a constant-time comparison.
  • The replay window is enforced: deliveries more than five minutes old are rejected.
  • You have deliberately tested that a forged request is refused.
  • The webhook route is excluded from authentication and CSRF, and from nothing else.

Correctness

  • Amounts are integers in minor units everywhere, converted once at the boundary.
  • Fulfilment is idempotent and keyed on the session id.
  • Orders are fulfilled on payment.succeeded, never on the redirect alone.
  • The webhook handler checks environment before touching real orders.
  • The handler compares data.amount against your own order total before fulfilling.
  • Every create sends a stable Idempotency-Key derived from the order, generated outside the retry loop.
  • You store data.id against the order at creation time, before redirecting.

Resilience

  • The webhook endpoint acknowledges within 10 seconds and does the work asynchronously.
  • Duplicate events are absorbed by a unique constraint on the event id.
  • A reconciliation sweep catches payments whose webhook never arrived.
  • Retries are exponential with jitter, and only on 5xx, timeouts and connection failures.
  • You alert when the sweep recovers payments, and when webhook deliveries start failing.

Operations

  • Your live webhook URL is configured and a test event has been delivered to it successfully.
  • Your team knows that payouts are listed in the dashboard, not in the session object, and that the 2% fee comes out of what you receive.
  • Someone knows how to issue a refund from the dashboard, and has done it once.
  • You have a runbook for "the shopper says they paid but the order says unpaid": retrieve the session, check the delivery log.

Téléchargez BOONKA

Scannez ce code avec votre téléphone. Vous serez redirigé vers le bon store.

Télécharger dansApp Store Disponible surGoogle Play