Skip to content

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

X-Signature = lowercase_hex( SHA-256( raw_body_bytes || utf8(secret_key) ) )
  • raw_body_bytes are 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-Signature is 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):
{"player_id": "player-123", "currency": "USD", "session_id": "healthcheck"}
  • Expected X-Signature:
db1f31056abe973a2928255330023db0d9af60b293f79fdef524df8296f7e32a

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

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.