Rifana · API v1

Documentación para desarrolladores

API REST versionada (/v1) y webhooks firmados para conectar tus sistemas con tus rifas en Rifana.

Introducción

La API pública de Rifana permite que tú, como organizador, leas tus rifas, la disponibilidad de números, órdenes, boletos, sorteos publicados y ventas netas desde tus propios sistemas (ERP, CRM, hojas de cálculo, tu sitio). Es de solo lectura, salvo una escritura expresamente acotada: crear una reserva de comprador.

No puede confirmar pagos, emitir boletos, tocar sorteos ni ganadores: eso sigue siendo tuyo, en el panel. Los webhooks avisan a tu servidor de lo que ocurre, solo de salida.

Requiere un plan que incluya acceso a la API. Los datos de cada organizador están aislados: una clave solo ve lo de su organizador.

Descargar OpenAPI v1 (JSON)

Autenticación

Envía tu clave en la cabecera Authorization de cada petición:

Authorizationhttp
Authorization: Bearer rh_live_<prefix 8>_<secret 43>

No se usan cookies ni CSRF: las cookies de sesión del panel no autentican la API. Una clave ausente, mal formada, desconocida, caducada o revocada responde siempre igual (401 invalid_api_key), sin revelar la causa.

Cómo se obtiene: el propietario del organizador la crea en el panel (Desarrolladores → Claves API). Se muestra una sola vez al crearla o rotarla; Rifana solo guarda un HMAC-SHA256 con un secreto del servidor. Los primeros 16 caracteres (rh_live_ más el prefijo) son visibles en el panel para identificarla.

Seguridad de la clave: úsala solo desde servidores (nunca en una web pública ni en una app móvil), guárdala en variables de entorno o en un gestor de secretos, dale solo los permisos necesarios, ponle caducidad y rótala si sospechas que se filtró.

Permisos (scopes)

Cada endpoint exige un permiso. Sin él responde 403 insufficient_scope con details.required.

Permisos (scopes)
PermisoPermite
raffles:readLeer rifas. Lista y detalle de tus rifas publicadas (nunca borradores).
numbers:readConsultar números. Disponibilidad de números: libres, retenidos o vendidos.
orders:readLeer órdenes. Órdenes con los datos del comprador minimizados (nombre abreviado, correo y teléfono enmascarados).
tickets:readLeer boletos. Boletos y su estado (vendido, retenido, anulado).
draws:readLeer sorteos. Resultados de sorteos ya publicados, sin datos del ganador.
stats:readLeer ventas netas. Ventas netas por periodo, rifa y moneda.
orders:createCrear reservas. Crea reservas de comprador (órdenes pendientes de pago). Atención: la clave podrá retener números de tus rifas y el comprador recibirá un WhatsApp. No confirma pagos ni emite boletos.
buyers:read_piiVer datos personales de compradores. Atención: permite ver nombre, correo y teléfono completos. Cada acceso queda registrado. Úsalo solo si tu integración lo necesita y trata esos datos conforme a tu aviso de privacidad.

Plan y estado del organizador

Sin la función de plan api_access no se pueden crear claves (402 plan_feature_required) y las existentes responden 403 api_not_in_plan al instante; vuelven a funcionar si el plan la recupera. Un organizador suspendido responde 403 tenant_inactive. Con la facturación restringida las lecturas siguen y crear reservas responde 409 billing_restricted; en mantenimiento de la plataforma, 503 platform_maintenance.

Versionado y cambios

La versión mayor va en la ruta (/v1). Un cambio incompatible (quitar o renombrar campos, cambiar tipos o semántica, endurecer validaciones) solo llega en /v2, con aviso previo y /v1 operativa un mínimo de 6 meses desde el aviso.

Dentro de v1 solo hay cambios aditivos: campos, endpoints, valores de enumeración y permisos nuevos. Tu cliente debe ignorar campos desconocidos y tolerar valores de enumeración nuevos. Los códigos de error son estables y pueden añadirse otros nuevos. La versión del contrato (info.version del OpenAPI) sube con cada cambio aditivo o documental.

Formato de las respuestas

