Partner API for promo codes
Companies that help us reach customers can make Safeavio Sentinel promo codes for their own customers with a signed HTTPS API. Billing, tax invoices and support stay with BIDUA Industries Pvt Ltd; Ramaera Industries Ltd is the marketing partner.
A code your customer types at checkout
Call the API to make one code or up to 500 at a time, with a percentage off, an end date, how many times a code can be used and, if you want, which items it applies to (phone keys, computer keys, server keys, a bundle or a plan).
The customer adds what they want to the cart on safeavio.com and types the code. The discount comes off before tax, so GST is charged only on what they pay. The tax invoice shows the list price, the code, the discount and the amount paid.
Look up any of your codes to see how often it was used and how much it took off, or receive a webhook for every use. No customer name, e-mail or phone number is ever returned to you.
https://safeavio.com/api/partner/v1 · JSON in and out, UTF-8 · money is in paise (₹1 = 100) · prices include GST · HTTPS only. To become a partner, contact us.A key id and a secret
We create your partner account with your limits: the discount percentages you may issue, the highest percentage, which items a code may be limited to, a monthly and a total quota, a requests-per-minute limit, the IP address of your server (required), an optional webhook, and a code prefix (your codes look like ACME-7KQ2-M9XD). Then we issue a key:
| Value | Example | Secret? |
|---|---|---|
| Key id | sgk_3f9a0c1d2e4b5a69 | No: sent with every request |
| Secret | sgs_ (47 characters) | Yes: shown once, never sent |
The secret is shown exactly once and we do not store it (it is derived on our side), so it cannot be recovered. If it is lost, we issue a new key. Two keys can be active at once, so you can rotate without downtime: start signing with the new one, then ask us to revoke the old one.
Every request is signed
There are no cookies, bearer tokens or sessions. Send four headers:
| Header | Value |
|---|---|
| X-SG-Key | your key id |
| X-SG-Timestamp | current Unix time in seconds |
| X-SG-Nonce | 16 to 64 random characters from A-Z a-z 0-9 _ -, new for every request |
| X-SG-Signature | lower-case hex HMAC-SHA256(secret, canonical) |
canonical = "SG1-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: the SHA-256 of "")- The timestamp must be within 300 seconds of our clock.
- A (key, nonce) pair is accepted once. Sending the same signed request again is refused with REPLAY; to retry, sign again with a new nonce, and use an Idempotency-Key for creating codes.
- Sign the exact bytes you send. Send Content-Type: application/json with a body. A body is limited to 64 KB.
- For a GET the body is empty, and its hash is e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.
import crypto from 'node:crypto';
const BASE = 'https://safeavio.com';
const KEY_ID = process.env.SG_KEY_ID; // sgk_...
const SECRET = process.env.SG_SECRET; // sgs_... (keep it in a secret store)
export async function sentinel(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 = ['SG1-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-SG-Key': KEY_ID, 'X-SG-Timestamp': ts, 'X-SG-Nonce': nonce, 'X-SG-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 the phone key, valid until 31 Dec 2026:
const batch = await sentinel('POST', '/api/partner/v1/promo-codes', {
percent: 20, count: 25, items: ['product:phone'], validUntil: '2026-12-31T23:59:59+05:30', campaign: 'Diwali 2026',
}, 'order-48213');
console.log(batch.codes.map((c) => c.code));import hashlib, hmac, json, os, secrets, time, urllib.request
BASE = "https://safeavio.com"
KEY_ID, SECRET = os.environ["SG_KEY_ID"], os.environ["SG_SECRET"]
def sentinel(method, path_and_query, body=None, idempotency_key=None):
raw = b"" if body is None else json.dumps(body, separators=(",", ":")).encode()
ts, nonce = str(int(time.time())), secrets.token_hex(16)
canonical = "\n".join(["SG1-HMAC-SHA256", method.upper(), path_and_query, ts, nonce, hashlib.sha256(raw).hexdigest()])
sig = hmac.new(SECRET.encode(), canonical.encode(), hashlib.sha256).hexdigest()
headers = {"X-SG-Key": KEY_ID, "X-SG-Timestamp": ts, "X-SG-Nonce": nonce, "X-SG-Signature": sig}
if raw: headers["Content-Type"] = "application/json"
if idempotency_key: headers["Idempotency-Key"] = idempotency_key
req = urllib.request.Request(BASE + path_and_query, data=raw or None, headers=headers, method=method)
with urllib.request.urlopen(req) as r:
return json.load(r)
print(sentinel("GET", "/api/partner/v1/usage"))KEY_ID='sgk_...'; SECRET='sgs_...'
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 'SG1-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://safeavio.com$REQ_PATH" \
-H "X-SG-Key: $KEY_ID" -H "X-SG-Timestamp: $TS" -H "X-SG-Nonce: $NONCE" -H "X-SG-Signature: $SIG" \
-H 'Content-Type: application/json' -H 'Idempotency-Key: order-48213' --data "$BODY"What you can call
All paths are under /api/partner/v1. Every response carries X-Request-Id; quote it when you contact us.
| Call | What it does |
|---|---|
| GET /catalog | The items you may discount, with the list price in paise and the exact price a customer pays for every percentage you may issue. |
| POST /promo-codes | Make codes. Body: percent (required, one of your allowed percentages), count (1 to 500), or code (one custom code that starts with your prefix), items (a list such as product:phone, product:desktop, product:server, bundle:family, or a plan slug; empty means every item you may discount), maxRedemptions (1 to 1000, default 1), maxPerAccount (default 1), validFrom, validUntil (at most 730 days ahead, default 365), campaign (your label, up to 80 characters). Optional header Idempotency-Key. |
| GET /promo-codes | Your codes, newest first. Query: status (active or revoked), campaign, batchId, limit (1 to 200), cursor. |
| GET /promo-codes/{code} | One code with how often it was used, how much it took off, and each use (item, amount, time). Case and hyphens do not matter. |
| POST /promo-codes/{code}/revoke | Stop a code for good. Uses already made stand, and the quota the code used is not returned. Body (optional): { "reason": "..." }. |
| GET /usage | Your quota (this month and in total), your limits, and counts of codes and uses. |
A code is in one of these states: active, scheduled, expired, exhausted, revoked or paused (your account is suspended). A request with an Idempotency-Key (8 to 100 characters) that is repeated within 7 days returns the same codes with "replayed": true and uses no more quota. Use your order number.
The quota counts redemption capacity. A request for 25 single-use codes uses 25; one code that can be used 100 times uses 100. The monthly quota resets on the 1st (UTC). Empty means no cap.
Rules at checkout
- One code per order. Codes are not case sensitive, and spaces and hyphens are ignored.
- A code that is limited to certain items takes its discount only from those items in the cart.
- The discount comes off before tax. For example, a cart listed at ₹30,000 (GST included) with a code of 80% costs ₹6,000, and the taxable value and the GST are worked out on those ₹6,000.
- A code of 100% gives the keys at no charge, with no payment step and no tax invoice, because nothing was paid.
- A code that has ended, been used up, been revoked, or does not apply to the cart says so, instead of failing without a reason.
- If your account is suspended, your codes that have not been used stop working at once.
- If we refund a purchase, only the amount actually charged is refunded, and the use of the code still counts.
Know when a code is used (optional)
If we set a webhook for you (a public https address only), every use of one of your codes is sent to it. Each delivery has a 5-second timeout and is retried after 30 seconds, 2 minutes, 10 minutes, 1 hour and 6 hours; after that it is marked failed. Private addresses, localhost and plain http addresses are refused. The webhook secret (sgwh_) is separate from your API secret. Verify the signature, reject timestamps older than 5 minutes, and de-duplicate on id. No customer data is sent.
POST <your url>
Content-Type: application/json
X-SG-Event: promo.redeemed
X-SG-Timestamp: <unix seconds>
X-SG-Signature: hex HMAC-SHA256(webhookSecret, timestamp + "\n" + rawBody)
{ "id": "evt_...", "event": "promo.redeemed", "at": 1790000000000,
"data": { "code": "ACME-7KQ2-M9XD", "percent": 20, "campaign": "Diwali 2026", "paymentId": 812, "discountInr": 399.8 } }Every error has a code
An error is { "error": "...", "code": "...", "requestId": "req_..." }.
| HTTP | code | Meaning |
|---|---|---|
| 400 | BAD_JSON, BAD_TIER, BAD_COUNT, BAD_CODE, BAD_PLAN, BAD_DATE, BAD_CAMPAIGN, BAD_LIMIT, BAD_FILTER, BAD_IDEMPOTENCY_KEY, BAD_REQUEST | The input is invalid; the message says what. |
| 401 | MISSING_AUTH, BAD_TIMESTAMP, BAD_NONCE, BAD_SIGNATURE | Not signed, signed wrongly, or an unknown key (an unknown key looks the same as a wrong signature). |
| 401 | CLOCK_SKEW | The timestamp is more than 300 seconds from the hub's clock. Keep NTP on. |
| 401 | REPLAY | That nonce was already used with this key. Sign again with a new nonce. |
| 401 | KEY_REVOKED | The key was revoked. |
| 403 | PARTNER_SUSPENDED, IP_NOT_ALLOWED | Your account is suspended (your unredeemed codes stop working too), or the request did not come from your registered server address. |
| 403 | TIER_NOT_ALLOWED, PLAN_NOT_ALLOWED, QUOTA_EXCEEDED | Outside what your account may issue, or the monthly or total quota would be passed. |
| 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 | The body is over 64 KB, or is not application/json. |
| 429 | RATE_LIMITED, TOO_MANY_FAILURES | Over your requests-per-minute limit, or 20 failed authentications from your address in 10 minutes. |
Limits: the per-partner rate (60 requests a minute unless we set another), 500 codes per request, 1000 uses per code, validity at most 730 days, and 20 failed authentications per address in 10 minutes.
How it is built
- Signing: HMAC-SHA256 over method, path and query, timestamp, nonce and body hash, compared in constant time. An unknown key does the same work as a wrong signature, so key ids cannot be probed. The account status, the registered server address and the rate limit are checked only after the signature is valid.
- No secret at rest: a key's secret is derived from a server-side pepper and a random salt. Our table keeps the key id, the salt and a short fingerprint only.
- Replay: every (key, nonce) pair is remembered for ten minutes in the database, so a restart does not reopen the window.
- Isolation: you only ever see codes made by your own account; any other code is a 404.
- Audit: making and revoking codes, and every change to your account and keys, is recorded.
- Suspension: suspending a partner refuses its API calls and makes its unused codes invalid at checkout immediately.
Known limits
- The request-rate and failed-authentication counters are kept in memory by each backend process. Today the production backend is one process; if that ever changes, the limits would apply per process.
- A code's total-use limit is checked when the customer gets a quote, and again at payment only through the per-account limit, so two buyers can race for the last use of a code.
- A partner is not paid a commission, and there is no partner dashboard. You see your codes and their use through this API; our staff see them in the portal. Commissions for people who invite friends are a separate thing: see the referral programme.
Re-checked against our documentation on 2026-10-14.