> **Can't find what you're looking for?** Use `search_docs` on the docs MCP server at `https://docs.prividium.com/api/mcp` to find what you need.
>
> **Have feedback?** Use `submit_feedback` on the same MCP server.

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](/configuring/applications/m2m-applications).

:::info[Important]
Never send an M2M API key to a browser, mobile app, wallet, support tool, or customer-controlled runtime. Store it only
in your backend secret manager or local shell environment.
:::

### 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:

```bash
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:

```bash
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**

```typescript
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:

```bash
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.

```bash
# 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:

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

Next, start the service with the command below:

```bash
npm run dev
```

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

```bash
curl http://localhost:8787/health
```

You should see this response:

```json
{ "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.

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

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

```json
{ "authorized": false }
```

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

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

You should see that the admin user does have access:

```json
{ "authorized": true }
```

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

| Upstream status | What to check                                                                                                                               |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `401`           | The API key expired, was revoked, or the request source IP is not on the M2M app's IP whitelist.                                            |
| `404`           | The 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. |
| `5xx`           | The 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, the error message says whether the key is malformed or was rejected. Ask an administrator to check **Admin panel → Audit Logs** for sampled API-key authentication failures.

An entry records the IP of the sampled request, which may differ from your request's IP. Your request may have no entry. See [API-Key Failure Audits](/configuring/applications/rate-limiting#api-key-failure-audits) for the limits and [Developer troubleshooting](/developers/applications/developer-troubleshooting#api-key-errors) for each error message.

On a local Prividium setup running via Docker, this query returns the latest sampled IP for the route, not necessarily your request:

```bash
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.

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

You should see a response like this:

```json
{
  "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:

```json
{ "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

* Use [Rate Limiting](/configuring/applications/rate-limiting) to understand per-key limits.
* Use [M2M Applications](/configuring/applications/m2m-applications) to configure keys, roles, IP whitelists, and
  organizations.
* Use [Webhooks Overview](/developers/webhooks/overview) if your backend also processes chain events.