Un recurso: { "data": { … } }. Una lista: { "data": [ … ], "pagination": { … } }. Campos en snake_case; importes en centavos (enteros) más currency ISO; fechas ISO-8601 en UTC. Las monedas nunca se mezclan ni se convierten. Toda respuesta lleva la cabecera X-Request-Id: cítala al contactar soporte (si envías la tuya, de 8 a 64 caracteres A-Za-z0-9._-, se conserva).

Las lecturas devuelven un ETag débil. Reenvíalo en If-None-Match y recibirás 304 Not Modified sin cuerpo mientras el dato no cambie (un 304 también cuenta para el límite).

Límites de uso

El límite de peticiones por minuto sale de tu plan (si el plan con acceso a la API no lo define, son 60) y puedes fijar uno propio por clave, siempre menor o igual al del plan. Se aplica con una ventana deslizante de un minuto por clave y cuenta todas las peticiones autenticadas, también las que responden 4xx o 304.

Toda respuesta autenticada trae RateLimit-Limit, RateLimit-Remaining y RateLimit-Reset (segundos hasta que rueda la ventana). Al excederlo: 429 rate_limited con Retry-After (segundos, entero ≥ 1) y details: { retryAfterSeconds, limitPerMinute }. Espera ese tiempo antes de reintentar.

Otros límites del plan: claves activas, endpoints de webhooks y eventos con entrega por día. Los valores de tu plan están en la pestaña Resumen del panel.

Paginación

Las listas usan paginación por cursor: ?limit= (1–100, por defecto 25; boletos 1–500, por defecto 100) y ?cursor= con el next_cursor opaco de la página anterior. Sigue mientras pagination.has_more sea verdadero. Cada elemento aparece exactamente una vez aunque lleguen datos nuevos entre páginas. Un cursor inválido o manipulado responde 422 invalid_cursor.

curlbash
curl -s "$BASE_URL/v1/orders?status=paid&limit=100" -H "Authorization: Bearer $RIFANA_API_KEY"
# {"data":[…],"pagination":{"limit":100,"next_cursor":"…","has_more":true}}
curl -s "$BASE_URL/v1/orders?status=paid&limit=100&cursor=<next_cursor>" -H "Authorization: Bearer $RIFANA_API_KEY"

Idempotencia

La única escritura, POST /v1/raffles/:id/orders, exige la cabecera Idempotency-Key (8–64 caracteres A-Za-z0-9_-). Repetir la petición con la misma clave devuelve la misma orden con 200 y Idempotent-Replayed: true; con un cuerpo distinto, 422 idempotency_key_reused. Peticiones simultáneas con la misma clave crean una sola orden, así que reintentar es seguro.

La reserva sigue el mismo flujo de compra de la tienda (disponibilidad, retención, límites, consentimientos). No confirma pagos: el comprador recibe las instrucciones por WhatsApp y tú confirmas el pago en el panel.

curlbash
curl -s -X POST "$BASE_URL/v1/raffles/$RAFFLE_ID/orders" \
  -H "Authorization: Bearer $RIFANA_API_KEY" -H "Idempotency-Key: pedido-9f3c2a71-0001" -H "Content-Type: application/json" \
  -d '{"buyer_name":"Ana Compradora","buyer_email":"[email protected]","buyer_phone":"9811234567","whatsapp_consent":true,"age_confirmed":true,"quantity":2}'

Errores

Los errores tienen siempre este formato. El code es el contrato (estable); el message es informativo (en inglés); details es opcional; requestId coincide con la cabecera X-Request-Id.

