Connect a POS, an online checkout or your own app to Eknas. Record bills in rupees so customers earn loyalty points, show balances and rewards, redeem reward codes at your till, and get signed webhooks when anything changes.
The Eknas API lets a business’s own systems take part in its Eknas loyalty program. Every call runs through the same engine as the Eknas counter screen: the same goals, rewards, double points days, large bill check and duplicate protection apply, and customers see their points in the Eknas app straight away.
POS scan and pay
Scan the customer’s Eknas QR at your till, take payment as usual, and record the bill so points land on their card in the same second.
Online checkout
When an order is paid, record the bill against a customer of your business by their mobile number, so online orders earn points too.
Balances in your app
Show a customer’s points, goal and ready rewards inside your own app or website, through your own server.
Redeem at your till
Check and use the single-use reward code a customer shows, without opening the Eknas counter screen.
Webhooks work the other way round: Eknas tells your server when a purchase is recorded or voided, when a reward is unlocked or redeemed, and when a bill gives a customer their first card, wherever it happened (at the counter, in the app or through the API).
Conventions
API conventions
How it works
Base URL
https://eknas.com/api/v1
Transport
HTTPS on this exact host. Plain http:// or another host name (such as www.) answers 400 instead of redirecting; a key that was ever sent over plain http should be rolled.
Format
JSON in and out. Send Content-Type: application/json with request bodies, of at most 32 KB. Unknown request fields are ignored.
Money
Nepalese rupees (NPR) as whole numbers. Money fields end in Npr, like amountNpr. There are no paisa.
Points
Whole numbers. A bill earns 1 point per rupee, or 2 on the business’s double points days, and the business can set a minimum bill and a cap per bill. Show pointsEarned from the response rather than working it out yourself.
Time
ISO 8601 timestamps in UTC, like 2026-09-22T08:16:40.512Z. The business’s own time zone is in GET /business (Asia/Kathmandu).
Names
Field names are camelCase.
IDs
Opaque strings of up to 64 characters. Store them as they are; don’t parse them.
Request id
Every response from the API carries an Eknas-Request-Id header. Log it; support will ask for it.
Getting access
API access comes with the business’s Eknas plan: it is part of Pro. If the plan doesn’t include it, every call answers 402 FEATURE_LOCKED and webhooks stop being sent. Keys and webhooks are managed by the Eknas team on a business’s behalf, not from the dashboard.
The business owner asks the Eknas team for a key, saying what it’s for (like “Main till”) and which permissions it needs.
The team creates it and shows the secret key once. Only a keyed hash is stored, so it can never be shown again. If it’s lost, ask for a new key.
The owner passes the key to the integrator through a password manager or secret store, not chat or email. It goes in an environment variable on the integrator’s server.
A business can have up to 10 active keys (revoked and expired keys don’t count). Use one key per till or integration, so one can be revoked without stopping the others.
Permissions (scopes)
Each key carries only the permissions the owner ticks. New keys start with everything a till needs for scan, pay and earn (6 of the 7); voiding purchases is left off until the owner adds it. Calling an endpoint without its permission answers 403 INSUFFICIENT_SCOPE, naming the missing one in details.required.
API permissions
Scope
Allows
Endpoints
business:read
Read business setup. The business, its locations and loyalty programs with their goals and rewards.
GET /businessGET /programs
customers:read
Find customers. Identify a customer from their Eknas member QR or mobile number, and read their points and ready rewards.
POST /customers/resolveGET /customers/{customerId}
customers:write
Add customers. Add a customer by mobile number, so their first bill earns points even without the app.
POST /customersPOST /purchases (new numbers)
purchases:read
Read purchases. List bills recorded at this business and look one up.
GET /purchasesGET /purchases/{purchaseId}
purchases:write
Record purchases. Record a paid bill so the customer earns points and unlocks rewards.
POST /purchases
purchases:voidnot on by default
Void purchases. Take back a bill recorded by mistake (within 7 days), with a reason. Give this only to trusted systems.
POST /purchases/{purchaseId}/void
rewards:redeem
Redeem rewards. Check and use the single-use reward code a customer shows.
POST /redemptions/checkPOST /redemptions
POST /purchases needs customers:write as well only to add someone new by mobile number. Joining a customer from their scanned member QR needs purchases:write alone.
403 without the permission
{"error": {"code": "INSUFFICIENT_SCOPE","message": "This key doesn't have the \"purchases:void\" permission (Void purchases). Add it to the key in Developers.","details": {"required": "purchases:void" },"requestId": "req_4fT9sK2mQx7Lp0aZ8vN3bWcE" }}
Start here
Quickstart: scan and pay in 3 calls
The most common integration: a customer shows the My code QR in their Eknas app at your till, you take payment in your own system, and the bill earns them points. The samples use this small helper for Node.js; curl works the same from any shell.
eknas.mjs
// eknas.mjs: calls the Eknas API from your server (Node.js 18 or later).// Keep the key in an environment variable, never in code or in a browser.const BASE = "https://eknas.com/api/v1";export async function eknas(method, path, body, headers = {}) { const res = await fetch(BASE + path, { method, headers: { Authorization: `Bearer ${process.env.EKNAS_SECRET_KEY}`, ...(body ? { "Content-Type": "application/json" } : {}), ...headers, }, body: body ? JSON.stringify(body) : undefined, }); const data = await res.json(); if (!res.ok) { // data is { error: { code, message, details?, requestId } } throw Object.assign(new Error(data.error.message), data.error, { status: res.status }); } return data;}
1. Scan the customer’s code and show their points
Send the text your scanner reads from the QR, unchanged, as memberCode. You get the customer’s short name, masked mobile number and their cards at your business. If member is false they haven’t joined your business yet; that’s fine, the bill in step 3 gives them a card.
Eknas doesn’t handle money. Charge the customer as you normally do. The QR in the app refreshes every 30 seconds and each code is valid for 60 seconds, but the API keeps accepting a scanned code for 10 more minutes, which leaves time to take payment before recording the bill.
3. Record the bill
Send the same memberCode (or the customer’s id from step 1), the amount in whole rupees, and an Idempotency-Key header: your order number with a prefix for this system. If the request times out, send it again with the same key: the bill is never recorded twice.
purchase.pointsEarned is what this bill earned, card.points the new balance, and rewardsUnlocked the rewards this bill reached. On their first bill a customer may also get the program’s welcome points (welcomePoints), which are already in card.points.
Two questions Eknas may ask
A business with more than one program answers 409 with details.state: "choose_program", and a bill above the program’s large bill amount answers 409 with "confirm_large". No bill is recorded in either case. See Several programs and Large bills.
Authentication and key safety
Send the secret key in the Authorization header of every request:
HTTP
Authorization: Bearer eknas_sk_live_…
Keys are eknas_sk_live_ followed by 40 letters and digits. The fixed prefix makes a leaked key easy to spot, for you and for secret scanners. Developers shows each key by its first characters (like eknas_sk_live_7Hq2), so keys can be told apart without revealing them.
Server side only
A secret key can record bills, which give points, and redeem rewards. Anything shipped to a browser or a phone app can be read by whoever holds it, so the key must stay on your server. Browsers mark their requests with a Sec-Fetch-Site header (and an Origin on cross-site calls); Eknas refuses any request carrying either with 403 BROWSER_NOT_ALLOWED, and it sends no CORS headers. Server clients such as Node’s built-in fetch, axios, curl, PHP or Python don’t send them and work normally. A customer-facing app or website calls your own backend, and your backend calls Eknas.
Key options
Allowed IP addresses. Up to 20 IPv4 or IPv6 addresses or ranges (like 203.0.113.0/24). Calls from anywhere else answer 403 IP_NOT_ALLOWED. Recommended when your server has a fixed address.
Default location. Bills, new customers and redemptions sent without a locationId are recorded at this location. Handy with one key per branch.
Expiry. After 1 to 730 days, or never. An expired key answers 401 API_KEY_EXPIRED.
The name, permissions, allowed addresses and default location can be changed later. The secret itself can’t; roll the key instead.
Rolling and revoking
Roll to replace a secret without downtime. The new key keeps the name, permissions, allowed addresses and default location (and the expiry date, if it hasn’t passed). The old key keeps working for the overlap the owner picks, 0 to 72 hours, while you deploy the new one.
Revoke to stop a key at once. It then answers 401 API_KEY_REVOKED, and the owner still sees any calls made with it.
Keep keys out of code
Never commit a key to a repository or paste it in a ticket. Read it from an environment variable or a secret manager, and don’t log the Authorization header. If a key may have leaked, or was ever sent over plain http, roll it with no overlap, or revoke it.
What is checked, in order
The request doesn’t come from a browser (403 BROWSER_NOT_ALLOWED).
A key is present (401 API_KEY_MISSING) and is a real key (401 API_KEY_INVALID). More than 30 invalid keys a minute from one address answer 429 RATE_LIMITED.
The key is within its own rate limit (429 RATE_LIMITED). This comes before anything else about the key, so even a revoked key can’t be used to flood the API.
The key isn’t revoked (401 API_KEY_REVOKED) or expired (401 API_KEY_EXPIRED).
The caller’s IP address is allowed (403 IP_NOT_ALLOWED).
The business’s plan includes the API (402 FEATURE_LOCKED), and its account isn’t suspended (423, suspended).
The business is within its own rate limit (429 RATE_LIMITED).
The key has the endpoint’s permission (403 INSUFFICIENT_SCOPE).
Every call made with a real key, revoked and expired ones included, is logged for 30 days (method, path without the query string, status, duration, IP address and error code), so the Eknas team can look into what happened if something goes wrong. Only a key far over its rate limit stops being logged. Each key only ever sees its own business.
Idempotency
POST /purchases and POST /redemptions require an Idempotency-Key header: a unique id for that bill or redemption, 8 to 100 characters of letters, digits and . _ : -. Without a valid one the call answers 400 IDEMPOTENCY_KEY_REQUIRED, before the body is even read.
Keys are shared across the whole business
Idempotency keys are unique per business, across all of its API keys, and kept for good. If two systems (say a POS and a website) could both use order number 10482, prefix each system’s keys: pos1-order-10482, web-order-10482. Purchase keys and redemption keys are kept apart, so the same value can serve one purchase and one redemption.
Purchases
Same key, same bill:200 OK with the original purchase and duplicate: true. No points are added. card.points shows the balance now; rewardsUnlocked is empty, welcomePoints is 0, bonus and joined are false and card.goalReward is null. Retries work even after the member QR has expired.
Same key, different bill: a different amountNpr, a different programId, or a different customer (by id, phone or memberCode) answers 409 IDEMPOTENCY_KEY_REUSED, with the original purchase’s id in details.purchaseId.
Refused requests record no bill. After a 4xx answer such as choose_program or confirm_large, resend with the same key.
A voided purchase still owns its key, and a retry returns it, voided.
A new purchase answers 201 Created; a replay answers 200 OK.
Redemptions
Same key, same code: the original result again, with replayed: true. Nothing new happens.
Same key, different code:409 IDEMPOTENCY_KEY_REUSED.
A code already used under another Idempotency-Key, by another key or at the counter answers 409 CONFLICT, saying when and where it was used.
Other calls are safe to repeat in their own way: lookups change nothing, POST /customers returns the existing customer, and voiding a voided purchase answers 409.
Errors
Every error from the API has the same shape, with an HTTP status to match:
JSON
{"error": {"code": "BAD_REQUEST","message": "amountNpr: Send the bill in whole rupees","details": {"param": "amountNpr" },"requestId": "req_4fT9sK2mQx7Lp0aZ8vN3bWcE" }}
message is plain English you can show to staff, but it may change. Branch on code and, for 409 and 423, on details.state. details is only present when there is something to add, and requestId matches the Eknas-Request-Id header. The one exception is the 400 for plain http or the wrong host name, which is answered before the API runs and has no request id.
Error codes
Status
Code
When
What to do
400
BAD_REQUEST
The body isn’t valid JSON, a field fails validation (named in details.param; query parameters in details.issues), a note or reason contains a link, the member code is invalid or expired (details.reason), the number isn’t a Nepal mobile, or the call used plain http or another host name.
Fix the request. For an expired member code, ask the customer to refresh My code and scan again.
400
IDEMPOTENCY_KEY_REQUIRED
POST /purchases or POST /redemptions without a valid Idempotency-Key.
Send one: 8 to 100 characters, unique per bill or redemption.
401
API_KEY_MISSING
No Authorization: Bearer header.
Send the key.
401
API_KEY_INVALID
The key doesn’t exist or wasn’t copied in full.
Check the key, or create a new one.
401
API_KEY_REVOKED
The owner revoked the key.
Ask the owner for a new key.
401
API_KEY_EXPIRED
The key’s expiry date has passed.
Ask the owner to roll it or create a new one.
402
FEATURE_LOCKED
The business’s plan doesn’t include the API (details.feature: "api_access").
The owner upgrades in Billing.
402
LIMIT_REACHED
The business has reached its plan’s member limit, on a new customer’s first bill.
The owner upgrades. Don’t retry.
403
BROWSER_NOT_ALLOWED
The request came from a browser.
Call from your server.
403
IP_NOT_ALLOWED
The key has allowed addresses and this isn’t one.
Add the address to the key.
403
INSUFFICIENT_SCOPE
The key lacks the permission (details.required).
The owner adds it to the key.
403
FORBIDDEN
The customer’s card at this business is on hold.
Ask the manager. Don’t retry.
404
NOT_FOUND
No customer, purchase or reward code of your business matches.
Check the id or code.
409
CONFLICT
Eknas needs something first (details.state: choose_program, confirm_large, member_code_required), or the action can’t be done (already voided, reward code used, and so on; the message says why).
Handle the state (see below), or show the message. A few conflicts say “try again”, such as two updates to one card at the same moment.
409
IDEMPOTENCY_KEY_REUSED
The key was used for a different bill (details.purchaseId) or a different reward code.
Use a new key for each bill or redemption.
413
BAD_REQUEST
The body is over 32 KB.
Send less.
423
FORBIDDEN
The business isn’t taking new points (details.state: "not_earning"), or its account is suspended ("suspended").
Stop recording bills. Redemptions still work. More
429
RATE_LIMITED
A rate limit was hit.
Wait for Retry-After seconds, then retry.
500
INTERNAL
Something went wrong at Eknas.
Retry with the same Idempotency-Key. Quote the request id if it keeps happening.
The states in details.state
Error states
State
Status
Meaning
choose_program
409
Several programs are taking points; resend with a programId from details.programs. More
confirm_large
409
The bill is above the program’s large bill amount; check it and resend with confirmLarge: true. More
member_code_required
409
The number is already on Eknas but isn’t your customer; scan their member QR. More
The business’s Eknas account is suspended; only reward codes and business:read calls work. More
Rate limits
Rate limits
Limit
Applies to
120 requests a minute
Each key.
600 requests a minute
Each business, across all its keys.
30 invalid keys a minute
Each IP address.
200 attempts a day
Adding customers by mobile number, per business, per day in the business’s time zone. Every attempt counts, including refused ones.
20 wrong reward codes per 15 minutes
Each key. Codes that exist don’t count.
Once the key is recognised, every response carries RateLimit-Limit (the key’s limit per minute) and RateLimit-Remaining (what is left of it this minute). Going over any limit answers 429 RATE_LIMITED with a Retry-After: 60 header. Windows are fixed: they start with the first request and reset a minute later, and refused requests count too. The business-wide limit can refuse a call while RateLimit-Remaining still shows room. For the daily and wrong-code limits, waiting 60 seconds isn’t enough; the message says so.
Pagination
GET /purchases returns purchases newest first, limit at a time (25 by default, up to 100). When hasMore is true, pass nextStartingAfter as startingAfter to get the next page. On the last page nextStartingAfter is null.
Reading every page
// Every purchase since 1 September, 100 at a time.let startingAfter = null;do { const query = new URLSearchParams({ since: "2026-09-01T00:00:00Z", limit: "100" }); if (startingAfter) query.set("startingAfter", startingAfter); const page = await eknas("GET", `/purchases?${query}`); for (const purchase of page.data) await saveToYourSystem(purchase); startingAfter = page.nextStartingAfter; // null on the last page} while (startingAfter);
Customers and privacy
Responses carry what a till or app needs and no more: the customer’s first name and last initial (Anita R.), the last three digits of their mobile number (•••••••678, or null when there is none), whether they use the Eknas app, and their cards at your business: points, goal, visits, total spent and ready rewards. Full names, full numbers, emails, birthdays, addresses and anything about other businesses are never returned, and the API doesn’t accept email addresses.
Three ways to identify a customer
Every call that needs a customer takes exactly one of these (sending none, or two, answers 400):
Customer identification
Field
Finds
Notes
memberCode
Anyone with an Eknas account, customer of your business or not.
The QR on the customer’s My code screen, which proves they’re at your till. A bill adds a newcomer to your business. Expired codes answer 400 with details.reason: "expired", forged or garbled ones "invalid".
phone
Customers of your business, and numbers new to Eknas (when adding).
Looking a number up never finds anyone else: it answers 404 whether or not they’re on Eknas, so numbers can’t be tried to see who uses Eknas.
id
Customers of your business.
Store the id from any response to refer to the customer later. Anyone else’s id answers 404, exactly like an id that doesn’t exist.
Adding people by mobile number
POST /customers, and POST /purchases with a key that also has customers:write, can add someone by number. When the number isn’t already one of your customers:
New to Eknas: the customer is created (send their name) and joins your business.
Already on Eknas, whether they use the app or another shop added them at its counter: 409 CONFLICT with details.state: "member_code_required". Scan their member QR instead, or add them at your Eknas counter. Both cases get the same answer.
409 member_code_required
{"error": {"code": "CONFLICT","message": "That mobile number is already on Eknas. Scan the customer's member QR (My code in the Eknas app) to add them, or add them at your Eknas counter.","details": {"state": "member_code_required" },"requestId": "req_4fT9sK2mQx7Lp0aZ8vN3bWcE" }}
Without customers:write, POST /purchases only finds existing customers by number, and any other number answers 404 with a hint. Every attempt to add someone by number counts towards the limit of 200 a day per business, refused ones included. A QR or id belonging to a deleted Eknas account answers 404.
Member QR timing
The My code QR refreshes every 30 seconds, and each code is valid for 60 seconds. The API accepts a scanned code for a further 10 minutes, so scan, take payment and record the bill with the same code. Beyond that, scan again, or use the customer’s id from the first response. POST /customers/resolve answers present: true when the customer was identified by their QR.
Several programs
A business can run more than one loyalty program (on plans that allow it), and a customer has one card per program. Each bill earns on one program. With one program taking points you can leave programId out. With several, POST /purchases answers 409 with details.state: "choose_program" and the choices in details.programs:
409 choose_program
{"error": {"code": "CONFLICT","message": "Choose which rewards program this is for.","details": {"state": "choose_program","programs": [ {"id": "cm5k2xa1c0004l408w2n7r5jd","name": "Coffee Club","description": "1 point for every rupee. Free drinks on the way to breakfast for two.","goalPoints": 5000,"isPrimary": true,"phase": "active","earningEndsAt": null }, {"id": "cm5k2xa1c0007l408k5b9s2ue","name": "Bakery Card","description": null,"goalPoints": 3000,"isPrimary": false,"phase": "active","earningEndsAt": null } ] },"requestId": "req_4fT9sK2mQx7Lp0aZ8vN3bWcE" }}
Show the choices to the cashier or the customer, then resend with programId. A programId that isn’t taking points answers the same way, with the programs that are. To choose ahead of time, read GET /programs and pick one with earning: true. POST /customers uses the primary program unless you pass a programId.
Handling choose_program
try { return await eknas("POST", "/purchases", bill, { "Idempotency-Key": orderId });} catch (err) { if (err.details?.state === "choose_program") { // Ask the cashier (or the customer) which card this bill is for. const programId = await pickProgram(err.details.programs); // Nothing was recorded, so the same Idempotency-Key is fine. return eknas("POST", "/purchases", { ...bill, programId }, { "Idempotency-Key": orderId }); } throw err;}
Large bills
Each program has a large bill amount set by the business (largeBillNpr in GET /programs) to catch typing mistakes. A bill above it answers 409 with details.state: "confirm_large" and no bill is recorded. Check the amount with the customer, then send the same request again with confirmLarge: true. Only send it after a person has confirmed the amount. No bill can be over NPR 10,000,000.
409 confirm_large
{"error": {"code": "CONFLICT","message": "NPR 48,000 is a large bill. Check the amount with the customer, then confirm.","details": {"state": "confirm_large","amountNpr": 48000 },"requestId": "req_4fT9sK2mQx7Lp0aZ8vN3bWcE" }}
A business can pause a program, or wind it down under the Customer Reward Protection Policy. When no program is taking points, POST /purchases, and POST /customers for anyone not already a customer, answer 423 with code FORBIDDEN and details.state: "not_earning". No bill is recorded, and the message says until when rewards can still be used. Lookups keep working.
423 not_earning
{"error": {"code": "FORBIDDEN","message": "Sunrise Coffee isn't giving new points on Eknas. Rewards already earned can be redeemed until 30 November 2026.","details": {"state": "not_earning" },"requestId": "req_4fT9sK2mQx7Lp0aZ8vN3bWcE" }}
When the business’s Eknas account is suspended (or its subscription cancelled), every call answers 423 with details.state: "suspended", except the ones needing rewards:redeem or business:read: customers can still use the rewards they earned.
423 suspended
{"error": {"code": "FORBIDDEN","message": "Sunrise Coffee's Eknas account is suspended. Rewards customers already earned can still be redeemed; everything else waits until billing is resolved.","details": {"state": "suspended" },"requestId": "req_4fT9sK2mQx7Lp0aZ8vN3bWcE" }}
In both cases reward codes can still be checked and redeemed until each program’s finalRedemptionAt. GET /programs shows where each program stands:
Program phases
phase
Meaning
active
Taking points and redeeming rewards.
paused
Not taking points for now; rewards can still be redeemed.
ending
Winding down: still taking points until earningEndsAt.
redeem_only
No new points; rewards can be redeemed until finalRedemptionAt.
ended
Finished. Nothing can be earned or redeemed.
Rely on the earning and redeeming flags rather than the phase alone: they also take the subscription and the plan into account.
Endpoint reference
Paths are relative to https://eknas.com/api/v1. Every endpoint can also answer the authentication, plan, permission and rate-limit errors in Errors, and 423 suspended (except the business:read and rewards:redeem ones) while the business’s account is suspended.
Get the business
GET/api/v1/businessScopebusiness:read
The business the key belongs to, its locations, and the calling key itself (its name, display prefix, permissions and default location). A good first call to check a new key, and the place to find locationId values.
active, paused, ending, redeem_only or ended. Phases
goalPoints
Points to reach the main reward.
welcomePoints
Points added on a customer’s first bill (never enough to reach the goal on their own).
largeBillNpr
Bills above this answer confirm_large. Large bills
earningEndsAt
When a winding-down program stops taking points, or null.
finalRedemptionAt
The last moment its rewards can be redeemed, or null.
rewards
The rewards on its current version, smallest first. The one at goalPoints is the main reward.
Find a customer
POST/api/v1/customers/resolveScopecustomers:read
Identifies a customer from their scanned member QR, their mobile number (customers of your business only) or their id, and returns their cards at your business. Adds no one and changes nothing. Send exactly one of id, memberCode or phone.
Find a customer: body
Field
Type
Description
idoptional
string
An Eknas customer id from an earlier response. Only finds customers of your business.1–64 characters
memberCodeoptional
string
The full text of the QR on the customer’s My code screen in the Eknas app. It starts with EKNAS1:M:.10–2000 characters
phoneoptional
string
A Nepal mobile number (starting 96, 97 or 98) in any common form, such as 9812345678, +977 981-234-5678 or 009779812345678.7–20 characters
The customer object is described under Get a customer. This endpoint adds present: true when the customer was identified by their QR. A QR from someone who isn’t your customer yet returns them with member: false and no cards.
Errors
400 BAD_REQUESTNot exactly one of id, memberCode, phone (details.param: "customer"); the member code is expired or invalid (details.reason); the number isn’t a Nepal mobile.
404 NOT_FOUNDNo customer of your business has that number or id; the QR’s account was deleted.
Add a customer
POST/api/v1/customersScopecustomers:write
Adds a customer to your business by mobile number and gives them an empty card on the chosen program (the primary one if you leave programId out), so they can be found by number and their bills earn points even without the app. Only numbers new to Eknas can be added this way. Welcome points and the plan’s member limit apply at their first bill, not here.
Add a customer: body
Field
Type
Description
phonerequired
string
The customer’s Nepal mobile number, in any common form.7–20 characters
namerequired
string
The customer’s name. Required by the request, but only used when the number is new to Eknas.2–80 characters
programIdoptional
string
The program to give them a card on. The primary program if left out.1–64 characters
locationIdoptional
string
Recorded as where they joined. Same default as purchases.1–64 characters
Someone already on Eknas who isn’t your customer (app user, or added at another shop’s counter)
409 member_code_required
Every attempt with a number that isn’t already your customer counts towards the limit of 200 a day, refused ones included. An existing customer is returned unchanged: no new card, even if you name a different program. name is only used for a number new to Eknas.
Errors
400 BAD_REQUESTA field fails validation; the number isn’t a Nepal mobile; locationId isn’t one of your locations.
409 CONFLICTmember_code_required (see above); choose_program when programId isn’t taking points.
423 FORBIDDENnot_earning: no program is taking new members.
429 RATE_LIMITED200 attempts to add by mobile number today.
The last three digits of their mobile number, like •••••••678, or null.
hasApp
The customer uses the Eknas app (so they can show a member QR and reward codes).
member
They have at least one card at your business.
cards[].points / goal
Current balance and the points needed for the main reward.
cards[].visits / totalSpentNpr
Bills recorded on this card and their total, in rupees.
cards[].onHold
The business put the card on hold; bills can’t be recorded on it.
cards[].rewardsReady
Unlocked rewards not used yet. expiresAt is null if the reward doesn’t expire; codeRequested means the customer has a live reward code for it.
Errors
404 NOT_FOUNDNo customer of your business has that id.
Record a purchase
POST/api/v1/purchasesScopepurchases:write
Records a paid bill: the customer earns points, may unlock rewards, and sees it in the Eknas app. A customer who isn’t a member of your business yet is added: from a scanned member QR with this permission alone, or by a mobile number new to Eknas when the key also has customers:write (see Adding people by mobile number). Requires an Idempotency-Key header.
Record a purchase: headers
Header
Description
Idempotency-Keyrequired
A unique id for this bill: 8 to 100 characters, letters, digits and . _ : -. Prefix it per system, like pos1-order-10482.
Record a purchase: body
Field
Type
Description
customerrequired
object
Who the bill is for. A customer reference: exactly one of id, memberCode or phone.
amountNprrequired
integer
The bill in whole rupees.1 to 10,000,000
programIdoptional
string
The program the bill earns on. Only needed when the business has more than one program taking points.1–64 characters
locationIdoptional
string
Where the bill was paid. Defaults to the key’s default location, then the business’s first location.1–64 characters
noteoptional
string
A short note kept with the purchase, such as your order number. It can reach the customer, so links and web addresses are refused.up to 140 characters
confirmLargeoptional
boolean
Send true after a confirm_large answer, once the amount has been checked with the customer.
customer takes exactly one of id, memberCode or phone, as in Find a customer. With a phone that is new to Eknas, add customer.name (2 to 80 characters).
400 IDEMPOTENCY_KEY_REQUIREDThe header is missing or malformed.
400 BAD_REQUESTA field fails validation (including a link in note); no single customer reference; expired or invalid member code; not a Nepal mobile; a new number without name; unknown locationId.
402 LIMIT_REACHEDA new customer’s first bill, and the business is at its plan’s member limit.
403 FORBIDDENThe customer’s card is on hold.
404 NOT_FOUNDNo customer of your business has that id; the QR’s account was deleted; without customers:write, a number that isn’t your customer (the message says so).
409 CONFLICTchoose_program, confirm_large, or (with customers:write) member_code_required in details.state.
409 IDEMPOTENCY_KEY_REUSEDThe key was used for a different amount, program or customer.
423 FORBIDDENnot_earning: the business isn’t taking points.
429 RATE_LIMITEDAlso when adding by mobile number would pass the 200-a-day limit.
List purchases
GET/api/v1/purchasesScopepurchases:read
Purchases at your business, newest first, however they were recorded: at the counter, from the customer’s check-in, or through the API. Voided purchases are left out unless you ask for them. See Pagination.
List purchases: query parameters
Field
Type
Description
customerIdoptional
string
Only this customer’s purchases.1–64 characters
sinceoptional
string
Purchases made at or after this time (ISO 8601, e.g. 2026-09-01T00:00:00Z).ISO 8601 date-time
untiloptional
string
Purchases made before this time (not including it).ISO 8601 date-time
limitoptional
integer
How many purchases to return.1 to 100, default 25
startingAfteroptional
string
The nextStartingAfter value from the previous page.1–64 characters
includeVoidedoptional
"true" | "false"
Send true to include voided purchases.default false
Takes back a bill recorded by mistake, within 7 days, whether it was recorded at the counter or through the API. Its points, spend and visit come off the card, unused rewards the card no longer reaches are withdrawn (with their codes), and the customer is told in the app, with your reason. This permission isn’t on new keys by default; give it only to trusted systems.
Void a purchase: body
Field
Type
Description
reasonrequired
string
Why the bill is being taken back. The customer sees it in their Eknas app, so links and web addresses are refused.3–140 characters
cardPoints is the card’s balance afterwards and rewardsWithdrawn the names of the rewards taken back.
Errors
400 BAD_REQUESTreason is shorter than 3 characters, longer than 140, or contains a link.
404 NOT_FOUNDNo purchase at your business has that id.
409 CONFLICTAlready voided; older than 7 days; or a reward unlocked since this bill has been redeemed. In the last two cases the owner adjusts points in the dashboard instead.
Check a reward code
POST/api/v1/redemptions/checkScoperewards:redeem
Looks up the reward code a customer shows, without using it: which reward, for whom, and whether it can be used now. Customers get a code by pressing Redeem in the Eknas app. Each code has six characters (like K7P-4QX), works once, and expires after 15 minutes; asking for a new one cancels the old.
Check a reward code: body
Field
Type
Description
coderequired
string
The reward code the customer shows, like K7P-4QX. Case, spaces and dashes don’t matter. From a scanned reward QR, send only the part after EKNAS1:R:.4–20 characters
locationIdoptional
string
Where the reward is handed over. Same default as purchases.1–64 characters
When valid is false, problem says why in words you can show: already used, replaced by a newer code, expired, the reward already redeemed, withdrawn or expired, the card on hold, or the program ended. expiresAt is when the code expires.
Errors
400 BAD_REQUESTNot a possible reward code (six letters and digits); unknown locationId.
404 NOT_FOUNDNo such code at your business.
429 RATE_LIMITED20 wrong codes within 15 minutes, for this key. Checking and redeeming share this budget, and codes that exist don’t count.
Redeem a reward code
POST/api/v1/redemptionsScoperewards:redeem
Uses the code: the reward is marked redeemed and the code can never be used again. Hand the reward over once this succeeds. Works while a program winds down, until its final redemption date, and while the business’s account is suspended. Requires an Idempotency-Key header.
Redeem a reward code: headers
Header
Description
Idempotency-Keyrequired
A unique id for this redemption, like pos1-redeem-7731: 8 to 100 characters, letters, digits and . _ : -.
Redeem a reward code: body
Field
Type
Description
coderequired
string
The reward code the customer shows, like K7P-4QX. Case, spaces and dashes don’t matter. From a scanned reward QR, send only the part after EKNAS1:R:.4–20 characters
locationIdoptional
string
Where the reward is handed over. Same default as purchases.1–64 characters
cardReset is true when this was the main reward and the card started its next round; carriedPoints is then the points carried over above the goal. Otherwise carriedPoints is the card’s unchanged balance.
replayed is true when this Idempotency-Key already redeemed this code: you get the original result and nothing new happens. Both answers are 200 OK.
Errors
400 IDEMPOTENCY_KEY_REQUIREDThe header is missing or malformed.
400 BAD_REQUESTNot a possible reward code (details.param: "code"); unknown locationId.
404 NOT_FOUNDNo such code at your business.
409 IDEMPOTENCY_KEY_REUSEDThis Idempotency-Key was used for a different code.
409 CONFLICTThe code can’t be used, for example because it was already used some other way; the message says why (as problem does in Check).
429 RATE_LIMITEDToo many wrong codes, as in Check.
Webhooks
Webhooks send events to your server as they happen, so your POS can print “you earned 250 points”, your app can refresh a balance, or your CRM can stay in step. Ask the Eknas team to add up to 5 endpoints for the business, each with its own events and its own signing secret, shown once, starting eknas_whsec_. Events are sent while the business’s plan includes the API.
Events
Events cover everything at the business, wherever it happened: at the counter, in the customer’s app or through the API.
Webhook events
Event
When
Payload
purchase.recorded
A bill was recorded and the customer earned points (at the counter, in the app or through the API).
data: purchase, customer, card (with the new balance) and rewardsUnlocked.
purchase.voided
A bill was voided and its points taken back.
data: the voided purchase, customer, card (balance afterwards), reason and rewardsWithdrawn.
reward.unlocked
A customer reached a reward.
One event per reward, sent with the purchase that unlocked it. data: reward.name, customer, card and purchaseId.
reward.redeemed
A customer used a reward code.
data: reward (id, name), code, customer, card, locationId and cardReset.
customer.joined
A customer got their first card at this business.
Sent just before purchase.recorded when that bill gave the customer a new card. Joining without a bill (from the shop’s page, a counter check-in or POST /customers) doesn’t send it. data: customer and card.
The Test button in Developers sends a test.ping event once, straight away, with no retries (up to 10 tests per 10 minutes per business). Ignore event types you don’t know, and answer them with a 2xx: new ones may be added.
Delivery and headers
Each event is a POST with a JSON body, the same envelope for every type:
The event’s id. The same on every retry, and for every endpoint that receives the event.
Eknas-Event-Type
The event’s type.
Eknas-Delivery-Attempt
1 for the first try, 2 for the first retry, and so on.
Eknas-Signature
t=<unix seconds>,v1=<hex>. See below.
Deliveries go only to public https:// addresses on port 443 (the default) or 8443. The address is checked when it is saved and again on every delivery, redirects are not followed, and a private or local address is refused. Your server must answer with a 2xx status: a connection that sits silent for 10 seconds is dropped, and the whole attempt must finish within 15 seconds. The response body is ignored. Anything else, including a redirect or a timeout, counts as a failure.
Verifying signatures
Anyone can send a request to your endpoint, so check every one. v1 is the hex HMAC-SHA256 of <t>.<raw body>, keyed with the endpoint’s whole signing secret (including eknas_whsec_). Recompute it over the body exactly as it arrived, before any JSON parsing, compare in constant time, and reject timestamps more than five minutes away from your clock. Each attempt is signed afresh, so retries carry a current timestamp.
verify-eknas.mjs
// verify-eknas.mjsimport crypto from "node:crypto";const TOLERANCE_SECONDS = 5 * 60;/** * rawBody: the request body exactly as received (a string or Buffer), before any JSON parsing. * header: the Eknas-Signature header, e.g. "t=1790064965,v1=5f2b…". * secret: the endpoint's whole signing secret, including the "eknas_whsec_" prefix. */export function verifyEknasSignature(rawBody, header, secret) { if (!header) return false; const parts = Object.fromEntries(header.split(",").map((pair) => pair.trim().split("="))); const timestamp = Number(parts.t); if (!Number.isInteger(timestamp) || !parts.v1) return false; // Reject old (or far-future) timestamps, so a captured request can't be replayed later. if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > TOLERANCE_SECONDS) return false; const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest(); const received = Buffer.from(parts.v1, "hex"); // Constant-time comparison, so the signature can't be guessed byte by byte. return received.length === expected.length && crypto.timingSafeEqual(received, expected);}
Express
// Express: read the body raw on this route, or the signature can't be checked.import express from "express";import { verifyEknasSignature } from "./verify-eknas.mjs";const app = express();app.post("/eknas/webhook", express.raw({ type: "application/json" }), async (req, res) => { const ok = verifyEknasSignature(req.body, req.get("Eknas-Signature"), process.env.EKNAS_WEBHOOK_SECRET); if (!ok) return res.status(400).send("Invalid signature"); const event = JSON.parse(req.body.toString("utf8")); // The same event can arrive more than once: remember the ids you've handled. if (await alreadyHandled(event.id)) return res.sendStatus(200); await queueForProcessing(event); // do slow work later; answer within seconds res.sendStatus(200);});
Next.js route handler
// Next.js App Router: app/eknas/webhook/route.jsimport { verifyEknasSignature } from "@/lib/verify-eknas.mjs";export async function POST(req) { const raw = await req.text(); // the raw body, before JSON.parse if (!verifyEknasSignature(raw, req.headers.get("eknas-signature"), process.env.EKNAS_WEBHOOK_SECRET)) { return new Response("Invalid signature", { status: 400 }); } const event = JSON.parse(raw); if (!(await alreadyHandled(event.id))) await queueForProcessing(event); return new Response(null, { status: 200 });}
Common mistakes
Verifying a re-serialised body (JSON.stringify(req.body)) instead of the raw bytes; a framework that parses JSON before your handler; using the secret without its prefix; and doing slow work before answering, which can pass the time limit.
Rolling the signing secret in Developers replaces it at once: the old secret stops verifying immediately, so deploy the new one straight away.
Retries and switching off
A failed delivery is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours, 24 hours: 8 attempts over about two days. Retries are picked up every minute, so they can be a little late.
Handle each event once. The same event can arrive more than once (for example when your 2xx was lost), and events can arrive out of order. Store the ids you’ve handled and skip repeats; use createdAt and the latest balance in card rather than arrival order.
Answer fast. Acknowledge with a 2xx, then do the work in a queue.
Switching off. When 5 events in a row use up every retry, the endpoint is switched off and the owner sees why in Developers. A successful delivery resets the count. After fixing the endpoint, the owner switches it back on and can retry failed deliveries from there.
Event payloads
customer in events has the same privacy rules as the API: id, short name, the last three digits of the phone (or null) and hasApp. purchase is the same object as Get a purchase returns.
{"id": "evt_9Qm2Lx7Vb4Tz1Kc8Rn5Hs0Wd","object": "event","type": "test.ping","createdAt": "2026-09-22T08:16:40.771Z","businessId": "cm5k2x9qa0001l408bq7d3f2e","data": {"message": "Test event from Sunrise Coffee on Eknas. If you can read this, your endpoint works." }}
Versioning and changes
This is version 1, and every path starts with /api/v1. Within v1, fields won’t be removed or renamed and their types and meanings won’t change. Additive changes can arrive at any time:
new endpoints, and new optional request fields;
new fields in responses and event payloads;
new webhook event types, error codes and details.state values.
Build for that: ignore fields you don’t know, answer unknown events with a 2xx, and treat an unknown error code by its HTTP status. A breaking change would ship as a new version under /api/v2. The OpenAPI document always describes the current v1.
Support
Every response carries an Eknas-Request-Id header (also requestId in error bodies). Log it with every call. When something looks wrong, send the request id, the time and the endpoint to [email protected]. Never send a secret key.
Ask the Eknas team about recent calls (kept for 30 days) or recent webhook deliveries, with their status and error, for a business.
Questions about a business’s plan, keys or webhooks go to the Eknas team, who manage them on the business’s behalf.