Authentication¶
How your calls to 19flip are authenticated, and how you verify that a wallet callback really comes from 19flip.
There are two keys: api_key authenticates both directions, secret_key signs every callback we send you.
Your keys¶
| Key | Used by you | Used by 19flip |
|---|---|---|
api_key |
Sent in the Api-Key header on launch-game |
Sent back to you in Authorization: Bearer and X-API-Key on every callback |
secret_key |
Used to verify X-Signature on callbacks |
Used to compute X-Signature on callbacks |
Both are issued by 19flip. Keep them server-side. There is no separate "provider key": the key in our callbacks is your own api_key.
Calls from you to 19flip¶
Api-Key header¶
Every request to the operator API must carry your key:
POST /api/operator/v1/launch-game HTTP/1.1
Host: {PROVIDER_HOST}
Content-Type: application/json
Api-Key: YOUR_API_KEY
The header name is Api-Key (HTTP header names are case-insensitive). Authorization: Bearer is not accepted on the operator API.
You do not need to sign requests to the operator API. An X-Signature header on launch-game is ignored.
The checks run in this order:
| Check | Failure | HTTP | code |
detail |
|---|---|---|---|---|
| Header present | missing | 401 | AUTH_ERROR |
Api key not provided |
| Key matches your current key, or your previous key during a rotation grace period | unknown or expired key | 401 | AUTH_ERROR |
Operator not found |
| Caller IP is in your IP allowlist (if you have one) | IP not listed | 403 | IP_NOT_ALLOWED |
IP 203.0.113.7 is not allowed for this operator |
Your account is ACTIVE |
account suspended | 403 | OPERATOR_SUSPENDED |
Operator is suspended |
IP allowlist¶
- The allowlist is a list of IP addresses or CIDR ranges (for example
203.0.113.0/24,2001:db8::/32). A single IP is stored as a/32(or/128). - An empty allowlist allows any IP.
- The caller IP is the address our edge proxy sees for your connection. If you call us through your own proxy or NAT, list its public egress address.
- The allowlist applies to your calls to 19flip. It does not affect callbacks.
Key rotation¶
19flip can rotate your api_key, your secret_key, or both.
| What is rotated | Your calls to us (Api-Key) |
Our callbacks to you |
|---|---|---|
api_key |
The previous key keeps working for a grace period (default 24 hours; set per rotation between 0 and 720 hours; 0 means it stops immediately). After that it is rejected with 401 Operator not found. |
Carry the new key in Authorization / X-API-Key immediately. |
secret_key |
Not affected. | Are signed with the new secret immediately. There is no grace period. |
To rotate without failed callbacks, for the duration of the switch accept both the old and the new api_key on callbacks and verify X-Signature against both the old and the new secret_key. Agree the rotation time with 19flip.
Callbacks from 19flip to you¶
Every wallet callback is an HTTP POST with these headers:
| Header | Value |
|---|---|
Content-Type |
application/json |
Authorization |
Bearer <your api_key> |
X-API-Key |
<your api_key> |
X-Signature |
hex(sha256(raw_body + secret_key)), lowercase hex, 64 characters |
Check the key in either Authorization or X-API-Key (they carry the same value), and check the signature. The signature is what proves the body was not changed.
Signature algorithm¶
raw_body_bytesare the exact bytes of the HTTP request body as received.- The secret is appended after the body. There is no separator, no HMAC, no timestamp.
- The body is always JSON and always non-empty on callbacks, so
X-Signatureis always present.
Verify against the raw bytes
Hash the body exactly as it arrived. Do not parse the JSON and serialize it again before hashing: key order, spaces and number formatting would change and the signature would never match.
Our bodies are serialized with a space after : and , (for example {"player_id": "player-123", "currency": "USD"}) and non-ASCII characters escaped as \uXXXX. You do not need to rely on this – just hash what you received.
Test vector¶
Use this to check your implementation.
secret_key:my-secret-key- Raw body (75 bytes, no trailing newline):
- Expected
X-Signature:
A second vector, a bet debit body with the same secret:
{"player_id": "player-123", "amount": 1.5, "currency": "USD", "round_id": 48213, "session_id": "3f6c2a9e-8d41-4b7a-9c55-1e2f0a7b9d10", "idempotency_key": "bb-tkt-7-48213-9921", "game_id": "bingo-bongo", "transaction_id": null}
Verification in Python¶
import hashlib
import hmac
import json
from fastapi import FastAPI, HTTPException, Request
API_KEY = "YOUR_API_KEY"
SECRET_KEYS = ["YOUR_SECRET_KEY"] # add the old secret here during a rotation
app = FastAPI()
def signature_ok(raw_body: bytes, received: str) -> bool:
for secret in SECRET_KEYS:
expected = hashlib.sha256(raw_body + secret.encode("utf-8")).hexdigest()
if hmac.compare_digest(expected, received.lower()):
return True
return False
def api_key_ok(request: Request) -> bool:
auth = request.headers.get("authorization", "")
bearer = auth[7:] if auth.startswith("Bearer ") else None
key = bearer or request.headers.get("x-api-key")
return key is not None and hmac.compare_digest(key, API_KEY)
async def verified_json(request: Request) -> dict:
raw = await request.body() # exact bytes, before any parsing
if not signature_ok(raw, request.headers.get("x-signature", "")):
raise HTTPException(status_code=401, detail="Invalid signature")
if not api_key_ok(request):
raise HTTPException(status_code=401, detail="Invalid API key")
return json.loads(raw)
@app.post("/api/providers/19flip/balance")
async def balance(request: Request):
body = await verified_json(request)
...
import hashlib
import hmac
def verify(raw_body: bytes, x_signature: str, secret_key: str) -> bool:
expected = hashlib.sha256(raw_body + secret_key.encode("utf-8")).hexdigest()
return hmac.compare_digest(expected, x_signature.lower())
raw = b'{"player_id": "player-123", "currency": "USD", "session_id": "healthcheck"}'
assert verify(raw, "db1f31056abe973a2928255330023db0d9af60b293f79fdef524df8296f7e32a", "my-secret-key")
Verification in Node.js¶
const crypto = require("crypto");
const express = require("express");
const API_KEY = process.env.NINETEENFLIP_API_KEY;
const SECRET_KEYS = [process.env.NINETEENFLIP_SECRET_KEY]; // add the old secret during a rotation
const app = express();
// Keep the raw bytes: use express.raw, NOT express.json, on these routes.
const rawJson = express.raw({ type: "application/json", limit: "1mb" });
function signatureOk(rawBody, received) {
if (typeof received !== "string" || received.length !== 64) return false;
const got = Buffer.from(received.toLowerCase(), "utf8");
return SECRET_KEYS.some((secret) => {
const expected = crypto
.createHash("sha256")
.update(Buffer.concat([rawBody, Buffer.from(secret, "utf8")]))
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected, "utf8"), got);
});
}
function apiKeyOk(req) {
const auth = req.get("authorization") || "";
const key = auth.startsWith("Bearer ") ? auth.slice(7) : req.get("x-api-key");
return typeof key === "string" && key === API_KEY;
}
function verified(req, res, next) {
if (!signatureOk(req.body, req.get("x-signature"))) {
return res.status(401).json({ error: "Invalid signature" });
}
if (!apiKeyOk(req)) {
return res.status(401).json({ error: "Invalid API key" });
}
req.payload = JSON.parse(req.body.toString("utf8"));
next();
}
app.post("/api/providers/19flip/balance", rawJson, verified, async (req, res) => {
const { player_id, currency, session_id } = req.payload;
// ...
});
const crypto = require("crypto");
function verify(rawBody /* Buffer */, xSignature, secretKey) {
const expected = crypto
.createHash("sha256")
.update(Buffer.concat([rawBody, Buffer.from(secretKey, "utf8")]))
.digest("hex");
const got = Buffer.from(String(xSignature).toLowerCase(), "utf8");
const exp = Buffer.from(expected, "utf8");
return got.length === exp.length && crypto.timingSafeEqual(got, exp);
}
const raw = Buffer.from('{"player_id": "player-123", "currency": "USD", "session_id": "healthcheck"}', "utf8");
console.log(verify(raw, "db1f31056abe973a2928255330023db0d9af60b293f79fdef524df8296f7e32a", "my-secret-key")); // true
Common mistakes¶
| Mistake | Symptom |
|---|---|
Hashing JSON.stringify(req.body) / json.dumps(parsed) instead of the raw bytes |
Every signature fails |
| A body-parsing middleware consumed the stream before your handler | Raw body is empty or re-encoded |
Hashing secret + body instead of body + secret |
Every signature fails |
| Using HMAC-SHA256 instead of plain SHA-256 | Every signature fails |
Comparing with == |
Works, but leaks timing; use a constant-time compare |
Rejecting callbacks right after an api_key or secret_key rotation |
Callbacks fail until you switch keys; accept both during the switch |
What to return when authentication fails¶
Answer 401 with any JSON body. We treat 4xx as a definitive refusal: the call is not retried and the operation is recorded as failed on our side. Make sure a key or signature problem is visible in your monitoring – it stops all money movement for your players.