Rifana · API v1
Developer documentation
Versioned REST API (/v1) and signed webhooks to connect your systems to your Rifana raffles.
Introduction
The Rifana public API lets you, as an organizer, read your raffles, number availability, orders, tickets, published draws and net sales from your own systems (ERP, CRM, spreadsheets, your site). It is read-only except for one expressly bounded write: creating a buyer reservation.
It cannot confirm payments, issue tickets or touch draws or winners: that remains yours, in the dashboard. Webhooks notify your server of what happens, outbound only.
It requires a plan that includes API access. Each organizer's data is isolated: a key only sees its own organizer's data.
Authentication
Send your key in the Authorization header of every request:
Authorization: Bearer rh_live_<prefix 8>_<secret 43>No cookies or CSRF are used: dashboard session cookies do not authenticate the API. A missing, malformed, unknown, expired or revoked key always answers the same (401 invalid_api_key), without revealing the cause.
How to get one: the organizer owner creates it in the dashboard (Developers → API keys). It is shown only once when created or rotated; Rifana only stores an HMAC-SHA256 with a server-side secret. The first 16 characters (rh_live_ plus the prefix) are visible in the dashboard to identify it.
Key security: use it only from servers (never in a public website or a mobile app), keep it in environment variables or a secrets manager, grant only the permissions needed, set an expiry and rotate it if you suspect a leak.
Scopes
Each endpoint requires a scope. Without it the answer is 403 insufficient_scope with details.required.
| Scope | Allows |
|---|---|
raffles:read | Read raffles. List and details of your published raffles (never drafts). |
numbers:read | Query numbers. Number availability: free, held or sold. |
orders:read | Read orders. Orders with minimized buyer data (abbreviated name, masked email and phone). |
tickets:read | Read tickets. Tickets and their status (sold, held, voided). |
draws:read | Read draws. Results of already-published draws, with no winner personal data. |
stats:read | Read net sales. Net sales by period, raffle and currency. |
orders:create | Create reservations. Creates buyer reservations (orders pending payment). Careful: the key can hold numbers in your raffles and the buyer receives a WhatsApp message. It cannot confirm payments or issue tickets. |
buyers:read_pii | See buyers' personal data. Careful: lets the key see full name, email and phone. Every access is logged. Use it only if your integration needs it and handle that data according to your privacy notice. |
Plan and organizer status
Without the api_access plan feature, keys cannot be created (402 plan_feature_required) and existing ones answer 403 api_not_in_plan right away; they work again if the plan regains it. A suspended organizer answers 403 tenant_inactive. With restricted billing reads continue and creating reservations answers 409 billing_restricted; during platform maintenance, 503 platform_maintenance.
Versioning and changes
The major version is in the path (/v1). A breaking change (removing or renaming fields, changing types or semantics, tightening validations) only arrives in /v2, with prior notice and /v1 operating for at least 6 months from the notice.
Within v1 there are only additive changes: new fields, endpoints, enum values and scopes. Your client must ignore unknown fields and tolerate new enum values. Error codes are stable and new ones may be added. The contract version (info.version in the OpenAPI) goes up with each additive or documentation change.
Response format
One resource: { "data": { … } }. A list: { "data": [ … ], "pagination": { … } }. Fields in snake_case; amounts in integer cents plus an ISO currency; ISO-8601 UTC dates. Currencies are never mixed or converted. Every response carries an X-Request-Id header: quote it when contacting support (if you send your own, 8 to 64 characters A-Za-z0-9._-, it is kept).
Reads return a weak ETag. Send it back in If-None-Match and you get 304 Not Modified with no body while the data is unchanged (a 304 also counts toward the limit).
Rate limits
The requests-per-minute limit comes from your plan (if the plan with API access does not define it, it is 60) and you can set an own limit per key, never above the plan's. It uses a sliding one-minute window per key and counts all authenticated requests, including those answering 4xx or 304.
Every authenticated response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (seconds until the window rolls). When exceeded: 429 rate_limited with Retry-After (seconds, integer ≥ 1) and details: { retryAfterSeconds, limitPerMinute }. Wait that long before retrying.
Other plan limits: active keys, webhook endpoints and events with delivery per day. Your plan's values are in the dashboard Overview tab.
Pagination
Lists use cursor pagination: ?limit= (1–100, default 25; tickets 1–500, default 100) and ?cursor= with the opaque next_cursor from the previous page. Continue while pagination.has_more is true. Every item appears exactly once even if new data arrives between pages. An invalid or tampered cursor answers 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"Idempotency
The only write, POST /v1/raffles/:id/orders, requires the Idempotency-Key header (8–64 characters A-Za-z0-9_-). Repeating the request with the same key returns the same order with 200 and Idempotent-Replayed: true; with a different body, 422 idempotency_key_reused. Simultaneous requests with the same key create a single order, so retrying is safe.
The reservation follows the same purchase flow as the storefront (availability, hold, limits, consents). It does not confirm payments: the buyer receives instructions on WhatsApp and you confirm the payment in the dashboard.
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}'Errors
Errors always have this format. The code is the contract (stable); the message is informational (in English); details is optional; requestId matches the X-Request-Id header.
{
"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 | Code | Meaning |
|---|---|---|
| 401 | invalid_api_key | Key missing, malformed, unknown, expired or revoked (which one is not revealed). |
| 403 | insufficient_scope | The key lacks the scope the endpoint requires (details.required). |
| 403 | api_not_in_plan | The organizer's plan does not include API access. |
| 403 | tenant_inactive | The organizer is suspended. |
| 404 | raffle_not_found, order_not_found, ticket_not_found, draw_not_found, not_found | It does not exist (or belongs to another organizer or is a draft: not distinguished). Variants: raffle_not_found, order_not_found, ticket_not_found, draw_not_found. |
| 409 | numbers_unavailable | A requested number is no longer free (details.numbers). |
| 409 | not_enough_available | Not enough numbers are available. |
| 409 | raffle_not_open | The raffle is not open for sale. |
| 409 | sales_closed | Sales for the raffle have closed. |
| 409 | billing_restricted | Billing is restricted: creating reservations is blocked (reads continue). |
| 422 | validation_failed | Invalid request; details lists the messages. |
| 422 | invalid_cursor | Invalid or tampered cursor. |
| 422 | range_too_large | The number range exceeds the cap (details.max). |
| 422 | numbers_query_required | Use exactly one query form: numbers, from/to or sample. |
| 422 | idempotency_key_required | The Idempotency-Key header is missing. |
| 422 | idempotency_key_reused | The same Idempotency-Key was used with a different body. |
| 422 | whatsapp_consent_required | whatsapp_consent must be true. |
| 422 | age_confirmation_required | age_confirmed must be true. |
| 422 | numbers_or_quantity_required | Provide numbers or quantity. |
| 422 | invalid_numbers | A number is out of range or repeated. |
| 422 | max_per_order_exceeded | The raffle's maximum tickets per order is exceeded. |
| 429 | rate_limited | Request limit exceeded; honor Retry-After. |
| 503 | platform_maintenance | Platform in maintenance: reads continue, creating reservations does not. |
| 503 | db_lock_timeout, db_statement_timeout | db_lock_timeout / db_statement_timeout: retry in a few seconds. |
| 500 | internal_error | Unexpected error; quote the requestId when contacting support. |
Personal data
For data minimization, orders return the abbreviated name (María R.), a masked email and a masked phone (only the last two digits), with pii_included: false.
Full data is only returned to a key with the buyers:read_pii scope (which also requires orders:read) and by asking for it with ?include=buyer_pii. Every access is logged in the personal-data access log (one row per buyer returned); if logging fails, the request fails. The person can learn of it through their access request. Ask for it only if your integration needs it.
Webhook payloads never include buyer names, emails or phones, nor support message content.
Endpoint reference
Generated live from the API's OpenAPI document (GET /v1/openapi.json). The technical reference texts come from the contract and are in English.
Webhooks guide
Rifana notifies your server when something happens in your organizer by sending a signed POST request to the URL you register. It is outbound only: whatever your server answers never changes orders, payments, tickets, draws or winners (only a text excerpt is archived in the history). It requires a plan with API access.
Events and payloads
Each fact produces exactly one event, created in the same transaction as the fact: if the operation rolls back, the event does not exist; if it commits, it exists even if your server is down. An endpoint only receives the types it subscribes to (or all). Events that happen while an endpoint is paused or disabled by failures are not queued for it; recover them with manual resend.
{
"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": { }
}Payloads are minimal: identifiers, codes, amounts in cents and currency. Never buyer names, emails or phones, nor support subjects or message content.
order.createdA reservation (order pending payment) was created from the storefront or the API.Example 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.expiredA reservation expired unpaid and its numbers were released.Example 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.cancelledAn order was cancelled (by the buyer or by you) and its numbers were released.Example 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.confirmedYou confirmed a payment (or a verified gateway did); the order becomes paid.Example 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.issuedThe tickets of a paid order were issued (one event per order).Example 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.completedA draw was executed (internal result; it may not be published yet).Example 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_publishedThe draw result and its winning ticket were published (no winner personal data).Example 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_updatedA support case changed (status, priority, assignment, new message…). Metadata only, no message content.Example 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" }
Headers of every request
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 identifies the fact (the same across all retries and all your endpoints); X-Rifana-Delivery identifies the delivery (endpoint + event; stable across retries).
Signature and verification
The signature is v1 = HMAC-SHA256(secret, t + "." + raw_body) in hex, where t is the header's Unix timestamp (seconds), the secret is your full whsec_… and the body is the exact text received (do not re-serialize it).
Time tolerance: reject requests whose t differs from your clock by more than 5 minutes (300 s). Each retry is signed again with a new t. Compare signatures in constant time.
During a secret rotation the header carries two signatures (t=…,v1=<new>,v1=<previous>): accept the request if either matches.
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);
});Delivery, retries and pausing
Success = an HTTP 2xx response. Anything else (3xx, 4xx, 5xx, network error, timeout, blocked destination) is a failure. Redirects are not followed. Timeouts: 5 s to connect and 10 s in total per attempt.
Retries with growing delay after each failure: 1 min, 5 min, 30 min, 2 h, 6 h, 24 h and 24 h (±10 % jitter), up to 8 attempts in total; the eighth failure leaves the delivery as abandoned. A manual resend starts a new cycle of 8 attempts.
Automatic pausing: after several consecutive failures of the endpoint (10 by default) it becomes “disabled by failures”, stops receiving events and the owner is notified by email. Resuming it resets the counter. A success also resets it.
Answer fast (2xx in under 10 s) and process afterwards; if your processing is slow, queue it internally. Delivery states: pending, delivering, succeeded, failed (a retry will follow), abandoned and skipped (skipped by the plan event limit or because the API is not included).
Idempotency and ordering at your receiver
Delivery is at-least-once: after a network failure, a restart or a timeout the same event may arrive more than once. Deduplicate by X-Rifana-Event-Id (or the body id) by storing the ones already processed; processing the same id twice must be harmless. Order between different events is not guaranteed: rely on created_at and the entity state.
URL requirements (SSRF protection)
Since you choose the URL, Rifana prevents it from pointing at the internal network. It is validated on create, edit, test and on every delivery: https only; ports 443 and 8443; no username or password; no localhost or internal names (.local, .internal, .lan…); and every address the name resolves to must be public (private, local, link-local, cloud-metadata, multicast and reserved ranges are rejected, in IPv4 and IPv6).
DNS is resolved again on every delivery and the connection goes straight to the already-validated IP (against DNS rebinding). If a URL is rejected on create or edit you get 422 webhook_url_invalid with the reason in details.reason; a blocked delivery is recorded as a failure with blocked:<reason> and never connects. Use HTTPS with a valid certificate.
Limits and resend
Your plan sets how many endpoints you may have and how many events with delivery are generated per UTC day; the excess is recorded but its deliveries are skipped with reason plan_limit and not sent. From the delivery history the owner can resend a delivery or an event: a new retry cycle starts and your receiver must deduplicate.
The whsec_… secret is shown only once (when creating the endpoint or rotating it) and stored encrypted. It never appears in lists, history or emails. The history stores each attempt with redacted headers (the signature as [redacted]), response code, time and an excerpt of up to 1 KB.
Security and best practices
- Keep the key and the webhook secret in environment variables or a secrets manager; never in code or repositories.
- Use the key from servers only. A key in a public website or a mobile app is a leaked key.
- Least privilege: one key per integration, only with the permissions needed and, if possible, an expiry and an own limit.
- Rotate keys and secrets regularly using the grace period; revoke any exposed one immediately.
- Always verify the signature and timestamp of every webhook over the raw body, comparing in constant time.
- Deduplicate events by their ID and do not assume arrival order.
- Honor
Retry-Afterand theRateLimit-*headers; useETag/If-None-Matchto save requests. - Request buyers' personal data only if you really need it and handle it according to your privacy notice.
- Log the
requestIdof errors: it is the first thing support will ask for.