Skip to main content
The Builder API exposes a set of user management endpoints scoped to your app tenant. These endpoints let your backend provision the user records that PymtHouse needs to issue user-scoped JWTs and attribute usage to individuals. All endpoints require a confidential M2M client for authentication. See Machine access for the two auth patterns (Bearer token and HTTP Basic auth).

Identity model

PymtHouse maps your user identifiers using two distinct id spaces:
IdentifierSourceStabilityUse
externalUserIdYour systemYou control itJoin key between your user system and PymtHouse. Pass in create/upsert requests.
endUserIdPymtHouse-assigned UUIDStable after creationInternal reference; returned by the Usage API for per-user attribution.
Never construct Builder API paths with internal PymtHouse IDs — always use externalUserId in paths and request bodies. Internal database ids are implementation details and are not part of the API contract.

Base path

/api/v1/apps/{clientId}/users
{clientId} is the public app_… client id. The tenant boundary is enforced server-side: the authenticated M2M client must belong to the same app as the clientId in the path. A mismatch returns 404.

Prerequisites

export BASE_URL="https://your-pymthouse.example"
export CLIENT_ID="app_yourClientId"       # public client id
export M2M_ID="m2m_yourClientId"
export M2M_SECRET="pmth_cs_yourSecret"
Required M2M scopes per endpoint:
OperationRequired scope
List usersusers:read
Create or upsertusers:write
Updateusers:write
Deactivateusers:write

List users

GET /api/v1/apps/{clientId}/users
Authorization: Basic base64(m2m_id:m2m_secret)
Returns all provisioned users for the app tenant.
curl -sS \
  -u "${M2M_ID}:${M2M_SECRET}" \
  "${BASE_URL}/api/v1/apps/${CLIENT_ID}/users" | jq .

Create or upsert a user

POST /api/v1/apps/{clientId}/users
Authorization: Basic base64(m2m_id:m2m_secret)
Content-Type: application/json
This operation is idempotent: sending the same externalUserId again updates the existing record rather than creating a duplicate. Use this for both first-time provisioning and attribute updates in a single call path.

Request body

{
  "externalUserId": "user-123",
  "email": "alice@example.com",
  "status": "active"
}
FieldTypeRequiredDescription
externalUserIdstringYesYour stable identifier for this user. Used as the join key; must be unique within the app.
emailstringNoUser’s email address.
statusactive | inactiveNoUser status. Defaults to active.

Example

curl -sS \
  -u "${M2M_ID}:${M2M_SECRET}" \
  -H "Content-Type: application/json" \
  -d '{
    "externalUserId": "user-123",
    "email": "alice@example.com",
    "status": "active"
  }' \
  "${BASE_URL}/api/v1/apps/${CLIENT_ID}/users"
The upsert uses a database-level ON CONFLICT DO UPDATE to avoid duplicate-key races under concurrent provisioning. It is safe to call from multiple backend instances simultaneously.

Update user attributes

PUT /api/v1/apps/{clientId}/users
Authorization: Basic base64(m2m_id:m2m_secret)
Content-Type: application/json
Update attributes on an existing user record. The request body follows the same shape as the create/upsert body.
curl -sS -X PUT \
  -u "${M2M_ID}:${M2M_SECRET}" \
  -H "Content-Type: application/json" \
  -d '{
    "externalUserId": "user-123",
    "email": "alice-new@example.com"
  }' \
  "${BASE_URL}/api/v1/apps/${CLIENT_ID}/users"

Deactivate a user

DELETE /api/v1/apps/{clientId}/users?externalUserId={externalUserId}
Authorization: Basic base64(m2m_id:m2m_secret)
Sets status: inactive on the user. Records are not hard-deleted — deactivation preserves the record for usage attribution and audit purposes. You can reactivate a deactivated user by calling POST/PUT with status: active.
curl -sS -X DELETE \
  -u "${M2M_ID}:${M2M_SECRET}" \
  "${BASE_URL}/api/v1/apps/${CLIENT_ID}/users?externalUserId=user-123"

Bulk provisioning

There is no batch endpoint. For bulk provisioning, loop over your user set and call POST for each user. The upsert semantics make it safe to re-run the loop — already-provisioned users will be updated in place.
# Example: provision a list of users from a newline-delimited file
while IFS=',' read -r external_id email; do
  curl -sS \
    -u "${M2M_ID}:${M2M_SECRET}" \
    -H "Content-Type: application/json" \
    -d "{\"externalUserId\": \"${external_id}\", \"email\": \"${email}\", \"status\": \"active\"}" \
    "${BASE_URL}/api/v1/apps/${CLIENT_ID}/users"
done < users.csv
For high-volume initial imports, acquire one machine token (client credentials grant) and reuse it across the loop rather than re-authenticating per request.

Error responses

StatusCondition
400 Bad RequestMissing externalUserId, malformed JSON, or invalid status value.
401 UnauthorizedInvalid M2M credentials or expired Bearer token.
403 ForbiddenM2M client lacks the required scope (users:write or users:read).
404 Not FoundclientId in the path does not match the authenticated M2M client’s app.

Key design decisions

  1. externalUserId as the join key, not an internal ID. This eliminates the need for integrators to store PymtHouse-internal IDs and removes the risk of foreign-key coupling between two systems. The internal id (endUserId) is exposed only where needed for usage attribution.
  2. Upsert semantics by default on POST. A pure “create” operation would require integrators to handle 409 Conflict on every retry or network failure. Upsert makes provisioning idempotent, which is the correct default for infrastructure-level user sync operations.
  3. Soft delete only (status: inactive). Hard-deleting a user record would orphan historical usage records. Keeping the record with an inactive status preserves the join between usage_records.user_id and app_users for retrospective billing and audit.
  4. Tenant boundary enforced by path + auth match. Encoding the clientId in the URL path and verifying it server-side against the authenticated credential makes the tenant scope explicit in every request and prevents a valid but cross-tenant credential from reading or modifying another app’s user set.

Implementation tasks

  • Call POST with externalUserId during your user creation flow so the PymtHouse record is ready before the first JWT mint.
  • Implement a lightweight reconciliation job that calls POST/PUT for users whose attributes have changed in your system, keeping PymtHouse’s records in sync.
  • When deactivating users in your system, call DELETE on the PymtHouse side to prevent new JWT issuance for inactive users.
  • Ensure your externalUserId values are stable and unique within your app. Changing them after provisioning breaks the join key and results in a duplicate user record.
  • For high-concurrency environments, use the Bearer token pattern (one token per batch) rather than re-authenticating on each call.