# Mekante Developer Documentation — Full Reference (EN & TR)
> Complete single-file developer documentation for the Mekante Public Till API (`https://app.mekante.com/api/v1/public`) and Webhooks (`X-Mekante-Signature`). Concise index: https://docs.mekante.com/llms.txt
================================================================================
# PART 1: ENGLISH DOCUMENTATION
================================================================================
# Mekante Developers
The Mekante Till API lets your point-of-sale (POS) software scan a customer's loyalty card, award points or stamps, redeem rewards, and void mistaken entries. Webhooks do the reverse: whenever a customer joins, earns points, redeems a reward, or has an entry voided, Mekante notifies your server in real time.
## In this guide
| Page | What it covers |
| --------------------------------------------- | ---------------------------------------------------- |
| [Getting started](./baslangic.md) | Creating an API key and sending your first request |
| [Authentication](./kimlik-dogrulama.md) | `Authorization` header, revoking keys, permissions |
| [Till flow](./kasa-akisi.md) | Step-by-step from scanning a QR to awarding points |
| [Endpoints](./uc-noktalar.md) | Request bodies, responses, and errors for every path |
| [Errors](./hatalar.md) | Error envelope and stable machine-readable codes |
| [Limits](./limitler.md) | Rate limits, payload limits, and program guardrails |
| [Webhooks](./webhooklar.md) | Event types, payloads, delivery, and retry schedule |
| [Signature verification](./imza-dogrulama.md) | Verifying `X-Mekante-Signature` in Node, Python, PHP |
## Essentials
- **Base URL:** `https://app.mekante.com/api/v1/public` (same host as the business dashboard).
- **Format:** Requests and responses are JSON. Field names and error codes are in English.
- **Currency:** Monetary amounts are integer **kuruş** (minor units of TRY): `15000` = 150 TL.
- **Timestamps:** Dates are ISO 8601 in UTC (`2026-10-23T09:57:08.246Z`).
- **Language:** The Turkish version of this guide is available at [`/`](/).
- **AI & LLM support:** A concise index for coding agents is available at [`/llms.txt`](/llms.txt), and the complete single-file developer documentation is at [`/llms-full.txt`](/llms-full.txt).
---
# Getting started
## 1. Create an API key
Only the business **owner** can create API keys.
1. Open the **Developers** (_Geliştiriciler_) page in the business dashboard.
2. Click **New API key** (_Yeni API anahtarı_), give the key a descriptive name (for example `POS integration`), and choose the **location** (_şube_) the key will operate at.
3. Click **Create key** (_Anahtar oluştur_). The key starts with `sk_live_` and is shown **only once**. Store it somewhere safe immediately. If you lose it, revoke it and create a new one.
Each API key acts as a hidden cashier bound to a single location, so you do not need to pass `locationId` in `POST /scan` requests.
::: warning Keep your key secret
Never embed an API key in browser code, mobile apps, or public repositories. Only use it on the server side of your POS integration. If you suspect a key was leaked, revoke it immediately on the Developers page.
:::
## 2. Send your first request
Test your key by calling `GET /me`:
```bash
curl https://app.mekante.com/api/v1/public/me \
-H "Authorization: Bearer sk_live_EXAMPLE_KEY"
```
The response returns the business, loyalty program, and location associated with the key:
```json
{
"role": "cashier",
"scopeLocationIds": null,
"tenant": { "name": "Docs Cafe", "status": "trial", "joinCode": "8ET7P7H3" },
"program": { "type": "points", "earnRate": 0.05, "expiryMonths": 12 },
"locations": [{ "id": "…", "name": "…" }]
}
```
`earnRate` is the number of points earned per 1 TL spent; 1 point is worth 1 TL, so `0.05` means 5%. Additional fields are returned in the response; only the most relevant ones are shown above. If you receive `401`, check [Authentication](./kimlik-dogrulama.md).
## 3. Next step
Read [Till flow](./kasa-akisi.md) to learn how to turn a customer purchase into points or redeem a reward.
---
# Authentication
Send your API key as a `Bearer` token on every request:
```http
Authorization: Bearer sk_live_EXAMPLE_KEY
```
If the header is missing or malformed:
```json
{
"error": "Unauthorized",
"code": "UNAUTHORIZED",
"message": "Send your API key as a Bearer token."
}
```
If the key is unknown, revoked, or its backing cashier row is disabled:
```json
{
"error": "Unauthorized",
"code": "UNAUTHORIZED",
"message": "That API key isn't valid."
}
```
Both cases return HTTP status `401`.
## Revoking a key
Open the **Developers** (_Geliştiriciler_) page in the dashboard and choose **Revoke** (_İptal et_) from the row menu for that key. Once revoked, every request made with that key immediately returns `401`. Keys are stored on the server only as cryptographic hashes, so a lost key cannot be recovered.
## Key permissions
An API key acts with **cashier** privileges at the location it is bound to: it can scan QR codes, award points or stamps, redeem rewards, and void its own entries within the 15-minute void window. It cannot access management endpoints such as reports, program settings, or team administration.
---
# Till flow
The customer opens their loyalty card in the Mekante Wallet app on their phone and presents a **QR code**. Your POS reads that code and follows the steps below.
```text
Scan QR ──► POST /scan ──► (preview points) ──► POST /earn award points/stamps
└──────────────────────► POST /redeem redeem reward
```
## Earning points or stamps
1. **Scan the QR code.** The QR payload is an opaque token string. Send it to `POST /scan`. The response includes `scanId` and member details (name, masked phone, balances, tier) that you can display on the cashier screen.
2. **Preview (optional).** Call `POST /earn/preview` to calculate how many points and stamps a purchase amount will award without writing to the ledger.
3. **Commit the earn.** Call `POST /earn` with `scanId` and the purchase amount (or stamp count). The `Idempotency-Key` header is **required**.
## Redeeming a reward
There are two ways to redeem a reward:
1. **Cashier chooses.** Scan the customer's standard QR code with `POST /scan`. The response's `rewards` array lists all rewards the member can (or cannot, with `reason` and `missing`) redeem. After the cashier picks one, send `scanId` and the chosen `rewardId` to `POST /redeem`.
2. **Customer chooses.** The customer selects a reward in the Wallet app and generates a dedicated redeem QR code for that reward. Scan it with `POST /scan`: the response has `intent: "redeem"` and populates `reward`. You do not need to pass `rewardId` to `POST /redeem`; if you do, it must match the scanned reward (`400 REWARD_MISMATCH`).
The response returns a 6-character confirmation `code`. The customer receives an instant push notification that their reward was redeemed, and the cashier/API key is recorded on the ledger entry. A cashier or API key can redeem at most the daily limit configured by the business (`429 CASHIER_DAILY_LIMIT`).
## Important rules
**QR codes expire after 30 seconds.** An expired code returns `400 QR_EXPIRED`. Ask the customer to refresh their screen.
**Each QR code can only be scanned once.** A second scan returns `409 QR_ALREADY_USED`. This prevents replay via screenshots or photos.
**A scan is valid for 5 minutes.** After `POST /scan` succeeds, you have 5 minutes to call `POST /earn` or `POST /redeem` with that `scanId`. Once consumed or expired, it returns `409 SCAN_INVALID` and you must scan a fresh QR code. If your POS UI reloads, you can re-fetch an active scan with `GET /scans/:id`.
**Amounts are integer kuruş.** `15000` = 150 TL. Decimal, zero, or negative values are rejected. The hard ceiling is `100000000` kuruş; the business's own per-transaction limit is usually lower ([Limits](./limitler.md)).
## Idempotency-Key: never double-count a sale
If the network drops mid-request, you won't know whether the server committed the transaction. Retrying the **same** request with the **same** `Idempotency-Key` guarantees points are not awarded twice; the server replays the original response with `"replayed": true`.
- Generate one key per sale and tie it to the sale (for example from the receipt ID: `pos-2026-000123`).
- Must be between 8 and 100 characters long.
- Reusing the same key for a **different** operation or payload returns `409 DUPLICATE_REQUEST`.
- Passing `receiptNo` adds a second layer of protection: the same receipt number cannot be recorded twice (`409 DUPLICATE_RECEIPT`).
```text
First request → 201 Created "replayed": false
Retry → 200 OK "replayed": true (same entry, no duplicate points)
```
## Voiding a mistaken entry
`POST /ledger/:id/void` reverses an `earn` or `redeem` ledger entry. Because an API key operates with cashier privileges, it can **only void its own entries** and **only within 15 minutes** of creation. If earned points have already been spent in the meantime, the entry cannot be voided (`409 POINTS_ALREADY_SPENT`).
---
# Endpoints
All paths are relative to `https://app.mekante.com/api/v1/public`. Every request requires an `Authorization: Bearer sk_live_…` header ([Authentication](./kimlik-dogrulama.md)). UUIDs (`id`) in the examples below are abbreviated for readability.
| Method | Path | Description |
| ------ | ------------------------------------------ | -------------------------------------------------------- |
| `GET` | [`/me`](#get-me) | Business, loyalty program, and location bound to the key |
| `POST` | [`/scan`](#post-scan) | Scan a customer's QR token |
| `GET` | [`/scans/:id`](#get-scans-id) | Re-read an active, unconsumed scan |
| `POST` | [`/earn/preview`](#post-earn-preview) | Preview points/stamps for a purchase without saving |
| `POST` | [`/earn`](#post-earn) | Award points and/or stamps |
| `POST` | [`/redeem`](#post-redeem) | Redeem a reward |
| `POST` | [`/ledger/:id/void`](#post-ledger-id-void) | Void a recent ledger entry |
| `GET` | [`/ledger/recent`](#get-ledger-recent) | Last 20 transactions made by this key |
| `GET` | [`/ledger/mine`](#get-ledger-mine) | Paginated history of transactions made by this key |
## GET /me
Returns the business, loyalty program, and locations associated with the API key. Useful for health-checking your credentials and reading program configuration.
```bash
curl https://app.mekante.com/api/v1/public/me \
-H "Authorization: Bearer sk_live_EXAMPLE_KEY"
```
A `200` response contains `role`, `tenant`, `program`, and `locations`. `program.type` is `points`, `stamps`, or `hybrid`.
## POST /scan
Verifies and consumes a customer's 30-second QR token and creates a 5-minute `scanId`.
**Request body**
| Field | Type | Description |
| ------------ | --------------------- | --------------------------------------------------------------- |
| `token` | string, 10–1000 chars | Raw QR code content |
| `locationId` | uuid, optional | Location ID. Optional because an API key is bound to one branch |
```bash
curl -X POST https://app.mekante.com/api/v1/public/scan \
-H "Authorization: Bearer sk_live_EXAMPLE_KEY" \
-H "Content-Type: application/json" \
-d '{ "token": "QR_TOKEN_CONTENT" }'
```
**Response `200`**
```json
{
"scanId": "e227e15a-…",
"intent": "earn",
"member": {
"id": "db997e9d-…",
"name": "Ayşe Yılmaz",
"phone": "+90 559 *** ** 39",
"balance": 0,
"stampBalance": 0,
"tier": null
},
"program": { "type": "points", "stampsPerReward": null, "maxStampsPerTransaction": 10 },
"reward": null
}
```
`intent` is either `earn` (ordinary customer card QR) or `redeem` (reward-specific QR). When `intent` is `redeem`, `reward` contains `{ id, name, costPoints, costCurrency }`, where `costPoints` is the price in `costCurrency` (`points` or `stamps`). Customer phone numbers are always masked.
`program.type` indicates how the business's loyalty program works:
| Type | What gets awarded |
| -------- | ------------------------------------------------------------------------------------------------------- |
| `points` | Points calculated from purchase amount. `amount` is required |
| `stamps` | Stamps. No purchase amount is needed; pass `stamps` for how many stamps to award (defaults to 1) |
| `hybrid` | Both balances. Awards `stamps` stamps, and if `amount` is also provided, awards points from that amount |
`rewards` is populated when scanning the customer's standard QR code (and empty when the customer scanned a single-reward QR, where `reward` is populated instead). Each item in `rewards` has `{ id, name, type, costPoints, costCurrency, eligible, reason, missing }`: `eligible` indicates whether the member can redeem it right now, `reason` explains why not (`INSUFFICIENT_BALANCE`, `OUT_OF_STOCK`, `LIMIT_REACHED`, `EXPIRED`, `NOT_STARTED`, `INACTIVE`), and `missing` is how many more points or stamps are needed. Sorted cheapest first.
`member.balance` is the point balance and `member.stampBalance` is the stamp balance. `program.maxStampsPerTransaction` is the maximum number of stamps that can be awarded in a single visit.
**Possible errors:** `400 QR_INVALID`, `400 QR_EXPIRED`, `409 QR_ALREADY_USED`, `404 MEMBERSHIP_NOT_FOUND`, `402 TRIAL_EXPIRED`, `402 SUBSCRIPTION_SUSPENDED`.
## GET /scans/:id
Returns an unconsumed, non-expired scan with the exact same payload shape as `POST /scan`. Useful for recovering state after a POS screen refresh. Returns `409 SCAN_INVALID` if already consumed or expired.
## POST /earn/preview
Calculates how many points and stamps a visit will award without writing to the database. Does not require an `Idempotency-Key`.
**Request body:** `scanId` (uuid); `amount` (integer kuruş, positive; required for `points`, optional for `hybrid`, ignored for `stamps`); `stamps` (integer; used for `stamps` and `hybrid` programs, defaults to 1).
```json
{ "scanId": "e227e15a-…", "amount": 15000 }
```
**Response `200`**
```json
{
"points": 150,
"stamps": 0,
"multiplier": 1,
"multiplierCampaignId": null,
"bonuses": [],
"total": 150,
"stampsTotal": 0,
"expiresAt": "2027-10-23T09:57:08.237Z",
"balanceAfter": 150,
"stampBalanceAfter": 0
}
```
`multiplier` is the active tier or campaign multiplier (multipliers never stack; the highest wins) and applies to points only. `total` is the total points to be awarded on this visit and `stampsTotal` is the total stamps (including welcome/birthday bonuses, which follow the program's main currency). `expiresAt` is when the earned points/stamps expire.
## POST /earn
Awards points and/or stamps. **The `Idempotency-Key` header is required** ([Till flow](./kasa-akisi.md#idempotency-key)).
**Request body**
| Field | Type | Description |
| ----------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `scanId` | uuid | From `POST /scan` |
| `amount` | integer, kuruş | Purchase total in kuruş. Required for `points`, optional for `hybrid` (awards points when given), ignored for `stamps` |
| `stamps` | integer, optional | Number of stamps to award (`stamps` and `hybrid` programs, defaults to 1, up to the business's per-visit cap). Pass `0` in `hybrid` for points-only |
| `receiptNo` | string, 1–40, optional | POS receipt number. Prevents recording the same receipt twice |
```bash
curl -X POST https://app.mekante.com/api/v1/public/earn \
-H "Authorization: Bearer sk_live_EXAMPLE_KEY" \
-H "Idempotency-Key: pos-2026-000123" \
-H "Content-Type: application/json" \
-d '{ "scanId": "e227e15a-…", "amount": 15000, "receiptNo": "A-1042" }'
```
**Response `201`** (`200` with `"replayed": true` on idempotent retry)
```json
{
"replayed": false,
"entries": [{ "id": "3f99048a-…", "type": "earn", "points": 150, "stamps": 0 }],
"total": 150,
"stamps": 0,
"balance": 150,
"stampBalance": 0,
"membershipId": "db997e9d-…"
}
```
`entries[].id` is the ledger entry ID used if you need to void the transaction. `total` and `stamps` are the points and stamps awarded in this transaction; `balance` and `stampBalance` are the updated member balances.
**Possible errors:** `400` (missing header or invalid body), `409 SCAN_INVALID`, `404 SCAN_NOT_FOUND`, `409 DUPLICATE_RECEIPT`, `409 DUPLICATE_REQUEST`, `422 AMOUNT_TOO_HIGH`, `429 DAILY_EARN_LIMIT`, `429 CASHIER_DAILY_LIMIT`, `400 INVALID_AMOUNT`, `400 INVALID_STAMPS`, `422 TOO_MANY_STAMPS`, `400 NOTHING_TO_EARN` (neither stamps nor amount provided in a hybrid program).
## POST /redeem
Redeems a reward. **The `Idempotency-Key` header is required.**
**Request body:** `scanId` (uuid) and `rewardId` (uuid, optional). If you scanned the customer's ordinary QR code, `rewardId` is required (`400 REWARD_REQUIRED`). If the customer presented a reward-specific QR code, the reward is embedded in the scan; if you also pass `rewardId`, it must match (`400 REWARD_MISMATCH`).
**Response `201`**
```json
{
"replayed": false,
"entries": [{ "id": "922b40fd-…", "type": "redeem", "points": -100, "stamps": 0 }],
"total": -100,
"stamps": 0,
"balance": 250,
"stampBalance": 3,
"membershipId": "db997e9d-…",
"code": "WZ86BV",
"reward": {
"id": "7d5cb889-…",
"name": "Bedava kahve",
"costPoints": 100,
"costCurrency": "points"
}
}
```
`code` is the 6-character redemption code shown to the customer and cashier.
**Possible errors:** `400 REWARD_REQUIRED`, `400 REWARD_MISMATCH`, `429 CASHIER_DAILY_LIMIT`, `409 INSUFFICIENT_BALANCE`, `409 REWARD_OUT_OF_STOCK`, `409 REWARD_INACTIVE`, `409 REWARD_EXPIRED`, `409 REWARD_NOT_STARTED`, `409 REWARD_LIMIT_REACHED`, `404 REWARD_NOT_FOUND`, `409 SCAN_INVALID`, `409 DUPLICATE_REQUEST`.
## POST /ledger/:id/void
`:id` is the ID of an `earn` or `redeem` ledger entry (`entries[].id`). No request body is needed.
```bash
curl -X POST https://app.mekante.com/api/v1/public/ledger/922b40fd-…/void \
-H "Authorization: Bearer sk_live_EXAMPLE_KEY"
```
**Response `200`**: reverses the original entry by appending a `reverse` ledger entry.
```json
{
"replayed": false,
"entries": [{ "id": "fab26888-…", "type": "reverse", "points": 100, "stamps": 0 }],
"total": 100,
"stamps": 0,
"balance": 350,
"stampBalance": 3,
"membershipId": "db997e9d-…"
}
```
**Possible errors:** `404 ENTRY_NOT_FOUND`, `409 ALREADY_VOIDED`, `409 POINTS_ALREADY_SPENT`, `409 NOT_VOIDABLE`, `403 FORBIDDEN` (another cashier's entry), `403 VOID_WINDOW_PASSED` (older than 15 minutes).
## GET /ledger/recent
Returns an array of the 20 most recent `earn` and `redeem` transactions performed by this API key, newest first.
```json
[
{
"id": "3f99048a-…",
"type": "earn",
"points": 150,
"amount": 15000,
"createdAt": "2026-10-23T09:57:08.246Z",
"voided": false,
"member": "Ayşe Yılmaz"
}
]
```
## GET /ledger/mine
Returns a cursor-paginated list of transactions performed by this API key.
**Query parameters:** `limit` (1–100, default 50), `cursor` (`nextCursor` from the previous page).
```json
{
"items": [
{
"id": "3f99048a-…",
"type": "earn",
"points": 150,
"amount": 15000,
"createdAt": "2026-10-23T09:57:08.246Z",
"voided": false,
"member": "Ayşe Yılmaz"
}
],
"nextCursor": null
}
```
When `nextCursor` is `null`, you have reached the last page.
---
# Errors
Failed requests return a 4xx or 5xx HTTP status code and a JSON error envelope.
```json
{
"error": "Conflict",
"code": "POINTS_ALREADY_SPENT",
"message": "These points were already spent."
}
```
| Field | Description |
| --------- | --------------------------------------------------------------------------------- |
| `error` | HTTP status name (`Bad Request`, `Conflict`, …) |
| `code` | Stable, machine-readable error code. **Branch your application logic on `code`.** |
| `message` | Human-readable English explanation. Wording may change over time; do not parse it |
::: tip Validation errors do not include `code`
Requests with an invalid body, query, or header return `400` without a `code` field. `message` identifies which field failed validation:
```json
{ "error": "Bad Request", "message": "idempotency-key: Idempotency-Key header is required" }
```
:::
## HTTP status codes
| Status | Meaning |
| ------------- | ----------------------------------------------------------------------------------------- |
| `200` / `201` | Success. `201` is a newly created entry; `200` is an idempotent replay (`replayed: true`) |
| `400` | Bad request: missing field, invalid QR token |
| `401` | Missing or invalid API key |
| `402` | Business subscription (expired trial or suspended) blocks till transactions |
| `403` | Forbidden: another cashier's entry, or the 15-minute void window has passed |
| `404` | Resource not found |
| `409` | Conflict: already-used QR, insufficient balance, duplicate receipt or request |
| `413` | Request body exceeds 1 MB (`PAYLOAD_TOO_LARGE`) |
| `422` | Amount or stamp count violates business rules |
| `429` | Rate limit exceeded or daily earn/redeem cap reached |
| `5xx` | Server error on our side. Retry safely with the same `Idempotency-Key` |
## Error codes returned by Till endpoints
### Authentication & QR
| Code | Status | Meaning |
| ---------------------- | ------ | -------------------------------------------------------- |
| `UNAUTHORIZED` | 401 | API key is missing, malformed, or revoked |
| `QR_INVALID` | 400 | QR token is invalid or belongs to another business |
| `QR_EXPIRED` | 400 | 30-second QR token has expired; customer must refresh |
| `QR_ALREADY_USED` | 409 | This QR token was already scanned |
| `SCAN_NOT_FOUND` | 404 | `scanId` was not found; scan the QR again |
| `SCAN_INVALID` | 409 | Scan was already consumed or its 5-minute window expired |
| `MEMBERSHIP_NOT_FOUND` | 404 | Member not found |
| `MEMBERSHIP_BLOCKED` | 403 | Membership is blocked |
### Earning points & stamps
| Code | Status | Meaning |
| --------------------- | ------ | ------------------------------------------------------------------- |
| `INVALID_AMOUNT` | 400 | Purchase amount must be greater than zero |
| `INVALID_STAMPS` | 400 | Stamp count must be at least 1 (or 0 in a hybrid program) |
| `TOO_MANY_STAMPS` | 422 | Stamp count exceeds the per-visit maximum |
| `NOTHING_TO_EARN` | 400 | Neither stamps nor purchase amount was provided in a hybrid program |
| `AMOUNT_TOO_HIGH` | 422 | Purchase amount exceeds the business's per-transaction ceiling |
| `DAILY_EARN_LIMIT` | 429 | Member has reached their daily earn limit |
| `CASHIER_DAILY_LIMIT` | 429 | This API key/cashier has reached its daily earn limit |
| `DUPLICATE_RECEIPT` | 409 | This `receiptNo` has already been recorded |
| `DUPLICATE_REQUEST` | 409 | This `Idempotency-Key` was already used for a different request |
| `NO_PROGRAM` | 409 | Business has not configured a loyalty program yet |
### Redeeming rewards
| Code | Status | Meaning |
| ---------------------- | ------ | ------------------------------------------------------------------ |
| `REWARD_REQUIRED` | 400 | No reward selected (`rewardId` is required for standard QR scans) |
| `REWARD_MISMATCH` | 400 | `rewardId` does not match the reward encoded in the customer's QR |
| `CASHIER_DAILY_LIMIT` | 429 | This cashier or API key has reached its daily redemption limit |
| `INSUFFICIENT_BALANCE` | 409 | Member does not have enough points or stamps |
| `REWARD_NOT_FOUND` | 404 | Reward not found |
| `REWARD_INACTIVE` | 409 | Reward is currently inactive |
| `REWARD_OUT_OF_STOCK` | 409 | Reward is out of stock |
| `REWARD_EXPIRED` | 409 | Reward validity period has ended |
| `REWARD_NOT_STARTED` | 409 | Reward validity period has not started yet |
| `REWARD_LIMIT_REACHED` | 409 | Member has reached the per-member redemption limit for this reward |
### Voiding entries
| Code | Status | Meaning |
| ---------------------- | ------ | --------------------------------------------------------- |
| `ENTRY_NOT_FOUND` | 404 | Ledger entry not found |
| `NOT_VOIDABLE` | 409 | Only `earn` and `redeem` entries can be voided |
| `ALREADY_VOIDED` | 409 | Entry has already been voided |
| `POINTS_ALREADY_SPENT` | 409 | Points earned in this entry have already been spent |
| `FORBIDDEN` | 403 | An API key can only void its own entries |
| `VOID_WINDOW_PASSED` | 403 | 15-minute void window has elapsed; a manager must void it |
### Subscription & rate limits
| Code | Status | Meaning |
| ------------------------ | ------ | -------------------------------------------------------------- |
| `TRIAL_EXPIRED` | 402 | Business's free trial has expired |
| `SUBSCRIPTION_SUSPENDED` | 402 | Business's subscription is suspended |
| `LOCATION_REQUIRED` | 400 | No location specified (does not happen for single-branch keys) |
| `LOCATION_FORBIDDEN` | 403 | Key is not permitted to operate at this location |
| `LOCATION_NOT_FOUND` | 404 | Location was not found or has been archived |
| `RATE_LIMITED` | 429 | Rate limit exceeded |
| `PAYLOAD_TOO_LARGE` | 413 | Request body exceeds 1 MB |
For `402` errors, the business owner must renew their subscription or contact Mekante support; no action can resolve it from the POS side.
---
# Limits
## Rate limit
Each API key can make up to **120 requests per minute**. Exceeding this limit returns `429`:
```json
{ "error": "Too Many Requests", "code": "RATE_LIMITED", "message": "Too many requests. Slow down." }
```
Wait one minute before retrying. There is also a broad per-IP limit of 1,200 requests per minute across all `/api` endpoints, which normal POS traffic will not hit.
## Request body size
A request body can be at most **1 MB**. Larger payloads return `413 PAYLOAD_TOO_LARGE`. Till requests are orders of magnitude smaller than this limit.
## Business program guardrails
The limits below are configured by the business owner on the **Program rules** (_Program kuralları_) page in the dashboard. Default values for a new business are:
| Setting | Default | When exceeded |
| ------------------------------------------- | ------------------------- | -------------------------------------------------- |
| Maximum purchase amount per transaction | 5,000 TL (`500000` kuruş) | `422 AMOUNT_TOO_HIGH` |
| Maximum earns per member per day | 5 earns | `429 DAILY_EARN_LIMIT` |
| Maximum earns per cashier (API key) per day | 500 earns | `429 CASHIER_DAILY_LIMIT` |
| Maximum points per transaction | 1,000 points | Awarded points are capped at this value (no error) |
| Maximum stamps per visit | 10 stamps | `422 TOO_MANY_STAMPS` |
Daily counters reset at midnight Turkey time (`Europe/Istanbul`). You can inspect the active values for your business in the `program` object returned by `GET /me`.
::: tip High-value sale alerts
A business can configure a threshold for high-value sales. Transactions above that threshold are not rejected; they are flagged in the business's fraud & audit reports.
:::
## Subscription status
When a business's trial expires or its subscription is suspended, `POST /scan` returns `402` (`TRIAL_EXPIRED` or `SUBSCRIPTION_SUSPENDED`). Because scanning is blocked, neither earning nor redeeming can proceed until the owner activates their plan or contacts Mekante support.
## QR & scan time windows
| Window | Duration |
| -------------------------------------------- | ---------- |
| Customer QR token lifetime | 30 seconds |
| Time to call `/earn` or `/redeem` after scan | 5 minutes |
| Cashier/API key void window | 15 minutes |
---
# Webhooks
A webhook is an HTTP `POST` request that Mekante sends to your server whenever an event occurs. This lets you sync memberships, earned points, redemptions, and voids into your own systems (ERP, accounting, CRM, custom messaging) without polling.
## Adding an endpoint
Only the business **owner** can manage webhook endpoints: open **Developers** (_Geliştiriciler_) in the dashboard, click **New webhook** (_Yeni webhook_), enter your URL, select the events you want to subscribe to, and click **Add endpoint** (_Adres ekle_).
- The URL must be a publicly reachable **https** address. Local networks, `localhost`, and private IP ranges (`10.x`, `192.168.x`, `172.16–31.x`, loopback, link-local) are rejected both at creation time and before every delivery attempt.
- The URL must not contain inline credentials (`user:pass@`).
- When the endpoint is created, a **signing secret** (`whsec_…`) is generated and shown **only once**. You need it to verify incoming requests ([Signature verification](./imza-dogrulama.md)). If you lose it, delete the endpoint and create a new one.
- Choose **Send test** (_Test gönder_) from the endpoint's row menu to send a `ping` event to that endpoint.
## Events
| Event | When it fires |
| ----------------- | -------------------------------------------------------------------------------------------------- |
| `member.joined` | A customer joins the business's loyalty program |
| `points.earned` | Points or stamps are awarded (`POST /earn`) |
| `reward.redeemed` | A reward is redeemed (`POST /redeem`) |
| `entry.voided` | A ledger entry is voided (`POST /ledger/:id/void`) |
| `ping` | Sent only when you click **Send test** in the dashboard, regardless of the endpoint's event filter |
Each endpoint receives only the events it subscribed to. Events are dispatched **after** the database transaction commits; a slow or failing webhook never blocks till operations.
## Request format
```http
POST /hooks HTTP/1.1
Content-Type: application/json
User-Agent: Mekante-Webhooks/1
X-Mekante-Event: points.earned
X-Mekante-Signature: t=1792749428,v1=794b3947df267c3354af75b5d8931538c765d768e5ca83983dcd52f7c6f93270
```
Every webhook payload shares the same outer envelope:
| Field | Description |
| ----------- | --------------------------------------------------------------- |
| `id` | Unique delivery ID. **Remains identical** across retry attempts |
| `event` | Event name |
| `createdAt` | Timestamp when the event occurred (ISO 8601, UTC) |
| `data` | Event-specific payload (shown below) |
### `member.joined`
```json
{
"id": "564c1467-e98d-434d-8722-9ffe766b1eeb",
"event": "member.joined",
"createdAt": "2026-10-23T09:57:08.193Z",
"data": {
"joinedVia": "join_code",
"membershipId": "db997e9d-3e8c-469b-b385-6d29633f7c6e",
"marketingConsent": false
}
}
```
`joinedVia` indicates how the customer enrolled: `join_code` (scanned a table/counter QR or entered the join code themselves) or `till` (enrolled at the register via SMS invite link sent by a cashier).
### `points.earned`
```json
{
"id": "d537115b-5aeb-47cb-9232-8e3df03070da",
"event": "points.earned",
"createdAt": "2026-10-23T09:57:08.257Z",
"data": {
"membershipId": "db997e9d-3e8c-469b-b385-6d29633f7c6e",
"points": 150,
"stamps": 0,
"balance": 150,
"stampBalance": 0,
"entryIds": ["3f99048a-4226-4482-a341-682fe57225b2"]
}
}
```
`points` is the total points awarded in the transaction (including any bonuses), `stamps` is the total stamps awarded, and `balance` / `stampBalance` are the member's new balances.
### `reward.redeemed`
```json
{
"id": "8b3d35eb-db0c-4a0e-98c8-198e5dddbc8c",
"event": "reward.redeemed",
"createdAt": "2026-10-23T09:57:08.336Z",
"data": {
"membershipId": "db997e9d-3e8c-469b-b385-6d29633f7c6e",
"rewardId": "7d5cb889-f95c-4c59-8e7d-a55a92b6113e",
"code": "WZ86BV",
"points": -100,
"stamps": 0,
"balance": 250,
"stampBalance": 3
}
}
```
### `entry.voided`
`data` fields: `membershipId`, `entryId` (the original entry that was voided), `points` and `stamps` (the delta restored or deducted), and `balance` / `stampBalance` (the member's updated balances).
### `ping`
```json
{
"id": "d6cbcbea-9dfc-45fe-8a44-87c5e7f92ed9",
"event": "ping",
"createdAt": "2026-10-23T09:57:08.349Z",
"data": { "hello": "world" }
}
```
## Responding to webhooks
Your endpoint must acknowledge receipt with any `2xx` HTTP status code (such as `200`). Any other status code, timeout, or connection error is treated as a **failed** delivery.
- **Respond quickly.** Requests time out after **5 seconds**. Enqueue heavy processing asynchronously and return `200` immediately.
- **Redirects are not followed.** A `301` or `302` response is treated as a failure; point the webhook URL directly at the final destination.
## Retries
Failed deliveries are retried with exponential backoff according to this schedule:
| Attempt | Delay after previous attempt |
| ------- | ---------------------------- |
| 2nd | 1 minute |
| 3rd | 5 minutes |
| 4th | 30 minutes |
| 5th | 2 hours |
| 6th | 12 hours |
After the 6th attempt fails, the delivery is marked as **failed** and no further retries are made (roughly 14.5 hours total).
::: warning Handle duplicate deliveries idempotently
Because of retries, your endpoint may receive the same event more than once. The top-level `id` field is identical across all attempts for the same event: record processed `id` values and ignore duplicates.
:::
Delivery order is not guaranteed. Order events by `createdAt` if needed, or rely on the authoritative `balance` and `stampBalance` fields included in every event.
---
# Signature verification
Because your webhook URL is publicly reachable on the internet, you should verify the cryptographic signature on every incoming request to ensure it genuinely came from Mekante.
## How requests are signed
Every webhook request includes an `X-Mekante-Signature` header:
```text
X-Mekante-Signature: t=1792749428,v1=794b3947df267c3354af75b5d8931538c765d768e5ca83983dcd52f7c6f93270
```
- `t`: Unix timestamp in seconds when the request was signed.
- `v1`: lowercase hexadecimal **HMAC-SHA256** digest of:
```text
HMAC_SHA256(signing_secret, ".")
```
`signing_secret` is the `whsec_…` secret shown when you created the webhook endpoint. `raw_body` is the **exact raw byte stream** sent by the server: do not parse and re-serialize the JSON before verifying, or whitespace and key ordering differences will invalidate the signature.
## Verification steps
1. Split the `X-Mekante-Signature` header to extract `t` and `v1`.
2. Check that `t` is within **5 minutes** (300 seconds) of the current clock to prevent replay attacks.
3. Compute `HMAC_SHA256(signing_secret, ".")`.
4. Compare your computed hex digest with `v1` using a **constant-time** comparison function (never plain `===` or `==`).
5. If valid, process the event and return `200`; otherwise return `400`.
## Node.js (Express)
```js
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.MEKANTE_WEBHOOK_SECRET; // whsec_…
const TOLERANCE_SECONDS = 300;
function verify(rawBody, header) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
const v1 = parts.v1;
if (!t || !v1) return false;
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = crypto.createHmac("sha256", SECRET).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
// Raw body is required: do NOT use express.json() on this route.
app.post("/hooks", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString("utf8");
if (!verify(raw, req.get("X-Mekante-Signature") ?? "")) return res.sendStatus(400);
const event = JSON.parse(raw);
// Deduplicate by event.id, then handle event.event.
res.sendStatus(200);
});
```
## Python (Flask)
```python
import hashlib, hmac, os, time
from flask import Flask, request, abort
SECRET = os.environ["MEKANTE_WEBHOOK_SECRET"].encode() # whsec_…
TOLERANCE = 300
app = Flask(__name__)
def verify(raw: bytes, header: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1 or abs(time.time() - int(t)) > TOLERANCE:
return False
expected = hmac.new(SECRET, f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
@app.post("/hooks")
def hooks():
raw = request.get_data() # raw bytes
if not verify(raw, request.headers.get("X-Mekante-Signature", "")):
abort(400)
event = request.get_json()
# Deduplicate by event["id"] before processing.
return "", 200
```
## PHP
```php
## Idempotency-Key: aynı satışı iki kez yazma
Ağ kesilirse isteğin işlenip işlenmediğini bilemezsin. Aynı isteği **aynı** `Idempotency-Key` ile tekrar gönderirsen puan ikinci kez yazılmaz; ilk yanıt `replayed: true` ile döner.
- Anahtarı satış başına bir kez üret ve satışa bağla (örneğin fiş numarasından: `pos-2026-000123`).
- Uzunluğu 8 ile 100 karakter arasında olmalı.
- Aynı anahtarı **başka** bir işlem için kullanırsan `409 DUPLICATE_REQUEST` alırsın.
- Aynı satış için `receiptNo` göndermek ek bir koruma sağlar: aynı fiş numarası ikinci kez yazılmaz (`409 DUPLICATE_RECEIPT`).
```text
İlk istek → 201 Created "replayed": false
Yeniden dene → 200 OK "replayed": true (aynı kayıt, ikinci puan yok)
```
## Yanlış girişi geri alma
`POST /ledger/:id/void` bir puan kazanımını ya da ödül kullanımını geri alır. API anahtarı kasiyer gibi çalıştığı için **yalnızca kendi girişlerini** ve **ilk 15 dakika içinde** iptal edebilir. Kazanılan puanlar bu arada harcandıysa iptal edilemez (`409 POINTS_ALREADY_SPENT`).
---
# Uç noktalar
Tüm adresler `https://app.mekante.com/api/v1/public` ile başlar. Her istekte `Authorization: Bearer sk_live_…` başlığı gerekir ([Kimlik doğrulama](./kimlik-dogrulama.md)). Örneklerdeki kimlikler (`id`) kısaltılmıştır.
| Yöntem | Yol | Ne yapar |
| ------ | ------------------------------------------ | ----------------------------------------------- |
| `GET` | [`/me`](#get-me) | Anahtarın bağlı olduğu işletme, program ve şube |
| `POST` | [`/scan`](#post-scan) | Müşterinin QR kodunu okutur |
| `GET` | [`/scans/:id`](#get-scans-id) | Kullanılabilir bir okutmayı yeniden okur |
| `POST` | [`/earn/preview`](#post-earn-preview) | Yazılacak puanı hesaplar, kaydetmez |
| `POST` | [`/earn`](#post-earn) | Puan yazar |
| `POST` | [`/redeem`](#post-redeem) | Ödül düşer |
| `POST` | [`/ledger/:id/void`](#post-ledger-id-void) | Bir girişi geri alır |
| `GET` | [`/ledger/recent`](#get-ledger-recent) | Son 20 işlem |
| `GET` | [`/ledger/mine`](#get-ledger-mine) | Anahtarın kendi işlemleri, sayfa sayfa |
## GET /me
Anahtarın bağlı olduğu işletmeyi, sadakat programını ve şubeleri döner. Anahtarının çalıştığını denemek için de kullanışlıdır.
```bash
curl https://app.mekante.com/api/v1/public/me \
-H "Authorization: Bearer sk_live_ORNEK_ANAHTAR"
```
`200` yanıtında `role`, `tenant`, `program` ve `locations` alanları bulunur. `program.type` `points` ya da `stamps` olur.
## POST /scan
Müşterinin QR kodunu okutur ve bir `scanId` üretir.
**Gövde**
| Alan | Tür | Açıklama |
| ------------ | ----------------------- | --------------------------------------------------- |
| `token` | metin, 10–1000 karakter | QR kodun içeriği |
| `locationId` | uuid, isteğe bağlı | Şube. Anahtar tek şubeye bağlı olduğu için gerekmez |
```bash
curl -X POST https://app.mekante.com/api/v1/public/scan \
-H "Authorization: Bearer sk_live_ORNEK_ANAHTAR" \
-H "Content-Type: application/json" \
-d '{ "token": "QR_KODUN_ICERIGI" }'
```
**Yanıt `200`**
```json
{
"scanId": "e227e15a-…",
"intent": "earn",
"member": {
"id": "db997e9d-…",
"name": "Ayşe Yılmaz",
"phone": "+90 559 *** ** 39",
"balance": 0,
"stampBalance": 0,
"tier": null
},
"program": { "type": "points", "stampsPerReward": null, "maxStampsPerTransaction": 10 },
"reward": null
}
```
`intent` `earn` (puan ya da damga yazma) ya da `redeem` (ödül düşme) olur. `redeem` ise `reward` alanı `{ id, name, costPoints, costCurrency }` içerir; `costPoints` ödülün bedelidir, `costCurrency` bedelin `points` (puan) ya da `stamps` (damga) olarak ödendiğini söyler. Telefon numarası her zaman maskelenir.
`program.type` işletmenin programını gösterir:
| Tür | Ne yazılır |
| -------- | ---------------------------------------------------------------------------------------------- |
| `points` | Satış tutarından puan. `amount` zorunludur |
| `stamps` | Damga. Tutar sorulmaz; `stamps` ile kaç damga verileceğini yaz (varsayılan 1) |
| `hybrid` | İki ayrı bakiye. `stamps` kadar damga yazılır; `amount` da verirsen o tutardan puan da yazılır |
`rewards` yalnızca müşterinin normal QR kodunda gelir (müşteri bir ödül seçtiyse boştur ve `reward` doludur). Her ödül `{ id, name, type, costPoints, costCurrency, eligible, reason, missing }` içerir: `eligible` ödülün şimdi kullanılıp kullanılamayacağı, `reason` kullanılamıyorsa nedeni (`INSUFFICIENT_BALANCE`, `OUT_OF_STOCK`, `LIMIT_REACHED`, `EXPIRED`, `NOT_STARTED`, `INACTIVE`) ve `missing` yetmeyen puan ya da damga sayısıdır. Liste en ucuz ödülden başlar.
`member.balance` puan bakiyesi, `member.stampBalance` damga bakiyesidir. `program.maxStampsPerTransaction` tek ziyarette verilebilecek en çok damgadır.
**Olası hatalar:** `400 QR_INVALID`, `400 QR_EXPIRED`, `409 QR_ALREADY_USED`, `404 MEMBERSHIP_NOT_FOUND`, `402 TRIAL_EXPIRED`, `402 SUBSCRIPTION_SUSPENDED`.
## GET /scans/:id
Henüz kullanılmamış ve süresi dolmamış bir okutmayı, `POST /scan` ile aynı gövdeyle döner. Ekran yenilense bile tutar giriş ekranına dönebilmek için kullan. Kullanılmış ya da süresi dolmuşsa `409 SCAN_INVALID`.
## POST /earn/preview
Bir ziyaretin kaç puan ve damga yazacağını hesaplar. Hiçbir şey kaydetmez ve `Idempotency-Key` gerektirmez.
**Gövde:** `scanId` (uuid); `amount` (kuruş, pozitif tam sayı; `points` programında zorunlu, `hybrid` programında isteğe bağlı, `stamps` programında yok sayılır); `stamps` (tam sayı; `stamps` ve `hybrid` programlarında, varsayılan 1).
```json
{ "scanId": "e227e15a-…", "amount": 15000 }
```
**Yanıt `200`**
```json
{
"points": 150,
"stamps": 0,
"multiplier": 1,
"multiplierCampaignId": null,
"bonuses": [],
"total": 150,
"stampsTotal": 0,
"expiresAt": "2027-10-23T09:57:08.237Z",
"balanceAfter": 150,
"stampBalanceAfter": 0
}
```
`multiplier` seviye ya da kampanya çarpanıdır (çarpanlar birikmez, en yükseği uygulanır); yalnızca puanlara uygulanır. `total` bu ziyarette yazılacak toplam puandır, `stampsTotal` toplam damgadır (bonuslar dahil: damga programında hoş geldin ve doğum günü bonusları damga olarak, diğerlerinde puan olarak yazılır). `expiresAt` bu puan ve damgaların son kullanma tarihidir.
## POST /earn
Puan ve/veya damga yazar. **`Idempotency-Key` başlığı zorunludur** ([Kasa akışı](./kasa-akisi.md#idempotency-key)).
**Gövde**
| Alan | Tür | Açıklama |
| ----------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `scanId` | uuid | `POST /scan` yanıtından |
| `amount` | tam sayı, kuruş | Satış tutarı. `points` programında zorunlu, `hybrid` programında isteğe bağlı (verilirse puan yazılır), `stamps` programında yok sayılır |
| `stamps` | tam sayı, isteğe bağlı | Verilecek damga sayısı (`stamps` ve `hybrid` programları, varsayılan 1, işletmenin üst sınırına kadar). Hibritte `0` yazıp yalnızca puan verebilirsin |
| `receiptNo` | metin, 1–40, isteğe bağlı | Fiş numarası. Aynı numara ikinci kez yazılamaz |
```bash
curl -X POST https://app.mekante.com/api/v1/public/earn \
-H "Authorization: Bearer sk_live_ORNEK_ANAHTAR" \
-H "Idempotency-Key: pos-2026-000123" \
-H "Content-Type: application/json" \
-d '{ "scanId": "e227e15a-…", "amount": 15000, "receiptNo": "A-1042" }'
```
**Yanıt `201`** (tekrar gönderimde `200` ve `"replayed": true`)
```json
{
"replayed": false,
"entries": [{ "id": "3f99048a-…", "type": "earn", "points": 150, "stamps": 0 }],
"total": 150,
"stamps": 0,
"balance": 150,
"stampBalance": 0,
"membershipId": "db997e9d-…"
}
```
`entries[].id` geri alma için kullanacağın kayıt kimliğidir. `total` ve `stamps` bu işlemde yazılan puan ve damgadır, `balance` ve `stampBalance` işlemden sonraki bakiyelerdir. Damga programında `total` `0` olur; damga sayısını `stamps` alanından oku.
**Olası hatalar:** `400` (başlık ya da gövde eksik), `409 SCAN_INVALID`, `404 SCAN_NOT_FOUND`, `409 DUPLICATE_RECEIPT`, `409 DUPLICATE_REQUEST`, `422 AMOUNT_TOO_HIGH`, `429 DAILY_EARN_LIMIT`, `429 CASHIER_DAILY_LIMIT`, `400 INVALID_AMOUNT`, `400 INVALID_STAMPS`, `422 TOO_MANY_STAMPS`, `400 NOTHING_TO_EARN` (hibritte ne damga ne tutar var).
## POST /redeem
Ödül düşer. `Idempotency-Key` zorunludur.
**Gövde:** `scanId` (uuid) ve `rewardId` (uuid, isteğe bağlı). Müşterinin normal QR kodunu okuttuysan kasiyerin seçtiği `rewardId` zorunludur (`400 REWARD_REQUIRED`). Müşteri bir ödül için QR üretmişse ödül QR'dadır; `rewardId` gönderirsen aynı olmalıdır (`400 REWARD_MISMATCH`).
**Yanıt `201`**
```json
{
"replayed": false,
"entries": [{ "id": "922b40fd-…", "type": "redeem", "points": -100, "stamps": 0 }],
"total": -100,
"stamps": 0,
"balance": 250,
"stampBalance": 3,
"membershipId": "db997e9d-…",
"code": "WZ86BV",
"reward": {
"id": "7d5cb889-…",
"name": "Bedava kahve",
"costPoints": 100,
"costCurrency": "points"
}
}
```
`code` müşteriye gösterilecek ya da kasa ekranında doğrulanacak 6 karakterlik ödül kodudur.
**Olası hatalar:** `400 REWARD_REQUIRED`, `400 REWARD_MISMATCH`, `429 CASHIER_DAILY_LIMIT`, `409 INSUFFICIENT_BALANCE`, `409 REWARD_OUT_OF_STOCK`, `409 REWARD_INACTIVE`, `409 REWARD_EXPIRED`, `409 REWARD_NOT_STARTED`, `409 REWARD_LIMIT_REACHED`, `404 REWARD_NOT_FOUND`, `409 SCAN_INVALID`, `409 DUPLICATE_REQUEST`.
## POST /ledger/:id/void
`:id` bir `earn` ya da `redeem` kaydının kimliğidir (`entries[].id`). Gövde gerekmez.
```bash
curl -X POST https://app.mekante.com/api/v1/public/ledger/922b40fd-…/void \
-H "Authorization: Bearer sk_live_ORNEK_ANAHTAR"
```
**Yanıt `200`**: kayıt bir `reverse` girişiyle geri alınır.
```json
{
"replayed": false,
"entries": [{ "id": "fab26888-…", "type": "reverse", "points": 100, "stamps": 0 }],
"total": 100,
"stamps": 0,
"balance": 350,
"stampBalance": 3,
"membershipId": "db997e9d-…"
}
```
**Olası hatalar:** `404 ENTRY_NOT_FOUND`, `409 ALREADY_VOIDED`, `409 POINTS_ALREADY_SPENT`, `409 NOT_VOIDABLE`, `403 FORBIDDEN` (başkasının girişi), `403 VOID_WINDOW_PASSED` (15 dakika geçti).
## GET /ledger/recent
Anahtarın yaptığı son 20 puan kazanımı ve ödül kullanımını, en yeniden eskiye doğru, bir dizi olarak döner.
```json
[
{
"id": "3f99048a-…",
"type": "earn",
"points": 150,
"amount": 15000,
"createdAt": "2026-10-23T09:57:08.246Z",
"voided": false,
"member": "Ayşe Yılmaz"
}
]
```
## GET /ledger/mine
Anahtarın kendi yaptığı işlemleri sayfa sayfa döner.
**Sorgu:** `limit` (1–100, varsayılan 50), `cursor` (önceki yanıttaki `nextCursor`).
```json
{
"items": [
{
"id": "3f99048a-…",
"type": "earn",
"points": 150,
"amount": 15000,
"createdAt": "2026-10-23T09:57:08.246Z",
"voided": false,
"member": "Ayşe Yılmaz"
}
],
"nextCursor": null
}
```
`nextCursor` boş (`null`) olduğunda son sayfadasın.
---
# Hatalar
Başarısız bir istek, 4xx ya da 5xx HTTP durumuyla ve JSON bir gövdeyle döner.
```json
{
"error": "Conflict",
"code": "POINTS_ALREADY_SPENT",
"message": "These points were already spent."
}
```
| Alan | Açıklama |
| --------- | ------------------------------------------------------------------------------- |
| `error` | HTTP durumunun adı (`Bad Request`, `Conflict`, …) |
| `code` | Kararlı, makine tarafından okunabilir hata kodu. **Kodunu `code` üzerine kur.** |
| `message` | İngilizce, insanın okuyacağı açıklama. Metni değişebilir, ona güvenme |
::: tip Doğrulama hatalarında `code` yoktur
Gövdesi ya da başlığı geçersiz bir istek `400` döner ve `code` içermez. `message` hangi alanın yanlış olduğunu söyler:
```json
{ "error": "Bad Request", "message": "idempotency-key: Idempotency-Key header is required" }
```
:::
## HTTP durumları
| Durum | Anlamı |
| ------------- | -------------------------------------------------------------------------------- |
| `200` / `201` | Başarılı. `201` yeni kayıt, `200` yinelenen isteğin (`replayed: true`) yanıtıdır |
| `400` | İstek hatalı: eksik alan, geçersiz QR |
| `401` | API anahtarı yok ya da geçersiz |
| `402` | İşletmenin aboneliği (deneme bitti ya da askıda) okutmayı engelliyor |
| `403` | Yetkin yok: başkasının girişi, süresi geçmiş iptal |
| `404` | Kayıt bulunamadı |
| `409` | Çakışma: kullanılmış QR, yetersiz bakiye, tekrarlanan istek |
| `413` | İstek gövdesi 1 MB'tan büyük (`PAYLOAD_TOO_LARGE`) |
| `422` | Tutar kurallara uymuyor |
| `429` | Çok fazla istek ya da günlük sınır |
| `5xx` | Bizde bir sorun var. Aynı `Idempotency-Key` ile tekrar dene |
## Kasa isteklerinde karşılaşabileceğin kodlar
### Kimlik ve QR
| Kod | Durum | Anlamı |
| ---------------------- | ----- | ------------------------------------------------------ |
| `UNAUTHORIZED` | 401 | Anahtar eksik, hatalı ya da iptal edilmiş |
| `QR_INVALID` | 400 | QR geçerli değil ya da başka bir işletmeye ait |
| `QR_EXPIRED` | 400 | QR'ın 30 saniyelik ömrü doldu; müşteri yenilemeli |
| `QR_ALREADY_USED` | 409 | Bu QR daha önce okutuldu |
| `SCAN_NOT_FOUND` | 404 | `scanId` bulunamadı; QR'ı yeniden okut |
| `SCAN_INVALID` | 409 | Okutma kullanıldı ya da 5 dakikası doldu; yeniden okut |
| `MEMBERSHIP_NOT_FOUND` | 404 | Üye bulunamadı |
| `MEMBERSHIP_BLOCKED` | 403 | Üyelik engellenmiş |
### Puan yazma
| Kod | Durum | Anlamı |
| --------------------- | ----- | ---------------------------------------------------------- |
| `INVALID_AMOUNT` | 400 | Tutar sıfırdan büyük olmalı |
| `INVALID_STAMPS` | 400 | Damga sayısı en az 1 olmalı (hibritte 0 olabilir) |
| `TOO_MANY_STAMPS` | 422 | Damga sayısı tek ziyarette verilebilecek üst sınırı aşıyor |
| `NOTHING_TO_EARN` | 400 | Hibrit programda ne damga ne tutar var |
| `AMOUNT_TOO_HIGH` | 422 | Tutar işletmenin izin verdiği üst sınırı aşıyor |
| `DAILY_EARN_LIMIT` | 429 | Bu müşteri bugünkü kazanım sınırına ulaştı |
| `CASHIER_DAILY_LIMIT` | 429 | Bu anahtar bugünkü kazanım sınırına ulaştı |
| `DUPLICATE_RECEIPT` | 409 | Bu `receiptNo` zaten kullanıldı |
| `DUPLICATE_REQUEST` | 409 | Bu `Idempotency-Key` başka bir işlem için kullanılmış |
| `NO_PROGRAM` | 409 | İşletmenin henüz sadakat programı yok |
### Ödül düşme
| Kod | Durum | Anlamı |
| ---------------------- | ----- | -------------------------------------------------------------- |
| `REWARD_REQUIRED` | 400 | Kullandırılacak ödül seçilmemiş (normal QR'da `rewardId` şart) |
| `REWARD_MISMATCH` | 400 | `rewardId`, QR'daki ödülle aynı değil |
| `CASHIER_DAILY_LIMIT` | 429 | Bu kasiyer ya da anahtar bugünkü sınıra ulaştı |
| `INSUFFICIENT_BALANCE` | 409 | Yeterli puan ya da damga yok |
| `REWARD_NOT_FOUND` | 404 | Ödül bulunamadı |
| `REWARD_INACTIVE` | 409 | Ödül kapalı |
| `REWARD_OUT_OF_STOCK` | 409 | Ödülün stoğu bitti |
| `REWARD_EXPIRED` | 409 | Ödülün süresi doldu |
| `REWARD_NOT_STARTED` | 409 | Ödül henüz başlamadı |
| `REWARD_LIMIT_REACHED` | 409 | Müşteri bu ödül için sınıra ulaştı |
### Geri alma
| Kod | Durum | Anlamı |
| ---------------------- | ----- | --------------------------------------------------------- |
| `ENTRY_NOT_FOUND` | 404 | Kayıt bulunamadı |
| `NOT_VOIDABLE` | 409 | Yalnızca puan kazanımı ve ödül kullanımı iptal edilebilir |
| `ALREADY_VOIDED` | 409 | Kayıt zaten iptal edilmiş |
| `POINTS_ALREADY_SPENT` | 409 | Kazanılan puanlar harcanmış |
| `FORBIDDEN` | 403 | Yalnızca kendi girişini iptal edebilirsin |
| `VOID_WINDOW_PASSED` | 403 | 15 dakika geçti; yalnızca yönetici iptal edebilir |
### Abonelik ve limitler
| Kod | Durum | Anlamı |
| ------------------------ | ----- | ----------------------------------------------------- |
| `TRIAL_EXPIRED` | 402 | Deneme süresi doldu |
| `SUBSCRIPTION_SUSPENDED` | 402 | Abonelik askıda |
| `LOCATION_REQUIRED` | 400 | Şube belirtilmedi (anahtar tek şubeye bağlıysa olmaz) |
| `LOCATION_FORBIDDEN` | 403 | Bu şubeyi kullanamazsın |
| `LOCATION_NOT_FOUND` | 404 | Şube bulunamadı ya da kapatılmış |
| `RATE_LIMITED` | 429 | İstek sınırını aştın |
| `PAYLOAD_TOO_LARGE` | 413 | İstek gövdesi çok büyük |
İşletme sahibi `402` hatalarında Mekante destek ekibiyle iletişime geçmelidir; kasa tarafında yapılacak bir şey yoktur.
---
# Limitler
## İstek sınırı
Her API anahtarı **dakikada 120 istek** yapabilir. Aşarsan `429` alırsın:
```json
{ "error": "Too Many Requests", "code": "RATE_LIMITED", "message": "Too many requests. Slow down." }
```
Bir dakika bekle ve tekrar dene. Bunun yanında tüm `/api` istekleri için IP başına dakikada 1200 istek gibi geniş bir üst sınır da vardır; normal bir kasa yazılımı buna takılmaz.
## İstek gövdesi
Bir istek en fazla **1 MB** olabilir. Daha büyüğü `413 PAYLOAD_TOO_LARGE` döner. Kasa istekleri bunun çok altındadır.
## İşletmenin program ayarları
Aşağıdakiler işletme sahibinin **Program kuralları** sayfasında belirlediği sınırlardır. Aşıldığında ilgili hata dönülür. Varsayılan değerler yeni bir işletmede şöyledir:
| Ayar | Varsayılan | Aşılırsa |
| ------------------------------------ | ------------------------- | -------------------------------------------------- |
| Tek satışta en yüksek tutar | 5.000 TL (`500000` kuruş) | `422 AMOUNT_TOO_HIGH` |
| Bir müşteri günde en fazla | 5 puan kazanımı | `429 DAILY_EARN_LIMIT` |
| Bir kasiyer (anahtar) günde en fazla | 500 puan kazanımı | `429 CASHIER_DAILY_LIMIT` |
| Tek işlemde en çok puan | 1000 | yazılacak puan bu değerle sınırlanır (hata dönmez) |
| Tek ziyarette en çok damga | 10 | `422 TOO_MANY_STAMPS` |
Günlük sayımlar Türkiye saatiyle gece yarısı sıfırlanır. Bu değerleri kendi işletmenin ayarlarından `GET /me` yanıtındaki `program` alanında görebilirsin.
::: tip Büyük satış uyarısı
İşletme, belirli bir tutarın üstündeki satışlar için uyarı tanımlayabilir. Bu satışlar reddedilmez; yalnızca işletmenin raporlarında işaretlenir.
:::
## Abonelik
Deneme süresi dolan ya da askıya alınan işletmelerde `POST /scan` `402` döner (`TRIAL_EXPIRED`, `SUBSCRIPTION_SUSPENDED`). Okutma yapılamadığı için puan yazma ve ödül düşme de başlamaz. Sahibinin Mekante destek ekibiyle iletişime geçmesi gerekir.
## QR ve okutma süreleri
| Ne | Süre |
| -------------------------------------- | --------- |
| QR kodun ömrü | 30 saniye |
| Okutma sonrası puan yazma / ödül düşme | 5 dakika |
| Girişi geri alma (anahtar için) | 15 dakika |
---
# Webhook'lar
Webhook, bir şey olduğunda Mekante'nin senin sunucuna yaptığı `POST` isteğidir. Böylece sürekli sorgulamak zorunda kalmadan üyelik, puan ve ödül hareketlerini kendi sisteminde (muhasebe, CRM, SMS…) yakalayabilirsin.
## Uç nokta ekleme
Webhook adreslerini yalnızca işletmenin **sahibi** ekler: panelde **Geliştiriciler** sayfasında **Yeni webhook**'a bas, adresi gir, hangi olayları almak istediğini seç ve **Adres ekle**'ye bas.
- Adres herkese açık bir **https** adresi olmalıdır. Yerel ağ, `localhost` ve özel IP aralıkları (`10.x`, `192.168.x`, `172.16–31.x`…) hem eklerken hem göndermede reddedilir.
- Adreste kullanıcı adı ve parola olmamalıdır.
- Uç nokta oluşturulurken bir **imza anahtarı** (`whsec_…`) üretilir ve **yalnızca o anda bir kez** gösterilir. Gelen isteği doğrulamak için gerekir ([İmza doğrulama](./imza-dogrulama.md)). Kaybedersen uç noktayı (**Sil**) silip yenisini ekle.
- Uç noktanın satırındaki menüden **Test gönder**'i seçersen uç noktana bir `ping` olayı gider.
## Olaylar
| Olay | Ne zaman gönderilir |
| ----------------- | ------------------------------------------------------------------------------------------------- |
| `member.joined` | Bir müşteri işletmeye üye olduğunda |
| `points.earned` | Puan yazıldığında (`POST /earn`) |
| `reward.redeemed` | Ödül düşüldüğünde (`POST /redeem`) |
| `entry.voided` | Bir giriş geri alındığında (`POST /ledger/:id/void`) |
| `ping` | Yalnızca **Test gönder** ile. Hangi olaylara abone olduğundan bağımsız, sadece o uç noktaya gider |
Her uç nokta yalnızca abone olduğu olayları alır. Olaylar işlem kaydedildikten **sonra** gönderilir; webhook göndermede bir sorun olsa bile puan yazma işlemi etkilenmez.
## İstek biçimi
```http
POST /hooks HTTP/1.1
Content-Type: application/json
User-Agent: Mekante-Webhooks/1
X-Mekante-Event: points.earned
X-Mekante-Signature: t=1792749428,v1=794b3947df267c3354af75b5d8931538c765d768e5ca83983dcd52f7c6f93270
```
Gövde her olayda aynı zarfı kullanır:
| Alan | Açıklama |
| ----------- | ------------------------------------------------------- |
| `id` | Bu gönderimin kimliği. Yeniden denemelerde **değişmez** |
| `event` | Olay adı |
| `createdAt` | Olayın oluştuğu an (ISO 8601, UTC) |
| `data` | Olaya özel veri (aşağıda) |
### `member.joined`
```json
{
"id": "564c1467-e98d-434d-8722-9ffe766b1eeb",
"event": "member.joined",
"createdAt": "2026-10-23T09:57:08.193Z",
"data": {
"joinedVia": "join_code",
"membershipId": "db997e9d-3e8c-469b-b385-6d29633f7c6e",
"marketingConsent": false
}
}
```
`joinedVia` müşterinin nasıl katıldığını söyler: `join_code` (QR ya da kod ile kendisi) ya da `till` (kasada, kasiyer SMS bağlantısı gönderdi).
### `points.earned`
```json
{
"id": "d537115b-5aeb-47cb-9232-8e3df03070da",
"event": "points.earned",
"createdAt": "2026-10-23T09:57:08.257Z",
"data": {
"membershipId": "db997e9d-3e8c-469b-b385-6d29633f7c6e",
"points": 150,
"stamps": 0,
"balance": 150,
"stampBalance": 0,
"entryIds": ["3f99048a-4226-4482-a341-682fe57225b2"]
}
}
```
`points` bu işlemde yazılan toplam puandır (bonuslar dahil), `stamps` yazılan toplam damgadır, `balance` ve `stampBalance` yeni bakiyelerdir.
### `reward.redeemed`
```json
{
"id": "8b3d35eb-db0c-4a0e-98c8-198e5dddbc8c",
"event": "reward.redeemed",
"createdAt": "2026-10-23T09:57:08.336Z",
"data": {
"membershipId": "db997e9d-3e8c-469b-b385-6d29633f7c6e",
"rewardId": "7d5cb889-f95c-4c59-8e7d-a55a92b6113e",
"code": "WZ86BV",
"points": -100,
"stamps": 0,
"balance": 250,
"stampBalance": 3
}
}
```
### `entry.voided`
`data` alanları: `membershipId`, `entryId` (geri alınan kayıt), `points` ve `stamps` (geri verilen ya da düşülen puan ve damga), `balance` ve `stampBalance` (yeni bakiyeler).
### `ping`
```json
{
"id": "d6cbcbea-9dfc-45fe-8a44-87c5e7f92ed9",
"event": "ping",
"createdAt": "2026-10-23T09:57:08.349Z",
"data": { "hello": "world" }
}
```
## Yanıt vermen gerekenler
Uç noktan isteği aldığını `2xx` bir durumla (örneğin `200`) bildirmelidir. Başka bir durum, zaman aşımı ya da bağlantı hatası **başarısız** sayılır.
- **Hızlı yanıt ver.** İstek **5 saniye** içinde yanıtlanmazsa zaman aşımına uğrar. Ağır işi kuyruğa at, önce `200` dön.
- **Yönlendirmeler izlenmez.** `301`/`302` yanıtı başarısızdır; adresi doğrudan son adrese ayarla.
## Yeniden deneme
Başarısız bir gönderim şu aralıklarla yeniden denenir:
| Deneme | Önceki denemeden sonra |
| ------ | ---------------------- |
| 2. | 1 dakika |
| 3. | 5 dakika |
| 4. | 30 dakika |
| 5. | 2 saat |
| 6. | 12 saat |
Altıncı denemeden sonra gönderim **başarısız** olarak işaretlenir ve vazgeçilir (toplam yaklaşık 14,5 saat).
::: warning Aynı olay iki kez gelebilir
Yeniden deneme yüzünden aynı olayı birden fazla alabilirsin. Gövdedeki `id` her denemede aynıdır: işlediğin `id`'leri sakla ve tekrar geleni yok say.
:::
Sıra garantisi yoktur. Olayları `createdAt`'e göre sırala; bakiye için en güvenilir değer her olaydaki `balance` alanıdır.
---
# İmza doğrulama
Webhook adresin herkese açıktır; birinin sahte bir istek göndermesini engellemek için her isteği **imzayla** doğrula.
## Nasıl imzalanır
Her istekte `X-Mekante-Signature` başlığı bulunur:
```text
X-Mekante-Signature: t=1792749428,v1=794b3947df267c3354af75b5d8931538c765d768e5ca83983dcd52f7c6f93270
```
- `t`: isteğin imzalandığı an, saniye cinsinden Unix zamanı.
- `v1`: şu metnin **HMAC-SHA256** özeti (küçük harfli onaltılık):
```text
HMAC_SHA256(imza_anahtarın, ".")
```
`imza_anahtarın`, uç noktayı eklerken gösterilen `whsec_…` değeridir. Gövde, sunucunun gönderdiği **ham metindir**: JSON'u çözüp yeniden oluşturma, boşluklar ve alan sırası değişir ve imza tutmaz.
## Doğrulama adımları
1. Başlıktan `t` ve `v1` değerlerini al.
2. `t` değerinin şimdiki zamandan en fazla **5 dakika** farklı olduğunu kontrol et (eski bir isteğin yeniden gönderilmesini engeller).
3. `"."` metninin HMAC-SHA256'sını kendi anahtarınla hesapla.
4. Sonucu `v1` ile **sabit zamanlı** karşılaştır (`===` kullanma).
5. Tutuyorsa isteği işle ve `200` dön; tutmuyorsa `400` dön.
## Node.js (Express)
```js
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.MEKANTE_WEBHOOK_SECRET; // whsec_…
const TOLERANCE_SECONDS = 300;
function verify(rawBody, header) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
const v1 = parts.v1;
if (!t || !v1) return false;
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = crypto.createHmac("sha256", SECRET).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
// Ham gövde gerekli: express.json() KULLANMA.
app.post("/hooks", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString("utf8");
if (!verify(raw, req.get("X-Mekante-Signature") ?? "")) return res.sendStatus(400);
const event = JSON.parse(raw);
// event.id'yi daha önce işlediysen yok say, sonra event.event'e göre işle.
res.sendStatus(200);
});
```
## Python (Flask)
```python
import hashlib, hmac, os, time
from flask import Flask, request, abort
SECRET = os.environ["MEKANTE_WEBHOOK_SECRET"].encode() # whsec_…
TOLERANCE = 300
app = Flask(__name__)
def verify(raw: bytes, header: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1 or abs(time.time() - int(t)) > TOLERANCE:
return False
expected = hmac.new(SECRET, f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
@app.post("/hooks")
def hooks():
raw = request.get_data() # ham gövde
if not verify(raw, request.headers.get("X-Mekante-Signature", "")):
abort(400)
event = request.get_json()
# event["id"]'yi daha önce işlediysen yok say.
return "", 200
```
## PHP
```php