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.

Baixar OpenAPI v1 (JSON)

Autenticação

Envie sua chave no cabeçalho Authorization de cada requisição:

Authorizationhttp
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.

Escopos
EscopoPermite
raffles:readLer rifas. Lista e detalhes das suas rifas publicadas (nunca rascunhos).
numbers:readConsultar números. Disponibilidade de números: livres, retidos ou vendidos.
orders:readLer pedidos. Pedidos com dados do comprador minimizados (nome abreviado, e-mail e telefone mascarados).
tickets:readLer bilhetes. Bilhetes e seu status (vendido, retido, anulado).
draws:readLer sorteios. Resultados de sorteios já publicados, sem dados pessoais do ganhador.
stats:readLer vendas líquidas. Vendas líquidas por período, rifa e moeda.
orders:createCriar 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_piiVer 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.

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"

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.

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}'

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.

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"
  }
}
Erros
HTTPCódigoSignificado
401invalid_api_keyChave ausente, malformada, desconhecida, expirada ou revogada (não se revela qual).
403insufficient_scopeFalta o escopo que o endpoint exige (details.required).
403api_not_in_planO plano do organizador não inclui acesso à API.
403tenant_inactiveO organizador está suspenso.
404raffle_not_found, order_not_found, ticket_not_found, draw_not_found, not_foundNã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.
409numbers_unavailableAlgum número pedido não está mais livre (details.numbers).
409not_enough_availableNão restam números suficientes disponíveis.
409raffle_not_openA rifa não está aberta para venda.
409sales_closedAs vendas da rifa já foram encerradas.
409billing_restrictedA cobrança está restrita: criar reservas está bloqueado (as leituras continuam).
422validation_failedRequisição inválida; details lista as mensagens.
422invalid_cursorCursor inválido ou adulterado.
422range_too_largeO intervalo de números excede o teto (details.max).
422numbers_query_requiredUse exatamente uma forma de consulta: numbers, from/to ou sample.
422idempotency_key_requiredFalta o cabeçalho Idempotency-Key.
422idempotency_key_reusedA mesma Idempotency-Key foi usada com outro corpo.
422whatsapp_consent_requiredwhatsapp_consent deve ser true.
422age_confirmation_requiredage_confirmed deve ser true.
422numbers_or_quantity_requiredInforme numbers ou quantity.
422invalid_numbersAlgum número está fora do intervalo ou repetido.
422max_per_order_exceededO máximo de bilhetes por pedido da rifa foi excedido.
429rate_limitedLimite de requisições excedido; respeite o Retry-After.
503platform_maintenancePlataforma em manutenção: as leituras continuam, criar reservas não.
503db_lock_timeout, db_statement_timeoutdb_lock_timeout / db_statement_timeout: tente novamente em alguns segundos.
500internal_errorErro 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.

Carregando…

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.

Envelope do corpo JSON (data varia conforme o 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": { }
}

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.created Uma 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.expired Uma 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.cancelled Um 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.confirmed Você 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.issued Os 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.completed Um 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_published O 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_updated Um 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

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 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.

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, 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-After e os cabeçalhos RateLimit-*; use ETag / If-None-Match para 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 requestId dos erros: é a primeira coisa que o suporte vai pedir.