Skip to main content

M2M (Machine-to-Machine) Authentication

Machine-to-machine authentication allows your backend services, cron jobs, and automation scripts to securely call the ZewstID API without user interaction.

Overview

M2M authentication uses the OAuth 2.0 Client Credentials grant. Your service authenticates with a client ID and secret, and receives an access token scoped to specific permissions.

Your Service ZewstID API │ │ │ POST /oauth/token │ │ grant_type=client_credentials│ │ client_id=xxx │ │ client_secret=yyy │ │ ─────────────────────────► │ │ │ │ { access_token: "eyJ..." } │ │ ◄───────────────────────── │ │ │ │ GET /api/v1/m2m/users │ │ Authorization: Bearer eyJ... │ │ ─────────────────────────► │ │ │ │ { users: [...] } │ │ ◄───────────────────────── │

Creating a Service Account

Via the Developer Portal

  1. Navigate to Service Accounts in the left sidebar
  2. Click Create Service Account
  3. Enter a name and description
  4. Select the scopes your service needs
  5. Click Create — you'll receive a
    clientId
    and
    clientSecret

Important: Save the client secret immediately. It is only shown once.

Via the API

curl -X POST https://api.zewstid.com/api/v1/service-accounts \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "My Backend Service", "description": "Handles nightly user sync", "scopes": ["users:read", "users:write"] }'

Response:

{ "id": "sa_abc123", "clientId": "sa-my-backend-service", "clientSecret": "zs_secret_xxxxx", "name": "My Backend Service", "scopes": ["users:read", "users:write"], "createdAt": "2026-02-20T10:00:00Z" }

Getting an Access Token

Use the

client_credentials
grant to obtain an access token:

curl -X POST https://api.zewstid.com/api/v1/oauth/token \ -H "Content-Type: application/json" \ -d '{ "grant_type": "client_credentials", "client_id": "sa-my-backend-service", "client_secret": "zs_secret_xxxxx", "scope": "users:read" }'

Response:

{ "access_token": "eyJhbGciOiJSUzI1NiIs...", "token_type": "Bearer", "expires_in": 3600, "scope": "users:read" }

Tokens expire after 1 hour by default. Your service should request a new token before the current one expires.

Scope System

Scopes control what your service account can access:

ScopeDescription
users:read
List and get user profiles
users:write
Create and update users
users:invite
Invite users via email
users:admin
Delete users, manage credentials
orgs:read
List organizations and members
orgs:write
Create and manage organizations
analytics:read
Read authentication analytics
agents:read
List and view agents
agents:write
Register and manage agents
delegations:read
View delegation requests
delegations:write
Create and manage delegations

Request only the scopes your service needs. You can update scopes later via the portal or API.

M2M API Endpoints

Once you have an access token, use it to call the M2M API:

MethodEndpointScope RequiredDescription
GET
/api/v1/m2m/whoami
AnyVerify token and get service identity
GET
/api/v1/m2m/users
users:read
List users with pagination
GET
/api/v1/m2m/users/:id
users:read
Get user by ID
POST
/api/v1/m2m/users
users:write
Create a new user
PATCH
/api/v1/m2m/users/:id
users:write
Update user profile
DELETE
/api/v1/m2m/users/:id
users:admin
Delete a user
GET
/api/v1/m2m/users/:id/sessions
users:admin
List user sessions
DELETE
/api/v1/m2m/users/:id/sessions
users:admin
Revoke all sessions
GET
/api/v1/m2m/orgs
orgs:read
List organizations
GET
/api/v1/m2m/orgs/:id
orgs:read
Get organization
GET
/api/v1/m2m/orgs/:id/members
orgs:read
List organization members
GET
/api/v1/m2m/analytics/overview
analytics:read
Authentication analytics
GET
/api/v1/m2m/analytics/events
analytics:read
Auth event log
GET
/api/v1/m2m/analytics/users/active
analytics:read
Active user metrics
POST
/api/v1/m2m/users/invite
users:invite
Send user invite email
GET
/api/v1/invites/:token
PublicGet invite info (read-only)
POST
/api/v1/m2m/invites/:token/accept
users:invite
Consume invite token

User Invites

