What this fork adds
Admin REST API v2
Full provisioning API β orgs, servers, domains, credentials, users. Per-user access with role enforcement.
Multi-user orgs
Multiple users per org with admin, member, readonly roles via API or SCIM.
Role-based access
Enforced on every API endpoint and across all web UI controllers.
Auto SMTP server
Creating an org via API or SCIM automatically provisions a Live SMTP server + credential.
SCIM v2 per-tenant
/scim/v2/tenants/:org/Users β users and org params managed per tenant.
SSO auto-org
New SSO users get a default org created automatically from their username.
Multi-provider OIDC
Multiple IdPs (Keycloak, Entra, Googleβ¦) β one login button per provider, each with its own JWKS cache.
BCL & RP logout
Back-Channel Logout invalidates Postal sessions when the IdP logs a user out. RP-Initiated Logout redirects to the IdP end_session_endpoint on sign-out.
Configuration
Add to postal.yml. Postal fetches IdP public keys automatically via JWKS β no signing secret stored locally.
Single provider (legacy / simple setup)
api: enabled: true required_azp: postal-api # optional β restrict to one specific KC client oidc: enabled: true name: "Login with Keycloak" issuer: https://login.example.com/realms/myrealm # must match 'iss' in your JWTs identifier: postal # OAuth2 client_id β required for the SSO login button secret: <client_secret> # required for the SSO login button; omit if API-only # jwks_uri: ... # override; auto-discovered from issuer by default auto_create_org_on_signup: true auto_provision_org: false auto_create_user: true # false = SCIM-only: unknown identities rejected at login (no JIT account creation) scim: enabled: true bearer_token: <scim-static-token>
Multiple providers
Use the oidc.providers array to register several IdPs. Each gets its own login button, JWKS cache, and discovery document.
oidc:
enabled: true
providers:
- id: keycloak
display_name: "Login with Keycloak"
issuer: https://login.example.com/realms/myrealm
identifier: postal
secret: <client_secret>
scopes: [openid, email]
- id: entra
display_name: "Login with Microsoft"
issuer: https://login.microsoftonline.com/<tenant_id>/v2.0
identifier: <app_id>
secret: <app_secret>
scopes: [openid, email, profile]
- id: google
display_name: "Login with Google"
issuer: https://accounts.google.com
identifier: <client_id>.apps.googleusercontent.com
secret: <client_secret>
identifier / secret are only required for the SSO login button (Authorization Code flow). For API JWT verification and BCL, Postal only needs issuer (public JWKS). Omit them if the provider is used for token validation only.
Authentication
Postal is a Resource Server only β it never issues tokens.
Your IdP (Keycloak, Microsoft Entra, Google) issues JWTs; Postal verifies them via JWKS,
auto-discovered from {issuer}/.well-known/openid-configuration and cached 1 h.
On key rotation, the cache busts automatically on the next unknown kid.
Authorization: Bearer <access_token>
Token types & access level
| Token | How to get it | Access |
|---|---|---|
| super-admin | KC client_credentials where azp matches api.required_azpβ or any token for a Postal user with admin: true |
Everything β all orgs, all users, create/delete/suspend orgs |
| org-admin | KC user login (authorization_code or password grant) User must have admin role in the org |
Their orgs: CRUD servers/domains/credentials, manage members, edit org settings |
| member | KC user login β member role in org |
Their orgs: CRUD servers/domains/credentials β no org settings, no member management |
| readonly | KC user login β readonly role in org |
Their orgs: GET only on all resources |
User identity is resolved from the JWT email or preferred_username claim, matched against User.email_address.
Keycloak β service account (super-admin)
Realm β Clients β Create client: set Client authentication ON, Service accounts roles ON.
curl -s -X POST \ https://login.example.com/realms/myrealm/protocol/openid-connect/token \ -d grant_type=client_credentials \ -d client_id=postal-api \ -d client_secret=<secret> | jq -r .access_token
Keycloak β user token (org-admin / member / readonly)
Standard authorization_code flow or, for testing, the password grant (if enabled in realm):
curl -s -X POST \ https://login.example.com/realms/myrealm/protocol/openid-connect/token \ -d grant_type=password \ -d client_id=postal-ui \ -d username=alice@example.com \ -d password=<password> | jq -r .access_token
Microsoft Entra
curl -s -X POST \ https://login.microsoftonline.com/<tenant_id>/oauth2/v2.0/token \ -d grant_type=client_credentials \ -d client_id=<app_id> \ -d client_secret=<secret> \ -d scope=<app_id>/.default | jq -r .access_token
oidc: issuer: https://login.microsoftonline.com/<tenant_id>/v2.0 api: required_azp: <app_id>
# Using oauth2l oauth2l fetch --credentials service-account.json --scope openid email
oidc: issuer: https://accounts.google.com api: required_azp: <client_id from service-account.json>
| Provider | Issuer (iss) | Algorithm |
|---|---|---|
| Keycloak | {host}/realms/{realm} | RS256 |
| Microsoft Entra | https://login.microsoftonline.com/{tenant}/v2.0 | RS256 |
https://accounts.google.com | RS256 |
API v2 reference
Base URL: https://postal.example.com/api/v2. All requests need Authorization: Bearer <token> and Content-Type: application/json on writes.
Pagination: ?page=1&per_page=50 (max 200). Errors: {"errors": ["Name can't be blank"]} or {"error": "forbidden"}.
Organizations
super-admin returns all orgs. org-admin / member / readonly returns only their own orgs.
curl https://postal.example.com/api/v2/organizations \ -H "Authorization: Bearer $TOKEN"
Creates an org. Auto-provisions a Live SMTP server + default credential unless skip_default_server: true.
curl -X POST https://postal.example.com/api/v2/organizations \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Acme",
"permalink": "acme",
"time_zone": "UTC",
"owner_email": "admin@acme.com",
"skip_default_server": false
}'
super-admin: any org. Others: only their orgs (404 if not a member).
curl https://postal.example.com/api/v2/organizations/acme \ -H "Authorization: Bearer $TOKEN"
curl -X PATCH https://postal.example.com/api/v2/organizations/acme \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Acme Corp", "time_zone": "Europe/Paris"}'
curl -X DELETE https://postal.example.com/api/v2/organizations/acme \ -H "Authorization: Bearer $TOKEN"
curl -X POST https://postal.example.com/api/v2/organizations/acme/suspend \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"reason": "Payment overdue"}'
# Unsuspend
curl -X POST https://postal.example.com/api/v2/organizations/acme/unsuspend \
-H "Authorization: Bearer $TOKEN"
Members
curl https://postal.example.com/api/v2/organizations/acme/members \ -H "Authorization: Bearer $TOKEN"
user_id accepts UUID or email address. role: admin, member (default), readonly.
curl -X POST https://postal.example.com/api/v2/organizations/acme/members \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"user_id": "auditor@acme.com", "role": "readonly"}'
curl -X PATCH https://postal.example.com/api/v2/organizations/acme/members/auditor@acme.com \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"role": "member"}'
curl -X DELETE https://postal.example.com/api/v2/organizations/acme/members/auditor@acme.com \ -H "Authorization: Bearer $TOKEN"
Servers
curl https://postal.example.com/api/v2/organizations/acme/servers \ -H "Authorization: Bearer $TOKEN"
mode: Live or Test.
curl -X POST https://postal.example.com/api/v2/organizations/acme/servers \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Production", "permalink": "production", "mode": "Live"}'
curl -X PATCH https://postal.example.com/api/v2/organizations/acme/servers/production \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"send_limit": 5000, "message_retention_days": 30}'
curl -X DELETE https://postal.example.com/api/v2/organizations/acme/servers/production \ -H "Authorization: Bearer $TOKEN"
curl -X POST https://postal.example.com/api/v2/organizations/acme/servers/production/suspend \
-H "Authorization: Bearer $TOKEN" \
-d '{"reason": "Abuse detected"}'
curl -X POST .../unsuspend -H "Authorization: Bearer $TOKEN"
Domains
Response includes dkim_record_name and dkim_record for DNS setup.
curl -X POST https://postal.example.com/api/v2/organizations/acme/servers/production/domains \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "mail.acme.com"}'
Force-marks the domain as verified without checking DNS. Use when ownership was verified out-of-band (e.g., during automated tenant provisioning).
curl -X POST https://postal.example.com/api/v2/organizations/acme/servers/production/domains/<uuid>/verify \ -H "Authorization: Bearer $TOKEN"
Re-runs DNS checks (SPF, DKIM, MX, return-path) and returns updated statuses.
curl -X POST https://postal.example.com/api/v2/organizations/acme/servers/production/domains/<uuid>/dns_check \ -H "Authorization: Bearer $TOKEN"
Credentials
Response includes the generated key β only returned on creation, not retrievable later.
curl -X POST https://postal.example.com/api/v2/organizations/acme/servers/production/credentials \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "App mailer", "type": "SMTP"}'
# Response:
# { "id": "...", "name": "App mailer", "type": "SMTP", "key": "smtp+login://...", ... }
curl https://postal.example.com/api/v2/organizations/acme/servers/production/credentials \ -H "Authorization: Bearer $TOKEN"
curl -X DELETE https://postal.example.com/api/v2/organizations/acme/servers/production/credentials/<uuid> \ -H "Authorization: Bearer $TOKEN"
Users
super-admin: all Postal users. org-admin: only members of their orgs.
curl "https://postal.example.com/api/v2/users?page=1&per_page=50" \ -H "Authorization: Bearer $TOKEN"
admin field is ignored unless the caller is super-admin.
curl -X POST https://postal.example.com/api/v2/users \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"first_name": "Alice",
"last_name": "Smith",
"email_address": "alice@acme.com",
"time_zone": "UTC",
"password": "hunter2"
}'
:id accepts UUID or email. org-admin can only update users in their orgs and cannot set admin.
curl -X PATCH https://postal.example.com/api/v2/users/alice@acme.com \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"first_name": "Alicia"}'
Permanently deletes the Postal user. To remove from an org without deleting, use the members endpoint instead.
curl -X DELETE https://postal.example.com/api/v2/users/alice@acme.com \ -H "Authorization: Bearer $TOKEN"
SCIM v2 β per-tenant
All SCIM endpoints are namespaced per org: /scim/v2/tenants/:org_permalink/β¦. Auth: static Authorization: Bearer <scim.bearer_token>.
Users
| Method | Path | Description |
|---|---|---|
GET | /scim/v2/tenants/:org/Users | List org members as SCIM users |
POST | /scim/v2/tenants/:org/Users | Create user (if new) + add to org with optional role |
GET | /scim/v2/tenants/:org/Users/:id | Get user (scoped to org) |
PUT / PATCH | /scim/v2/tenants/:org/Users/:id | Update user attrs + optional role change via Operations |
DELETE | /scim/v2/tenants/:org/Users/:id | Remove from org; delete user globally if no other orgs |
# Create user in org with member role
curl -X POST https://postal.example.com/scim/v2/tenants/acme/Users \
-H "Authorization: Bearer $SCIM_TOKEN" \
-H "Content-Type: application/scim+json" \
-d '{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName": "bob@acme.com",
"name": { "givenName": "Bob", "familyName": "Jones" },
"emails": [{ "value": "bob@acme.com", "primary": true }],
"role": "member"
}'
Groups (= the org)
In this model, one org = one SCIM group. The group is the org β there are no sub-groups or teams within an org.
Postal has no sub-group concept: roles (admin, member, readonly) are set directly on the userβorg link.
If you need separate access scopes, create one org per scope and provision users into each.
No POST or DELETE β org lifecycle is managed via API v2. PATCH handles org attributes and members in one request.
| Method | Path | Description |
|---|---|---|
GET | /scim/v2/tenants/:org/Groups | Returns the org as a SCIM group |
GET | /scim/v2/tenants/:org/Groups/:id | Get the org group |
PUT / PATCH | /scim/v2/tenants/:org/Groups/:id | Update org name, time_zone, permalink + manage members |
# Update org name + add a member + change a role in one request
curl -X PATCH https://postal.example.com/scim/v2/tenants/acme/Groups/42 \
-H "Authorization: Bearer $SCIM_TOKEN" \
-H "Content-Type: application/scim+json" \
-d '{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"displayName": "Acme Corporation",
"urn:postal:1.0:org": { "time_zone": "Europe/Paris" },
"Operations": [
{ "op": "add", "path": "members", "value": [{ "value": "alice@acme.com", "role": "admin" }] },
{ "op": "remove", "path": "members", "value": [{ "value": "old@acme.com" }] }
]
}'
urn:postal:1.0:org extension fields
| Field | Type | Description |
|---|---|---|
permalink | string | Org slug (URL identifier) |
time_zone | string | Rails time zone name (e.g. Europe/Paris) |
status | string | Read-only: active or suspended |
Member roles
| Permission | super-admin | admin | member | readonly |
|---|---|---|---|---|
| Create / delete orgs | β | β | β | β |
| Suspend / unsuspend orgs | β | β | β | β |
| Edit org settings | β | β | β | β |
| Manage members (add/remove/role) | β | β | β | β |
| Create / delete / suspend servers | β | β | β | β |
| Create / delete domains & credentials | β | β | β | β |
| Manage routes, webhooks, endpoints | β | β | β | β |
| View messages, logs, stats | β | β | β | β |
| List / update users (scoped to org) | β | β | β | β |
| Delete users globally | β | β | β | β |
Roles are enforced on both the API v2 and the web UI. readonly users see everything but cannot write.
SSO / OIDC provisioning
Disable local (password) login
Set oidc.local_authentication_enabled: false to force SSO-only access. When disabled:
- The email/password form is hidden from the login page
POST /login, password reset requests and password reset flows are all blocked server-side (403)- Only the Login with {provider} button remains
- Users with no SSO identity can no longer log in β ensure all users are in your IdP before enabling this
oidc: enabled: true issuer: https://login.example.com/realms/myrealm local_authentication_enabled: false # SSO only, no password login
JIT user provisioning
When a user logs in via SSO and does not exist in Postal, they are automatically created (JIT provisioning). Name and email are read from the JWT claims (given_name, family_name, email).
Auto-create org on signup
Enable oidc.auto_create_org_on_signup: true. When a brand-new user is JIT-provisioned, Postal automatically creates an org named after their preferred_username (or email prefix if absent). The slug is parameterized and deduplicated if taken.
oidc: auto_create_org_on_signup: true # alice.smith@example.com logs in for the first time # β user created # β org "alice-smith" created (Live SMTP server + default credential auto-provisioned) # β alice is org admin
Org provisioning from JWT claim
Enable oidc.auto_provision_org: true. On every login, Postal reads the organization claim and creates/joins the org:
# JWT claim β Keycloak can send a custom claim:
"organization": { "acme": { "name": "Acme Corp" } }
# or as array:
"organization": ["acme", "beta-corp"]
Both options can be active at the same time: auto-create on first login + sync from claim on every login.
Back-Channel Logout (BCL)
When a user logs out of the IdP (or is force-logged-out), the IdP POSTs a signed
logout_token to Postal. Postal validates the RS256 signature against the provider's
JWKS, then invalidates all active Postal sessions for that user.
Endpoint: POST /auth/oidc/backchannel_logout (no authentication header required β the logout_token itself is the proof).
Configure in Keycloak: Realm β Clients β your client β Advanced β Backchannel logout URL:
https://postal.example.com/auth/oidc/backchannel_logout
Enable Backchannel logout session required so Keycloak always includes sid and sub in the logout_token.
With multiple providers, the single endpoint handles all of them β Postal identifies the provider from the iss claim and picks the right JWKS.
RP-Initiated Logout (redirect logout)
When a user clicks Sign out in Postal, if a Keycloak id_token is present in the session,
Postal redirects to the IdP's end_session_endpoint with:
id_token_hintβ the user's original id_tokenpost_logout_redirect_uriβ Postal's login pagestateβ random nonce for CSRF protection
The IdP then terminates the SSO session and redirects back to the Postal login page. If the provider does not expose an end_session_endpoint in its discovery document, Postal falls back to a local session invalidation only.
Configure in Keycloak: Realm β Clients β your client β Advanced β Valid post logout redirect URIs:
https://postal.example.com/login