Service Accounts
Service accounts provide programmatic access to the Wallow API for server-to-server integrations. They use the OAuth2 client credentials flow, unlike user authentication which requires browser-based OIDC login.
Use service accounts for automated systems, background jobs, external integrations, and custom tooling that needs to read or write data.
| Aspect | User Token | Service Account |
|---|---|---|
| Authentication | Browser login (OIDC) | Client credentials (OAuth2) |
| Lifespan | Session-based (hours) | Short-lived (5-15 min) |
| Scope | User permissions | Explicit scopes |
| Use case | Interactive applications | Server-to-server |
| Credential type | Password + 2FA | Client ID + Secret |
Getting Started
1. Register a service account
A service account is one of an organization's clients, registered on the same route as a
developer application with kind: "service-account". The caller needs OrganizationClientsManage
in that organization — an organization admin has it through their role. The wallow-web
organization page offers the same registration as a two-step form (Basics → Scopes) and shows the
env block below when it completes.
POST /v1/identity/organizations/{orgId}/clients
Authorization: Bearer <user-token>
Content-Type: application/json
{
"kind": "service-account",
"name": "Nightly sync",
"redirectUris": [],
"postLogoutRedirectUris": [],
"scopes": ["organizations.read", "inquiries.read"]
}
Response (201 Created):
{
"client": {
"clientId": "sa-acme-nightly-sync",
"name": "Nightly sync",
"kind": "service-account",
"status": "active",
"redirectUris": [],
"postLogoutRedirectUris": [],
"scopes": ["organizations.read", "inquiries.read"],
"createdByUserId": "6f1c…",
"createdAt": "2026-08-30T00:00:00Z"
},
"clientSecret": "xK9mN2pL8qR5sT7vW3yZ1aB4cD6eF8gH0iJ2kL5mN7oP9qR1sT3uV5wX7yZ9",
"issuer": "https://auth.example.com/auth",
"apiBaseUrl": "https://api.example.com"
}
The secret is shown once. The client id is derived as sa-<organization>-<name>, and the URI
fields are ignored for a service account: it can only use the client_credentials grant, so it
has no redirect. Scopes follow one rule for both client kinds: any scope in the catalog may be
granted except a platform-only one, which is never grantable on the org-scoped surface.
Registration binds the client to the organization, so every token it obtains carries that
organization's org_id and is tenant-scoped exactly like an interactive user's.
2. Configure the SDK
The response maps onto the env block createServiceClient() reads
(see TypeScript SDK):
OIDC_ISSUER=https://auth.example.com/auth
OIDC_SERVICE_CLIENT_ID=sa-acme-nightly-sync
OIDC_SERVICE_CLIENT_SECRET=xK9mN2pL8qR5sT7vW3yZ1aB4cD6eF8gH0iJ2kL5mN7oP9qR1sT3uV5wX7yZ9
OIDC_SERVICE_SCOPES=organizations.read inquiries.read
BFF_API_BASE_URL=https://api.example.com
3. Request an Access Token
Without the SDK, exchange the credentials at the token endpoint directly:
curl -X POST https://api.example.com/connect/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=sa-acme-nightly-sync" \
-d "client_secret=<client-secret>" \
-d "grant_type=client_credentials" \
-d "scope=organizations.read inquiries.read"
The access token's sub and azp are the client id, org_id is the organization the client was
registered for, and scope is the granted subset of what was requested.
4. Call the API
Use the access token in the Authorization header:
curl -X GET https://api.example.com/v1/identity/organizations \
-H "Authorization: Bearer <access-token>"
Cache tokens and refresh before expiry. Each token request counts toward rate limits.
API Reference
Service accounts and developer applications share the organization-scoped clients surface, one
permission (OrganizationClientsManage) and one record. {clientId} is the clientId returned at
registration, e.g. sa-acme-nightly-sync.
| Method | Route | Purpose |
|---|---|---|
GET |
/v1/identity/organizations/{orgId}/clients |
List the organization's clients; kind tells them apart |
POST |
/v1/identity/organizations/{orgId}/clients |
Register an application or a service account |
GET |
/v1/identity/organizations/{orgId}/clients/{clientId} |
Get one client |
PATCH |
/v1/identity/organizations/{orgId}/clients/{clientId} |
Update scopes (and, for an application, URIs and refresh-token lifetime) |
DELETE |
/v1/identity/organizations/{orgId}/clients/{clientId} |
Delete the client; its tokens stop validating |
POST |
/v1/identity/organizations/{orgId}/clients/{clientId}/rotate-secret |
Issue a new secret (shown once); body { "revokeActiveTokens": true } also ends every token the client holds |
GET |
/v1/identity/scopes |
List the scope catalog (ScopeRead); platformOnly scopes cannot be granted here |
PATCH takes the same redirectUris, postLogoutRedirectUris, backchannelLogoutUri and
scopes fields as registration, minus kind and name, which are immutable, plus an optional
refreshTokenLifetime (seconds, 60–31,536,000; null or absent keeps the current value, and a
change applies to new logins only). For a service account only scopes matters — it uses the
client_credentials grant and holds no refresh tokens, so it has no lifetime either.
Pre-registered service accounts
The seeder (api/seed.json) can define a service account for a deployment: a client whose id
starts with sa- gets the client_credentials grant, and its tenantName names the organization
the tokens are bound to. The dev/e2e seed defines sa-wallow-nightly-sync in the Wallow
organization for exactly this.
Available Scopes
Identity
| Scope | Description | Default |
|---|---|---|
users.read |
Read user profiles and data | Yes |
users.write |
Create and update users | No |
users.manage |
Full user management | No |
roles.read |
Read roles and role assignments | Yes |
roles.write |
Create and update roles | No |
roles.manage |
Full role management | No |
organizations.read |
Read organization data | Yes |
organizations.write |
Create and update organizations | No |
organizations.manage |
Full organization management | No |
apikeys.read |
Read API key metadata | Yes |
apikeys.write |
Create and update API keys | No |
apikeys.manage |
Full API key management | No |
Storage
| Scope | Description | Default |
|---|---|---|
storage.read |
Read files and storage data | Yes |
storage.write |
Upload and modify files | No |
Announcements
| Scope | Description | Default |
|---|---|---|
announcements.read |
Read announcements | Yes |
announcements.manage |
Manage announcements | No |
changelog.manage |
Manage changelog entries | No |
Notifications
| Scope | Description | Default |
|---|---|---|
notifications.read |
Read notifications | No |
notifications.write |
Send notifications | No |
Configuration
| Scope | Description | Default |
|---|---|---|
configuration.read |
Read configuration data | Yes |
configuration.manage |
Manage configuration | No |
Inquiries
| Scope | Description | Default |
|---|---|---|
inquiries.read |
Read inquiries | No |
inquiries.write |
Create and update inquiries | No |
Platform
| Scope | Description | Default |
|---|---|---|
webhooks.manage |
Manage webhook subscriptions | No |
Best Practices
- Store credentials securely. Use environment variables or a secret manager (AWS Secrets Manager, Azure Key Vault, HashiCorp Vault). Never commit secrets to source control.
- Cache access tokens. Tokens are valid for 5-15 minutes. Refresh 30-60 seconds before expiry to avoid mid-request failures.
- Use minimum necessary scopes. Request only the scopes your integration needs. This limits exposure if credentials are compromised.
- Rotate credentials regularly. Every 90 days, or immediately after a security incident or personnel change. An organization manager rotates a service account's secret from the organization page (or
POST /v1/identity/organizations/{orgId}/clients/{clientId}/rotate-secret); the old secret stops working the moment the new one is issued, so update the consumer before rotating or expect a short outage. Tick also revoke active tokens after a suspected leak — it ends every access token the account already holds, not just the secret. - Handle errors gracefully. Implement retry logic for 401 (token expired) and 429 (rate limited) responses with exponential backoff.
- Never use service accounts from client-side code. Client secrets cannot be kept secure in browsers or mobile apps. Use OIDC for interactive applications.
- Never log secrets or tokens. Log only non-sensitive metadata like token expiry times.
Error Responses
| Status | Error | Meaning |
|---|---|---|
| 401 | invalid_token |
Token expired. Refresh and retry. |
| 401 | invalid_client |
Wrong client_id or client_secret, or the client was deleted or suspended. |
| 403 | insufficient_scope |
Service account lacks required scopes. Grant them with PATCH. |
| 429 | rate_limit_exceeded |
Too many requests. Respect the Retry-After header. |
Related Documentation
- Authorization — the scope and permission model these scopes plug into
- Authentication — the interactive OIDC path this credential replaces
- BFF Pattern — what an interactive frontend uses instead of a service account
- TypeScript SDK —
createServiceClient()and theOIDC_SERVICE_*env block api/src/Modules/ApiKeys/README.md— the other machine credential (X-Api-Key, not OAuth2)