JSONjson
{
  "error": {
    "code": "insufficient_scope",
    "message": "The API key does not have the scope required by this endpoint.",
    "details": { "required": "orders:read" },
    "requestId": "req_5f2c9a1b7d3e4f6a8b0c"
  }
}
Errores
HTTPCódigoSignificado
401invalid_api_keyClave ausente, mal formada, desconocida, caducada o revocada (no se revela cuál).
403insufficient_scopeFalta el permiso que exige el endpoint (details.required).
403api_not_in_planEl plan del organizador no incluye acceso a la API.
403tenant_inactiveEl organizador está suspendido.
404raffle_not_found, order_not_found, ticket_not_found, draw_not_found, not_foundNo existe (o es de otro organizador o un borrador: no se distingue). Variantes: raffle_not_found, order_not_found, ticket_not_found, draw_not_found.
409numbers_unavailableAlgún número pedido ya no está libre (details.numbers).
409not_enough_availableNo quedan suficientes números disponibles.
409raffle_not_openLa rifa no está abierta a la venta.
409sales_closedLa venta de la rifa ya cerró.
409billing_restrictedLa facturación está restringida: se bloquea crear reservas (las lecturas siguen).
422validation_failedSolicitud inválida; details lista los mensajes.
422invalid_cursorCursor inválido o manipulado.
422range_too_largeEl rango de números excede el tope (details.max).
422numbers_query_requiredUsa exactamente una forma de consulta: numbers, from/to o sample.
422idempotency_key_requiredFalta la cabecera Idempotency-Key.
422idempotency_key_reusedLa misma Idempotency-Key se usó con un cuerpo distinto.
422whatsapp_consent_requiredwhatsapp_consent debe ser true.
422age_confirmation_requiredage_confirmed debe ser true.
422numbers_or_quantity_requiredIndica numbers o quantity.
422invalid_numbersAlgún número está fuera de rango o repetido.
422max_per_order_exceededSe excede el máximo de boletos por orden de la rifa.
429rate_limitedLímite de peticiones excedido; respeta Retry-After.
503platform_maintenancePlataforma en mantenimiento: las lecturas siguen, crear reservas no.
503db_lock_timeout, db_statement_timeoutdb_lock_timeout / db_statement_timeout: reintenta en unos segundos.
500internal_errorError inesperado; cita el requestId al contactar soporte.

Datos personales

Por minimización, las órdenes devuelven el nombre abreviado (María R.), el correo enmascarado y el teléfono enmascarado (solo los dos últimos dígitos), con pii_included: false.

Los datos completos solo se devuelven con la clave que tenga el permiso buyers:read_pii (que requiere también orders:read) y pidiéndolos con ?include=buyer_pii. Cada acceso se registra en el registro de accesos a datos personales (una fila por comprador devuelto); si el registro falla, la petición falla. La persona puede saberlo mediante su solicitud de acceso. Pídelo solo si tu integración lo necesita.

Las cargas de los webhooks nunca incluyen nombre, correo ni teléfono de compradores, ni el contenido de mensajes de soporte.

Referencia de endpoints

Generada en vivo desde el documento OpenAPI de la API (GET /v1/openapi.json). Los textos de la referencia técnica vienen del contrato y están en inglés.

Cargando…

Guía de webhooks

Rifana avisa a tu servidor cuando ocurre algo en tu organizador enviando una petición POST firmada a la URL que registres. Es solo de salida: lo que responda tu servidor nunca cambia órdenes, pagos, boletos, sorteos ni ganadores (solo se archiva un extracto de texto en el historial). Requiere un plan con acceso a la API.

Eventos y cargas

Cada hecho produce exactamente un evento, creado en la misma transacción que el hecho: si la operación revierte, el evento no existe; si se confirma, existe aunque tu servidor esté caído. Un endpoint solo recibe los tipos a los que se suscribe (o todos). Los eventos ocurridos mientras un endpoint está pausado o desactivado por fallos no se encolan para él; recupéralos con el reenvío manual.

Sobre del cuerpo JSON (data cambia según el evento)json
{
  "id": "evt_3f9a1c0b7d2e4a6f8b1c5d7e",
  "type": "payment.confirmed",
  "created_at": "2026-11-01T18:10:00.123Z",
  "api_version": "2026-10-01",
  "tenant_id": "d2c8e1a4-6f30-4b79-8a15-9e4b7c2f0d63",
  "data": { }
}