Invite users to your application via email. ZewstID creates the user account (if needed), sends a branded email, and provides a flow for the user to authenticate and join your app.

Sending an Invite

curl -X POST https://api.zewstid.com/api/v1/m2m/users/invite \ -H "Authorization: Bearer $M2M_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "email": "[email protected]", "redirect_uri": "https://yourapp.com/api/auth/callback/zewstid", "invite_accept_uri": "https://yourapp.com/invites/accept", "app_name": "Your App", "first_name": "Jane", "last_name": "Doe", "metadata": { "role": "member" } }'

Response (201):

{ "invite_id": "uuid", "email": "[email protected]", "status": "sent", "user_exists": false, "expires_at": "2026-04-03T12:00:00Z" }

Fields:

FieldRequiredDescription
email
YesEmail address to invite
redirect_uri
YesMust match a registered redirect URI on your service account
invite_accept_uri
NoPage in your app where invite UI is shown. Origin must match a registered redirect URI. If omitted, falls back to
redirect_uri
.
state
NoOpaque state string, forwarded to redirect
first_name
NoPre-populate user's first name
last_name
NoPre-populate user's last name
app_name
NoDisplay name for your app in the invite email
metadata
NoArbitrary JSON stored with the invite, returned on acceptance

How the Flow Works

  1. You call
    POST /m2m/users/invite
    — ZewstID creates the user (if new) and sends the invite email
  2. User clicks the email link → ZewstID redirects to your
    invite_accept_uri
    with query params:
    ?invite_token=xxx&[email protected]&app_name=YourApp&user_exists=true
  3. Your page shows an invite UI and triggers a standard OAuth sign-in (e.g. NextAuth
    signIn()
    with
    login_hint
    )
  4. After authentication, your backend calls
    POST /m2m/invites/:token/accept
    to consume the invite

Consuming the Invite

After the user authenticates on your app, call this from your backend:

curl -X POST https://api.zewstid.com/api/v1/m2m/invites/$INVITE_TOKEN/accept \ -H "Authorization: Bearer $M2M_TOKEN"

Response (200):

{ "invite_id": "uuid", "email": "[email protected]", "user_id": "keycloak-uuid", "user_exists": true, "app_name": "Your App", "accepted_at": "2026-03-31T12:00:00Z", "metadata": { "role": "member" } }

The token is single-use — a second call returns 404. Only the service account that created the invite can consume it (returns 403 otherwise).

Checking Invite Status

Read invite details without consuming:

curl https://api.zewstid.com/api/v1/invites/$INVITE_TOKEN

Response (200):

{ "email": "[email protected]", "app_name": "Your App", "user_exists": false, "status": "pending", "expires_in": 259200 }

Building Your Invite Accept Page (Next.js Example)

Your app needs an invite accept page that the user lands on after clicking the email link. This page handles authentication via your normal OAuth flow, then consumes the invite token.

1. Create the invite accept page (e.g.

app/invites/accept/page.tsx
):

