Your backend (Admin API)
Your own server talks to the service in three ways, like Firebase's Admin SDK:
- it calls the Admin API to manage users, points, Remote Config, push, files and data;
- it verifies access tokens that the app sends to it;
- it receives webhooks when users change.
The examples are PHP, with Laravel's HTTP client; any HTTP client works the same way.
Server keys
Create a key on the app's Server keys page in the dashboard (owner or team admin) and choose its
scopes. The key (wz_sk_…) is shown once: store it in your server's secrets, never in an app or a
repository. Revoke a key there at any time.
| Scope | Allows |
|---|---|
users.read / users.write |
Read users / create, update, disable, delete them, end their sessions, set custom claims |
tokens.verify |
Check access tokens |
points.write |
Award loyalty points |
config.read / config.write |
Read / publish and roll back Remote Config |
push.send |
Send push notifications |
files.read / files.write |
Read / upload, change and delete every file |
data.read / data.write |
Read and query / write every data document |
Calls go to https://auth.wizgu.com/api/v2/admin with Authorization: Bearer <server key> (no
X-App-Key: the key names the app). A missing, wrong or revoked key answers 401 server/invalid-key; a
missing scope answers 403 permission-denied. The limit is 600 requests a minute per key.
// config/services.php: 'wizgu' => ['key' => env('WIZGU_SERVER_KEY')]
use Illuminate\Support\Facades\Http;
function wizgu(): \Illuminate\Http\Client\PendingRequest
{
return Http::baseUrl('https://auth.wizgu.com/api/v2/admin')
->withToken(config('services.wizgu.key'))
->acceptJson()
->timeout(10)
->throw(); // throws on 4xx/5xx; $e->response->json('error.code') has the code
}
$app = wizgu()->get('/app')->json('data'); // the app and the key's scopes: a quick key check
Users
// Find users (search matches part of the email or name)
$page = wizgu()->get('/users', ['search' => 'jane', 'limit' => 50])->json();
// $page['data'] = users; $page['meta']['next_cursor'] = next page, or null
$user = wizgu()->post('/users', [
'email' => 'jane@example.com',
'first_name' => 'Jane',
'email_verified' => true, // optional: 'password', 'last_name', 'disabled'
])->json('data');
wizgu()->patch("/users/{$user['uid']}", ['disabled' => true]); // also signs the user out everywhere
wizgu()->post("/users/{$user['uid']}/revoke-sessions");
wizgu()->delete("/users/{$user['uid']}"); // the same deletion as the user's own
The user object has uid, email, email_verified, first_name, last_name, photo_url,
disabled, claims, providers, created_at and last_login_at. Creating or changing an email that
another user of the app has answers 409 auth/email-already-in-use.
Custom claims
Custom claims mark users for your app and backend, for example a role. They appear as claims in the
user's next access token (after a refresh) and can drive Remote Config:
wizgu()->put("/users/{$uid}/claims", ['claims' => ['role' => 'vip']]); // {} clears them; at most 1000 bytes
Loyalty points
$entry = wizgu()->post("/users/{$uid}/points", ['points' => 250, 'description' => 'Order #1042'])->json('data');
// ['points' => 250, 'description' => …, 'balance' => 3250, 'tier' => 'gold', 'created_at' => …]
Negative points take points away. Tiers: silver below 3,000, gold below 10,000, platinum from 10,000.
Remote Config
$live = wizgu()->get('/remote-config');
$version = trim($live->header('ETag'), '"');
$template = $live->json('data.template');
$template['parameters']['max_offers']['default'] = 8;
wizgu()->withHeaders(['If-Match' => "\"{$version}\""]) // 412 if someone published meanwhile
->post('/remote-config/versions', ['template' => $template, 'description' => 'More offers']);
GET /remote-config/versions lists versions; POST /remote-config/versions/{version}/rollback publishes
an old one again. The template format is in the Remote Config guide.
Push notifications
$message = wizgu()->post('/push/send', [
'target' => ['type' => 'users', 'uids' => [$uid]], // or ['type' => 'topic', 'topic' => 'offers'], ['type' => 'all']
'title' => 'Your order is ready',
'body' => 'Pick it up at the counter.',
'data' => ['order_id' => '1042'], // string values, for the app
])->json('data');
// Sending runs in the background (202). Follow it:
$status = wizgu()->get("/push/messages/{$message['id']}")->json('data'); // status, counts.sent, counts.failed…
Files
// A public banner owned by the app, and a file for a user
wizgu()->attach('file', file_get_contents($path), 'banner.png')
->post('/files', ['folder' => 'public/banners', 'visibility' => 'public']);
wizgu()->attach('file', $pdf, 'invoice.pdf')->post('/files', ['uid' => $uid, 'folder' => 'invoices']);
$files = wizgu()->get('/files', ['uid' => $uid])->json('data'); // or ['owner' => 'app'], ['folder' => …]
$url = wizgu()->get("/files/{$files[0]['id']}/url")->json('data.url'); // valid 10 minutes
Data
The backend reads and writes every document, whatever the collection's access mode. A document it
creates belongs to the app, unless the body names an owner:
$collections = wizgu()->get('/data')->json('data');
// A document in an owner_only collection, for a user: they can read and change it in the app
wizgu()->put("/data/rewards/{$uid}", ['fields' => ['level' => 3], 'owner' => $uid]);
$page = wizgu()->post('/data/orders/query', ['where' => [['status', '==', 'paid']], 'limit' => 100])->json();
wizgu()->patch('/data/orders/'.$page['data'][0]['id'], ['fields' => ['status' => 'shipped']]);
Numbers in PHP arrays keep their type; to keep 5.0 a float when forwarding documents, encode JSON
with JSON_PRESERVE_ZERO_FRACTION.
Verifying access tokens
When the app calls your API, it can send its access token; your server checks it. Two ways:
Offline (fast, no call): access tokens are RS256 JWTs. Verify them with the public keys at
https://auth.wizgu.com/.well-known/jwks.json, for example with firebase/php-jwt:
use Firebase\JWT\JWK;
use Firebase\JWT\JWT;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
function verifyAccessToken(string $jwt): object
{
$keys = fn () => Cache::remember('wizgu.jwks', 300, fn () => Http::get('https://auth.wizgu.com/.well-known/jwks.json')->throw()->json());
try {
$claims = JWT::decode($jwt, JWK::parseKeySet($keys()));
} catch (UnexpectedValueException $e) {
// Keys rotate: after an unknown "kid", fetch the keys again once
Cache::forget('wizgu.jwks');
$claims = JWT::decode($jwt, JWK::parseKeySet($keys()));
}
if ($claims->iss !== 'https://auth.wizgu.com' || $claims->aud !== config('services.wizgu.app_key')) {
throw new UnexpectedValueException('Token for another issuer or app.');
}
return $claims; // sub = the user's uid, email, email_verified, claims, exp…
}
JWT::decode checks the signature and expiry. Check iss (the issuer of
/.well-known/openid-configuration) and aud (your app key) yourself. Claims: sub (the uid), sid
(the session), iat, exp, auth_time, email, email_verified, name, picture, provider and
claims.
Online: POST /tokens/verify (scope tokens.verify) with {"token"} always answers 200, with
{"active": false, "reason"} or {"active": true, "uid", "sid", "email", "claims", …}. It also knows
about sessions that were signed out and users that were disabled since the token was issued. Use it for
sensitive actions, offline checks for everything else.
Webhooks
Add endpoints on the app's Webhooks page and choose events: user.created, user.updated,
user.deleted, user.signed_in, points.awarded. Each event is a POST with
{"type", "timestamp", "data"}, signed with Standard Webhooks
headers (webhook-id, webhook-timestamp, webhook-signature) and the endpoint's whsec_… secret.
// routes/api.php in your backend: Route::post('/wizgu/webhook', WizguWebhook::class);
use Illuminate\Http\Request;
class WizguWebhook
{
public function __invoke(Request $request)
{
$id = $request->header('webhook-id');
$timestamp = $request->header('webhook-timestamp');
$key = base64_decode(substr(config('services.wizgu.webhook_secret'), strlen('whsec_')));
$expected = 'v1,'.base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$request->getContent()}", $key, true));
$valid = abs(time() - (int) $timestamp) <= 300
&& collect(explode(' ', (string) $request->header('webhook-signature')))->contains(fn ($s) => hash_equals($expected, $s));
abort_unless($valid, 401);
// webhook-id is the same on retries: skip ids you have already handled
$event = $request->json()->all();
match ($event['type']) {
'user.created' => /* welcome $event['data']['user'] */ null,
'user.deleted' => /* delete your own data for $event['data']['user']['uid'] */ null,
default => null,
};
return response()->noContent();
}
}
Answer with any 2xx within 10 seconds (queue slow work). Failed deliveries are retried after 1, 5
and 30 minutes and 2 hours; the dashboard shows every delivery and can send it again. Endpoints must be
public https:// URLs.