Rifana · API v1
Documentação para desenvolvedores
API REST versionada (/v1) e webhooks assinados para conectar seus sistemas às suas rifas no Rifana.
Introdução
A API pública do Rifana permite que você, como organizador, leia suas rifas, a disponibilidade de números, pedidos, bilhetes, sorteios publicados e vendas líquidas a partir dos seus próprios sistemas (ERP, CRM, planilhas, seu site). É somente leitura, exceto por uma escrita expressamente restrita: criar uma reserva de comprador.
Ela não pode confirmar pagamentos, emitir bilhetes nem mexer em sorteios ou ganhadores: isso continua sendo seu, no painel. Os webhooks avisam seu servidor do que acontece, somente de saída.
Requer um plano que inclua acesso à API. Os dados de cada organizador são isolados: uma chave só vê os do seu organizador.
Autenticação
Envie sua chave no cabeçalho Authorization de cada requisição:
Authorization: Bearer rh_live_<prefix 8>_<secret 43>Não há cookies nem CSRF: os cookies de sessão do painel não autenticam a API. Uma chave ausente, malformada, desconhecida, expirada ou revogada responde sempre igual (401 invalid_api_key), sem revelar a causa.
Como obter: o proprietário do organizador a cria no painel (Desenvolvedores → Chaves de API). Ela é mostrada uma única vez ao criar ou rotacionar; o Rifana guarda apenas um HMAC-SHA256 com um segredo do servidor. Os primeiros 16 caracteres (rh_live_ mais o prefixo) ficam visíveis no painel para identificá-la.
Segurança da chave: use-a apenas em servidores (nunca em um site público nem em um app móvel), guarde-a em variáveis de ambiente ou em um gerenciador de segredos, dê apenas as permissões necessárias, defina uma expiração e rotacione-a se suspeitar de vazamento.
Escopos
Cada endpoint exige um escopo. Sem ele, a resposta é 403 insufficient_scope com details.required.
| Escopo | Permite |
|---|---|
raffles:read | Ler rifas. Lista e detalhes das suas rifas publicadas (nunca rascunhos). |
numbers:read | Consultar números. Disponibilidade de números: livres, retidos ou vendidos. |
orders:read | Ler pedidos. Pedidos com dados do comprador minimizados (nome abreviado, e-mail e telefone mascarados). |
tickets:read | Ler bilhetes. Bilhetes e seu status (vendido, retido, anulado). |
draws:read | Ler sorteios. Resultados de sorteios já publicados, sem dados pessoais do ganhador. |
stats:read | Ler vendas líquidas. Vendas líquidas por período, rifa e moeda. |
orders:create | Criar reservas. Cria reservas de comprador (pedidos pendentes de pagamento). Atenção: a chave poderá reter números das suas rifas e o comprador recebe um WhatsApp. Não confirma pagamentos nem emite bilhetes. |
buyers:read_pii | Ver dados pessoais de compradores. Atenção: permite ver nome, e-mail e telefone completos. Cada acesso fica registrado. Use apenas se sua integração precisar e trate esses dados conforme seu aviso de privacidade. |
Plano e status do organizador
Sem o recurso de plano api_access não é possível criar chaves (402 plan_feature_required) e as existentes respondem 403 api_not_in_plan na hora; voltam a funcionar se o plano o recuperar. Um organizador suspenso responde 403 tenant_inactive. Com a cobrança restrita, as leituras continuam e criar reservas responde 409 billing_restricted; em manutenção da plataforma, 503 platform_maintenance.
Versionamento e mudanças
A versão maior vai no caminho (/v1). Uma mudança incompatível (remover ou renomear campos, mudar tipos ou semântica, endurecer validações) só chega na /v2, com aviso prévio e a /v1 operando por no mínimo 6 meses a partir do aviso.
Dentro da v1 há apenas mudanças aditivas: novos campos, endpoints, valores de enumeração e escopos. Seu cliente deve ignorar campos desconhecidos e tolerar novos valores de enumeração. Os códigos de erro são estáveis e novos podem ser adicionados. A versão do contrato (info.version no OpenAPI) sobe a cada mudança aditiva ou documental.
Formato das respostas
Um recurso: { "data": { … } }. Uma lista: { "data": [ … ], "pagination": { … } }. Campos em snake_case; valores em centavos (inteiros) mais currency ISO; datas ISO-8601 em UTC. As moedas nunca são misturadas nem convertidas. Toda resposta traz o cabeçalho X-Request-Id: cite-o ao contatar o suporte (se você enviar o seu, de 8 a 64 caracteres A-Za-z0-9._-, ele é mantido).
As leituras retornam um ETag fraco. Reenvie-o em If-None-Match e você recebe 304 Not Modified sem corpo enquanto o dado não mudar (um 304 também conta para o limite).
Limites de uso
O limite de requisições por minuto vem do seu plano (se o plano com acesso à API não o definir, são 60) e você pode definir um limite próprio por chave, sempre menor ou igual ao do plano. Usa uma janela deslizante de um minuto por chave e conta todas as requisições autenticadas, também as que respondem 4xx ou 304.
Toda resposta autenticada traz RateLimit-Limit, RateLimit-Remaining e RateLimit-Reset (segundos até a janela rolar). Ao exceder: 429 rate_limited com Retry-After (segundos, inteiro ≥ 1) e details: { retryAfterSeconds, limitPerMinute }. Espere esse tempo antes de tentar de novo.
Outros limites do plano: chaves ativas, endpoints de webhooks e eventos com entrega por dia. Os valores do seu plano estão na aba Resumo do painel.
Paginação
As listas usam paginação por cursor: ?limit= (1–100, padrão 25; bilhetes 1–500, padrão 100) e ?cursor= com o next_cursor opaco da página anterior. Continue enquanto pagination.has_more for verdadeiro. Cada item aparece exatamente uma vez mesmo que cheguem dados novos entre páginas. Um cursor inválido ou adulterado 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"Idempotência
A única escrita, POST /v1/raffles/:id/orders, exige o cabeçalho Idempotency-Key (8–64 caracteres A-Za-z0-9_-). Repetir a requisição com a mesma chave devolve o mesmo pedido com 200 e Idempotent-Replayed: true; com outro corpo, 422 idempotency_key_reused. Requisições simultâneas com a mesma chave criam um único pedido, então tentar de novo é seguro.
A reserva segue o mesmo fluxo de compra da loja (disponibilidade, retenção, limites, consentimentos). Não confirma pagamentos: o comprador recebe as instruções por WhatsApp e você confirma o pagamento no painel.
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}'Erros
Os erros sempre têm este formato. O code é o contrato (estável); o message é informativo (em inglês); details é opcional; requestId coincide com o cabeçalho 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 | Chave ausente, malformada, desconhecida, expirada ou revogada (não se revela qual). |
| 403 | insufficient_scope | Falta o escopo que o endpoint exige (details.required). |
| 403 | api_not_in_plan | O plano do organizador não inclui acesso à API. |
| 403 | tenant_inactive | O organizador está suspenso. |
| 404 | raffle_not_found, order_not_found, ticket_not_found, draw_not_found, not_found | Não existe (ou é de outro organizador ou um rascunho: não se distingue). Variantes: raffle_not_found, order_not_found, ticket_not_found, draw_not_found. |
| 409 | numbers_unavailable | Algum número pedido não está mais livre (details.numbers). |
| 409 | not_enough_available | Não restam números suficientes disponíveis. |
| 409 | raffle_not_open | A rifa não está aberta para venda. |
| 409 | sales_closed | As vendas da rifa já foram encerradas. |
| 409 | billing_restricted | A cobrança está restrita: criar reservas está bloqueado (as leituras continuam). |
| 422 | validation_failed | Requisição inválida; details lista as mensagens. |
| 422 | invalid_cursor | Cursor inválido ou adulterado. |
| 422 | range_too_large | O intervalo de números excede o teto (details.max). |
| 422 | numbers_query_required | Use exatamente uma forma de consulta: numbers, from/to ou sample. |
| 422 | idempotency_key_required | Falta o cabeçalho Idempotency-Key. |
| 422 | idempotency_key_reused | A mesma Idempotency-Key foi usada com outro corpo. |
| 422 | whatsapp_consent_required | whatsapp_consent deve ser true. |
| 422 | age_confirmation_required | age_confirmed deve ser true. |
| 422 | numbers_or_quantity_required | Informe numbers ou quantity. |
| 422 | invalid_numbers | Algum número está fora do intervalo ou repetido. |
| 422 | max_per_order_exceeded | O máximo de bilhetes por pedido da rifa foi excedido. |
| 429 | rate_limited | Limite de requisições excedido; respeite o Retry-After. |
| 503 | platform_maintenance | Plataforma em manutenção: as leituras continuam, criar reservas não. |
| 503 | db_lock_timeout, db_statement_timeout | db_lock_timeout / db_statement_timeout: tente novamente em alguns segundos. |
| 500 | internal_error | Erro inesperado; cite o requestId ao contatar o suporte. |
Dados pessoais
Por minimização, os pedidos retornam o nome abreviado (María R.), o e-mail mascarado e o telefone mascarado (apenas os dois últimos dígitos), com pii_included: false.
Os dados completos só são retornados a uma chave com o escopo buyers:read_pii (que também exige orders:read) e pedindo-os com ?include=buyer_pii. Cada acesso é registrado no registro de acessos a dados pessoais (uma linha por comprador retornado); se o registro falhar, a requisição falha. A pessoa pode saber disso por meio de sua solicitação de acesso. Peça apenas se sua integração precisar.
As cargas dos webhooks nunca incluem nome, e-mail nem telefone de compradores, nem o conteúdo de mensagens de suporte.
Referência de endpoints
Gerada ao vivo a partir do documento OpenAPI da API (GET /v1/openapi.json). Os textos da referência técnica vêm do contrato e estão em inglês.
Guia de webhooks
O Rifana avisa seu servidor quando algo acontece no seu organizador enviando uma requisição POST assinada para a URL que você cadastrar. É somente de saída: o que seu servidor responder nunca altera pedidos, pagamentos, bilhetes, sorteios nem ganhadores (apenas um trecho de texto é arquivado no histórico). Exige um plano com acesso à API.
Eventos e cargas
Cada fato produz exatamente um evento, criado na mesma transação do fato: se a operação for revertida, o evento não existe; se for confirmada, ele existe mesmo que seu servidor esteja fora do ar. Um endpoint só recebe os tipos que assina (ou todos). Eventos ocorridos enquanto um endpoint está pausado ou desativado por falhas não são enfileirados para ele; recupere-os com o reenvio 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": { }
}As cargas são mínimas: identificadores, códigos, valores em centavos e moeda. Nunca nome, e-mail nem telefone de compradores, nem assunto ou conteúdo de mensagens de suporte.
order.createdUma reserva (pedido pendente de pagamento) foi criada pela loja ou pela API.Exemplo 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.expiredUma reserva expirou sem pagamento e seus números foram liberados.Exemplo 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.cancelledUm pedido foi cancelado (pelo comprador ou por você) e seus números foram liberados.Exemplo 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.confirmedVocê confirmou um pagamento (ou um gateway verificado o confirmou); o pedido passa a pago.Exemplo 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.issuedOs bilhetes de um pedido pago foram emitidos (um evento por pedido).Exemplo 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.completedUm sorteio foi executado (resultado interno; ainda pode não estar publicado).Exemplo 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_publishedO resultado do sorteio e seu bilhete vencedor foram publicados (sem dados pessoais do ganhador).Exemplo 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_updatedUm caso de suporte mudou (status, prioridade, atribuição, nova mensagem…). Somente metadados, sem o conteúdo das mensagens.Exemplo 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" }
Cabeçalhos de cada requisição
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 o fato (igual em todas as tentativas e em todos os seus endpoints); X-Rifana-Delivery identifica a entrega (endpoint + evento; estável entre tentativas).
Assinatura e verificação
A assinatura é v1 = HMAC-SHA256(segredo, t + "." + corpo_cru) em hexadecimal, onde t é o timestamp Unix (segundos) do cabeçalho, o segredo é o seu whsec_… completo e o corpo é o texto exato recebido (não o serialize de novo).
Tolerância de tempo: rejeite requisições cujo t difira mais de 5 minutos (300 s) do seu relógio. Cada nova tentativa é assinada de novo com um t novo. Compare as assinaturas em tempo constante.
Durante uma rotação de segredo o cabeçalho leva duas assinaturas (t=…,v1=<novo>,v1=<anterior>): aceite a requisição se alguma coincidir.
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, novas tentativas e pausa
Sucesso = resposta HTTP 2xx. Qualquer outra coisa (3xx, 4xx, 5xx, erro de rede, tempo esgotado, destino bloqueado) é falha. Redirecionamentos não são seguidos. Prazos: 5 s para conectar e 10 s no total por tentativa.
Novas tentativas com espera crescente após cada falha: 1 min, 5 min, 30 min, 2 h, 6 h, 24 h e 24 h (±10 % de variação), até 8 tentativas no total; a oitava falha deixa a entrega como abandoned. Um reenvio manual abre um novo ciclo de 8 tentativas.
Pausa automática: após várias falhas consecutivas do endpoint (10 por padrão) ele passa a “desativado por falhas”, deixa de receber eventos e o proprietário é avisado por e-mail. Retomá-lo reinicia o contador. Um sucesso também o reinicia.
Responda rápido (2xx em menos de 10 s) e processe depois; se seu processamento demora, enfileire-o internamente. Estados de uma entrega: pending, delivering, succeeded, failed (haverá nova tentativa), abandoned e skipped (ignorada pelo limite de eventos do plano ou por não incluir a API).
Idempotência e ordem no seu receptor
A entrega é pelo menos uma vez: após uma falha de rede, uma reinicialização ou um tempo esgotado o mesmo evento pode chegar mais de uma vez. Deduplique por X-Rifana-Event-Id (ou pelo id do corpo) guardando os já processados; processar o mesmo id duas vezes deve ser inofensivo. A ordem entre eventos diferentes não é garantida: apoie-se em created_at e no estado da entidade.
Requisitos da URL (proteção SSRF)
Como você escolhe a URL, o Rifana impede que ela aponte para a rede interna. É validada ao criar, editar, testar e em cada entrega: somente https; portas 443 e 8443; sem usuário nem senha; nada de localhost nem nomes internos (.local, .internal, .lan…); e todos os endereços para os quais o nome resolve devem ser públicos (faixas privadas, locais, de link local, de metadados de nuvem, multicast e reservadas são rejeitadas, em IPv4 e IPv6).
O DNS é resolvido de novo em cada entrega e a conexão vai direto ao IP já validado (contra “DNS rebinding”). Se uma URL for rejeitada ao criar ou editar, você recebe 422 webhook_url_invalid com o motivo em details.reason; uma entrega bloqueada fica registrada como falha com blocked:<motivo> e não chega a conectar. Use HTTPS com certificado válido.
Limites e reenvio
Seu plano define quantos endpoints você pode ter e quantos eventos com entrega são gerados por dia UTC; o excedente é registrado, mas suas entregas ficam skipped com o motivo plan_limit e não são enviadas. No histórico de entregas o proprietário pode reenviar uma entrega ou um evento: um novo ciclo de tentativas começa e seu receptor deve deduplicar.
O segredo whsec_… é mostrado uma única vez (ao criar o endpoint ou rotacioná-lo) e guardado cifrado. Nunca aparece em listas, histórico nem e-mails. O histórico guarda cada tentativa com os cabeçalhos redigidos (a assinatura como [redacted]), código de resposta, tempo e um trecho de até 1 KB.
Segurança e boas práticas
- Guarde a chave e o segredo de webhooks em variáveis de ambiente ou em um gerenciador de segredos; nunca no código nem em repositórios.
- Use a chave apenas em servidores. Uma chave em um site público ou em um app móvel é uma chave vazada.
- Princípio do menor privilégio: uma chave por integração, só com as permissões necessárias e, se possível, com expiração e limite próprio.
- Rotacione chaves e segredos regularmente usando o período de carência; revogue imediatamente qualquer um que tenha sido exposto.
- Verifique sempre a assinatura e o timestamp de cada webhook sobre o corpo cru, comparando em tempo constante.
- Deduplique os eventos pelo ID e não presuma ordem de chegada.
- Respeite o
Retry-Aftere os cabeçalhosRateLimit-*; useETag/If-None-Matchpara não gastar requisições. - Peça dados pessoais de compradores apenas se realmente precisar e trate-os conforme seu aviso de privacidade.
- Registre o
requestIddos erros: é a primeira coisa que o suporte vai pedir.