Summary and recommendation
Expensify's Integration Server API uses a command-pattern design over HTTP POST with application/x-www-form-urlencoded encoding. Every request - regardless of operation - is serialized as a JSON string in a single requestJobDescription form field. There is no REST/JSON body API.
Authentication uses static partner credentials (partnerUserID + partnerUserSecret) passed as form fields in every request; there is no OAuth 2.0 flow.
The API returns HTTP 200 for many error conditions. Callers must inspect the responseCode field inside the JSON response body to determine actual success or failure. Rate limits are not numerically documented; empirical observation suggests a low threshold (tens of requests per minute).
Batch multiple employee operations into a single API call rather than looping per-user to stay within undocumented limits.
For teams building identity graph pipelines - mapping users across HR systems, IdPs, and SaaS apps - Expensify's user object supports managerEmail, approvalLimit, overLimitApprover, and two custom fields (customField1, customField2), enabling approval hierarchy data to be written programmatically alongside provisioning events.
A platform like Stitchflow, which operates as an MCP server with 60+ deep IT/identity integrations, can use these fields to keep Expensify's approval graph in sync with changes in the authoritative HR source.
API quick reference
| Has user API | Yes |
| Auth method | Partner credentials (partnerUserID + partnerUserSecret) passed as form-encoded parameters in every request. No OAuth 2.0 flow. |
| Base URL | Official docs |
| SCIM available | Yes |
| SCIM plan required | Control Plan (enterprise/custom pricing) |
Authentication
Auth method: Partner credentials (partnerUserID + partnerUserSecret) passed as form-encoded parameters in every request. No OAuth 2.0 flow.
Setup steps
- Log in to Expensify and navigate to https://www.expensify.com/tools/integrations/ to generate API credentials.
- Note the partnerUserID and partnerUserSecret displayed on that page.
- Include both values as form fields (requestJobDescription JSON blob) in every API request.
- Credentials are scoped to the Expensify account that generated them; use a dedicated service account for automation.
User object / data model
| Field | Type | Description | On create | On update | Notes |
|---|---|---|---|---|---|
| string | Primary identifier for a user/member in Expensify. | required | immutable | Must be a valid email address. Used as the unique key across all policy operations. | |
| role | string | Member role within a policy. Values: 'admin', 'auditor', 'user'. | optional | updatable | Defaults to 'user' if omitted on invite. |
| isTerminated | boolean | Marks the employee record as terminated in an employee list (HR feed). | optional | updatable | Used in the employee updater command; triggers offboarding workflow. |
| firstName | string | Employee first name, used in HR/employee feed integrations. | optional | updatable | Part of the employee object in the updateEmployeeList command. |
| lastName | string | Employee last name. | optional | updatable | Part of the employee object in the updateEmployeeList command. |
| managerEmail | string | Email of the employee's direct manager for approval workflow routing. | optional | updatable | Must correspond to an existing Expensify user. |
| approvalLimit | number | Maximum report total (in cents) this employee can approve. | optional | updatable | Relevant for multi-level approval workflows. |
| overLimitApprover | string | Email of approver for reports exceeding approvalLimit. | optional | updatable | Used in advanced approval chain configuration. |
| customField1 | string | Custom employee attribute (e.g., employee ID, cost center). | optional | updatable | Mapped to custom fields configured on the policy. |
| customField2 | string | Second custom employee attribute. | optional | updatable | Same mapping rules as customField1. |
Core endpoints
Add or update policy members
- Method: POST
- URL:
https://integrations.expensify.com/Integration-Server/api - Watch out for: All employees in the payload are upserted; omitting an employee from the list does NOT remove them. Use the 'delete' action explicitly to remove members.
Request example
POST /Integration-Server/api
Content-Type: application/x-www-form-urlencoded
requestJobDescription={
"type": "update",
"credentials": {"partnerUserID": "user", "partnerUserSecret": "secret"},
"inputSettings": {
"type": "policyMembers",
"policyID": "POLICY_ID",
"employees": [{"email": "alice@example.com", "role": "user"}]
}
}
Response example
{
"responseCode": 200
}
Remove policy members
- Method: POST
- URL:
https://integrations.expensify.com/Integration-Server/api - Watch out for: Deleted members lose access immediately. Ensure outstanding reports are submitted or reassigned before removal.
Request example
requestJobDescription={
"type": "update",
"credentials": {"partnerUserID": "user", "partnerUserSecret": "secret"},
"inputSettings": {
"type": "policyMembers",
"policyID": "POLICY_ID",
"employees": [{"email": "alice@example.com"}],
"action": "delete"
}
}
Response example
{
"responseCode": 200
}
Update employee list (HR feed / approval hierarchy)
- Method: POST
- URL:
https://integrations.expensify.com/Integration-Server/api - Watch out for: Setting isTerminated=true deprovisions the user from the policy but does not delete their Expensify account.
Request example
requestJobDescription={
"type": "update",
"credentials": {"partnerUserID": "user", "partnerUserSecret": "secret"},
"inputSettings": {
"type": "employees",
"policyID": "POLICY_ID",
"employees": [{
"email": "bob@example.com",
"managerEmail": "mgr@example.com",
"isTerminated": false
}]
}
}
Response example
{
"responseCode": 200
}
Get policy members list
- Method: POST
- URL:
https://integrations.expensify.com/Integration-Server/api - Watch out for: Returns all members for the specified policy IDs in a single response object; no pagination.
Request example
requestJobDescription={
"type": "get",
"credentials": {"partnerUserID": "user", "partnerUserSecret": "secret"},
"inputSettings": {
"type": "policyMembers",
"policyIDList": ["POLICY_ID"]
}
}
Response example
{
"responseCode": 200,
"policyMembers": {
"POLICY_ID": {
"alice@example.com": {"role": "user"},
"bob@example.com": {"role": "admin"}
}
}
}
Create expense report (on behalf of user)
- Method: POST
- URL:
https://integrations.expensify.com/Integration-Server/api - Watch out for: The calling partner credentials must have admin rights on the policy to create reports on behalf of other users.
Request example
requestJobDescription={
"type": "create",
"credentials": {"partnerUserID": "user", "partnerUserSecret": "secret"},
"inputSettings": {
"type": "report",
"policyID": "POLICY_ID",
"report": {"title": "Q1 Expenses"},
"employeeEmail": "alice@example.com"
}
}
Response example
{
"responseCode": 200,
"reportID": "R123456789"
}
Rate limits, pagination, and events
- Rate limits: Expensify enforces rate limits on the Integration Server API but does not publish explicit numeric thresholds in official documentation. Requests that exceed limits receive an HTTP 429 or an error response in the JSON body.
- Rate-limit headers: No
- Retry-After header: No
- Rate-limit notes: Official docs do not specify rate limit headers. Implement exponential backoff on error responses. Bulk operations (e.g., policy member updates with multiple employees in one call) are preferred over looping single-user calls.
- Pagination method: none
- Default page size: 0
- Max page size: 0
- Pagination pointer: Not documented
| Plan | Limit | Concurrent |
|---|---|---|
| All plans | Not publicly documented; empirically observed limit is low (tens of requests per minute). | 0 |
- Webhooks available: No
- Webhook notes: Expensify does not offer outbound webhooks for user lifecycle events (create, update, delete) via its Integration Server API. Export/report-level callbacks exist for report status changes but are not user-management webhooks.
- Alternative event strategy: Poll the policyMembers GET command on a schedule to detect membership changes. For provisioning automation, use SCIM via Okta.
SCIM API status
SCIM available: Yes
SCIM version: 2.0
Plan required: Control Plan (enterprise/custom pricing)
Endpoint: Not publicly documented as a standalone URL; provisioning is configured through Okta's SCIM integration tile for Expensify. Activation must be requested via concierge@expensify.com.
Supported operations: Create user, Deactivate user, Update user attributes, Push groups (map Okta groups to Expensify policies)
Limitations:
- SCIM is only supported via Okta as the identity provider; direct SCIM endpoint access is not documented for other IdPs.
- Requires SAML SSO to be configured and domain verification completed before SCIM activation.
- Must contact concierge@expensify.com to enable SCIM provisioning on the account.
- Microsoft Entra ID SCIM support is referenced in context but not confirmed in official Expensify SCIM documentation; verify with Expensify support.
- Group push maps Okta groups to Expensify workspace policies; granular role assignment via SCIM may be limited.
Common scenarios
Bulk onboarding: Collect new employee emails, roles, and managerEmail values from your HR system. Construct a single POST with type='update', inputSettings.
type='policyMembers', and an employees array containing all new members. The upsert behavior means re-sending existing members is safe.
Follow up with a type='employees' call to set customField1/customField2 and approvalLimit values if your approval workflow requires them.
Offboarding: Two distinct paths exist. Sending action='delete' immediately revokes policy access. Sending isTerminated=true via the employee feed triggers Expensify's offboarding workflow, which reassigns open reports to the terminated employee's manager before removing access. Choose based on whether outstanding report continuity matters for your offboarding policy. Neither path deletes the Expensify account; account deletion is not available via the API.
SCIM via Okta: SCIM 2.0 is available on the Control plan but requires SAML SSO, domain verification, and manual activation via concierge@expensify.com before the Okta integration tile can be configured. Okta group push maps groups to Expensify workspace policies. SCIM support for Microsoft Entra ID is referenced in third-party context but is not confirmed in official Expensify documentation - verify directly with Expensify support before building against it.
Bulk onboard new employees to a policy
- Collect new employee emails, roles, and manager emails from your HR system.
- Construct a single POST request to the Integration Server with type='update', inputSettings.type='policyMembers', and an employees array containing all new members.
- Include managerEmail and role fields for each employee to configure approval routing.
- Check responseCode=200 in the response; log any non-200 codes for retry.
- Optionally follow up with a type='employees' update call to set customField1/customField2 and approvalLimit values.
Watch out for: Batch all employees in one request to avoid rate limiting. The upsert behavior means re-sending existing members is safe but redundant.
Offboard a terminated employee
- Identify the employee's email and the policy IDs they belong to (retrieve via policyMembers GET if unknown).
- Send a POST with type='update', action='delete', and the employee's email for each affected policy.
- Alternatively, send a type='employees' update with isTerminated=true to trigger Expensify's offboarding workflow (reassigns open reports to manager).
- Confirm removal by re-fetching policyMembers and verifying the email is absent.
Watch out for: Using action='delete' immediately revokes access. Using isTerminated=true is softer and may preserve report history routing. Choose based on your offboarding policy.
Set up SCIM provisioning via Okta
- Ensure the Expensify account is on the Control plan with domain verification and SAML SSO already configured.
- Email concierge@expensify.com to request SCIM provisioning activation for your domain.
- Once activated, add the Expensify app from the Okta Integration Network in your Okta admin console.
- Configure SCIM provisioning in the Okta app settings using the credentials/token provided by Expensify support.
- Assign Okta users and groups to the Expensify app; Okta will push create/deactivate events via SCIM 2.0.
- Map Okta groups to Expensify workspace policies using Okta's group push feature.
Watch out for: SCIM activation is not self-serve and can take time to enable. Test with a pilot group before full rollout. Deprovisioning via SCIM deactivates policy membership but does not delete the Expensify account.
Why building this yourself is a trap
The most significant API caveat is the upsert-only behavior on member updates: omitting an employee from a policyMembers payload does not remove them. Removals require an explicit action='delete' call. Automation that syncs a full employee list without explicitly handling the delete path will silently leave terminated employees on policies.
Partner credentials are scoped to the Expensify account that generated them. If the generating account's password is rotated, the partnerUserSecret is invalidated. Use a dedicated admin service account for all API automation and document the credential dependency explicitly in your runbook.
The API has no endpoint to list all policies for an account or to create policies programmatically. Any workflow that needs to discover policy IDs must either hardcode them or retrieve them out-of-band.
There is also no pagination on the policyMembers GET response - all members are returned in a single object, which can become a performance consideration for large policies.
Automate Expensify 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.