Documentación

API Indik — Manual de integración

Descargar OpenAPI

o impórtala desde la URL https://indik.app/es/docs/api/openapi.yaml

Índice

Versión v1 · Base: https://indik.app/api/v1 · Formato: JSON · Idioma de los mensajes: el de la tienda (o el del Accept-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": {}
  }
}

code es el contrato estable: programa tus condiciones sobre él. message es 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 header Accept-Language (es, en o pt-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 429 como una espera, no como un error

Respeta el Retry-After antes 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_value

En 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 a used y 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_terms es 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

  1. GET /ping responde 200 con el slug correcto de la tienda.
  2. GET /coupons/{code} de un cupón real devuelve status: "available".
  3. POST /reserve → POST /release devuelve el cupón a available.
  4. POST /redeem con sale_value guarda prize_value correctamente.
  5. Repetir el redeem con la misma Idempotency-Key devuelve Idempotency-Replayed: true y no vuelve a canjear.
  6. POST /customers con referrer_phone crea el cliente y el cupón de bienvenida.
  7. Canjear ese cupón de bienvenida confirma la recomendación en el panel y premia a quien recomendó.
  8. GET /metrics?period=30d coincide con /painel/dashboard en el mismo periodo.
  9. Una clave revocada en el panel empieza a responder 401 de inmediato.