Skip to content
Prividium

Build an M2M App

Backend services should authenticate as a machine-to-machine (M2M) application when they call the Prividium API without an interactive user session. This guide builds a small Node.js service that:

  • demonstrates how to get started building an M2M application
  • checks whether a Prividium user has full read access for chain data
  • fetches registered contract metadata by address

Prerequisites

  • Node.js 22 or newer
  • a local or testnet Prividium environment

1. Create a new role

Ask a Prividium administrator to create a dedicated role for this backend service with the minimal permissions required. For this service, those minimal permissions include checking if a user has full read access and reading contract metadata.

  1. Open the admin panel.
  2. Select Roles in the sidebar menu.
  3. Click the Add role button.
  4. Name the role Backend_Service_Test.
  5. Under the "M2M / Organization" system permissions, select the "Check User Read Access" and "Contract Metadata Read" permissions. This will allow the backend service to check whether a user has full read access and read registered contract metadata.
  6. Click the Create button to finish creating the new role.

2. Create the M2M Application

Ask a Prividium administrator to create a dedicated M2M application for this backend service.

  1. Open the admin panel.
  2. Select M2M Apps in the sidebar menu.
  3. Click the New M2M app button.
  4. Name the app.
  5. Assign the Backend_Service_Test role.
  6. Click the Create M2M App button to finish creating the new M2M app.

Create an API Key

After creating the M2M app, create one API key for local testing:

  1. Open the M2M application's detail page.
  2. Select the API Keys tab.
  3. Click Add API Key.
  4. Use a short expiration for local testing.
  5. Store the full API key immediately. The admin panel displays it only once.

For the full admin workflow, see Manage M2M Applications.

Add the Backend Source IP to the Whitelist

Prividium rejects M2M API-key requests unless the request comes from a whitelisted source IP address. The IP address to whitelist is the client IP as observed by the Permissions API, which may not be your machine's local address.

If you are running a local Prividium via Docker, note that Docker Desktop on macOS routes host-to-container traffic through an internal VM proxy, so the Permissions API sees a source IP that does not match your laptop's Wi-Fi, Ethernet, loopback, or Docker bridge addresses. This IP varies across machines.

Find your Docker client IP

Run a throwaway container to discover the source IP that Docker containers see for requests from your host:

docker run --rm -d --name ip-probe -p 9876:80 nginx:alpine > /dev/null
sleep 1
curl -s http://localhost:9876 > /dev/null
docker logs ip-probe 2>&1 | grep -oE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+'
docker rm -f ip-probe > /dev/null 2>&1

The command prints a single IP address. This is the address you need to whitelist.

Add the IP to the whitelist

  1. Open the M2M application's detail page.
  2. Select the IP Whitelist tab.
  3. Click Add IP Address.
  4. Enter the IP address.
  5. Click Add.

3. Create the Service

Create a small TypeScript Node.js project:

mkdir prividium-safe-backend
cd prividium-safe-backend
npm init -y
npm pkg set type=module
npm install --save-dev tsx typescript @types/node

Create server.ts:

server.ts
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { pathToFileURL } from 'node:url';

type PrividiumResult<T> =
    | {
          ok: true;
          status: number;
          body: T;
      }
    | {
          ok: false;
          status: number;
          body: string;
      };

type ReadAccessResponse = {
    authorized: boolean;
};

type ContractMetadataResponse = {
    contractAddress: string;
    name: string | null;
    description: string | null;
};

type AppConfig = {
    prividiumApiUrl: string;
    prividiumM2mApiKey: string;
};

type DenyReason = 'contract_not_found' | 'invalid_contract_address' | 'prividium_check_failed';
type DenyBody = {
    authorized: false;
    reason: DenyReason;
    upstreamStatus?: number;
};

function readConfig(): AppConfig {
    return {
        prividiumApiUrl: (process.env.PRIVIDIUM_API_URL ?? 'http://localhost:8000/api').replace(/\/+$/, ''),
        prividiumM2mApiKey: readRequiredEnv('PRIVIDIUM_M2M_API_KEY')
    };
}

function readRequiredEnv(name: string): string {
    const value = process.env[name];
    if (!value) {
        throw new Error(`Missing required environment variable: ${name}`);
    }
    return value;
}