Las cargas son mínimas: identificadores, códigos, importes en centavos y moneda. Nunca nombre, correo ni teléfono de compradores, ni asunto ni contenido de mensajes de soporte.

  • order.created Se creó una reserva (orden pendiente de pago) desde la tienda o la API.
    Ejemplo de data
    order.created · datajson
    {
      "order": {
        "id": "6d1f0a52-3b8e-4c57-9d0e-0f4c1a7b2e11",
        "code": "K7M2Q9XA",
        "raffle_id": "0b9d7b2e-5c1a-4f3e-8a64-2f1d9c3e7a10",
        "status": "pending",
        "qty": 3,
        "numbers": [
          12,
          345,
          678
        ],
        "total_cents": 60000,
        "currency": "MXN",
        "channel": "web",
        "expires_at": "2026-11-01T18:15:00.000Z",
        "created_at": "2026-11-01T18:00:00.000Z",
        "paid_at": null,
        "cancelled_at": null
      }
    }
  • order.expired Una reserva venció sin pago y sus números volvieron a estar libres.
    Ejemplo de data
    order.expired · datajson
    {
      "order": {
        "id": "6d1f0a52-3b8e-4c57-9d0e-0f4c1a7b2e11",
        "code": "K7M2Q9XA",
        "raffle_id": "0b9d7b2e-5c1a-4f3e-8a64-2f1d9c3e7a10",
        "status": "expired",
        "qty": 3,
        "numbers": [
          12,
          345,
          678
        ],
        "total_cents": 60000,
        "currency": "MXN",
        "channel": "web",
        "expires_at": "2026-11-01T18:15:00.000Z",
        "created_at": "2026-11-01T18:00:00.000Z",
        "paid_at": null,
        "cancelled_at": null
      }
    }
  • order.cancelled Una orden fue cancelada (por el comprador o por ti) y sus números se liberaron.
    Ejemplo de data
    order.cancelled · datajson
    {
      "order": {
        "id": "6d1f0a52-3b8e-4c57-9d0e-0f4c1a7b2e11",
        "code": "K7M2Q9XA",
        "raffle_id": "0b9d7b2e-5c1a-4f3e-8a64-2f1d9c3e7a10",
        "status": "cancelled",
        "qty": 3,
        "numbers": [
          12,
          345,
          678
        ],
        "total_cents": 60000,
        "currency": "MXN",
        "channel": "web",
        "expires_at": "2026-11-01T18:15:00.000Z",
        "created_at": "2026-11-01T18:00:00.000Z",
        "paid_at": null,
        "cancelled_at": "2026-11-01T18:05:00.000Z"
      }
    }
  • payment.confirmed Confirmaste un pago (o una pasarela verificada lo confirmó); la orden pasa a pagada.
    Ejemplo de data
    payment.confirmed · datajson
    {
      "payment": {
        "id": "c3a1f7e0-8d52-4b9a-a1c6-5e7d2b9f4a08",
        "order_code": "K7M2Q9XA",
        "amount_cents": 60000,
        "currency": "MXN",
        "decided_at": "2026-11-01T18:10:00.000Z"
      },
      "order": {
        "id": "6d1f0a52-3b8e-4c57-9d0e-0f4c1a7b2e11",
        "code": "K7M2Q9XA",
        "raffle_id": "0b9d7b2e-5c1a-4f3e-8a64-2f1d9c3e7a10",
        "status": "paid",
        "qty": 3,
        "numbers": [
          12,
          345,
          678
        ],
        "total_cents": 60000,
        "currency": "MXN",
        "channel": "web",
        "expires_at": null,
        "created_at": "2026-11-01T18:00:00.000Z",
        "paid_at": "2026-11-01T18:10:00.000Z",
        "cancelled_at": null
      }
    }
  • ticket.issued Se emitieron los boletos de una orden pagada (un evento por orden).
    Ejemplo de data
    ticket.issued · datajson
    {
      "order": {
        "id": "6d1f0a52-3b8e-4c57-9d0e-0f4c1a7b2e11",
        "code": "K7M2Q9XA",
        "raffle_id": "0b9d7b2e-5c1a-4f3e-8a64-2f1d9c3e7a10"
      },
      "tickets": [
        {
          "number": 12,
          "code": "P4T8C2W6ZD",
          "issued_at": "2026-11-01T18:10:00.000Z"
        },
        {
          "number": 345,
          "code": "H9J3N5R7VB",
          "issued_at": "2026-11-01T18:10:00.000Z"
        }
      ]
    }
  • draw.completed Se ejecutó un sorteo (resultado interno; aún puede no estar publicado).
    Ejemplo de data
    draw.completed · datajson
    {
      "draw": {
        "id": "9a7e5c31-2b84-4d06-b1f3-7c0e8a4d5b22",
        "raffle_id": "0b9d7b2e-5c1a-4f3e-8a64-2f1d9c3e7a10",
        "attempt": 1,
        "method": "national_lottery",
        "unsold": false,
        "winning_number": 345,
        "sold_pct": 62.5,
        "executed_at": "2026-12-05T03:00:00.000Z",
        "published": false
      }
    }
  • draw.winner_published Se publicó el resultado del sorteo y su boleto ganador (sin datos del ganador).
    Ejemplo de data
    draw.winner_published · datajson
    {
      "draw": {
        "id": "9a7e5c31-2b84-4d06-b1f3-7c0e8a4d5b22",
        "raffle_id": "0b9d7b2e-5c1a-4f3e-8a64-2f1d9c3e7a10",
        "attempt": 1,
        "winning_number": 345,
        "winner_ticket_code": "H9J3N5R7VB",
        "published_at": "2026-12-05T03:10:00.000Z"
      }
    }
  • support.ticket_updated Cambió un caso de soporte (estado, prioridad, asignación, mensaje nuevo…). Solo metadatos, sin el contenido de los mensajes.
    Ejemplo de data
    support.ticket_updated · datajson
    {
      "ticket": {
        "id": "4f2b6d90-1c37-4e8a-9b05-3d7a1e6c8f44",
        "number": "T-000123",
        "scope": "buyer_org",
        "status": "in_progress",
        "priority": "normal",
        "category": "buyer_payment",
        "updated_at": "2026-11-02T10:00:00.000Z"
      },
      "change": "status_changed"
    }

