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.