'use client'; import { useSearchParams } from 'next/navigation'; import { signIn, useSession } from 'next-auth/react'; import { useEffect, useState } from 'react'; export default function AcceptInvitePage() { const params = useSearchParams(); const { data: session } = useSession(); const [status, setStatus] = useState<'loading' | 'accepting' | 'done' | 'error'>('loading'); const inviteToken = params.get('invite_token'); const email = params.get('email'); const appName = params.get('app_name'); const userExists = params.get('user_exists') === 'true'; // If user is authenticated, consume the invite via your backend useEffect(() => { if (session && inviteToken) { setStatus('accepting'); fetch(`/api/invites/accept?invite_token=${inviteToken}`) .then(res => res.json()) .then(() => setStatus('done')) .catch(() => setStatus('error')); } }, [session, inviteToken]); if (!inviteToken) return <p>Invalid invite link.</p>; if (session) { if (status === 'done') return <p>Welcome! You've joined {appName}.</p>; if (status === 'error') return <p>Something went wrong. Please try again.</p>; return <p>Setting up your account...</p>; } // Not authenticated — show sign-in button return ( <div> <h1>{appName} has invited {email} to join</h1> <button onClick={() => signIn('zewstid', { callbackUrl: `/invites/accept?invite_token=${inviteToken}`, }, { login_hint: email ?? '', prompt: userExists ? 'login' : 'create', }) }> {userExists ? 'Sign in with ZewstID' : 'Create account with ZewstID'} </button> </div> ); }

2. Create a backend API route to consume the invite (e.g.

app/api/invites/accept/route.ts
):

import { NextRequest, NextResponse } from 'next/server'; const M2M_TOKEN_URL = 'https://api.zewstid.com/api/v1/oauth/token'; const INVITE_ACCEPT_URL = 'https://api.zewstid.com/api/v1/m2m/invites'; async function getM2MToken() { const res = await fetch(M2M_TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grant_type: 'client_credentials', client_id: process.env.ZEWSTID_SA_CLIENT_ID, client_secret: process.env.ZEWSTID_SA_CLIENT_SECRET, scope: 'users:invite', }), }); const data = await res.json(); return data.access_token; } export async function GET(req: NextRequest) { const token = req.nextUrl.searchParams.get('invite_token'); if (!token) return NextResponse.json({ error: 'Missing token' }, { status: 400 }); const m2mToken = await getM2MToken(); const res = await fetch(`${INVITE_ACCEPT_URL}/${token}/accept`, { method: 'POST', headers: { Authorization: `Bearer ${m2mToken}` }, }); const data = await res.json(); return NextResponse.json(data, { status: res.status }); }

3. Set environment variables for your service account:

ZEWSTID_SA_CLIENT_ID=sa-your-app ZEWSTID_SA_CLIENT_SECRET=zs_secret_xxxxx

4. When sending invites, include

invite_accept_uri
pointing to your page:

{ "email": "[email protected]", "redirect_uri": "https://yourapp.com/api/auth/callback/zewstid", "invite_accept_uri": "https://yourapp.com/invites/accept", "app_name": "Your App" }

Tip: The

invite_accept_uri
origin must match one of your service account's registered redirect URIs. The
redirect_uri
is your NextAuth callback URL.

Node SDK Usage

The

@zewstid/node
SDK provides a
ZewstIDServiceAccount
class for M2M authentication:

import { ZewstIDServiceAccount } from '@zewstid/node'; const serviceAccount = new ZewstIDServiceAccount({ domain: 'auth.zewstid.com', clientId: process.env.ZEWSTID_SA_CLIENT_ID!, clientSecret: process.env.ZEWSTID_SA_CLIENT_SECRET!, scopes: ['users:read', 'users:write'], }); // Get a token (automatically cached and refreshed) const token = await serviceAccount.getToken(); // Use the token const res = await fetch('https://api.zewstid.com/api/v1/m2m/users', { headers: { Authorization: `Bearer ${token}` }, }); const users = await res.json();

The SDK handles token caching, automatic refresh, and retry logic.

Credential Rotation

Rotate your service account credentials periodically for security:

curl -X POST https://api.zewstid.com/api/v1/service-accounts/sa_abc123/rotate \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Response:

{ "clientId": "sa-my-backend-service", "clientSecret": "zs_secret_new_yyyyy", "rotatedAt": "2026-02-20T12:00:00Z" }

The old secret is invalidated immediately. Update your service configuration with the new secret.

Rate Limits

M2M endpoints have the following rate limits:

EndpointLimit
POST /oauth/token
30 requests/minute per client
GET /m2m/*
(read)
100 requests/minute per client
POST/PATCH /m2m/*
(write)
30 requests/minute per client
DELETE /m2m/*
(admin)
10 requests/minute per client

Rate limit headers are included in every response:

X-RateLimit-Limit: 100 X-RateLimit-Remaining: 95 X-RateLimit-Reset: 1708420800

Best Practices

  1. Least privilege — Request only the scopes your service needs
  2. Rotate credentials — Rotate secrets every 90 days
  3. Cache tokens — Don't request a new token for every API call
  4. Handle expiration — Check
    expires_in
    and refresh before expiry
  5. Secure storage — Store credentials in environment variables or a secrets manager, never in source code
  6. Monitor usage — Check the audit log for unexpected access patterns

Next Steps

Was this page helpful?

Let us know how we can improve our documentation