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.
Autenticación
Envía tu clave en la cabecera Authorization de cada petición:
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.
| Permiso | Permite |
|---|---|
raffles:read | Leer rifas. Lista y detalle de tus rifas publicadas (nunca borradores). |
numbers:read | Consultar números. Disponibilidad de números: libres, retenidos o vendidos. |
orders:read | Leer órdenes. Órdenes con los datos del comprador minimizados (nombre abreviado, correo y teléfono enmascarados). |
tickets:read | Leer boletos. Boletos y su estado (vendido, retenido, anulado). |
draws:read | Leer sorteos. Resultados de sorteos ya publicados, sin datos del ganador. |
stats:read | Leer ventas netas. Ventas netas por periodo, rifa y moneda. |
orders:create | Crear 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_pii | Ver 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.
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.
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.
{
"error": {
"code": "insufficient_scope",
"message": "The API key does not have the scope required by this endpoint.",
"details": { "required": "orders:read" },
"requestId": "req_5f2c9a1b7d3e4f6a8b0c"
}
}| HTTP | Código | Significado |
|---|---|---|
| 401 | invalid_api_key | Clave ausente, mal formada, desconocida, caducada o revocada (no se revela cuál). |
| 403 | insufficient_scope | Falta el permiso que exige el endpoint (details.required). |
| 403 | api_not_in_plan | El plan del organizador no incluye acceso a la API. |
| 403 | tenant_inactive | El organizador está suspendido. |
| 404 | raffle_not_found, order_not_found, ticket_not_found, draw_not_found, not_found | No 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. |
| 409 | numbers_unavailable | Algún número pedido ya no está libre (details.numbers). |
| 409 | not_enough_available | No quedan suficientes números disponibles. |
| 409 | raffle_not_open | La rifa no está abierta a la venta. |
| 409 | sales_closed | La venta de la rifa ya cerró. |
| 409 | billing_restricted | La facturación está restringida: se bloquea crear reservas (las lecturas siguen). |
| 422 | validation_failed | Solicitud inválida; details lista los mensajes. |
| 422 | invalid_cursor | Cursor inválido o manipulado. |
| 422 | range_too_large | El rango de números excede el tope (details.max). |
| 422 | numbers_query_required | Usa exactamente una forma de consulta: numbers, from/to o sample. |
| 422 | idempotency_key_required | Falta la cabecera Idempotency-Key. |
| 422 | idempotency_key_reused | La misma Idempotency-Key se usó con un cuerpo distinto. |
| 422 | whatsapp_consent_required | whatsapp_consent debe ser true. |
| 422 | age_confirmation_required | age_confirmed debe ser true. |
| 422 | numbers_or_quantity_required | Indica numbers o quantity. |
| 422 | invalid_numbers | Algún número está fuera de rango o repetido. |
| 422 | max_per_order_exceeded | Se excede el máximo de boletos por orden de la rifa. |
| 429 | rate_limited | Límite de peticiones excedido; respeta Retry-After. |
| 503 | platform_maintenance | Plataforma en mantenimiento: las lecturas siguen, crear reservas no. |
| 503 | db_lock_timeout, db_statement_timeout | db_lock_timeout / db_statement_timeout: reintenta en unos segundos. |
| 500 | internal_error | Error 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.
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.
{
"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.createdSe 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.expiredUna 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.cancelledUna 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.confirmedConfirmaste 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.issuedSe 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.completedSe 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_publishedSe 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_updatedCambió 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
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.
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-Aftery las cabecerasRateLimit-*; usaETag/If-None-Matchpara 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
requestIdde los errores: es lo primero que te pedirá soporte.