Summary and recommendation
Coupa exposes a REST API at https://{instance}.coupahost.com/api with two auth methods: legacy API key (OAUTH_KEY header) and OAuth 2.0 client credentials (Authorization: Bearer). Note that all responses default to XML - you must explicitly set Accept: application/json on every request or response parsing will fail.
There is no native SCIM 2.0 endpoint; Okta's Coupa integration uses the Core API directly, not the SCIM protocol, and no SCIM endpoint is publicly documented.
For teams building identity graph pipelines, the user object carries employee-number, manager (id + login reference), roles array, and user-groups array - enough to reconstruct org hierarchy and access topology, but role and group IDs are instance-specific integers that must be resolved via /api/roles and /api/user_groups before any write operation.
Pagination is offset-based with a hard ceiling of 50 records per page; there is no way to increase this limit, so full user enumeration requires iterating offset=0, 50, 100 until an empty array is returned.
API quick reference
| Has user API | Yes |
| Auth method | API Key (OAUTH_KEY header) or OAuth 2.0 (Bearer token) |
| Base URL | Official docs |
| SCIM available | No |
| SCIM plan required | Enterprise |
Authentication
Auth method: API Key (OAUTH_KEY header) or OAuth 2.0 (Bearer token)
Setup steps
- Log in to Coupa as an administrator.
- Navigate to Setup > Integrations > API Keys.
- Click 'Create API Key', assign a name, and select the appropriate permissions/scopes.
- Copy the generated API key and pass it in the OAUTH_KEY request header for API key auth.
- For OAuth 2.0: register an OAuth2 application under Setup > Integrations > OAuth2/OpenID Connect Clients, obtain client_id and client_secret, then POST to https://{instance}.coupahost.com/oauth2/token with grant_type=client_credentials to receive a Bearer token.
- Include the Bearer token in the Authorization header for subsequent requests.
Required scopes
| Scope | Description | Required for |
|---|---|---|
| core.user.read | Read access to user records. | GET /api/users, GET /api/users/:id |
| core.user.write | Create and update user records. | POST /api/users, PUT /api/users/:id |
| core.user.delete | Delete or deactivate user records. | DELETE /api/users/:id |
User object / data model
| Field | Type | Description | On create | On update | Notes |
|---|---|---|---|---|---|
| id | integer | Unique Coupa internal user ID. | system-assigned | read-only | Used as path parameter for PUT/DELETE. |
| login | string | Username for login. | required | optional | Must be unique across the instance. |
| string | User's email address. | required | optional | Used for notifications and SSO matching. | |
| firstname | string | User's first name. | required | optional | |
| lastname | string | User's last name. | required | optional | |
| active | boolean | Whether the user account is active. | optional (defaults true) | optional | Set to false to deactivate without deleting. |
| purchasing-user | boolean | Grants purchasing permissions. | optional | optional | |
| expense-user | boolean | Grants expense submission permissions. | optional | optional | |
| roles | array[object] | List of role objects assigned to the user. | optional | optional | Each role object contains id and name. |
| user-groups | array[object] | User group memberships. | optional | optional | |
| default-address | object | Default shipping/billing address. | optional | optional | |
| default-locale | string | Locale code (e.g., en-US). | optional | optional | |
| timezone | string | User's timezone string. | optional | optional | |
| manager | object | Reference to the user's manager (id, login). | optional | optional | |
| employee-number | string | HR employee identifier. | optional | optional | |
| department | object | Department association. | optional | optional | |
| account-type | object | Account type classification. | optional | optional | |
| sso-identifier | string | Identifier used for SAML SSO matching. | optional | optional | Required when SSO is enforced. |
| created-at | datetime | Record creation timestamp (ISO 8601). | system-assigned | read-only | |
| updated-at | datetime | Last update timestamp (ISO 8601). | system-assigned | read-only |
Core endpoints
List Users
- Method: GET
- URL:
https://{instance}.coupahost.com/api/users - Watch out for: Returns max 50 records per page. Iterate using offset=0, 50, 100, etc. until an empty array is returned.
Request example
GET /api/users?offset=0
Accept: application/json
Authorization: Bearer {token}
Response example
[
{
"id": 101,
"login": "jdoe",
"email": "jdoe@example.com",
"firstname": "Jane",
"lastname": "Doe",
"active": true
}
]
Get User by ID
- Method: GET
- URL:
https://{instance}.coupahost.com/api/users/{id} - Watch out for: Returns 404 if user does not exist; no partial match support on this endpoint.
Request example
GET /api/users/101
Accept: application/json
Authorization: Bearer {token}
Response example
{
"id": 101,
"login": "jdoe",
"email": "jdoe@example.com",
"active": true,
"roles": [{"id": 5, "name": "Requester"}]
}
Query Users by Field
- Method: GET
- URL:
https://{instance}.coupahost.com/api/users?email[eq]={email} - Watch out for: Coupa uses bracket-notation filter operators (eq, in, gt, lt). Unsupported operators return 400.
Request example
GET /api/users?email[eq]=jdoe@example.com
Accept: application/json
Authorization: Bearer {token}
Response example
[
{
"id": 101,
"login": "jdoe",
"email": "jdoe@example.com"
}
]
Create User
- Method: POST
- URL:
https://{instance}.coupahost.com/api/users - Watch out for: login and email must be unique. Duplicate login returns 422 with validation errors in the response body.
Request example
POST /api/users
Content-Type: application/json
Authorization: Bearer {token}
{"login":"jdoe","email":"jdoe@example.com","firstname":"Jane","lastname":"Doe","active":true}
Response example
{
"id": 101,
"login": "jdoe",
"email": "jdoe@example.com",
"active": true,
"created-at": "2024-01-15T10:00:00Z"
}
Update User
- Method: PUT
- URL:
https://{instance}.coupahost.com/api/users/{id} - Watch out for: Coupa uses PUT (full/partial replacement semantics vary by field). Omitting roles array does not clear roles; explicitly pass empty array to remove all roles.
Request example
PUT /api/users/101
Content-Type: application/json
Authorization: Bearer {token}
{"active":false,"roles":[{"id":5}]}
Response example
{
"id": 101,
"login": "jdoe",
"active": false,
"updated-at": "2024-06-01T12:00:00Z"
}
Deactivate User
- Method: PUT
- URL:
https://{instance}.coupahost.com/api/users/{id} - Watch out for: Coupa does not support hard-delete of users via API in most configurations. Setting active=false is the recommended deprovisioning method.
Request example
PUT /api/users/101
Content-Type: application/json
Authorization: Bearer {token}
{"active":false}
Response example
{
"id": 101,
"active": false,
"updated-at": "2024-06-01T12:00:00Z"
}
Get User Roles
- Method: GET
- URL:
https://{instance}.coupahost.com/api/roles - Watch out for: Role IDs are instance-specific. Always query /api/roles to resolve role names to IDs before assigning.
Request example
GET /api/roles
Accept: application/json
Authorization: Bearer {token}
Response example
[
{"id": 5, "name": "Requester"},
{"id": 8, "name": "Approver"}
]
Get User Groups
- Method: GET
- URL:
https://{instance}.coupahost.com/api/user_groups - Watch out for: User group IDs must be resolved before assigning via the users endpoint. Group membership is managed through the user object's user-groups array.
Request example
GET /api/user_groups
Accept: application/json
Authorization: Bearer {token}
Response example
[
{"id": 3, "name": "Finance Team"},
{"id": 7, "name": "Procurement"}
]
Rate limits, pagination, and events
- Rate limits: Coupa does not publish explicit rate limit tiers in public documentation. Practical limits are enforced per instance and negotiated at the enterprise contract level. Excessive requests may result in HTTP 429 responses.
- Rate-limit headers: No
- Retry-After header: No
- Rate-limit notes: No official rate limit headers documented. Coupa recommends implementing exponential backoff on 429 or 503 responses. Bulk operations should be batched and spaced to avoid throttling.
- Pagination method: offset
- Default page size: 50
- Max page size: 50
- Pagination pointer: offset
| Plan | Limit | Concurrent |
|---|---|---|
| Enterprise (all plans) | Not publicly documented; instance-level throttling applies | 0 |
- Webhooks available: No
- Webhook notes: Coupa does not offer native outbound webhooks for user lifecycle events. Event-driven integration is achieved via polling the API or using Coupa's Integration Framework (CIF) for scheduled data exports.
- Alternative event strategy: Poll GET /api/users with updated-at[gt]={timestamp} filter to detect changes. Coupa's Integration Framework supports scheduled flat-file exports for bulk sync.
SCIM API status
- SCIM available: No
- SCIM version: Not documented
- Plan required: Enterprise
- Endpoint: Not documented
Limitations:
- No native SCIM 2.0 endpoint is documented by Coupa.
- User provisioning via Okta is achieved through Okta's Coupa app integration using the Coupa Core API (not SCIM protocol).
- Okta Group Linking maps Okta groups to Coupa roles/groups via API calls.
- Microsoft Entra ID and OneLogin integrations similarly use SAML 2.0 for SSO; provisioning relies on the Core API or manual processes.
- No SCIM endpoint has been publicly documented as of the policy date.
Common scenarios
Three provisioning scenarios cover the majority of integration work against the Coupa user API.
Provisioning a new employee: Resolve role IDs via GET /api/roles and group IDs via GET /api/user_groups, then POST /api/users with login, email, firstname, lastname, active=true, roles, user-groups, employee-number, manager reference, and sso-identifier. If SSO is enforced, sso-identifier must match the IdP NameID attribute exactly at creation time - there is no fallback login path once SSO is active.
Syncing role changes from IdP group membership: Fetch the current user record via GET /api/users?login[eq]={username}, compute the desired roles array by merging IdP-driven changes with existing roles not managed by the IdP, then PUT /api/users/{id} with the full updated array. PUT replaces whatever roles array is sent; omitting roles not managed by your integration will silently remove them.
Offboarding: GET /api/users?email[eq]={email} to resolve the Coupa user ID, then PUT /api/users/{id} with active=false. Optionally pass roles=[] and user-groups=[] to strip access. Hard-delete via DELETE is generally unavailable if the user has associated purchase orders, invoices, or expense reports - deactivation is the supported deprovisioning path.
Provision a new employee from HR system
- Resolve required role IDs: GET /api/roles and match by name.
- Resolve user group IDs: GET /api/user_groups and match by name.
- POST /api/users with login, email, firstname, lastname, active=true, roles array, user-groups array, employee-number, manager reference, and sso-identifier.
- Verify response returns HTTP 201 with the new user id.
- Store the Coupa user id in the HR system for future updates.
Watch out for: If SSO is enforced, the sso-identifier must be set at creation time and match the IdP attribute exactly, or the user will be unable to log in.
Sync role changes from IdP group membership
- Detect group membership change in IdP (Okta/Entra) via IdP webhook or scheduled poll.
- Resolve the corresponding Coupa role ID via GET /api/roles.
- GET /api/users?login[eq]={username} to retrieve the Coupa user id and current roles array.
- Compute the new desired roles array (add or remove role objects).
- PUT /api/users/{id} with the full updated roles array.
- Confirm HTTP 200 response and updated roles in the response body.
Watch out for: PUT replaces the roles array with whatever is sent. Always fetch current roles first and merge, or you will inadvertently remove existing roles not managed by the IdP.
Offboard a departing employee
- GET /api/users?email[eq]={email} to retrieve the Coupa user id.
- PUT /api/users/{id} with active=false to deactivate the account.
- Optionally PUT /api/users/{id} with roles=[] and user-groups=[] to remove all access.
- Confirm HTTP 200 and active=false in the response.
Watch out for: Deactivated users retain their historical transaction records in Coupa. Hard-delete is generally not available via API if the user has associated purchase orders, invoices, or expense reports.
Why building this yourself is a trap
The most common integration failure mode is assuming PUT behaves like PATCH. Sending a partial roles array on any update silently removes roles not included in the payload - always fetch current state and merge before writing.
A second trap is filter syntax: Coupa uses bracket-notation operators (field[eq], field[in], field[cont]) and will return HTTP 400 for unsupported operator forms; standard field=value equality does not work reliably across all fields.
Rate limits are not publicly documented and are enforced at the instance level. There are no rate-limit headers in responses, and no Retry-After header on 429s - implement exponential backoff and space bulk operations to avoid throttling.
Webhooks for user lifecycle events do not exist; event-driven sync requires polling GET /api/users with an updated-at[gt]={timestamp} filter, which means your identity graph reflects Coupa state only as frequently as your poll interval allows.
Automate Coupa workflows without one-off scripts
Stitchflow builds and maintains end-to-end IT automation across your SaaS stack, including apps without APIs. Built for exactly how your company works, with human approvals where they matter.