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.
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
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:
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.
KHW-3F7Q) and the discount. If you subscribed, Khatwa sends voucher.redeemed to your server at the same moment.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.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.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.
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" }
}
Send your key as a bearer token on every request:
Authorization: Bearer kw_live_<40 letters and digits>
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.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.
| Live | Test | |
|---|---|---|
| Key | kw_live_… | kw_test_… |
| Vouchers it sees | Vouchers customers bought | Test vouchers made in the portal, and nothing else |
| Codes | KHW- and 4 characters, like KHW-3F7Q | KHW-T and 5 characters, like KHW-T3F7Q9 |
livemode | true | false |
| Offers | GET /v1/offers lists your real offers for both (read only either way) | |
"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" }.404 not_found.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.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:
used, and voucher.redeemed is sent.active and clears what your POS recorded, so you can run the same code again.404 after that.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" }
}KHW-T3F7Q9.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": { … } }voucher.redeemed arrives with "livemode": false.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
}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.voucher.applied arrives at your webhook, with "livemode": false. The portal's Webhook › Recent deliveries marks test events TEST.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.
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.
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.
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 ريال" }
}
}
]
}
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.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.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" }
}
| Answer | Meaning |
|---|---|
200 | Recorded. Also the answer to a repeat with the same reference, so a retry is safe. |
409 already_applied | Applied 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_redeemed | The customer has not redeemed it on their phone yet. Do not give the discount. |
409 not_usable | Expired or refunded. |
404 not_found | No such code, or another partner's. |
422 invalid_request | The 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.
Redeemed vouchers, newest first, for polling and reconciliation.
| Query | Meaning |
|---|---|
since | ISO 8601, inclusive, on redeemed_at. A date alone means midnight UTC. |
until | ISO 8601, exclusive. |
limit | 1 to 200, default 50. |
cursor | The 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.
| Field | Type | Notes |
|---|---|---|
livemode | boolean | true for a real voucher, false for a test voucher. |
code | string | KHW- and 4 to 12 letters and digits. A test voucher's is KHW-T and 5. |
status | string | active (bought, not redeemed), used (redeemed), expired, refunded. An active voucher past its expiry reads expired. |
offer | object | id, title.en, title.ar. |
discount | Discount | Below. |
points | number | What the customer paid in points. |
purchased_at | time | |
redeemed_at | time or null | |
redeemed_by | string or null | customer_pin (your PIN on the customer's phone) or staff_code. |
branch | object or null | id, name (English). |
pos | object or null | applied_at, reference: set by /apply. |
discount.type | Filled in | summary.en example |
|---|---|---|
free_item | amount_off_qar: the item's value | Free item, worth 20 QAR |
fixed_price | amount_off_qar; the customer pays usual_value_qar − amount_off_qar | Pay 60 QAR instead of 80 QAR |
amount_off | amount_off_qar | 15 QAR off |
percent_off | percent_off, max_discount_qar | 15% 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.
{ "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.
| Status | error.code | What to do |
|---|---|---|
| 401 | invalid_key | Missing, malformed, unknown or revoked key. Check the header; do not retry. |
| 404 | not_found | No such code, another partner's code, or an unknown path. |
| 405 | method_not_allowed | Wrong verb for the path; see Allow. |
| 409 | already_applied, not_redeemed, not_usable | See apply. Do not give the discount. |
| 413, 422 | invalid_request | Fix the request; the message says what. |
| 429 | rate_limited | Wait Retry-After seconds. |
| 500, 502 | server_error, upstream_unavailable | Retry with backoff (1 s, 2 s, 4 s…). |
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.
/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.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.429 after Retry-After, and 5xx or network errors with exponential backoff. Do not retry 401, 404, 409 or 422.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.
| Event | Sent when |
|---|---|
voucher.redeemed | The customer's PIN redemption succeeds. |
voucher.applied | Your POS records it with /apply. |
webhook.test | Someone 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" } }
}
t.Khatwa-Event-Id (the same as the body's id).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.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>")>
t and every v1.whsec_) as the key, over t, a full stop, and the raw body. Hex-encode it.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.
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);
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
$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);
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.
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.
GET /v1/ping with your key answers 200, your business name and the key's mode. A wrong key answers 401.GET /v1/offers lists the vouchers you have on sale, and each maps to a discount in your POS.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.POST /apply with reference A answers 200. Again with A: 200, the same. With B: 409 already_applied, and your till refuses the discount.404, and your till says so plainly.429 by waiting Retry-After.t older than 5 minutes.Khatwa-Event-Id."livemode": false) from real ones, and your live system never counts a test voucher.GET /v1/redemptions or the portal's Redemptions CSV.Questions: ask your Khatwa contact.