1. Overview
- Base URL:
https://kezayo.com/api/partner/v1 - JSON in, JSON out. Money is integer paise (₹1 = 100).
- Every request is HMAC-SHA256 signed — no cookies, bearer tokens or sessions. HTTPS only.
- Discount tiers: 10, 20, 40, 50, 70, 80 or 100% — your account is limited to the tiers we agree with you.
2. Credentials
We create your partner account and issue an API key: a key id (kzk_…, sent with every request) and a secret (kzs_…, never sent). The secret is shown to us exactly once and handed to you over a secure channel; KEZAYO does not store it, so it cannot be recovered — if it is lost we issue a new key.
Up to two keys can be active at once, so you can rotate without downtime: start signing with the new key, then ask us to revoke the old one.
3. Signing requests
Send four headers: X-KZ-Key (key id), X-KZ-Timestamp (Unix seconds), X-KZ-Nonce (16–64 random characters [A-Za-z0-9_-], new for every request) and X-KZ-Signature:
canonical = "KZ1-HMAC-SHA256" + "\n"
+ METHOD (upper-case, e.g. POST)
+ "\n" + PATH_AND_QUERY (exactly as sent, e.g. /api/partner/v1/promo-codes?limit=50)
+ "\n" + TIMESTAMP
+ "\n" + NONCE
+ "\n" + hex(SHA-256(raw request body bytes)) (empty body → SHA-256 of "")
X-KZ-Signature = lower-case hex HMAC-SHA256(secret, canonical)- The timestamp must be within ±300 seconds of our clock — keep NTP on.
- A (key, nonce) pair is accepted once; re-sending a signed request is refused with
REPLAY. To retry, sign again with a new nonce and reuse yourIdempotency-Key. - Sign the exact bytes you send, with
Content-Type: application/jsonwhen there is a body (max 64 KB). - For a GET the body hash is
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.
4. Examples
Node.js (18+)
import crypto from 'node:crypto';
const BASE = 'https://kezayo.com';
const KEY_ID = process.env.KZ_KEY_ID; // kzk_…
const SECRET = process.env.KZ_SECRET; // kzs_… (keep it in a secret store)
export async function kezayo(method, pathAndQuery, body, idempotencyKey) {
const raw = body === undefined ? '' : JSON.stringify(body);
const ts = String(Math.floor(Date.now() / 1000));
const nonce = crypto.randomBytes(16).toString('hex');
const bodyHash = crypto.createHash('sha256').update(raw).digest('hex');
const canonical = ['KZ1-HMAC-SHA256', method.toUpperCase(), pathAndQuery, ts, nonce, bodyHash].join('\n');
const signature = crypto.createHmac('sha256', SECRET).update(canonical).digest('hex');
const res = await fetch(BASE + pathAndQuery, {
method,
headers: {
'X-KZ-Key': KEY_ID, 'X-KZ-Timestamp': ts, 'X-KZ-Nonce': nonce, 'X-KZ-Signature': signature,
...(raw ? { 'Content-Type': 'application/json' } : {}),
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
},
body: raw || undefined,
});
const json = await res.json();
if (!res.ok) throw Object.assign(new Error(json.error), { status: res.status, code: json.code, requestId: json.requestId });
return json;
}
// 25 single-use 20%-off codes for Pro 100, valid until 31 Dec 2026:
const batch = await kezayo('POST', '/api/partner/v1/promo-codes', {
percent: 20, count: 25, plans: ['PRO_100'], validUntil: '2026-12-31T23:59:59+05:30', campaign: 'Diwali 2026',
}, 'order-48213');
console.log(batch.codes.map((c) => c.code));curl + openssl
KEY_ID='kzk_…'; SECRET='kzs_…'
BODY='{"percent":20,"count":5,"campaign":"Diwali 2026"}'
REQ_PATH='/api/partner/v1/promo-codes'
TS=$(date +%s); NONCE=$(openssl rand -hex 16)
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | sed 's/^.* //')
SIG=$(printf 'KZ1-HMAC-SHA256\nPOST\n%s\n%s\n%s\n%s' "$REQ_PATH" "$TS" "$NONCE" "$BODY_HASH" \
| openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -sS "https://kezayo.com$REQ_PATH" \
-H "X-KZ-Key: $KEY_ID" -H "X-KZ-Timestamp: $TS" -H "X-KZ-Nonce: $NONCE" -H "X-KZ-Signature: $SIG" \
-H 'Content-Type: application/json' -H 'Idempotency-Key: order-48213' --data "$BODY"5. Endpoints
POST /promo-codes — create codes
| Field | Type | Default | Notes |
|---|---|---|---|
percent | number | — | Required. One of your allowed tiers (subset of 10, 20, 40, 50, 70, 80, 100), ≤ your maximum. |
count | int | 1 | 1–500 random codes, PREFIX-XXXX-XXXX (no 0/O/1/I/L). |
code | string | — | Instead of count: one custom code starting with your prefix, 4–32 letters/digits (hyphens allowed). Sending both code and count is BAD_REQUEST; a single custom code has batchId null. |
plans | string[] | your plans | Plan ids from GET /plans. Empty = every plan you may discount. |
cycle | string | any | monthly, annual or any. |
maxRedemptions | int | 1 | Uses per code, 1–1000. |
maxPerAccount | int | 1 | Uses per KEZAYO account, ≤ maxRedemptions. |
validFrom | ISO-8601 | now | |
validUntil | ISO-8601 | +365 days | At most 730 days ahead; exclusive. |
firstPurchaseOnly | bool | false | Only accounts that never subscribed. |
campaign | string | "" | Your label, ≤ 80 characters. |
Optional header Idempotency-Key (8–100 characters from A-Z a-z 0-9 _ . : -): a retry with the same key within 7 days returns the same codes ("replayed": true, HTTP 200) and uses no extra quota. Use your order id. Response 201:
{ "batchId": "batch_…", "replayed": false, "count": 1,
"codes": [ { "code": "ACME-7KQ2-M9XD", "batchId": "batch_…", "percent": 20, "plans": ["PRO_100"], "cycle": "any",
"maxRedemptions": 1, "maxPerAccount": 1, "validFrom": null,
"validUntil": "2026-12-31T18:29:59.000Z", "firstPurchaseOnly": false,
"campaign": "Diwali 2026", "status": "active", "state": "active",
"redeemed": 0, "remaining": 1, "createdAt": "…" } ] }state is one of active, paused, revoked, scheduled, expired, exhausted.
GET /promo-codes — list your codes
Query: status (active | paused | revoked), campaign, batchId, limit (1–200, default 50), cursor. Returns { data, total, nextCursor }; pass nextCursor back as cursor (null = last page).
GET /promo-codes/{code} — one code with stats
Case-insensitive; hyphens optional. Returns the code, stats (redeemed, pending, remaining, listValueMinor, discountMinor, lastRedeemedAt) and redemptions (plan, cycle, prices, time). No customer personal data is ever returned.
POST /promo-codes/{code}/revoke — revoke a code
Optional body { "reason": "…" }. Permanent. Redemptions already made stand, and the quota the code used is not returned. Revoking an already revoked code returns it with "alreadyRevoked": true.
GET /usage — quota and limits
Monthly (resets on the 1st, UTC) and total quota used / remaining, your allowed tiers, plans, prefix and rate limit. Quota unit = redemption capacity: a request uses count × maxRedemptions units (25 single-use codes = 25; one 100-use code = 100).
GET /plans — plans and discounted prices
Every self-serve plan with its monthly and annual list price and, for each tier you may issue, the exact price your customer will pay. Rounding: discount = floor(list × percent / 100), final = list − discount.
6. Errors
Every error is { "error": "<message>", "code": "<CODE>", "requestId": "req_…" }. Every response carries an X-Request-Id header; quote it when you contact us. One exception: the 300 requests/minute per-IP ceiling is enforced in front of the API and answers HTTP 429 with only { "error": "…" } (no code or request id) — back off and retry.
| HTTP | code | Meaning |
|---|---|---|
| 400 | BAD_REQUEST, BAD_JSON, BAD_TIER, BAD_COUNT, BAD_CODE, BAD_PLAN, BAD_CYCLE, BAD_LIMIT, BAD_DATE, BAD_CAMPAIGN, BAD_FILTER, BAD_IDEMPOTENCY_KEY | Invalid input — the message says what. |
| 401 | MISSING_AUTH, BAD_TIMESTAMP, BAD_NONCE, BAD_SIGNATURE | Not signed, signed wrongly, or unknown key. |
| 401 | CLOCK_SKEW | Timestamp more than 300 s from server time. |
| 401 | REPLAY | That nonce was already used with this key. |
| 401 | KEY_REVOKED | The key was revoked. |
| 403 | PARTNER_SUSPENDED | Your account is suspended; your unredeemed codes do not work. |
| 403 | IP_NOT_ALLOWED, HTTPS_REQUIRED | Request IP not on your allowlist / plain HTTP. |
| 403 | TIER_NOT_ALLOWED, PLAN_NOT_ALLOWED | Outside what your account may issue. |
| 403 | QUOTA_EXCEEDED | Monthly or total quota would be exceeded. |
| 404 | NOT_FOUND | No such code of yours. |
| 409 | DUPLICATE_CODE | A custom code with that text already exists. |
| 413 / 415 | BODY_TOO_LARGE, UNSUPPORTED_MEDIA_TYPE | Body over 64 KB / not application/json. |
| 429 | RATE_LIMITED | Over your per-minute limit (default 60). |
| 429 | TOO_MANY_FAILURES | 20 failed authentications from your IP in 10 minutes. |
| 500 | INTERNAL | Our fault — retry with a new nonce and the same Idempotency-Key. |
7. Limits
- Your per-minute request limit (default 60), plus 300 requests/minute per source IP.
- 500 codes per request, 1000 redemptions per code, validity at most 730 days.
- 20 failed authentications from one IP in 10 minutes block that IP for the window.
- Optional IP allowlist (single IPs or IPv4 CIDRs) set by us on request.
8. Webhook
If we configure a webhook URL (public https:// only) for you, each redemption of one of your codes is posted to it — best-effort, 5 s timeout, one retry after 30 s:
POST <your url>
Content-Type: application/json
X-KZ-Event: promo.redeemed
X-KZ-Timestamp: <unix seconds>
X-KZ-Signature: hex HMAC-SHA256(webhookSecret, timestamp + "\n" + rawBody)
{ "id": "evt_…", "event": "promo.redeemed", "at": 1790000000000,
"data": { "code": "ACME-7KQ2-M9XD", "planId": "PRO_100", "cycle": "monthly",
"percent": 20, "kind": "new", "redemptionId": "rdm_…" } }The webhook secret (kzwh_…) is separate from your API secret and shown once. Verify the signature, reject timestamps older than 5 minutes, and de-duplicate on id. No customer personal data is sent.
9. Customer experience
- One code per purchase, entered on the Plans page. Codes are case-insensitive; spaces and hyphens are ignored; codes cannot be combined.
- The discount applies to the payment made now — a new subscription or an upgrade. Renewals are billed at the list price.
- A 100% code activates the plan with a ₹0 invoice and no charge.
- The invoice shows the list price, the code, the discount and the amount paid.
- After 10 wrong codes an account (30 per IP) is locked out of promo codes for 15 minutes.
- Cancelling keeps the plan to the end of the paid period; a used code is not returned. A refund returns only what was actually charged.