Documentation

Indik API — Integration Guide

Download OpenAPI

or import it from the URL https://indik.app/en/docs/api/openapi.yaml

Contents

Version v1 · Base URL: https://indik.app/api/v1 · Format: JSON · Message language: the store's (or the Accept-Language one)

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": {}
  }
}

code is the stable contract — write your conditions against it. message is 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 the Accept-Language header (en, es or pt-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 429 as a wait, not an error

Honor Retry-After before 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: true header. 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.


                    ┌──────────────┐
   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_value

For 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 becomes used and 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 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_terms is 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

  1. GET /ping returns 200 with the store's correct slug.
  2. GET /coupons/{code} for a real coupon returns status: "available".
  3. POST /reserve → POST /release puts the coupon back to available.
  4. POST /redeem with sale_value stores prize_value correctly.
  5. Repeating the redeem with the same Idempotency-Key returns Idempotency-Replayed: true and doesn't redeem again.
  6. POST /customers with referrer_phone creates the customer and the welcome coupon.
  7. Redeeming that welcome coupon confirms the referral on the dashboard and rewards the referrer.
  8. GET /metrics?period=30d matches /painel/dashboard for the same period.
  9. A key revoked on the dashboard starts returning 401 immediately.