Cabeceras de cada petición

HTTPhttp
POST /your-path HTTP/1.1
Content-Type: application/json; charset=utf-8
User-Agent: RaffleHub-Webhooks/1.0
X-RaffleHub-Event: payment.confirmed
X-RaffleHub-Event-Id: evt_3f9a1c0b7d2e4a6f8b1c5d7e
X-RaffleHub-Delivery: 5d7c1a9e-0b3f-4c2a-8e6d-1f9b3a5c7e90
X-RaffleHub-Timestamp: 1793556600
X-RaffleHub-Signature: t=1793556600,v1=<64 hex>

X-Rifana-Event-Id identifica el hecho (igual en todos los reintentos y en todos tus endpoints); X-Rifana-Delivery identifica la entrega (endpoint + evento; estable entre reintentos).

Firma y verificación

La firma es v1 = HMAC-SHA256(secreto, t + "." + cuerpo_crudo) en hexadecimal, donde t es el timestamp Unix (segundos) de la cabecera, el secreto es tu whsec_… completo y el cuerpo es el texto exacto recibido (no lo vuelvas a serializar).

Tolerancia temporal: rechaza peticiones cuyo t difiera más de 5 minutos (300 s) de tu reloj. Cada reintento se firma de nuevo con un t nuevo. Compara las firmas en tiempo constante.

Durante una rotación de secreto la cabecera lleva dos firmas (t=…,v1=<nuevo>,v1=<anterior>): acepta la petición si alguna coincide.

Node.js
import crypto from 'node:crypto';

export function verifyRifana(rawBody /* string or Buffer, EXACTLY as received */, header, secret, toleranceSec = 300) {
  const parts = String(header).split(',').map((p) => p.trim());
  const t = parts.find((p) => p.startsWith('t='))?.slice(2);
  const sigs = parts.filter((p) => p.startsWith('v1=')).map((p) => p.slice(3));
  if (!t || !/^\d+$/.test(t) || !sigs.length) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;           // outside the time tolerance
  const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
  return sigs.some((s) => { const b = Buffer.from(s, 'hex'); return b.length === expected.length && crypto.timingSafeEqual(b, expected); });
}

// Express: you need the RAW body
app.post('/rifana', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verifyRifana(req.body, req.get('X-RaffleHub-Signature'), process.env.RIFANA_WHSEC)) return res.sendStatus(400);
  const event = JSON.parse(req.body.toString('utf8'));
  // deduplicate by event.id and answer 2xx FAST; process in the background
  res.sendStatus(204);
});

