Version v1 · Base URL:
https://indik.app/api/v1· Format: JSON · Message language: the store's (or theAccept-Languageone)
A REST API that lets the merchant's ERP or POS run the referral program from inside its own system: validate, reserve and redeem coupons, sign up customers and read the metrics shown on the dashboard.
Formal reference (importable into Postman/Insomnia): openapi.yaml.
This guide is a translation of the Portuguese original. If the two ever disagree, the Portuguese version prevails.
Context
Without the API, validating a coupon means the cashier has to open /painel/validar on a
second screen, next to the register. With it, the whole flow fits inside the ERP.
An important architectural point: the API and the dashboard share the same business-rule
code (CouponRedemptionService, MetricsService, ReferralLinker). A coupon redeemed through
the API has exactly the same effects as one redeemed on the dashboard — including confirming the
referral and issuing the referrer's reward coupon.
1. Getting a key
On the dashboard, the store owner goes to Account → Integrations (/painel/integracoes),
clicks New key, gives it a name (e.g. POS Downtown Store) and picks the permissions.
⚠️ The key is shown only once
The server stores only the SHA-256 hash of the secret. If you lose the key there is no way to recover it — you can only generate a new one and revoke the old one.
Create one key per integration, with the smallest set of permissions it needs. That way, if a system is decommissioned or its credential is exposed, you revoke just that key without taking the others down.
When creating it you can also set two optional restrictions:
| Restriction | What it does |
|---|---|
| Expiration | The key stops working on the chosen date (30, 90, 180 days or 1 year). It limits the damage of a leak that goes unnoticed. Without an expiration, it works until someone revokes it. |
| Allowed addresses | The key is only accepted from the IPs or CIDR ranges you enter. Ask whoever runs your system for its outbound address. Calls from anywhere else get 403 ip_not_allowed. |
On the same screen you can also follow the store's recent calls — route, result, key used and origin — to check that the integration is behaving as expected.
2. Authentication
Every request carries the token in the Authorization header:
Authorization: Bearer indk_live_8c17d1238ad36c3f.5ZqL1HQvNkonJ3UM9pIWxj4tXrZNyEn7zcjcsXMI
The token has two parts separated by a dot: the prefix (public, shown in the dashboard's key list so you can tell which key is which) and the secret.
Test the credential before anything else:
curl https://indik.app/api/v1/ping \
-H "Authorization: Bearer $INDIK_TOKEN"
{
"data": {
"tenant": { "slug": "my-store", "name": "My Store", "status": "active" },
"api_key": { "name": "POS Downtown Store", "prefix": "indk_live_8c17d1238ad36c3f", "scopes": ["coupons.read", "coupons.write"] },
"server_time": "2026-08-13T19:36:44+00:00"
}
}
Scopes
Each key carries a list of permissions. Grant only what the ERP actually uses.
| Scope | Allows |
|---|---|
coupons.read |
Look up coupons and a customer's coupons |
coupons.write |
Reserve, redeem and release coupons |
customers.read |
List and look up customers |
customers.write |
Sign up customers |
referrals.read |
Look up referrals |
campaigns.read |
Look up campaigns and stores |
metrics.read |
Read metrics |
* |
All of the scopes above |
3. Response envelope
Success — always inside data; paginated lists also include meta:
{ "data": { "...": "..." } }
{ "data": [ ... ], "meta": { "page": 1, "per_page": 25, "total": 87, "last_page": 4 } }
Error — always inside error:
{
"error": {
"code": "coupon_already_redeemed",
"message": "This coupon has already been used.",
"details": {}
}
}
codeis the stable contract — write your conditions against it.messageis ready-to-display text for the cashier and may be reworded at any time without notice. It comes in the language of the store that owns the key (English, Spanish or Portuguese); to get another one, send theAccept-Languageheader (en,esorpt-BR).
Error codes
code |
HTTP | When it happens |
|---|---|---|
unauthenticated |
401 | Authorization header missing or malformed |
invalid_api_key |
401 | Key doesn't exist, was revoked, has expired or has the wrong secret |
subscription_inactive |
402 | The store's subscription was canceled or is overdue past the grace period |
insufficient_scope |
403 | The key doesn't have the scope the endpoint requires |
ip_not_allowed |
403 | The key has allowed addresses and the call came from another one |
not_found |
404 | Resource doesn't exist (or belongs to another store) |
coupon_not_found |
404 | No coupon with that code in this store |
coupon_already_redeemed |
409 | Coupon already used |
coupon_expired |
409 | Coupon past its expiration date or marked as expired |
coupon_unavailable |
409 | Coupon is in a state that doesn't allow the operation |
coupon_not_reserved |
409 | Tried to release a coupon that wasn't reserved |
below_min_purchase |
422 | Sale amount below the campaign's minimum purchase |
validation_failed |
422 | Invalid body/parameters (see details) |
idempotency_conflict |
409 | Same Idempotency-Key resent with a different body |
idempotency_in_progress |
409 | A request with that Idempotency-Key is still running |
rate_limited |
429 | Rate limit exceeded |
method_not_allowed |
405 | Wrong HTTP verb for the route |
server_error |
500 | Internal failure — safe to retry |
On validation_failed, details lists the fields and their messages:
{
"error": {
"code": "validation_failed",
"message": "The data sent is invalid.",
"details": { "accepted_terms": ["The customer's acceptance of the program terms must be recorded."] }
}
}
4. Limits and idempotency
Rate limiting
| Limit | Default | Counted per |
|---|---|---|
| General | 120 requests/minute | API key |
Coupon and sign-up mutations (POST) |
30 requests/minute | API key |
| Rejected authentication attempts | 10 every 5 minutes | IP address |
The first two limits are per key, not per IP — several registers in the same store go out
through the same address and must not eat into each other's quota. When you exceed them, the
response is 429 with the Retry-After and X-RateLimit-* headers.
The third one protects against credential guessing and is counted per IP, because a rejected request has no key to count against. A successful authentication resets the counter, so getting the token wrong once and fixing it doesn't leave the register locked out.
⚠️ Treat
429as a wait, not an errorHonor
Retry-Afterbefore retrying. An immediate retry loop only pushes the window forward and keeps the integration stalled for longer.
Idempotency
Every POST accepts the optional (but strongly recommended) Idempotency-Key header:
Idempotency-Key: sale-2026-08-13-000123
- Resending the same key with the same body returns the original response, with the
Idempotency-Replayed: trueheader. The coupon is not redeemed twice. - Resending the same key with a different body returns
409 idempotency_conflict. - The replay window is 24 hours.
Use an identifier of the sale itself in the ERP (receipt number, order ID). That turns your network layer's automatic retry into a safe operation.
⚠️ Concurrency
Every mutation runs in a transaction with a row lock. Two simultaneous calls to redeem the same coupon never result in two redemptions — the second one gets
coupon_already_redeemed.
5. Recommended checkout flow
┌──────────────┐
customer │ available │◄──────────────┐
shows └──────┬───────┘ │
the coupon │ │ POST /release
│ POST /reserve │ (sale canceled)
│ ▼ │
GET /coupons/{code} ┌──────────────┐───────────┘
(validate, no │ reserved │
side effects) └──────┬───────┘
│ POST /redeem { "sale_value": 150.00 }
▼
┌──────────────┐
│ redeemed │ ← final state
└──────────────┘
Two steps (recommended for POS): reserve when the sale starts, redeem when it closes, release if the customer gives up. This keeps the same coupon from being used at two registers at the same time.
One step: call POST /redeem directly on an available coupon — the reservation happens
implicitly. Good for simple or post-sale integrations.
⚠️ Always send
sale_valueFor percentage coupons, the sale amount determines the actual discount (
prize_value = sale_value × value / 100). Without it, the coupon is redeemed but the average ticket, campaign cost and CAC metrics have nothing to be calculated from.
6. Endpoint reference
GET /ping
Checks connectivity and the credential. No scope required. See the example in section 2.
GET /coupons/{code} — look up a coupon
Scope: coupons.read. No side effects: this is the register's "validate". The code is
case-insensitive.
curl https://indik.app/api/v1/coupons/ABC12345 \
-H "Authorization: Bearer $INDIK_TOKEN"
{
"data": {
"code": "ABC12345",
"status": "available",
"type": "percent",
"origin": "welcome",
"value": 10,
"value_label": "10% OFF",
"product": null,
"sale_value": null,
"prize_value": null,
"campaign_id": 1,
"customer_id": 26,
"referral_id": 23,
"store_id": null,
"seller_id": 6,
"min_purchase": 50,
"customer": { "id": 26, "name": "Anna", "phone": "+14155550102", "email": null },
"expires_at": "2026-09-02T14:36:50+00:00",
"reserved_at": null,
"redeemed_at": null,
"created_at": "2026-08-03T14:36:50+00:00"
}
}
| Field | Notes |
|---|---|
status |
available · reserved · redeemed · expired |
type |
percent · fixed · cashback · product |
origin |
welcome (welcome coupon) · referral (referrer's reward) · milestone (milestone reached) · manual |
value |
Percentage when type = percent; an amount in the store's currency otherwise |
value_label |
Ready-to-display label for the cashier ("10% OFF") |
seller_id |
Staff member linked to the coupon |
min_purchase |
The campaign's minimum purchase, if any — check it before closing the sale |
Errors: coupon_not_found (404), coupon_already_redeemed (409), coupon_expired (409).
POST /coupons/{code}/reserve — reserve
Scope: coupons.write. No body. Only works from available.
curl -X POST https://indik.app/api/v1/coupons/ABC12345/reserve \
-H "Authorization: Bearer $INDIK_TOKEN" \
-H "Idempotency-Key: sale-000123-reserve"
Returns the coupon with status: "reserved" and reserved_at filled in.
POST /coupons/{code}/redeem — redeem
Scope: coupons.write. Accepts a reserved or available coupon.
| Field | Type | Required | Notes |
|---|---|---|---|
sale_value |
number ≥ 0 | no (but recommended) | Total sale amount in the store's currency |
store_id |
integer | no | Store where it was redeemed; only saved if the coupon doesn't have a store yet |
seller_id |
integer | no | Staff member who redeemed it; same rule |
curl -X POST https://indik.app/api/v1/coupons/ABC12345/redeem \
-H "Authorization: Bearer $INDIK_TOKEN" \
-H "Idempotency-Key: sale-000123" \
-H "Content-Type: application/json" \
-d '{"sale_value": 150.00, "store_id": 3}'
{
"data": {
"code": "ABC12345",
"status": "redeemed",
"type": "percent",
"value": 10,
"sale_value": 150,
"prize_value": 15,
"redeemed_at": "2026-08-13T19:36:59+00:00"
}
}
prize_value is the actual discount in the store's currency — for percent, calculated from
sale_value; for the other types, equal to value.
⚠️ Important side effect
Redeeming a welcome coupon (
origin: "welcome") confirms the referral that created it: the referral becomesusedand the referrer automatically gets the reward coupon (and the milestone coupon, if they reached one). It's the same behavior as/painel/validar.
Errors: coupon_not_found (404), coupon_already_redeemed (409), coupon_expired (409),
below_min_purchase (422, with details.min_purchase), validation_failed (422).
POST /coupons/{code}/release — release a reservation
Scope: coupons.write. No body. Returns a reserved coupon to available — use it when the
sale is canceled, otherwise the coupon stays stuck.
Errors: coupon_not_reserved (409), coupon_already_redeemed (409).
GET /customers — list customers
Scope: customers.read.
| Parameter | Notes |
|---|---|
phone |
Accepts formatted input ((415) 555-0123) — compared by digits |
cpf |
Brazilian taxpayer ID (CPF), Brazilian stores only. Accepts formatted input (390.533.447-05) |
email |
Exact match |
q |
Free-text search on name, phone and email |
include_removed |
true includes customers removed from the program |
page, per_page |
per_page maximum 100 (default 25) |
curl "https://indik.app/api/v1/customers?phone=%2B14155550123" \
-H "Authorization: Bearer $INDIK_TOKEN"
GET /customers/{id} — customer details
Scope: customers.read. Includes the customer's coupons.
{
"data": {
"id": 26,
"name": "Anna",
"phone": "+14155550102",
"email": null,
"cpf": null,
"invite_token": "S17hEMaLzABq5vvRHePtE908d2nzXjTX",
"invite_url": "https://indik.app/my-store/?invite=S17hEMaLzABq5vvRHePtE908d2nzXjTX",
"referrer_id": 25,
"store_id": null,
"seller_id": null,
"accepted_terms_at": "2026-07-27T20:26:36+00:00",
"removed_at": null,
"created_at": "2026-07-31T20:26:36+00:00",
"coupons": [ ... ]
}
}
invite_url is the ready-made link the customer shares to refer friends — print it on the
receipt or send it by text or WhatsApp straight from the ERP.
POST /customers — sign up a customer
Scope: customers.write.
| Field | Type | Required | Notes |
|---|---|---|---|
name |
text (≤100) | no | |
phone |
phone | yes, if there is no email |
US and other non-Brazilian stores: international format (+14155550123) or the store's local number. Brazilian stores: area code + number (11988887777) |
email |
yes, if there is no phone |
||
cpf |
11 digits | no | Brazilian taxpayer ID, Brazilian stores only |
accepted_terms |
boolean | yes | Must be true — consent to the program terms (required by Brazil's LGPD privacy law and kept as a record for every store) |
store_id |
integer | no | Store where the customer signed up |
seller_id |
integer | no | Staff member who signed them up |
referrer_invite_token |
text | no | Who referred them, by the invite link token |
referrer_phone |
text | no | Who referred them, by phone |
referrer_cpf |
text | no | Who referred them, by CPF |
curl -X POST https://indik.app/api/v1/customers \
-H "Authorization: Bearer $INDIK_TOKEN" \
-H "Idempotency-Key: signup-phone-14155550123" \
-H "Content-Type: application/json" \
-d '{
"name": "Joanna Smith",
"phone": "+14155550123",
"accepted_terms": true,
"referrer_phone": "+14155550102"
}'
Returns 201 when the customer is created and 200 when they were already in the program —
meta.created tells the two apart, and meta.referral_id holds the referral that was opened
(or null).
If there is a valid referrer and an active campaign, the response already includes the
welcome coupon inside data.coupons:
{
"data": {
"id": 31,
"name": "Joanna Smith",
"coupons": [
{ "code": "RZSY2428", "status": "available", "type": "percent", "origin": "welcome", "value": 15, "value_label": "15% OFF" }
]
},
"meta": { "created": true, "referral_id": 23 }
}
⚠️ No OTP, so it's on you
Unlike the customer app, signing up through the API doesn't go through text-message verification — the ERP is a trusted channel. In exchange,
accepted_termsis required: you are stating that you collected the customer's consent to the program terms. The person's global identifier is still the phone number (or the email), so the same customer signed up at two Indik stores is recognized as the same person.
GET /customers/{id}/coupons — a customer's coupons
Scope: coupons.read. Accepts status (available · reserved · redeemed · expired) and
pagination.
GET /referrals — list referrals
Scope: referrals.read.
| Parameter | Notes |
|---|---|
status |
pending · used · expired |
campaign_id, referrer_id |
Filter by campaign and by referrer |
from, to |
Dates (YYYY-MM-DD) applied to when the referral was created |
page, per_page |
{
"data": [
{
"id": 23,
"status": "used",
"campaign_id": 1,
"referrer": { "id": 26, "name": "Anna", "phone": "+14155550102" },
"referred": { "id": 31, "name": "Joanna Smith", "phone": "+14155550123" },
"referred_contact": { "name": null, "phone": null, "email": null },
"coupon": { "code": "UQFHBGPO", "value": 50, "origin": "referral", "status": "available" },
"created_at": "2026-08-13T19:37:26+00:00"
}
],
"meta": { "page": 1, "per_page": 25, "total": 1, "last_page": 1 }
}
referred is the customer who already signed up; referred_contact holds the loose details of
a direct invite (text/email) that hasn't turned into a sign-up yet.
GET /campaigns — list campaigns
Scope: campaigns.read. The active_only=1 parameter limits the result to the current campaign.
Use this endpoint to read min_purchase and block the sale in the ERP before you even call the
redemption.
GET /stores — list stores
Scope: campaigns.read. Returns id, name, address, phone, is_active — the source of
the store_id values you send when redeeming and signing up.
GET /metrics — metrics
Scope: metrics.read. The same numbers as /painel/dashboard, in raw form.
| Parameter | Notes |
|---|---|
period |
7d · 30d (default) · 90d |
from, to |
Explicit range (YYYY-MM-DD), up to 366 days; overrides period |
campaign_id |
Limits to one campaign |
Each metric includes the value for the period, the value for the period immediately before and the change:
{
"data": {
"period": {
"start": "2026-07-15T00:00:00+00:00",
"end": "2026-08-13T23:59:59+00:00",
"previous_start": "2026-06-15T00:00:00+00:00",
"previous_end": "2026-07-14T23:59:59+00:00",
"days": 30
},
"metrics": {
"referrals": { "current": 2, "previous": 0, "delta": 100, "up": true },
"conversion_rate": { "current": 50, "previous": 0, "delta": 100, "up": true }
},
"series": { "referrals_daily": [0, 1, 0, ...] },
"active_campaign": { "id": 1, "name": "Demo Campaign", "progress": 12, "goal": 100 },
"usage": [ { "key": "coupons_included", "label": "Coupons this month", "used": 12, "limit": 500, "state": "ok" } ]
}
}
| Metric | Meaning |
|---|---|
referrals |
Referrals created in the period |
converted_referrals |
Confirmed referrals (status used) |
rewarded_referrals |
Confirmed referrals whose referrer reward has already been redeemed |
conversion_rate |
converted_referrals / referrals (%) |
redeemed_coupons |
Coupons redeemed |
redeemed_value / campaign_cost |
Cost of the redeemed coupons, in the store's currency |
participants |
Total customers signed up for the program |
unique_referrers |
Customers who made at least 1 referral in the period |
penetration_rate |
unique_referrers / participants (%) |
viral_effect |
Referrals per referrer (multiplier) |
influence_rate |
converted_referrals / unique_referrers (%) |
redemption_rate |
rewarded_referrals / converted_referrals (%) — never above 100 |
sales_value |
Sum of the sale_value sent with redemptions |
avg_ticket |
sales_value / redeemed_coupons |
cac_per_referral |
campaign_cost / converted_referrals |
The series in series have one point per day of the period, in chronological order:
referrals_daily, converted_daily, pending_daily, redeemed_daily.
7. Common integration errors
| Symptom | Likely cause |
|---|---|
401 invalid_api_key right after creating the key |
Token only half copied — it has a prefix and a secret separated by a dot |
402 subscription_inactive |
The store's subscription is overdue; settle it in /painel/planos |
403 insufficient_scope on redemption |
The key has coupons.read but not coupons.write |
403 ip_not_allowed after moving servers |
The outbound IP changed; update the allowed addresses on the dashboard |
401 invalid_api_key on a key that always worked |
It expired — generate a new one in Integrations |
404 coupon_not_found for a coupon that exists |
The coupon belongs to another store — each key only sees its own account |
409 coupon_already_redeemed on a retry |
Missing Idempotency-Key: the first call worked and you didn't see the response |
422 below_min_purchase |
The campaign requires a minimum purchase; read min_purchase in GET /coupons/{code} |
prize_value empty after redemption |
sale_value wasn't sent |
| Referrer didn't get the reward | The redeemed coupon wasn't a welcome coupon, or the referral had already been confirmed |
8. Go-live checklist
GET /pingreturns 200 with the store's correct slug.GET /coupons/{code}for a real coupon returnsstatus: "available".POST /reserve→POST /releaseputs the coupon back toavailable.POST /redeemwithsale_valuestoresprize_valuecorrectly.- Repeating the
redeemwith the sameIdempotency-KeyreturnsIdempotency-Replayed: trueand doesn't redeem again. POST /customerswithreferrer_phonecreates the customer and the welcome coupon.- Redeeming that welcome coupon confirms the referral on the dashboard and rewards the referrer.
GET /metrics?period=30dmatches/painel/dashboardfor the same period.- A key revoked on the dashboard starts returning
401immediately.