Versión v1 · Base:
https://indik.app/api/v1· Formato: JSON · Idioma de los mensajes: el de la tienda (o el delAccept-Language)
API REST para que el ERP o el punto de venta del comercio opere el programa de recomendaciones desde su propio sistema: validar, reservar y canjear cupones, registrar clientes y leer los indicadores que aparecen en el panel.
Referencia formal (importable en Postman/Insomnia): openapi.yaml.
Este manual es una traducción del original en portugués. Si las dos versiones no coinciden, prevalece la versión en portugués.
Contexto
Sin la API, validar un cupón obliga al cajero a abrir /painel/validar en una segunda pantalla,
junto a la caja. Con ella, todo el flujo cabe dentro del ERP.
Un punto importante de arquitectura: la API y el panel comparten el mismo código de reglas de
negocio (CouponRedemptionService, MetricsService, ReferralLinker). Un cupón canjeado por
la API produce exactamente los mismos efectos que uno canjeado en el panel, incluida la
confirmación de la recomendación y la emisión del cupón de recompensa para quien recomendó.
1. Cómo obtener la clave
En el panel, el dueño de la tienda entra en Cuenta → Integraciones (/painel/integracoes),
hace clic en Nueva clave, le pone un nombre (p. ej.: POS Tienda Centro) y elige los permisos.
⚠️ La clave se muestra una sola vez
El servidor guarda solo el hash SHA-256 del secreto. Si pierdes la clave, no hay forma de recuperarla: solo puedes generar una nueva y revocar la anterior.
Crea una clave por integración, con el menor conjunto de permisos que necesite. Así, si un sistema se da de baja o su credencial queda expuesta, revocas solo esa clave sin afectar a las demás.
Al crearla también puedes definir dos restricciones opcionales:
| Restricción | Qué hace |
|---|---|
| Vigencia | La clave deja de funcionar en la fecha elegida (30, 90, 180 días o 1 año). Limita el daño de una filtración que pase desapercibida. Sin vigencia, funciona hasta que alguien la revoque. |
| Direcciones autorizadas | La clave solo se acepta desde las IP o rangos CIDR indicados. Pide la dirección de salida a quien administra tu sistema. Una llamada desde otro origen recibe 403 ip_not_allowed. |
En la misma pantalla también puedes seguir las llamadas recientes de la tienda (ruta, resultado, clave usada y origen) para comprobar que la integración se comporta como esperas.
2. Autenticación
Cada solicitud lleva el token en el header Authorization:
Authorization: Bearer indk_live_8c17d1238ad36c3f.5ZqL1HQvNkonJ3UM9pIWxj4tXrZNyEn7zcjcsXMI
El token tiene dos partes separadas por un punto: el prefijo (público, aparece en la lista del panel para que identifiques cada clave) y el secreto.
Prueba la credencial antes que nada:
curl https://indik.app/api/v1/ping \
-H "Authorization: Bearer $INDIK_TOKEN"
{
"data": {
"tenant": { "slug": "mi-tienda", "name": "Mi Tienda", "status": "active" },
"api_key": { "name": "POS Tienda Centro", "prefix": "indk_live_8c17d1238ad36c3f", "scopes": ["coupons.read", "coupons.write"] },
"server_time": "2026-08-13T19:36:44+00:00"
}
}
Permisos (scopes)
Cada clave lleva una lista de permisos. Concede solo lo que el ERP realmente usa.
| Scope | Permite |
|---|---|
coupons.read |
Consultar cupones y los cupones de un cliente |
coupons.write |
Reservar, canjear y liberar cupones |
customers.read |
Listar y consultar clientes |
customers.write |
Registrar clientes |
referrals.read |
Consultar recomendaciones |
campaigns.read |
Consultar campañas y tiendas |
metrics.read |
Consultar indicadores |
* |
Todos los permisos anteriores |
3. Formato de respuesta
Éxito: siempre dentro de data; las listas paginadas incluyen también meta:
{ "data": { "...": "..." } }
{ "data": [ ... ], "meta": { "page": 1, "per_page": 25, "total": 87, "last_page": 4 } }
Error: siempre dentro de error:
{
"error": {
"code": "coupon_already_redeemed",
"message": "Este cupón ya fue utilizado.",
"details": {}
}
}
codees el contrato estable: programa tus condiciones sobre él.messagees texto listo para mostrar al cajero y puede cambiar en cualquier momento sin aviso. Sale en el idioma de la tienda dueña de la clave (español, inglés o portugués); para otro, envía el headerAccept-Language(es,enopt-BR).
Códigos de error
code |
HTTP | Cuándo ocurre |
|---|---|---|
unauthenticated |
401 | Header Authorization ausente o mal formado |
invalid_api_key |
401 | Clave inexistente, revocada, vencida o con el secreto incorrecto |
subscription_inactive |
402 | Suscripción de la tienda cancelada o vencida fuera del periodo de gracia |
insufficient_scope |
403 | La clave no tiene el permiso que exige el endpoint |
ip_not_allowed |
403 | La clave tiene direcciones autorizadas y la llamada vino de otra |
not_found |
404 | Recurso inexistente (o de otra tienda) |
coupon_not_found |
404 | No hay ningún cupón con ese código en esta tienda |
coupon_already_redeemed |
409 | Cupón ya utilizado |
coupon_expired |
409 | Cupón vencido o marcado como expirado |
coupon_unavailable |
409 | El estado del cupón no permite la operación |
coupon_not_reserved |
409 | Se intentó liberar un cupón que no estaba reservado |
below_min_purchase |
422 | Monto de la venta por debajo de la compra mínima de la campaña |
validation_failed |
422 | Cuerpo/parámetros inválidos (ver details) |
idempotency_conflict |
409 | La misma Idempotency-Key reenviada con un cuerpo distinto |
idempotency_in_progress |
409 | Una solicitud con esa Idempotency-Key todavía se está ejecutando |
rate_limited |
429 | Se superó el límite de solicitudes |
method_not_allowed |
405 | Verbo HTTP incorrecto para la ruta |
server_error |
500 | Falla interna: puedes reintentar |
En validation_failed, details trae los campos y sus mensajes:
{
"error": {
"code": "validation_failed",
"message": "Los datos enviados no son válidos.",
"details": { "accepted_terms": ["Es obligatorio registrar la aceptación de los términos del programa por parte del cliente."] }
}
}
4. Límites e idempotencia
Límite de solicitudes
| Límite | Valor | Se cuenta por |
|---|---|---|
| General | 120 solicitudes/minuto | clave de API |
Operaciones de cupón y registro (POST) |
30 solicitudes/minuto | clave de API |
| Intentos de autenticación rechazados | 10 cada 5 minutos | dirección IP |
Los dos primeros límites son por clave, no por IP: varias cajas de la misma tienda salen por
la misma dirección y no pueden consumir la cuota de las otras. Al superarlos, la respuesta es
429 con los headers Retry-After y X-RateLimit-*.
El tercero protege contra intentos de adivinar credenciales y se cuenta por IP, porque una solicitud rechazada no tiene clave a la cual atribuirse. Una autenticación correcta reinicia el contador, así que equivocarse una vez con el token y corregirlo no deja la caja bloqueada.
⚠️ Trata el
429como una espera, no como un errorRespeta el
Retry-Afterantes de reintentar. Reintentar de inmediato en un bucle solo empuja la ventana hacia adelante y deja la integración detenida por más tiempo.
Idempotencia
Todo POST acepta el header opcional (pero muy recomendado) Idempotency-Key:
Idempotency-Key: venta-2026-08-13-000123
- Reenviar la misma clave con el mismo cuerpo devuelve la respuesta original, con el header
Idempotency-Replayed: true. El cupón no se canjea dos veces. - Reenviar la misma clave con un cuerpo distinto devuelve
409 idempotency_conflict. - La ventana de repetición es de 24 horas.
Usa un identificador de la propia venta en el ERP (número de ticket, ID del pedido). Así el reintento automático de tu capa de red se vuelve una operación segura.
⚠️ Concurrencia
Cada operación corre en una transacción con bloqueo de fila. Dos llamadas simultáneas para canjear el mismo cupón nunca generan dos canjes: la segunda recibe
coupon_already_redeemed.
5. Flujo recomendado en la caja
┌──────────────┐
el cliente │ available │◄──────────────┐
presenta └──────┬───────┘ │
el cupón │ │ POST /release
│ POST /reserve │ (venta cancelada)
│ ▼ │
GET /coupons/{code} ┌──────────────┐───────────┘
(validar, sin │ reserved │
efectos) └──────┬───────┘
│ POST /redeem { "sale_value": 150.00 }
▼
┌──────────────┐
│ redeemed │ ← estado final
└──────────────┘
Dos pasos (recomendado para punto de venta): reserva al iniciar la venta, canjea al cerrarla y libera si el cliente desiste. Evita que el mismo cupón se use en dos cajas al mismo tiempo.
Un paso: llama a POST /redeem directamente sobre un cupón available; la reserva ocurre de
forma implícita. Sirve para integraciones simples o posventa.
⚠️ Envía siempre
sale_valueEn los cupones de porcentaje, el monto de la venta determina el descuento real (
prize_value = sale_value × value / 100). Sin él, el cupón se canjea, pero los indicadores de ticket promedio, costo de campaña y CAC quedan sin base de cálculo.
6. Referencia de endpoints
GET /ping
Verifica la conectividad y la credencial. No exige permiso. Ver el ejemplo en la sección 2.
GET /coupons/{code} — consultar un cupón
Permiso: coupons.read. Sin efectos: es el "validar" de la caja. El código no distingue
mayúsculas de minúsculas.
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": "Ana", "phone": "+13055550102", "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"
}
}
| Campo | Observación |
|---|---|
status |
available · reserved · redeemed · expired |
type |
percent · fixed · cashback · product |
origin |
welcome (bienvenida) · referral (recompensa de quien recomendó) · milestone (meta alcanzada) · manual |
value |
Porcentaje cuando type = percent; monto en la moneda de la tienda en los demás casos |
value_label |
Etiqueta lista para mostrar al cajero ("10% OFF") |
seller_id |
Vendedor vinculado al cupón |
min_purchase |
Compra mínima de la campaña, si la hay: valídala antes de cerrar la venta |
Errores: coupon_not_found (404), coupon_already_redeemed (409), coupon_expired (409).
POST /coupons/{code}/reserve — reservar
Permiso: coupons.write. Sin cuerpo. Solo funciona a partir de available.
curl -X POST https://indik.app/api/v1/coupons/ABC12345/reserve \
-H "Authorization: Bearer $INDIK_TOKEN" \
-H "Idempotency-Key: venta-000123-reserva"
Devuelve el cupón con status: "reserved" y reserved_at completado.
POST /coupons/{code}/redeem — canjear
Permiso: coupons.write. Acepta un cupón reserved o available.
| Campo | Tipo | Obligatorio | Observación |
|---|---|---|---|
sale_value |
número ≥ 0 | no (pero recomendado) | Monto total de la venta en la moneda de la tienda |
store_id |
entero | no | Tienda del canje; solo se guarda si el cupón todavía no tiene tienda |
seller_id |
entero | no | Vendedor del canje; misma regla |
curl -X POST https://indik.app/api/v1/coupons/ABC12345/redeem \
-H "Authorization: Bearer $INDIK_TOKEN" \
-H "Idempotency-Key: venta-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 es el descuento efectivo en la moneda de la tienda: para percent, se calcula a
partir de sale_value; para los demás tipos, es igual a value.
⚠️ Efecto importante
Canjear un cupón de bienvenida (
origin: "welcome") confirma la recomendación que lo generó: la recomendación pasa ausedy quien recomendó recibe automáticamente el cupón de recompensa (y el de meta, si alcanzó un milestone). Es el mismo comportamiento de/painel/validar.
Errores: coupon_not_found (404), coupon_already_redeemed (409), coupon_expired (409),
below_min_purchase (422, con details.min_purchase), validation_failed (422).
POST /coupons/{code}/release — liberar una reserva
Permiso: coupons.write. Sin cuerpo. Devuelve un cupón reserved a available: úsalo cuando se
cancela la venta; si no, el cupón queda trabado.
Errores: coupon_not_reserved (409), coupon_already_redeemed (409).
GET /customers — listar clientes
Permiso: customers.read.
| Parámetro | Observación |
|---|---|
phone |
Acepta el número con formato ((305) 555-0123): se compara por dígitos |
cpf |
Documento fiscal brasileño (CPF), solo tiendas de Brasil. Acepta formato (390.533.447-05) |
email |
Coincidencia exacta |
q |
Búsqueda libre por nombre, teléfono y correo |
include_removed |
true incluye clientes retirados del programa |
page, per_page |
per_page máximo 100 (por defecto 25) |
curl "https://indik.app/api/v1/customers?phone=%2B13055550123" \
-H "Authorization: Bearer $INDIK_TOKEN"
GET /customers/{id} — detalle del cliente
Permiso: customers.read. Incluye los cupones del cliente.
{
"data": {
"id": 26,
"name": "Ana",
"phone": "+13055550102",
"email": null,
"cpf": null,
"invite_token": "S17hEMaLzABq5vvRHePtE908d2nzXjTX",
"invite_url": "https://indik.app/mi-tienda/?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 es el enlace listo para que el cliente lo comparta y recomiende a sus amigos:
imprímelo en el ticket o envíalo por WhatsApp o SMS directamente desde el ERP.
POST /customers — registrar un cliente
Permiso: customers.write.
| Campo | Tipo | Obligatorio | Observación |
|---|---|---|---|
name |
texto (≤100) | no | |
phone |
teléfono | sí, si no hay email |
Tiendas de EE. UU. y otros países fuera de Brasil: formato internacional (+13055550123) o el número local de la tienda. Tiendas de Brasil: código de área + número (11988887777) |
email |
correo | sí, si no hay phone |
|
cpf |
11 dígitos | no | Documento fiscal brasileño, solo tiendas de Brasil |
accepted_terms |
booleano | sí | Debe ser true: aceptación de los términos del programa (exigida por la ley de privacidad LGPD de Brasil y guardada como registro en todas las tiendas) |
store_id |
entero | no | Tienda donde se registró el cliente |
seller_id |
entero | no | Vendedor que lo registró |
referrer_invite_token |
texto | no | Quién lo recomendó, por el token del enlace de invitación |
referrer_phone |
texto | no | Quién lo recomendó, por teléfono |
referrer_cpf |
texto | no | Quién lo recomendó, por CPF |
curl -X POST https://indik.app/api/v1/customers \
-H "Authorization: Bearer $INDIK_TOKEN" \
-H "Idempotency-Key: registro-tel-13055550123" \
-H "Content-Type: application/json" \
-d '{
"name": "Juana Pérez",
"phone": "+13055550123",
"accepted_terms": true,
"referrer_phone": "+13055550102"
}'
Devuelve 201 cuando el cliente se crea y 200 cuando ya participaba del programa:
meta.created distingue los dos casos, y meta.referral_id trae la recomendación abierta (o
null).
Si hay quien recomendó válido y una campaña activa, la respuesta ya incluye el cupón de
bienvenida dentro de data.coupons:
{
"data": {
"id": 31,
"name": "Juana Pérez",
"coupons": [
{ "code": "RZSY2428", "status": "available", "type": "percent", "origin": "welcome", "value": 15, "value_label": "15% OFF" }
]
},
"meta": { "created": true, "referral_id": 23 }
}
⚠️ Sin OTP, con responsabilidad
A diferencia de la app del cliente, el registro por la API no pasa por verificación por SMS: el ERP es un canal de confianza. A cambio,
accepted_termses obligatorio: estás afirmando que obtuviste la aceptación de los términos del programa por parte del cliente. El identificador global de la persona sigue siendo el teléfono (o el correo), así que el mismo cliente registrado en dos tiendas Indik se reconoce como la misma persona.
GET /customers/{id}/coupons — cupones de un cliente
Permiso: coupons.read. Acepta status (available · reserved · redeemed · expired) y
paginación.
GET /referrals — listar recomendaciones
Permiso: referrals.read.
| Parámetro | Observación |
|---|---|
status |
pending · used · expired |
campaign_id, referrer_id |
Filtros por campaña y por quien recomendó |
from, to |
Fechas (YYYY-MM-DD) sobre la creación de la recomendación |
page, per_page |
{
"data": [
{
"id": 23,
"status": "used",
"campaign_id": 1,
"referrer": { "id": 26, "name": "Ana", "phone": "+13055550102" },
"referred": { "id": 31, "name": "Juana Pérez", "phone": "+13055550123" },
"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 es el cliente ya registrado; referred_contact trae los datos sueltos de una
invitación directa (SMS/correo) que todavía no se convirtió en registro.
GET /campaigns — listar campañas
Permiso: campaigns.read. El parámetro active_only=1 limita el resultado a la campaña vigente.
Usa este endpoint para leer min_purchase y bloquear la venta en el ERP antes incluso de llamar
al canje.
GET /stores — listar tiendas
Permiso: campaigns.read. Devuelve id, name, address, phone, is_active: la fuente de
los store_id que envías en el canje y en el registro.
GET /metrics — indicadores
Permiso: metrics.read. Los mismos números de /painel/dashboard, en formato crudo.
| Parámetro | Observación |
|---|---|
period |
7d · 30d (por defecto) · 90d |
from, to |
Intervalo explícito (YYYY-MM-DD), máximo 366 días; ignora period |
campaign_id |
Limita a una campaña |
Cada métrica trae el valor del periodo, el del periodo inmediatamente anterior y la variación:
{
"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": "Campaña Demo", "progress": 12, "goal": 100 },
"usage": [ { "key": "coupons_included", "label": "Cupones este mes", "used": 12, "limit": 500, "state": "ok" } ]
}
}
| Métrica | Significado |
|---|---|
referrals |
Recomendaciones creadas en el periodo |
converted_referrals |
Recomendaciones confirmadas (status used) |
rewarded_referrals |
Recomendaciones confirmadas cuya recompensa de quien recomendó ya se canjeó |
conversion_rate |
converted_referrals / referrals (%) |
redeemed_coupons |
Cupones canjeados |
redeemed_value / campaign_cost |
Costo de los cupones canjeados, en la moneda de la tienda |
participants |
Total de clientes registrados en el programa |
unique_referrers |
Clientes que hicieron al menos 1 recomendación en el periodo |
penetration_rate |
unique_referrers / participants (%) |
viral_effect |
Recomendaciones por cada persona que recomienda (multiplicador) |
influence_rate |
converted_referrals / unique_referrers (%) |
redemption_rate |
rewarded_referrals / converted_referrals (%): nunca pasa de 100 |
sales_value |
Suma de los sale_value enviados en los canjes |
avg_ticket |
sales_value / redeemed_coupons |
cac_per_referral |
campaign_cost / converted_referrals |
Las series en series tienen un punto por día del periodo, en orden cronológico:
referrals_daily, converted_daily, pending_daily, redeemed_daily.
7. Errores comunes en la integración
| Síntoma | Causa probable |
|---|---|
401 invalid_api_key justo después de crear la clave |
Token copiado a medias: tiene prefijo y secreto separados por un punto |
402 subscription_inactive |
La suscripción de la tienda está vencida; regularízala en /painel/planos |
403 insufficient_scope al canjear |
La clave tiene coupons.read pero no coupons.write |
403 ip_not_allowed después de cambiar de servidor |
Cambió la IP de salida; actualiza las direcciones autorizadas en el panel |
401 invalid_api_key en una clave que siempre funcionó |
Venció la vigencia: genera una nueva en Integraciones |
404 coupon_not_found para un cupón que existe |
El cupón es de otra tienda: cada clave ve solo su propia cuenta |
409 coupon_already_redeemed en el reintento |
Faltó la Idempotency-Key: la primera llamada funcionó y no viste la respuesta |
422 below_min_purchase |
La campaña exige una compra mínima; lee min_purchase en GET /coupons/{code} |
prize_value vacío después del canje |
No se envió sale_value |
| Quien recomendó no recibió la recompensa | El cupón canjeado no era de bienvenida, o la recomendación ya estaba confirmada |
8. Lista de verificación para salir a producción
GET /pingresponde 200 con el slug correcto de la tienda.GET /coupons/{code}de un cupón real devuelvestatus: "available".POST /reserve→POST /releasedevuelve el cupón aavailable.POST /redeemconsale_valueguardaprize_valuecorrectamente.- Repetir el
redeemcon la mismaIdempotency-KeydevuelveIdempotency-Replayed: truey no vuelve a canjear. POST /customersconreferrer_phonecrea el cliente y el cupón de bienvenida.- Canjear ese cupón de bienvenida confirma la recomendación en el panel y premia a quien recomendó.
GET /metrics?period=30dcoincide con/painel/dashboarden el mismo periodo.- Una clave revocada en el panel empieza a responder
401de inmediato.