Entrega, reintentos y pausa

Éxito = respuesta HTTP 2xx. Cualquier otra cosa (3xx, 4xx, 5xx, error de red, tiempo agotado, destino bloqueado) es fallo. No se siguen redirecciones. Plazos: 5 s para conectar y 10 s en total por intento.

Reintentos con espera creciente tras cada fallo: 1 min, 5 min, 30 min, 2 h, 6 h, 24 h y 24 h (±10 % de variación), hasta 8 intentos en total; el octavo fallo deja la entrega en abandoned. Un reenvío manual abre un ciclo nuevo de 8 intentos.

Pausa automática: tras varios fallos consecutivos del endpoint (10 por defecto) pasa a «desactivado por fallos», deja de recibir eventos y se avisa por correo al propietario. Reanudarlo reinicia el contador. Un éxito también lo reinicia.

Responde rápido (2xx en menos de 10 s) y procesa después; si tu proceso tarda, encólalo internamente. Estados de una entrega: pending, delivering, succeeded, failed (habrá reintento), abandoned y skipped (omitida por el límite de eventos del plan o por no incluir la API).

Idempotencia y orden en tu receptor

La entrega es al menos una vez: tras un fallo de red, un reinicio o un tiempo agotado el mismo evento puede llegar más de una vez. Deduplica por X-Rifana-Event-Id (o por el id del cuerpo) guardando los ya procesados; procesar el mismo id dos veces debe ser inocuo. No se garantiza el orden entre eventos distintos: apóyate en created_at y en el estado de la entidad.

Requisitos de la URL (protección SSRF)

Como la URL la eliges tú, Rifana impide que apunte a la red interna. Se valida al crear, editar, probar y en cada entrega: solo https; puertos 443 y 8443; sin usuario ni contraseña; nada de localhost ni nombres internos (.local, .internal, .lan…); y todas las direcciones a las que resuelva el nombre deben ser públicas (se rechazan rangos privados, locales, de enlace local, de metadatos de la nube, multicast y reservados, en IPv4 e IPv6).

Se vuelve a resolver el DNS en cada entrega y la conexión va directa a la IP ya validada (contra «DNS rebinding»). Si una URL se rechaza al crear o editar obtienes 422 webhook_url_invalid con el motivo en details.reason; una entrega bloqueada queda como fallo con blocked:<motivo> y no llega a conectar. Usa HTTPS con certificado válido.

Límites y reenvío

Tu plan fija cuántos endpoints puedes tener y cuántos eventos con entrega se generan por día UTC; el excedente se registra pero sus entregas quedan skipped con el motivo plan_limit y no se envían. Desde el historial de entregas el propietario puede reenviar una entrega o un evento: se abre un ciclo nuevo de reintentos y tu receptor debe deduplicar.

El secreto whsec_… se muestra una sola vez (al crear el endpoint o rotarlo) y se guarda cifrado. Nunca aparece en listados, historial ni correos. El historial guarda cada intento con las cabeceras redactadas (la firma como [redacted]), código de respuesta, tiempo y un extracto de hasta 1 KB.

Seguridad y buenas prácticas

  • Guarda la clave y el secreto de webhooks en variables de entorno o en un gestor de secretos; nunca en el código ni en repositorios.
  • Usa la clave solo desde servidores. Una clave en una web pública o una app móvil es una clave filtrada.
  • Principio del mínimo privilegio: una clave por integración, solo con los permisos necesarios y, si puedes, con caducidad y límite propio.
  • Rota las claves y los secretos con regularidad usando el periodo de gracia; revoca de inmediato cualquiera que se haya expuesto.
  • Verifica siempre la firma y la marca de tiempo de cada webhook sobre el cuerpo crudo, y compara en tiempo constante.
  • Deduplica los eventos por su ID y no asumas orden de llegada.
  • Respeta Retry-After y las cabeceras RateLimit-*; usa ETag / If-None-Match para no gastar peticiones.
  • Pide datos personales de compradores solo si de verdad los necesitas y trátalos conforme a tu aviso de privacidad.
  • Registra el requestId de los errores: es lo primero que te pedirá soporte.