function sendJson(res: ServerResponse, statusCode: number, body: unknown): void {
    res.writeHead(statusCode, {
        'content-type': 'application/json',
        'cache-control': 'no-store'
    });
    res.end(JSON.stringify(body));
}

function deny(res: ServerResponse, statusCode: number, reason: DenyReason, upstreamStatus?: number): void {
    const body: DenyBody = {
        authorized: false,
        reason
    };
    if (upstreamStatus !== undefined) {
        body.upstreamStatus = upstreamStatus;
    }
    sendJson(res, statusCode, body);
}

async function callPrividium<T>(config: AppConfig, path: string, init: RequestInit = {}): Promise<PrividiumResult<T>> {
    if (!path.startsWith('/')) {
        throw new Error('Prividium API paths must be relative');
    }

    const url = `${config.prividiumApiUrl}${path}`;
    const response = await fetch(url, {
        ...init,
        signal: AbortSignal.timeout(5_000),
        headers: {
            'content-type': 'application/json',
            'x-api-key': config.prividiumM2mApiKey,
            ...init.headers
        }
    });

    if (!response.ok) {
        return {
            ok: false,
            status: response.status,
            body: await response.text()
        };
    }

    return {
        ok: true,
        status: response.status,
        body: (await response.json()) as T
    };
}

async function checkUserReadAccess(config: AppConfig, userId: string): Promise<PrividiumResult<ReadAccessResponse>> {
    return callPrividium<ReadAccessResponse>(config, '/m2m-app-queries/check-user-read-access', {
        method: 'POST',
        body: JSON.stringify({ userId })
    });
}

async function getContractMetadata(
    config: AppConfig,
    contractAddress: string
): Promise<PrividiumResult<ContractMetadataResponse>> {
    if (!/^0x[a-fA-F0-9]{40}$/.test(contractAddress)) {
        return {
            ok: false,
            status: 400,
            body: 'Invalid contract address'
        };
    }

    return callPrividium<ContractMetadataResponse>(config, `/m2m-app-queries/contracts/${contractAddress}`);
}

async function handleRequest(config: AppConfig, req: IncomingMessage, res: ServerResponse): Promise<void> {
    const url = new URL(req.url ?? '/', `http://${req.headers.host}`);

    if (req.method === 'GET' && url.pathname === '/health') {
        sendJson(res, 200, { ok: true });
        return;
    }

    const userReadMatch = url.pathname.match(/^\/users\/([^/]+)\/can-read-chain$/);
    if (req.method === 'GET' && userReadMatch) {
        const userId = decodeURIComponent(userReadMatch[1]);
        const result = await checkUserReadAccess(config, userId);

        if (!result.ok) {
            console.warn(`Prividium read-access check failed with HTTP ${result.status}`);
            deny(res, 503, 'prividium_check_failed', result.status);
            return;
        }

        sendJson(res, 200, { authorized: result.body.authorized === true });
        return;
    }

    const contractMatch = url.pathname.match(/^\/contracts\/([^/]+)$/);
    if (req.method === 'GET' && contractMatch) {
        const contractAddress = decodeURIComponent(contractMatch[1]);
        const result = await getContractMetadata(config, contractAddress);

        if (!result.ok) {
            if (result.status === 400) {
                sendJson(res, 400, { found: false, reason: 'invalid_contract_address' });
                return;
            }

            if (result.status === 404) {
                sendJson(res, 404, { found: false, reason: 'contract_not_found' });
                return;
            }

            console.warn(`Prividium contract metadata lookup failed with HTTP ${result.status}`);
            sendJson(res, 503, { found: false, reason: 'prividium_check_failed', upstreamStatus: result.status });
            return;
        }

        sendJson(res, 200, {
            found: true,
            contract: {
                address: result.body.contractAddress,
                name: result.body.name,
                description: result.body.description
            }
        });
        return;
    }

    sendJson(res, 404, { error: 'Not found' });
}

export function createPrividiumBackendService(config = readConfig()) {
    return createServer((req, res) => {
        handleRequest(config, req, res).catch((error: unknown) => {
            console.error('Request failed', error instanceof Error ? error.message : error);
            sendJson(res, 500, { error: 'Internal server error' });
        });
    });
}

