Skip to content

API authentication

The Fanzava API authenticates machine clients with a hub API key. You issue the key in your hub, your client sends it on every request, and the scopes on the key decide what it can reach.

There is no OAuth flow. Fanzava runs no authorization server, so there is nothing to fetch at /.well-known/openid-configuration or /.well-known/oauth-authorization-server, and no authorization code, refresh token or consent screen anywhere in the process.

Every request goes to https://api.fanzava.com.

Every route that acts on a hub carries the hub id in the path:

https://api.fanzava.com/api/v1/hubs/{hubId}/...

There is no ambient tenant. A hub-scoped route called without the hub id in the path is refused with HTTP 400 and the error code HUB_CONTEXT_MISSING, even when your key belongs to exactly one hub. Naming the hub in a header or a query parameter does not work and is not a supported alternative.

A hub admin creates keys in the hub admin console, under Settings, Integrations, API keys. When you create a key you choose:

  • A name. How the key is identified in the list and in the audit trail.
  • Its scopes. Tick only what the integration needs. A key holds no role and inherits nothing from the admin who created it, so its scopes are the whole of its authority.
  • An optional expiry. A key with an expiry stops working on that date with no further action.

Revoking a key in the same screen kills it immediately.

A key looks like fz_live_<id>:<secret>. Send the whole string, prefix and colon included, in either header:

GET /api/v1/hubs/{hubId}/leaderboards HTTP/1.1
Host: api.fanzava.com
Authorization: Bearer fz_live_a1b2c3d4e5f6g7h8:AbCdEf...
GET /api/v1/hubs/{hubId}/leaderboards HTTP/1.1
Host: api.fanzava.com
X-API-Key: fz_live_a1b2c3d4e5f6g7h8:AbCdEf...

The two are equivalent. Send one, not both.

The fz_ prefix is what tells an API key apart from a browser session token, and the part before the colon is the lookup handle. A key sent without its prefix or without its colon is not recognised as a key at all, and the request is refused as unauthenticated rather than as a bad key.

Scopes are a closed vocabulary. A key may hold any combination of these:

Scope Grants
read:competitions Read competitions, rounds and fixtures
write:competitions Create and update competitions
read:tips Read submitted tips
write:tips Submit and amend tips
read:leaderboards Read leaderboards and standings
read:achievements Read achievements and badges
write:achievements Trigger achievement evaluation, which can award badges
read:users Read user records visible to the hub
read:members Read hub membership
write:members Invite members and import a roster
read:webhooks Read webhook subscriptions
write:webhooks Create, update and delete webhook subscriptions
read:hub Read hub settings and branding
write:hub Update hub settings and branding

A request for a route the key has no scope for is refused with HTTP 403 and Missing required scope: <scope>.

write:achievements reads like a refresh and is not one: it inserts achievement records and dispatches the resulting events. It sits under write for that reason.

API access is a paid capability, and the plan sets both the depth of access and the request ceiling.

Plan API access Requests per minute
Free ✗ 0
Starter ✗ 0
Growth Read-only 60
Pro Full 300
Scale Full 1,000
Enterprise Full 10,000

These are the six plans on the Fanzava pricing page, which is the authority on plan names, prices and caps. Where a docs page disagrees with it, the pricing page is right.

Two things follow from that table.

A plan with no API access refuses a valid key with HTTP 402 and PAYMENT_REQUIRED, not 401. The credential is fine; the plan is the problem. The response carries an upgrade link so you are not left rotating a key that could never have worked.

On a read-only plan, write scopes are stripped from the key on every request. Narrowing happens at authentication rather than at issue, so a downgrade takes effect immediately and an upgrade restores the scopes already stored on the key without you reissuing it. The key list in the console shows the scopes stored against a key and the scopes your current plan actually permits, side by side.

The ceiling in the table above is measured over a rolling 60 second window, per key, not per hub. Two keys on the same hub each get the full ceiling.

Once your key has been accepted and your plan checked, every response carries the current state, the 429 included:

  • X-RateLimit-Limit: your plan’s ceiling
  • X-RateLimit-Remaining: requests left in this window
  • X-RateLimit-Reset: Unix timestamp when the window resets

Over the ceiling, the API answers HTTP 429 with the error code RATE_LIMIT_EXCEEDED. Read the reset header and back off rather than retrying immediately.

Do not treat the headers as guaranteed on every response. A request refused before the limiter runs carries none of them: that is the 401 for a credential we do not recognise, and the 402 for a plan without API access. Both are decided while authenticating, which happens first. Everything past that point is counted and carries the headers, HUB_CONTEXT_MISSING included. Read them when they are there; never require them.

Every response, success or failure, uses the same envelope.

{
"success": true,
"data": { },
"meta": { }
}
{
"success": false,
"error": {
"code": "FORBIDDEN",
"message": "Missing required scope: read:leaderboards"
},
"meta": { }
}

Branch on success and read error.code, not the message. Messages are written for a human reading a log and may be reworded.

Status Code What it means
400 HUB_CONTEXT_MISSING The route needs a hub id in the path and did not get one
401 UNAUTHORIZED No credential, or a credential that was not recognised
402 PAYMENT_REQUIRED The hub’s plan does not include API access
403 FORBIDDEN The key is valid but lacks the scope for this route
404 NOT_FOUND The resource does not exist, or belongs to another hub
429 RATE_LIMIT_EXCEEDED Over the plan’s request ceiling for this window

A resource in another hub answers 404, never 403. That is deliberate: a 403 would confirm the resource exists.

  • Store the key in a secret manager, never in source control, a client-side bundle or a mobile app. Anything running in a browser or on a device can be read.
  • Issue one key per integration. Revoking a shared key takes down everything that uses it.
  • Give each key the narrowest scope set that works. A read-only integration should hold no write scope.
  • Rotate on a schedule: create the replacement, cut the integration over, then revoke the old key.
  • Set an expiry on anything short-lived, such as a migration script or a contractor’s access.

Was this page helpful?

Raise a ticket about this page

Comments