KHATWAPartner API Partner portal

Khatwa Partner API

Verify a Khatwa voucher code from your point of sale, record that you gave the discount, and reconcile every redemption. Base URL:

https://merchant.khatwa.club/api/v1

Overview

Khatwa customers buy vouchers from you with points they earn by walking. Redemption does not happen through this API. At your counter, the customer opens the voucher on their phone and your staff type your counter PIN on it. Only then does the phone show the voucher code (like KHW-3F7Q) and the discount. Codes are never shown before that, so a code your till sees has already been redeemed.

The API lets your system:

  • Verify a code: is it yours, has it been redeemed, what discount does it carry (GET /v1/vouchers/{code}).
  • Record that your POS gave the discount, with your receipt or order number, so the same code cannot be applied twice (POST /apply).
  • Reconcile: list every redemption in a period (GET /v1/redemptions), or receive each one as it happens (webhooks).

Nothing here identifies the customer: no name, email, phone or user id, in any response or webhook. There is no ready-made Khatwa plugin for any POS; these endpoints are what a developer builds one from. If you only want to record Khatwa vouchers by hand, the partner portal's Integrations › Connect your POS has step-by-step guides that need no code.

Typical POS flow

Typical POS flow The customer redeems on their phone with the counter PIN; the phone shows the code; the till verifies it with GET, records it with POST apply, then gives the discount. Khatwa can also send webhooks to your server. Customer's phone Khatwa Your till (POS) Your server Cashier types the counter PIN redeem with PIN used: shows KHW-3F7Q + discount webhook voucher.redeemed cashier types or scans the code GET /v1/vouchers/KHW-3F7Q 200 Voucher (status: used) POST /apply { reference } 200 Voucher (pos.applied_at) Give the discount, print receipt webhook voucher.applied
  1. The customer opens the voucher in the Khatwa app. Your cashier types your counter PIN on their phone.
  2. The phone shows the code (KHW-3F7Q) and the discount. If you subscribed, Khatwa sends voucher.redeemed to your server at the same moment.
  3. The cashier types or scans the code into your till.
  4. Your till calls GET /v1/vouchers/KHW-3F7Q. 200 with "status": "used" means redeemed and yours. 404 means not a code of yours. If pos is already filled in, it was applied before.
  5. Your till calls POST /v1/vouchers/KHW-3F7Q/apply with your receipt or order number as reference. A 200 is your go-ahead; a 409 already_applied means another sale already used it.
  6. The cashier gives the discount. Khatwa sends voucher.applied if you subscribed.

Why apply before giving the discount? /apply is the lock: two tills applying the same code at once get one 200 and one 409. It is safe to retry (see idempotency), so a timeout never leaves you guessing.

Quick start

  1. An owner or manager signs in at merchant.khatwa.club, opens Integrations › API keys and creates a key. Pick Test while you build (see test mode) and Live for the till. A key is shown once: copy it into your server's secrets.
  2. Check it:
curl https://merchant.khatwa.club/api/v1/ping \
  -H "Authorization: Bearer $KHATWA_KEY"
{
  "livemode": true,
  "partner": { "id": "7d9f0c52-4c1e-4b8a-9d3e-2f6a1b0c9e11", "name": "Flat White" },
  "key": { "prefix": "kw_live_a1b2", "name": "Front counter POS", "mode": "live" }
}

Authentication

Send your key as a bearer token on every request:

Authorization: Bearer kw_live_<40 letters and digits>
  • A key is live (kw_live_) or test (kw_test_). A live key sees your real vouchers; a test key sees only the test vouchers you make in the portal. See test mode.
  • A key belongs to one partner and sees only that partner's vouchers.
  • Keys are created by an owner or manager in the portal, named (for example "Front counter POS"), shown once, and stored by Khatwa as a SHA-256 hash only. The portal shows the first 12 characters and when each key was last used.
  • Up to 10 active keys per partner, live and test together. Revoke a key in the portal and it stops working at once.
  • Keep keys on a server. The API answers browsers too (CORS allows any origin), but a key in a web page or a till's browser can be read by anyone with access to it. Put a small service of yours between the till and Khatwa.
  • To rotate: create a new key, deploy it, then revoke the old one.