export function startServer(): void {
    const port = Number.parseInt(process.env.PORT ?? '8787', 10);
    const server = createPrividiumBackendService();

    server.listen(port, '127.0.0.1', () => {
        console.log(`Prividium backend service listening on http://localhost:${port}`);
    });
}

function isMainModule(): boolean {
    const entrypoint = process.argv[1];
    return entrypoint ? import.meta.url === pathToFileURL(entrypoint).href : false;
}

if (isMainModule()) {
    startServer();
}

This service exposes three endpoints:

  1. /health: checks the health of the service.
  2. /users/<USER_ID>/can-read-chain: checks whether the user with the given ID has full read access to chain data (the Full Read Access system permission).
  3. /contracts/<CONTRACT_ADDRESS>: checks for information about the given contract address.

In production, you likely would not want to expose these particular endpoints. These examples are just used as a demonstration for how a backend service can interact with Prividium.

4. Configure Environment Variables

Configure the API key for the M2M app in your terminal:

export PRIVIDIUM_M2M_API_KEY="YOUR_API_KEY"

The server uses http://localhost:8000/api as the default Permissions API URL, which is what the default local Prividium configuration uses. For non-local environments, set PRIVIDIUM_API_URL to the deployed Permissions API URL.

# Only required if the API is running somewhere other than http://localhost:8000/api
export PRIVIDIUM_API_URL="YOUR_API_ENDPOINT"

5. Start the Service

In your package.json file, add a new script to start the server:

"scripts": {
  "dev": "tsx server.ts"
},

Next, start the service with the command below:

npm run dev

In a new terminal, check that the service is running using the /health endpoint:

curl http://localhost:8787/health

You should see this response:

{ "ok": true }

6. Validate a User's Access

Read a known Prividium user ID from your terminal. Use a user you can safely test. For a local Prividium network with the default users, you can use local-user1 for the user ID.

Run the command below to check local-user1's read access with the /users endpoint.

curl "http://localhost:8787/users/local-user1/can-read-chain"

Because local-user1 has limited read access, you should see this response:

{ "authorized": false }

If you try again with an admin account, such as usr_admin_test_00001 on a local network:

curl "http://localhost:8787/users/usr_admin_test_00001/can-read-chain"

You should see that the admin user does have access:

{ "authorized": true }

If the response includes reason: "prividium_check_failed", check the upstreamStatus value:

Upstream statusWhat to check
401The API key expired, was revoked, or the request source IP is not on the M2M app's IP whitelist.
404The user ID does not exist, the M2M role is scoped to a different organization than the user, or the M2M role lacks Check User Read Access.
5xxThe Permissions API could not complete the check. Inspect the Permissions API logs before continuing.

Your backend returns the minimum useful answer. It does not return the user's roles, wallets, organization membership, or raw Prividium response.

Troubleshooting: read the IP from audit logs

If you received a 401 response and want to confirm the IP the Permissions API recorded on a local Prividium setup running via Docker, query the audit log directly:

docker exec zksync-prividium-postgres-1 psql -U postgres -d prividium_api -t -c \
  "select action_details->>'clientIp' from audit_logs
   where action_details->>'requestPath' = '/api/m2m-app-queries/check-user-read-access'
   order by created_at desc limit 1;"

7. Read a Contract's Metadata

Next, we can check the metadata for the L2BaseToken contract, a system contract available on all Prividium networks, with the command below.

curl "http://localhost:8787/contracts/0x000000000000000000000000000000000000800A"

You should see a response like this:

{"found":true,"contract":{"address":"0x000000000000000000000000000000000000800A","name":"L2BaseToken","description":"Handles the network’s main token, including transfers back to L1"}}

If the contract is not registered, or the M2M role cannot read its metadata, you will see the response:

{"found":false,"reason":"contract_not_found"}

Production Checklist

Before deploying a backend that calls Prividium APIs:

  • Store PRIVIDIUM_M2M_API_KEY in a secret manager.
  • Give each service its own M2M app or API key.
  • Set a short API key expiration and rotate keys on a schedule.
  • Whitelist only the service's egress IP addresses.
  • Assign only the system permissions the service uses.
  • Return minimal responses to callers.
  • Log request IDs and outcomes, but never log API keys or raw authorization headers.
  • Treat 401, 403, and 404 from Prividium as deny decisions unless a human operator confirms otherwise.
  • Add retry logic only for network errors and 5xx responses.

Next Steps