Test mode

Build and check your integration without a real customer, real points or a voucher you bought yourself. Test mode uses the same base URL, the same endpoints and the same shapes as live mode. Only the key is different.

LiveTest
Keykw_live_…kw_test_…
Vouchers it seesVouchers customers boughtTest vouchers made in the portal, and nothing else
CodesKHW- and 4 characters, like KHW-3F7QKHW-T and 5 characters, like KHW-T3F7Q9
livemodetruefalse
OffersGET /v1/offers lists your real offers for both (read only either way)
  • Every answer to a valid key carries "livemode": true | false at the top level, errors included, and so does every Voucher and every webhook event. GET /v1/ping also says "key": { …, "mode": "test" }.
  • A test key never sees a real voucher and a live key never sees a test one: the other kind answers 404 not_found.
  • Test vouchers never touch real points, stock, offer statistics or the portal's Redemptions list and CSV.
  • Test vouchers fire the same webhook events (voucher.redeemed, voucher.applied) to your one webhook, with "livemode": false. Your endpoint should record them apart from real ones, or ignore them in production.

Get a test voucher

An owner or manager opens Integrations › Test mode in the portal and creates a test voucher for one of your vouchers (it copies that voucher's discount). Up to 25 at a time. Each one has three buttons:

  • Simulate customer redemption does what the customer's phone and your counter PIN would: the voucher becomes used, and voucher.redeemed is sent.
  • Reset puts it back to active and clears what your POS recorded, so you can run the same code again.
  • Delete removes it. Its code answers 404 after that.

A full test, step by step

  1. Check your test key:
    curl https://merchant.khatwa.club/api/v1/ping \
      -H "Authorization: Bearer $KHATWA_TEST_KEY"
    {
      "livemode": false,
      "partner": { "id": "7d9f0c52-4c1e-4b8a-9d3e-2f6a1b0c9e11", "name": "Flat White" },
      "key": { "prefix": "kw_test_c3d4", "name": "Developer sandbox", "mode": "test" }
    }
  2. In the portal, create a test voucher. Say its code is KHW-T3F7Q9.
  3. Look it up before the customer step. It is active, so your till must refuse the discount, and /apply says so:
    curl https://merchant.khatwa.club/api/v1/vouchers/KHW-T3F7Q9 \
      -H "Authorization: Bearer $KHATWA_TEST_KEY"
    # 200 { "livemode": false, "code": "KHW-T3F7Q9", "status": "active", … }
    
    curl -X POST https://merchant.khatwa.club/api/v1/vouchers/KHW-T3F7Q9/apply \
      -H "Authorization: Bearer $KHATWA_TEST_KEY" \
      -H "Content-Type: application/json" -d '{ "reference": "T-0001" }'
    # 409 { "livemode": false, "error": { "code": "not_redeemed", … }, "voucher": { … } }
  4. In the portal, press Simulate customer redemption. If your webhook is on, voucher.redeemed arrives with "livemode": false.
  5. Look it up again. It is used now:
    curl https://merchant.khatwa.club/api/v1/vouchers/khw-t3f7q9 \
      -H "Authorization: Bearer $KHATWA_TEST_KEY"
    {
      "livemode": false,
      "code": "KHW-T3F7Q9",
      "status": "used",
      "offer": { "id": "c0000000-0000-4000-8000-000000000001", "title": { "en": "Free flat white", "ar": "فلات وايت مجانًا" } },
      "discount": {
        "type": "free_item", "usual_value_qar": 20, "amount_off_qar": 20,
        "percent_off": null, "max_discount_qar": null,
        "summary": { "en": "Free item, worth 20 QAR", "ar": "منتج مجاني بقيمة 20 ريال" }
      },
      "points": 700,
      "purchased_at": "2026-09-26T09:12:03.412Z",
      "redeemed_at": "2026-09-26T09:14:40.208Z",
      "redeemed_by": "customer_pin",
      "branch": null,
      "pos": null
    }
  6. Apply it, as your till would:
    curl -X POST https://merchant.khatwa.club/api/v1/vouchers/KHW-T3F7Q9/apply \
      -H "Authorization: Bearer $KHATWA_TEST_KEY" \
      -H "Content-Type: application/json" -d '{ "reference": "T-0001" }'
    # 200 { "livemode": false, …, "pos": { "applied_at": "…", "reference": "T-0001" } }
    
    # The same again: 200, the same answer. With "T-0002": 409 already_applied.
  7. voucher.applied arrives at your webhook, with "livemode": false. The portal's Webhook › Recent deliveries marks test events TEST.
  8. Press Reset in the portal and run it again, or create more test vouchers to try the other discount types.

Going live is a change of key and nothing else: create a live key, put it where the test key was, and revoke the test key if you no longer need it.

Endpoints

Every response is JSON, and every answer to a valid key, errors included, carries "livemode" (true for a live key, false for a test key). Every time is ISO 8601 in UTC (2026-09-26T12:00:00.123Z). Money is in QAR.

GET /v1/ping

Checks a key and tells you whose it is, and whether it is live or test (key.mode). See the quick start for the request and answer.

GET /v1/offers

Your vouchers and their discounts, so you can map each one to a discount button or product in your POS. Lists every offer a customer can hold a voucher for: on sale (active), paused and ended. Offers that never went on sale are not listed.

curl https://merchant.khatwa.club/api/v1/offers \
  -H "Authorization: Bearer $KHATWA_KEY"
{
  "livemode": true,
  "offers": [
    {
      "id": "c0000000-0000-4000-8000-000000000001",
      "title": { "en": "Free flat white", "ar": "فلات وايت مجانًا" },
      "type": "free_item",
      "status": "active",
      "points": 700,
      "discount": {
        "type": "free_item",
        "usual_value_qar": 20,
        "amount_off_qar": 20,
        "percent_off": null,
        "max_discount_qar": null,
        "summary": { "en": "Free item, worth 20 QAR", "ar": "منتج مجاني بقيمة 20 ريال" }
      }
    }
  ]
}

GET /v1/vouchers/{code}

One voucher. The code is case-insensitive and the KHW- prefix is optional: khw-3f7q, 3F7Q and KHW3F7Q all mean KHW-3F7Q.

curl https://merchant.khatwa.club/api/v1/vouchers/KHW-3F7Q \
  -H "Authorization: Bearer $KHATWA_KEY"
{
  "livemode": true,
  "code": "KHW-3F7Q",
  "status": "used",
  "offer": { "id": "c0000000-0000-4000-8000-000000000001", "title": { "en": "Free flat white", "ar": "فلات وايت مجانًا" } },
  "discount": {
    "type": "free_item", "usual_value_qar": 20, "amount_off_qar": 20,
    "percent_off": null, "max_discount_qar": null,
    "summary": { "en": "Free item, worth 20 QAR", "ar": "منتج مجاني بقيمة 20 ريال" }
  },
  "points": 700,
  "purchased_at": "2026-09-24T09:12:03.412Z",
  "redeemed_at": "2026-09-26T07:41:55.108Z",
  "redeemed_by": "customer_pin",
  "branch": null,
  "pos": null
}
  • 404 not_found: no such code, or another partner's. The answer is the same on purpose. Anything that cannot be a code is also 404.
  • What to do with each status: used means redeemed, go ahead. active means the customer has not redeemed it on their phone yet: do not give the discount. expired and refunded cannot be used.

POST /v1/vouchers/{code}/apply

Records that your POS gave the discount. The body is optional; send your receipt or order number as reference (up to 100 characters), and the branch if you know it.

curl -X POST https://merchant.khatwa.club/api/v1/vouchers/KHW-3F7Q/apply \
  -H "Authorization: Bearer $KHATWA_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "reference": "R-000482", "branch_id": "b1f7e2c4-0d5a-4f2b-8c61-3e9a7d0f5b21" }'
{
  "livemode": true,
  "code": "KHW-3F7Q",
  "status": "used",
  "...": "the rest of the Voucher, as above",
  "branch": { "id": "b1f7e2c4-0d5a-4f2b-8c61-3e9a7d0f5b21", "name": "Flat White, West Bay" },
  "pos": { "applied_at": "2026-09-26T07:42:10.551Z", "reference": "R-000482" }
}
AnswerMeaning
200Recorded. Also the answer to a repeat with the same reference, so a retry is safe.
409 already_appliedApplied before with a different reference. The body's voucher.pos.reference says which sale. Do not give the discount again. No reference is a reference too: a code applied without one, then retried with one, is 409.
409 not_redeemedThe customer has not redeemed it on their phone yet. Do not give the discount.
409 not_usableExpired or refunded.
404 not_foundNo such code, or another partner's.
422 invalid_requestThe body is not JSON, reference is longer than 100 characters, or branch_id is not one of your branches.

Every 409 body is { "livemode", "error": { "code", "message" }, "voucher": Voucher }. branch_id fills in the redemption's branch when it has none (a redemption on the customer's phone does not know which branch it was) and never overwrites one. Checks run in this order: the body (422), the code (404), then the voucher's state (409). There is no undo: if a sale is voided and rung up again, reuse the same reference.

GET /v1/redemptions

Redeemed vouchers, newest first, for polling and reconciliation.

QueryMeaning
sinceISO 8601, inclusive, on redeemed_at. A date alone means midnight UTC.
untilISO 8601, exclusive.
limit1 to 200, default 50.
cursorThe next_cursor of the previous page, with the same since and until.
curl "https://merchant.khatwa.club/api/v1/redemptions?since=2026-09-01T00:00:00%2B03:00&until=2026-10-01T00:00:00%2B03:00&limit=200" \
  -H "Authorization: Bearer $KHATWA_KEY"
{
  "livemode": true,
  "redemptions": [ { "livemode": true, "code": "KHW-3F7Q", "status": "used", "...": "Voucher" } ],
  "next_cursor": "MjAyNi0wOS0yNlQwNzo0MTo1NS4xMDgrMDA6MDB8..."
}

next_cursor is null on the last page. Qatar is UTC+3 all year: a Doha day starts at T00:00:00+03:00.

Objects

Voucher

FieldTypeNotes
livemodebooleantrue for a real voucher, false for a test voucher.
codestringKHW- and 4 to 12 letters and digits. A test voucher's is KHW-T and 5.
statusstringactive (bought, not redeemed), used (redeemed), expired, refunded. An active voucher past its expiry reads expired.
offerobjectid, title.en, title.ar.
discountDiscountBelow.
pointsnumberWhat the customer paid in points.
purchased_attime
redeemed_attime or null
redeemed_bystring or nullcustomer_pin (your PIN on the customer's phone) or staff_code.
branchobject or nullid, name (English).
posobject or nullapplied_at, reference: set by /apply.

Discount

discount.typeFilled insummary.en example
free_itemamount_off_qar: the item's valueFree item, worth 20 QAR
fixed_priceamount_off_qar; the customer pays usual_value_qar − amount_off_qarPay 60 QAR instead of 80 QAR
amount_offamount_off_qar15 QAR off
percent_offpercent_off, max_discount_qar15% off, up to 50 QAR

usual_value_qar is the offer's usual price whatever the type (it can be null). summary is one line a cashier can read, in English and Arabic, with Western digits. Numbers carry no trailing zeros.

Errors

{ "error": { "code": "invalid_key", "message": "Missing, malformed, unknown or revoked API key." } }

Errors made with a valid key (404, 409, 422, 429, 5xx) carry "livemode" beside error. A 401, a 405 and an unknown path do not: without a valid key there is no mode to report.

Statuserror.codeWhat to do
401invalid_keyMissing, malformed, unknown or revoked key. Check the header; do not retry.
404not_foundNo such code, another partner's code, or an unknown path.
405method_not_allowedWrong verb for the path; see Allow.
409already_applied, not_redeemed, not_usableSee apply. Do not give the discount.
413, 422invalid_requestFix the request; the message says what.
429rate_limitedWait Retry-After seconds.
500, 502server_error, upstream_unavailableRetry with backoff (1 s, 2 s, 4 s…).

Rate limits

120 requests per key per clock minute. Over that, 429 rate_limited with a Retry-After header in seconds. A till that looks up and applies one voucher at a time will never come near it; for reconciliation, prefer webhooks or one /redemptions poll every few minutes with limit=200.

Retries and idempotency

  • GET requests have no side effects; retry freely.
  • /apply is idempotent per reference. The same code and the same reference give the same 200 every time. If a request times out, send it again unchanged: you cannot apply a voucher twice by retrying.
  • Always send a reference that stays the same for the sale (your receipt or order number), not one generated per attempt. A new reference on a retry is 409 already_applied.
  • Retry 429 after Retry-After, and 5xx or network errors with exponential backoff. Do not retry 401, 404, 409 or 422.

Webhooks

An owner or manager sets one endpoint per partner in the portal, under Integrations › Webhook: an https address with a host name (no IP address, localhost or .local name, no user:pass@, up to 500 characters) and the events it wants.

EventSent when
voucher.redeemedThe customer's PIN redemption succeeds.
voucher.appliedYour POS records it with /apply.
webhook.testSomeone presses Send a test in the portal. Always sent, even while the webhook is off; tried once.
POST https://your-server.example/khatwa/webhook
Content-Type: application/json
User-Agent: Khatwa-Webhooks/1
Khatwa-Event-Id: evt_5b8f2c1e-3a6d-4e9b-b0f4-7c2d1e8a9f60
Khatwa-Signature: t=1790412115,v1=5f2b…(64 hex characters)
{
  "id": "evt_5b8f2c1e-3a6d-4e9b-b0f4-7c2d1e8a9f60",
  "type": "voucher.redeemed",
  "created_at": "2026-09-26T07:41:55.108Z",
  "livemode": true,
  "data": { "voucher": { "livemode": true, "code": "KHW-3F7Q", "status": "used", "...": "Voucher" } }
}
  • Answer with any 2xx within 10 seconds. Anything else, including a redirect (they are not followed), is a failed attempt.
  • Retries: after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours, then the delivery is marked failed. Each retry sends the same body with a fresh t.
  • At least once: the same event can arrive twice. Dedupe on Khatwa-Event-Id (the same as the body's id).
  • The body is the voucher as it was when the event happened. Order is not guaranteed; use created_at, or fetch the voucher if you need its latest state.
  • "livemode": false marks an event from a test voucher. Test vouchers go to the same endpoint as real ones, so check it before you record anything.
  • webhook.test carries a made-up voucher, code KHW-TEST, with "test": true beside it in data and "livemode": false.
  • Switching the webhook off or deleting it stops queued deliveries. Events that happen while it is off are not queued.
  • The portal shows the last 50 deliveries with their status, tries and your endpoint's response code.

Verifying signatures

Every delivery is signed with your webhook's signing secret (whsec_ and 32 characters; owners and managers can show or rotate it in the portal). The header is

Khatwa-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>
  1. Read the raw request body, before any JSON parsing. Re-serialised JSON will not match.
  2. Split the header on commas; take t and every v1.
  3. Compute HMAC-SHA256 with the whole secret string (including whsec_) as the key, over t, a full stop, and the raw body. Hex-encode it.
  4. Compare with each v1 in constant time. Reject if none match, or if t is more than 5 minutes from your clock.

A rotated secret is used from the next attempt on, retries included, so update your server before you rotate.

Node.js

import http from "node:http";
import crypto from "node:crypto";

const SECRET = process.env.KHATWA_WEBHOOK_SECRET;   // the whole "whsec_…" string

function verify(raw, header, toleranceSeconds = 300) {
  let t = null;
  const sigs = [];
  for (const part of String(header || "").split(",")) {
    const i = part.indexOf("=");
    const k = part.slice(0, i).trim(), v = part.slice(i + 1).trim();
    if (k === "t" && /^\d+$/.test(v)) t = Number(v);
    if (k === "v1") sigs.push(v);
  }
  if (t === null || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = crypto.createHmac("sha256", SECRET).update(`${t}.`).update(raw).digest("hex");
  return sigs.some(s => s.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}

http.createServer((req, res) => {
  const chunks = [];
  req.on("data", c => chunks.push(c));
  req.on("end", () => {
    const raw = Buffer.concat(chunks);
    if (!verify(raw, req.headers["khatwa-signature"])) { res.writeHead(400).end(); return; }
    const event = JSON.parse(raw.toString("utf8"));
    // Dedupe on event.id (the Khatwa-Event-Id header): deliveries are at least once.
    // Then record event.data.voucher, quickly, and answer 2xx within 10 seconds.
    res.writeHead(200).end();
  });
}).listen(8080);

Python

import hashlib, hmac, json, os, time
from flask import Flask, request, abort

SECRET = os.environ["KHATWA_WEBHOOK_SECRET"].encode()   # the whole "whsec_…" string
app = Flask(__name__)

def verify(raw: bytes, header: str, tolerance: int = 300) -> bool:
    t, sigs = None, []
    for part in (header or "").split(","):
        k, _, v = part.strip().partition("=")
        if k == "t" and v.isdigit():
            t = int(v)
        elif k == "v1":
            sigs.append(v)
    if t is None or abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(SECRET, f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, s) for s in sigs)

@app.post("/khatwa/webhook")
def khatwa_webhook():
    raw = request.get_data()                      # raw bytes, before any parsing
    if not verify(raw, request.headers.get("Khatwa-Signature", "")):
        abort(400)
    event = json.loads(raw)
    # Dedupe on event["id"], record event["data"]["voucher"], answer quickly.
    return "", 200

PHP

<?php
$secret = getenv('KHATWA_WEBHOOK_SECRET');          // the whole "whsec_…" string
$raw    = file_get_contents('php://input');         // raw body, before json_decode
$header = $_SERVER['HTTP_KHATWA_SIGNATURE'] ?? '';

$t = null; $sigs = [];
foreach (explode(',', $header) as $part) {
    [$k, $v] = array_pad(explode('=', trim($part), 2), 2, '');
    if ($k === 't' && ctype_digit($v)) { $t = (int) $v; }
    if ($k === 'v1') { $sigs[] = $v; }
}
if ($t === null || abs(time() - $t) > 300) { http_response_code(400); exit; }

$expected = hash_hmac('sha256', $t . '.' . $raw, $secret);
$ok = false;
foreach ($sigs as $s) { if (hash_equals($expected, $s)) { $ok = true; } }
if (!$ok) { http_response_code(400); exit; }

$event = json_decode($raw, true);
// Dedupe on $event['id'], record $event['data']['voucher'], answer quickly.
http_response_code(200);

Notes per POS

What each system's own API and webhooks allow, as far as the vendors document it (checked September 2026). None of these is a ready-made Khatwa integration; each is a starting point for a developer. The portal has the matching no-code steps for the counter, in English and Arabic.

Loading the list of systems.

Test checklist

Run all of it with a test key and test vouchers first (see test mode), then once more with a live key before your first real customer.

  1. GET /v1/ping with your key answers 200, your business name and the key's mode. A wrong key answers 401.
  2. GET /v1/offers lists the vouchers you have on sale, and each maps to a discount in your POS.
  3. Before the customer step, GET /v1/vouchers/{code} answers "status": "active" and your till refuses the discount. After Simulate customer redemption in the portal it answers "status": "used", typed in lower case and without KHW- too.
  4. POST /apply with reference A answers 200. Again with A: 200, the same. With B: 409 already_applied, and your till refuses the discount.
  5. A code that is not yours (make one up) answers 404, and your till says so plainly.
  6. Your till handles a timeout by retrying the same request, and 429 by waiting Retry-After.
  7. The key lives on your server, not in a browser or the till's app.
  8. Webhook: Send a test in the portal shows Delivered. Your endpoint rejects the same body with one byte changed, and a t older than 5 minutes.
  9. Your endpoint answers in under 10 seconds and ignores a second delivery with the same Khatwa-Event-Id.
  10. Your endpoint tells test events ("livemode": false) from real ones, and your live system never counts a test voucher.
  11. At month end, your POS's Khatwa discounts match GET /v1/redemptions or the portal's Redemptions CSV.

Questions: ask your Khatwa contact.