Introduction
Base URL
https://app.procurementexpress.com
The ProcurementExpress API gives your systems the same purchasing data and actions as the web app: raise and approve purchase orders, manage budgets, suppliers and products, capture and approve invoices, record payments, and get notified through webhooks when something changes.
Two API versions
The API is served in two versions that share the same resources. Most endpoints exist in both; a few are only in one (see V3 and V1).
| V3 (recommended) | V1 | |
|---|---|---|
| Base path | /api/v3 |
/api/v1 |
| Credential | OAuth2 access token, sent as Authorization: Bearer <token> |
A user's API token, sent as authentication_token: <token> |
| How you get it | POST /oauth/token with your OAuth application's client id and secret |
POST /api/v1/login with the user's email and password |
| Lifetime | 2 hours, then request a new token | Long-lived, until it is rotated |
| Company context | app_company_id header |
app_company_id header |
Build new integrations on V3. V1 remains fully supported for existing integrations and for the endpoints that only exist there.
How to read this reference
- Every endpoint shows its availability as badges: V3 V1 (a dashed badge means the endpoint does not exist in that version).
- The tabs at the top of the dark column switch every example between cURL · V3, cURL · V1, Ruby · V3 and Ruby · V1.
- Examples use these environment variables:
export PEX_ACCESS_TOKEN="<V3 access token from POST /oauth/token>"
export PEX_AUTH_TOKEN="<V1 authentication_token from POST /api/v1/login>"
export PEX_COMPANY_ID="<your company id>"
- Every request and response in this reference is real. Each example was
sent to the current ProcurementExpress release in a test company called
Acme Corporation (company id
20000) and the response is shown as returned, with secrets replaced by placeholders and long lists shortened. The ids and values you see belong to that test company; yours will differ. A few endpoints depend on an outside service (SAM.gov, AI document extraction, email delivery); their examples were recorded without that service, and the endpoint's description explains the full behaviour. - A machine-readable OpenAPI 3.1 description and a Postman collection are generated from the same verified examples.
Getting help
Questions, or something here that doesn't match what you see? Email
support@procurementexpress.com and
include the X-Request-Id response header of the request in question.
Quickstart
Make your first V3 call in four steps. You need a ProcurementExpress user with the company admin role to register the OAuth application; the API then acts as that user.
1. Register an OAuth application
- Sign in to ProcurementExpress as a company admin.
- Open Integrations and choose Custom OAuth Applications → Integrate, then Add OAuth Application.
- Enter a name (for example ERP integration). Leave Redirect URI and Scopes blank: server-to-server integrations use the client credentials grant, which needs neither.
- Save and copy the Client ID and Client Secret. The secret is shown only once (it is stored hashed). If you lose it, use Renew secret on the application, which invalidates the old one.
2. Request an access token
curl -X POST "https://app.procurementexpress.com/oauth/token" \
--data-urlencode "grant_type=client_credentials" \
--data-urlencode "client_id=$PEX_CLIENT_ID" \
--data-urlencode "client_secret=$PEX_CLIENT_SECRET"
# V1 uses a user's API token instead of OAuth.
curl -X POST "https://app.procurementexpress.com/api/v1/login" \
-H "Content-Type: application/json" \
-d '{
"email": "jane.doe@example.com",
"password": "<password>"
}'
require "rest-client"
require "json"
response = RestClient.post(
"https://app.procurementexpress.com/oauth/token",
grant_type: "client_credentials",
client_id: ENV.fetch("PEX_CLIENT_ID"),
client_secret: ENV.fetch("PEX_CLIENT_SECRET")
)
access_token = JSON.parse(response.body).fetch("access_token")
require "rest-client"
require "json"
# V1 uses a user's API token instead of OAuth.
response = RestClient.post(
"https://app.procurementexpress.com/api/v1/login",
{ email: "jane.doe@example.com", password: ENV.fetch("PEX_PASSWORD") }.to_json,
{ content_type: :json }
)
auth_token = JSON.parse(response.body).fetch("authentication_token")
V3 response
200 OK:
{
"access_token": "<access-token>",
"token_type": "Bearer",
"expires_in": 7200,
"scope": "public",
"created_at": 1792576011
}
The token is valid for expires_in seconds (2 hours). Client credentials tokens
have no refresh token: when a token expires (the API answers 401 with
WWW-Authenticate: Bearer … error="invalid_token"), request a new one the same
way. Cache the token and reuse it until it expires instead of requesting one per
call. See Authentication for the V1 sign-in flow, two-factor
authentication and token revocation.
3. Find your company id
Every resource call needs the app_company_id header. List the companies the
token's user belongs to (this call does not need the header):
curl "https://app.procurementexpress.com/api/v3/companies" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN"
curl "https://app.procurementexpress.com/api/v1/companies" \
-H "authentication_token: $PEX_AUTH_TOKEN"
require "rest-client"
require "json"
response = RestClient.get(
"https://app.procurementexpress.com/api/v3/companies",
{ "Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}" }
)
puts JSON.parse(response.body).map { |c| [c["id"], c["name"]] }.inspect
require "rest-client"
require "json"
response = RestClient.get(
"https://app.procurementexpress.com/api/v1/companies",
{ "authentication_token" => ENV.fetch("PEX_AUTH_TOKEN") }
)
puts JSON.parse(response.body).map { |c| [c["id"], c["name"]] }.inspect
Response
200 OK(shortened):
[
{
"id": 20000,
"name": "Acme Corporation",
"roles": ["companyadmin", "approver", "finance", "teammember"],
"approval_limit": 1000000.0,
"membership_archived": false
}
]
4. Make your first request
List the company's purchase orders:
curl "https://app.procurementexpress.com/api/v3/purchase_orders" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/purchase_orders" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient.get(
"https://app.procurementexpress.com/api/v3/purchase_orders",
{
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient.get(
"https://app.procurementexpress.com/api/v1/purchase_orders",
{
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
From here:
- Purchase Orders covers creating, approving and receiving orders.
- Webhooks pushes changes to your system instead of polling.
- Requests and Responses explains pagination, dates and request formats; Errors lists every error shape you can receive.
Authentication
Every API request is made as a ProcurementExpress user and in one company. There are two ways to prove who you are:
| V3 (current) | V1 (legacy) | |
|---|---|---|
| Base path | /api/v3 |
/api/v1 |
| Credential | OAuth2 bearer access token | authentication_token (a per-user API token) |
| How to get it | POST /oauth/token with grant_type=client_credentials |
POST /api/v1/login (+ POST /api/v1/verify_otp when 2FA is required) |
| Header | Authorization: Bearer <access-token> |
authentication_token: <token> |
| Lifetime | 2 hours (expires_in: 7200), no refresh token |
Does not expire; replaced on logout |
| Company | app_company_id header |
app_company_id header |
New integrations should use V3 with the client credentials grant. The OAuth2
password grant is not available to integrations (the token endpoint answers
401 unauthorized_client), and neither is the authorization-code grant, which is
reserved for ProcurementExpress's own apps and MCP clients.
1. Create an OAuth application (V3)
A company admin creates the application in the web app under
Integrations → Custom OAuth Applications → Integrate
(/companies/<company id>/oauth_applications). Give it a name; the redirect URI can
be left blank for client credentials. The client secret is shown once, right
after creation (it is stored hashed); if it is lost, use Renew secret to issue a
new one.
The application is owned by the admin who created it, and every token issued
to it acts as that user: it has exactly that user's roles, approval limits and
permissions in the company named by app_company_id, and it can address any
company that user is a live member of. Create the application from the account you
want the integration to act as (many customers use a dedicated "API user").
2. Get an access token
curl https://app.procurementexpress.com/oauth/token \
-X POST \
--data-urlencode "grant_type=client_credentials" \
--data-urlencode "client_id=$PEX_CLIENT_ID" \
--data-urlencode "client_secret=$PEX_CLIENT_SECRET"
The token is valid for 7200 seconds. Client credentials tokens come without a
refresh token: when a token expires (any V3 call answers 401 with
WWW-Authenticate: Bearer ... error="invalid_token", error_description="The access
token expired"), simply request a new one. Every request to /oauth/token mints a
new token, so cache the token for its lifetime instead of requesting one per call,
and revoke tokens you no longer need (POST /oauth/revoke).
3. Call the API
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer <access-token> |
V3: yes | Send the token only in this header. |
authentication_token: <token> |
V1: yes | The V1 API token. Authentication-Token (hyphenated) is accepted too. |
app_company_id: <company id> |
Yes, except on the user-profile, sign-in and public endpoints that say otherwise | The company the request acts in. App-Company-Id is accepted too. You must be a member of it. |
Content-Type: application/json |
For JSON bodies | Form-encoded bodies are accepted as well. |
Locale: <code> |
No | Language of error messages, see Localization below. |
Header names are case-insensitive. Some proxies drop header names that contain
underscores; if yours does, use the hyphenated spellings Authentication-Token
and App-Company-Id.
Before any endpoint runs, the API checks the credential and the company, in this
order (both versions): credential present and valid → app_company_id present →
caller is a member of that company (otherwise 404 Invalid id passed) →
membership not archived (401) → company paid up and active (402
Billing Details Required) → 2FA requirement of that company (401
otp_required). See Authentication errors for every
response.
Use GET /currentuser to discover the companies the credential can act in: each
entry of companies[] tells you whether the membership is archived
(membership_archived), whether the company is blocked for billing (is_locked,
requests answer 402) or removed (is_removed).
V1 tokens
A V1 authentication_token belongs to the user, not to a device or an
integration: every app and script that signs in as the same user shares one token.
It is returned only by the sign-in endpoints (login, verify_otp), never by
GET /currentuser. It does not expire. POST /api/v1/logout replaces it with a new
one, which signs that user out everywhere the old token was used.
Two-factor authentication (2FA / OTP)
A company can require a one-time code at sign-in for all its members (company
setting) or for single members. API credentials are user-wide, so the requirement is
enforced per request, against the company the request addresses
(app_company_id; without that header, against the user's default company
employer_id):
- V1 tokens are accepted in a 2FA company only if the token was confirmed with
POST /verify_otpafter that company's requirement began. Signing in withPOST /api/v1/loginfor an account where any company requires 2FA returns{"otp_sent": true}and e-mails a 6-digit code (valid 5 minutes, 5 wrong attempts burn it);POST /verify_otpwith the code returns the token, now marked as OTP-verified. - V3 client credentials tokens have no user session behind them and are not subject to 2FA: they work in 2FA companies. Protect the client secret accordingly.
- V3 tokens bound to a user (issued to ProcurementExpress's own apps through a browser sign-in) are accepted only if that sign-in passed the second factor after the requirement began.
- Changing the account itself (
PUT /currentuser,POST /api_user_update,POST /users/deactivate_account) requires the credential to satisfy every company that requires 2FA, not just the addressed one.
A refused request gets 401 with "otp_required": true:
{
"error": "Two-factor authentication is required for this company. Sign in again with your one-time code.",
"otp_required": true,
"status": 401
}
The fix for a V1 client is to sign in again with login + verify_otp.
Rate limits
Sign-in endpoints are rate limited: POST /api/v1/login, /verify_otp and
/resend_otp allow 60 requests per minute per client IP and 20 attempts per
account (e-mail) per 15 minutes; reset_password 10 requests per 15 minutes per IP
and 3 per hour per e-mail; register 10 per 15 minutes per IP. All /api paths
also share a per-IP request budget. Over a limit the API answers 429 (sign-in
endpoints with an empty {} body and a Retry-After header). Repeated wrong
passwords also lock the account (Devise lockout), after which login keeps
answering Invalid email or password.
Localization (`Locale` header)
Error and validation messages are translated when the request carries a Locale
header naming an installed locale exactly (case-sensitive, hyphenated):
en (default), es, es-CL, es-MX, fr, de, nl, pt, ru, sk, pl,
lv, el, he, hi, af, ja, en-GB, en-insider, it, ko, km, tr,
uk, gd.
- A missing or unrecognised value (
ES,es_MX,xx) silently uses English. Accept-Languageand a?locale=query parameter are ignored.- Translation coverage varies: some messages stay in English even for supported
locales (for example
destill returnsApp company id is nil); messages without a translation in the requested locale are returned in English. - The locale applies to messages only; field names, enum values and data are never translated.
curl https://app.procurementexpress.com/api/v3/budgets \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "Locale: es"
# {"error":"El id de la compañía de la aplicación es nulo","status":401}
The old locale-bundle download (GET /locales/:locale) and the account-existence
check (GET /useralreadyregistered) are retired and answer 410 Gone.
Endpoints in this section
| Endpoint | Method | Path | V3 | V1 |
|---|---|---|---|---|
| Get an access token | POST |
/oauth/token |
✓ | — |
| Check an access token | GET |
/oauth/token/info |
✓ | — |
| Introspect an access token | POST |
/oauth/introspect |
✓ | — |
| Revoke an access token | POST |
/oauth/revoke |
✓ | — |
| Sign in (V1) | POST |
/login |
— | ✓ |
| Verify a one-time code | POST |
/verify_otp |
✓ | ✓ |
| Resend the one-time code | POST |
/resend_otp |
✓ | ✓ |
| Sign out (V1) | POST |
/logout |
— | ✓ |
| Request a password reset e-mail | POST |
/reset_password |
✓ | ✓ |
| Authentication errors | GET |
/budgets |
✓ | ✓ |
Get an access token
curl -X POST "https://app.procurementexpress.com/oauth/token" \
--data-urlencode "grant_type=client_credentials" \
--data-urlencode "client_id=<client-id>" \
--data-urlencode "client_secret=<client-secret>"
# V3 only: this endpoint has no V1 equivalent.
curl -X POST "https://app.procurementexpress.com/oauth/token" \
--data-urlencode "grant_type=client_credentials" \
--data-urlencode "client_id=<client-id>" \
--data-urlencode "client_secret=<client-secret>"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/oauth/token",
headers: {
},
payload: {
grant_type: "client_credentials",
client_id: "<client-id>",
client_secret: "<client-secret>"
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
# V3 only: this endpoint has no V1 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/oauth/token",
headers: {
},
payload: {
grant_type: "client_credentials",
client_id: "<client-id>",
client_secret: "<client-secret>"
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
V3 response
200 OK:
{
"access_token": "<access-token>",
"token_type": "Bearer",
"expires_in": 7200,
"scope": "public",
"created_at": 1792577106
}
Example: json (V3,
200 OK):
{
"access_token": "<access-token>",
"token_type": "Bearer",
"expires_in": 7200,
"scope": "public",
"created_at": 1792577121
}
V3 V1 —
Exchange your OAuth application's client id and secret for a V3 bearer access token.
OAuth2 token endpoint. Integrations use the client credentials grant only. The token acts as the user who owns the OAuth application.
- Send the fields as
application/x-www-form-urlencoded(shown) or as a JSON object; the client can also authenticate with HTTP Basic (Authorization: Basic base64(client_id:client_secret)) instead of the two body fields. - The only scope is
public(the default;scopecan be omitted). - No refresh token is issued for this grant and tokens are not reused: every call creates a new token valid for 7200 seconds.
grant_type=passwordandgrant_type=authorization_codeare refused for integration applications (unauthorized_client);grant_type=refresh_tokenis refused as well, because client-credentials tokens have no refresh token.- The response carries
Cache-Control: no-store. - The endpoint only accepts POST (
GET /oauth/tokenanswers 404, or 405 on deployments where the MCP integration is enabled).
HTTP request
| Version | Request |
|---|---|
| V3 | POST /oauth/token |
Permissions
No user credential. The application must be a confidential (secret-holding)
application created by a company admin. Unknown client or wrong secret: 401
invalid_client.
Body parameters (form-encoded)
| Parameter | Type | Required | Description |
|---|---|---|---|
grant_type |
string | Yes | Must be client_credentials. |
client_id |
string | Yes | The application's client id (UID). Not needed when using HTTP Basic. |
client_secret |
string | Yes | The application's client secret. Not needed when using HTTP Basic. |
scope |
string | No | Only public is valid; MCP scopes such as procurement:read answer 400 invalid_scope. Default: public. |
Response
200 with the token. token_type is always Bearer, expires_in is in seconds,
created_at is a Unix timestamp (seconds). Errors use the OAuth2 shape
{"error": "...", "error_description": "..."} (the refresh / authorization-code
refusals carry only error). A missing grant_type answers 400
invalid_request.
| Field | Type | Description |
|---|---|---|
access_token |
string | Bearer token for Authorization: Bearer .... |
token_type |
string | Bearer. |
expires_in |
integer | Lifetime in seconds (7200). |
scope |
string | public. |
created_at |
timestamp | Unix seconds. |
V3 and V1 differences
V3 only. V1 does not use OAuth: V1 clients obtain an authentication_token with
POST /api/v1/login. The token endpoint is not under /api, so the path is
absolute.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Invalid client | 401 |
V3 | {"error":"invalid_client","error_description":"Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method."} |
| Password refused | 401 |
V3 | {"error":"unauthorized_client","error_description":"The client is not authorized to perform this request using this method."} |
| Refresh refused | 400 |
V3 | {"error":"unauthorized_client"} |
| Authorization code refused | 400 |
V3 | {"error":"unauthorized_client"} |
| Unsupported grant | 400 |
V3 | {"error":"unsupported_grant_type","error_description":"The authorization grant type is not supported by the authorization server."} |
| Invalid scope | 400 |
V3 | {"error":"invalid_scope"} |
Check an access token
curl "https://app.procurementexpress.com/oauth/token/info" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN"
# V3 only: this endpoint has no V1 equivalent.
curl "https://app.procurementexpress.com/oauth/token/info" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/oauth/token/info",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}"
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
# V3 only: this endpoint has no V1 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/oauth/token/info",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}"
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
V3 response
200 OK:
{
}
V3 V1 —
Returns 200 while the bearer token is valid and 401 once it is invalid, expired or revoked.
The OAuth2 token-info endpoint. Send the token in the Authorization header.
Use the status code to decide whether the token is still usable: 200 means
valid, 401 (with WWW-Authenticate: Bearer realm="Doorkeeper", error="invalid_token", ...)
means request a new token. The body of the 200 response is currently an empty
object; use POST /oauth/introspect if you need the expiry time.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /oauth/token/info |
Permissions
Bearer token only (no client credentials, no app_company_id).
Response
200 {} for a valid token. 401 {"error":"invalid_token","error_description":"The access token is invalid","state":"unauthorized"}
otherwise.
V3 and V1 differences
V3 only (OAuth). V1 has no equivalent; any V1 call answers 401 for an invalid token.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Missing token | 401 |
V3 | {"error":"invalid_token","error_description":"The access token is invalid","state":"unauthorized"} |
Introspect an access token
curl -X POST "https://app.procurementexpress.com/oauth/introspect" \
--data-urlencode "token=<access-token>" \
--data-urlencode "client_id=<client-id>" \
--data-urlencode "client_secret=<client-secret>"
# V3 only: this endpoint has no V1 equivalent.
curl -X POST "https://app.procurementexpress.com/oauth/introspect" \
--data-urlencode "token=<access-token>" \
--data-urlencode "client_id=<client-id>" \
--data-urlencode "client_secret=<client-secret>"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/oauth/introspect",
headers: {
},
payload: {
token: "<access-token>",
client_id: "<client-id>",
client_secret: "<client-secret>"
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
# V3 only: this endpoint has no V1 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/oauth/introspect",
headers: {
},
payload: {
token: "<access-token>",
client_id: "<client-id>",
client_secret: "<client-secret>"
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
V3 response
200 OK:
{
"active": true,
"scope": "public",
"client_id": "<client-id>",
"token_type": "Bearer",
"exp": 1792584335,
"iat": 1792577135
}
Example: inactive (V3,
200 OK):
{
"active": false
}
V3 V1 —
RFC 7662 token introspection - tells whether a token is active, its scope and expiry.
Authenticate as the OAuth application (form fields client_id / client_secret,
or HTTP Basic) and pass the token to inspect. An unknown, expired or revoked token
answers 200 {"active": false}. Tokens of MCP clients are reported inactive
unless the request comes from the MCP client itself.
HTTP request
| Version | Request |
|---|---|
| V3 | POST /oauth/introspect |
Permissions
Client authentication required; without it the request answers 400
invalid_request. Authenticating with the inspected token itself as bearer is not
accepted (401 invalid_token).
Body parameters (form-encoded)
| Parameter | Type | Required | Description |
|---|---|---|---|
token |
string | Yes | The access token to inspect. |
client_id |
string | Yes | Your application's client id (or HTTP Basic). |
client_secret |
string | Yes | Your application's client secret (or HTTP Basic). |
token_type_hint |
string | No | Optional RFC 7662 hint (access_token). |
Response
200. Active tokens: active: true, scope, client_id (the application's
UID), token_type, exp and iat (Unix seconds). Inactive: only active: false.
| Field | Type | Description |
|---|---|---|
active |
boolean | Whether the token can be used. |
scope |
string | public. |
client_id |
string | UID of the application the token belongs to. |
token_type |
string | Bearer. |
exp |
timestamp | Expiry, Unix seconds. |
iat |
timestamp | Issued at, Unix seconds. |
V3 and V1 differences
V3 only (OAuth). V1 does not use OAuth.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Unauthenticated | 400 |
V3 | {"error":"invalid_request","error_description":"The request is missing a required parameter, includes an unsupported parameter value, or is otherwise malformed."} |
Revoke an access token
curl -X POST "https://app.procurementexpress.com/oauth/revoke" \
--data-urlencode "token=<access-token>" \
--data-urlencode "client_id=<client-id>" \
--data-urlencode "client_secret=<client-secret>"
# V3 only: this endpoint has no V1 equivalent.
curl -X POST "https://app.procurementexpress.com/oauth/revoke" \
--data-urlencode "token=<access-token>" \
--data-urlencode "client_id=<client-id>" \
--data-urlencode "client_secret=<client-secret>"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/oauth/revoke",
headers: {
},
payload: {
token: "<access-token>",
client_id: "<client-id>",
client_secret: "<client-secret>"
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
# V3 only: this endpoint has no V1 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/oauth/revoke",
headers: {
},
payload: {
token: "<access-token>",
client_id: "<client-id>",
client_secret: "<client-secret>"
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
V3 response
200 OK:
{
}
V3 V1 —
Revoke (sign out) an access token issued to your application.
RFC 7009 revocation. The token stops working immediately (V3 calls then answer
401 The access token was revoked, introspection reports active: false).
Revoking an unknown token also answers 200 {}. A token issued to a different
application cannot be revoked (403).
HTTP request
| Version | Request |
|---|---|
| V3 | POST /oauth/revoke |
Permissions
Client authentication (client_id + client_secret, or HTTP Basic) is
required for confidential applications; without it, or for another application's
token: 403 {"error":"unauthorized_client","error_description":"You are not authorized to revoke this token"}.
Body parameters (form-encoded)
| Parameter | Type | Required | Description |
|---|---|---|---|
token |
string | Yes | The access token to revoke. |
client_id |
string | Yes | Your application's client id (or HTTP Basic). |
client_secret |
string | Yes | Your application's client secret (or HTTP Basic). |
token_type_hint |
string | No | Optional (access_token). |
Response
200 {} on success (also for an unknown token).
V3 and V1 differences
V3 only (OAuth). The V1 counterpart is POST /api/v1/logout.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Unauthenticated | 403 |
V3 | {"error":"unauthorized_client","error_description":"You are not authorized to revoke this token"} |
| Other client | 403 |
V3 | {"error":"unauthorized_client","error_description":"You are not authorized to revoke this token"} |
Sign in (V1)
# V1 only: this endpoint has no V3 equivalent.
curl -X POST "https://app.procurementexpress.com/api/v1/login" \
-H "Content-Type: application/json" \
-d '{
"email": "lee.login@example.com",
"password": "<password>"
}'
curl -X POST "https://app.procurementexpress.com/api/v1/login" \
-H "Content-Type: application/json" \
-d '{
"email": "lee.login@example.com",
"password": "<password>"
}'
# V1 only: this endpoint has no V3 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/login",
headers: {
"Content-Type" => "application/json"
},
payload: {
email: "lee.login@example.com",
password: "<password>"
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/login",
headers: {
"Content-Type" => "application/json"
},
payload: {
email: "lee.login@example.com",
password: "<password>"
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
V1 response
200 OK:
{
"id": 4,
"email": "lee.login@example.com",
"name": "Lee Login",
"approver_name": "Lee Login",
"phone_number": "+15550199",
"setup_incomplete": false,
"employer_id": 3,
"approval_limit": 1000000.0,
"companies": [
{
"id": 3,
"name": "Lee Workshop",
"external_user_id": null,
"membership_archived": false,
"is_locked": false,
"is_removed": false,
"approval_limit": 1000000.0,
"in_trial": true,
"trial_expired": false,
"remaining_trial_days": 365,
"roles": [
"companyadmin",
"approver",
"finance",
"teammember"
],
"has_unassigned_budgets": false
}
],
"roles": null,
"authentication_token": "<authentication-token>",
"external_user_id": null
}
Example: slim companies (V1,
200 OK):
{
"id": 4,
"email": "lee.login@example.com",
"name": "Lee Login",
"approver_name": "Lee Login",
"phone_number": "+15550199",
"setup_incomplete": false,
"employer_id": 3,
"approval_limit": 1000000.0,
"companies": [
{
"id": 3,
"name": "Lee Workshop",
"external_user_id": null,
"membership_archived": false,
"is_locked": false,
"is_removed": false,
"approval_limit": 1000000.0,
"in_trial": true,
"trial_expired": false,
"remaining_trial_days": 365,
"roles": [
"companyadmin",
"approver",
"finance",
"teammember"
],
"has_unassigned_budgets": false
}
],
"roles": null,
"authentication_token": "<authentication-token>",
"external_user_id": null
}
V3 — V1
Exchange e-mail and password for the user's V1 authentication_token.
Validates the password (counting failures towards the Devise lockout).
- No 2FA required anywhere for this account: returns the user object with
authentication_token. The existing token is returned (a token is created only if the user has none), so all clients of the same user share it. The token is marked as not OTP-verified. - Any company or membership of the account requires 2FA: a 6-digit code is
generated and e-mailed, and the response is
{"otp_sent": true}. Complete the sign-in withPOST /verify_otp. Signing in again within 30 seconds re-sends the same code instead of a new one. - Wrong password, unknown e-mail, locked, deactivated or archived account all
answer the same
400 Invalid email or password.
email is matched case-insensitively. Pass slim_companies (any value) to shrink
every company except the default one to {id, name, is_removed}. The call also
starts a web session (a session cookie is set), which API clients can ignore.
HTTP request
| Version | Request |
|---|---|
| V1 | `POST |
Permissions
Unauthenticated. Rate limited (60/min per IP, 20 per 15 min per account).
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
email |
string | Yes | Login e-mail (case-insensitive). Missing: 400 Enter email or username. |
password |
string | Yes | Account password. |
slim_companies |
string | No | Any non-empty value returns slim company entries (see User object). |
Response
200 with the User object plus authentication_token, or 200 {"otp_sent": true}
when a one-time code is required.
Side effects
Updates last_sign_in_at / Devise trackable fields; with 2FA, e-mails the code
(synchronously, through Postmark).
V3 and V1 differences
V1 only. V3 has no sign-in route:
POST /api/v3/login falls through to the catch-all and answers 404 Invalid URL
(or 401 without a bearer token). V3 integrations authenticate with
POST /oauth/token.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Invalid credentials | 400 |
V1 | {"error":"Invalid email or password.","status":400} |
| Missing email | 400 |
V1 | {"error":"Enter email or username","status":400} |
Verify a one-time code
curl -X POST "https://app.procurementexpress.com/api/v3/verify_otp" \
-H "Content-Type: application/json" \
-d '{
"email": "olivia.otp@example.com",
"otp_attempt": "628354",
"slim_companies": "1"
}'
curl -X POST "https://app.procurementexpress.com/api/v1/verify_otp" \
-H "Content-Type: application/json" \
-d '{
"email": "olivia.otp@example.com",
"otp_attempt": "835374"
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/verify_otp",
headers: {
"Content-Type" => "application/json"
},
payload: {
email: "olivia.otp@example.com",
otp_attempt: "628354",
slim_companies: "1"
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/verify_otp",
headers: {
"Content-Type" => "application/json"
},
payload: {
email: "olivia.otp@example.com",
otp_attempt: "835374"
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1 (lists shortened to 2 items):
{
"id": 3,
"email": "olivia.otp@example.com",
"name": "Olivia Otp",
"approver_name": "Olivia Otp",
"phone_number": "+15550199",
"setup_incomplete": false,
"employer_id": 2,
"approval_limit": 1000000.0,
"companies": [
{
"id": 2,
"name": "Initech OTP",
"external_user_id": null,
"membership_archived": false,
"is_locked": false,
"is_removed": false,
"approval_limit": 1000000.0,
"in_trial": true,
"trial_expired": false,
"remaining_trial_days": 365,
"roles": [
"companyadmin",
"approver",
"finance",
"teammember"
],
"has_unassigned_budgets": false
},
{
"id": 6,
"name": "Initrode Inc (archived member)",
"external_user_id": null,
"membership_archived": false,
"is_locked": false,
"is_removed": false,
"approval_limit": 1000000.0,
"in_trial": true,
"trial_expired": false,
"remaining_trial_days": 365,
"roles": [
"companyadmin",
"approver",
"finance",
"teammember"
],
"has_unassigned_budgets": false
}
],
"roles": null,
"authentication_token": "<authentication-token>",
"external_user_id": null
}
Example: mixed case email (V3,
400 Bad Request):
{
"error": "Invalid OTP",
"status": 400
}
V3 V1
Complete a 2FA sign-in with the e-mailed 6-digit code and receive the V1 authentication_token.
Second step after POST /api/v1/login answered {"otp_sent": true}. On success
the code is consumed, the user's token is marked as OTP-verified (it is replaced
by a new one if it was issued while the account already required 2FA) and the
user object with authentication_token is returned. That token is then accepted
in companies that require 2FA.
Codes are valid for 5 minutes; after 5 wrong attempts the code is burned and the
user must sign in again. Every failure (wrong, expired or burned code, unknown
e-mail, no pending challenge) answers the same 400 Invalid OTP.
HTTP request
| Version | Request |
|---|---|
| V3 | POST /api/v3/verify_otp |
| V1 | POST /api/v1/verify_otp |
Permissions
Unauthenticated. Shares the sign-in rate limits.
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
email |
string | Yes | The e-mail used for login (V1: case-insensitive; V3: must match the stored lower-case address exactly). |
otp_attempt |
string | Yes | The 6-digit code as a string. |
slim_companies |
string | No | V1 only: slim company entries. |
Response
200 with the User object including authentication_token.
V3 and V1 differences
Routed in both versions, but V3 has no login, so a challenge can only be started
from V1 (or google_login), and V3 verify_otp returns the V1
authentication_token, not an OAuth token. V3 matches email case-sensitively
(Olivia.Otp@Example.com answers Invalid OTP in V3, succeeds in V1 - see the
extra capture) and ignores slim_companies.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Invalid | 400 |
V1 | {"error":"Invalid OTP","status":400} |
Resend the one-time code
curl -X POST "https://app.procurementexpress.com/api/v3/resend_otp" \
-H "Content-Type: application/json" \
-d '{
"email": "olivia.otp@example.com"
}'
curl -X POST "https://app.procurementexpress.com/api/v1/resend_otp" \
-H "Content-Type: application/json" \
-d '{
"email": "olivia.otp@example.com"
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/resend_otp",
headers: {
"Content-Type" => "application/json"
},
payload: {
email: "olivia.otp@example.com"
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/resend_otp",
headers: {
"Content-Type" => "application/json"
},
payload: {
email: "olivia.otp@example.com"
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"otp_sent": true
}
Example: unknown email (V1,
200 OK):
{
"otp_sent": true
}
V3 V1
Re-send a fresh code for a pending 2FA sign-in.
Always answers {"otp_sent": true}, whether or not the address exists or has a
pending challenge, so it cannot be used to probe accounts. The mail is sent in the
background, and only when a password (or Google) login started a challenge that
has not expired; the new code keeps the earlier wrong-attempt count.
HTTP request
| Version | Request |
|---|---|
| V3 | POST /api/v3/resend_otp |
| V1 | POST /api/v1/resend_otp |
Permissions
Unauthenticated. Shares the sign-in rate limits.
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
email |
string | Yes | The e-mail used for login. |
Response
200 {"otp_sent": true}.
Side effects
Emails a new one-time code (sent in the background).
V3 and V1 differences
None - identical request and response.
Sign out (V1)
# V1 only: this endpoint has no V3 equivalent.
curl -X POST "https://app.procurementexpress.com/api/v1/logout" \
-H "authentication_token: $PEX_AUTH_TOKEN"
curl -X POST "https://app.procurementexpress.com/api/v1/logout" \
-H "authentication_token: $PEX_AUTH_TOKEN"
# V1 only: this endpoint has no V3 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/logout",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/logout",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
V1 response
200 OK:
{
"success": true,
"info": "Logged out"
}
Example: old token rejected (V1,
401 Unauthorized):
{
"error": "Authentication token is invalid",
"status": 401
}
V3 — V1
Invalidate the user's V1 authentication_token.
Replaces the user's authentication_token with a new, unpublished one. Because the
token is shared by every client of the same user, this signs the user out of
all apps and integrations that use the V1 token. The next login returns the
new token. No body is needed and app_company_id is not required.
HTTP request
| Version | Request |
|---|---|
| V1 | `POST |
Permissions
A valid authentication_token; missing or invalid token answers 401.
Response
200 {"success": true, "info": "Logged out"}; afterwards the old token answers 401 Authentication token is invalid.
V3 and V1 differences
V1 only (route commented out in V3; POST /api/v3/logout answers 404 Invalid URL).
V3 clients revoke their bearer token with POST /oauth/revoke.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Missing token | 401 |
V1 | {"error":"Authentication token is nil","status":401} |
Request a password reset e-mail
curl -X POST "https://app.procurementexpress.com/api/v3/reset_password" \
-H "Content-Type: application/json" \
-d '{
"email": "lee.login@example.com"
}'
curl -X POST "https://app.procurementexpress.com/api/v1/reset_password" \
-H "Content-Type: application/json" \
-d '{
"email": "lee.login@example.com"
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/reset_password",
headers: {
"Content-Type" => "application/json"
},
payload: {
email: "lee.login@example.com"
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/reset_password",
headers: {
"Content-Type" => "application/json"
},
payload: {
email: "lee.login@example.com"
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"success": "true"
}
Example: unknown email (V3,
200 OK):
{
"success": "true"
}
V3 V1
E-mail password-reset instructions to an address.
Always answers {"success": "true"} (note: a string), whether or not the address
is registered. When it is, Devise reset instructions are e-mailed in the
background. Rate limited to 3 requests per e-mail per hour and 10 per IP per 15
minutes.
HTTP request
| Version | Request |
|---|---|
| V3 | `POST |
| V1 | `POST |
Permissions
Unauthenticated.
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
email |
string | Yes | Account e-mail (case-insensitive). |
Response
200 {"success": "true"}.
Side effects
Emails the password reset link (sent in the background).
V3 and V1 differences
None - identical request and response.
Authentication errors
curl "https://app.procurementexpress.com/api/v3/budgets" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/budgets" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/budgets",
headers: {
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts response.code
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/budgets",
headers: {
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
V3 response
401 Unauthorized(empty body)V1 response
401 Unauthorized:
{
"error": "Authentication token is nil",
"status": 401
}
Example: auth error missing company localized (V3,
401 Unauthorized):
{
"error": "El id de la compañía de la aplicación es nulo",
"status": 401
}
V3 V1
The responses every endpoint can give before it runs - credential, company and 2FA checks.
Shown here on GET /budgets, but every authenticated endpoint behaves the same.
V1 errors are JSON {"error": "<message>", "status": <code>}. V3 credential errors
come from the OAuth layer: empty body with a WWW-Authenticate header;
company and 2FA errors are JSON like V1.
| Case | V1 | V3 |
|---|---|---|
| No credential | 401 Authentication token is nil |
401, empty body, WWW-Authenticate: Bearer realm="Doorkeeper", error="invalid_token", error_description="The access token is invalid" |
| Unknown token | 401 Authentication token is invalid |
401, empty body, error_description="The access token is invalid" |
| Expired token | n/a (V1 tokens do not expire; a replaced token is "invalid") | 401, empty body, error_description="The access token expired" |
| Revoked token | n/a | 401, empty body, error_description="The access token was revoked" |
| Deactivated user | 401 Invalid credentials used. Please contact support for more info. |
401 Your account is archived, Please contact your admin to active your account. (company-scoped endpoints) |
| MCP token used on the REST API | n/a | 403, empty body |
app_company_id missing |
401 App company id is nil |
same |
| Not a member of that company (or no such company) | 404 Invalid id passed |
same |
| Membership archived | 401 Your account is archived, Please contact your admin to active your account. |
same |
| Company not paid up / inactive | 402 Billing Details Required |
same |
| Company requires 2FA the credential has not passed | 401 + "otp_required": true |
same (user-bound tokens only; client credentials tokens are exempt) |
Other status codes used across the API: 400 invalid parameters or validation
failure (error is a message string, sometimes an array), 401 Unauthorized access
when your role may not perform the action, 404 Invalid id passed for an unknown
record id and 404 Invalid URL for an unknown path, 410 retired endpoint,
422 some validation failures, 429 rate limited, and 400 {"status":400,"error":"Bad Request"}
for a malformed JSON body. Messages follow the Locale header.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/budgets |
| V1 | GET /api/v1/budgets |
Permissions
n/a
Response
See the table.
V3 and V1 differences
V3 credential failures (missing / invalid / expired / revoked token) have an empty
body and the reason only in WWW-Authenticate; V1 returns a JSON message. A
deactivated user gets a credential error in V1 but, in V3, only the company check
refuses it (as "account archived"). MCP tokens exist only in V3 (403). The
company and 2FA checks are identical.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Auth error invalid token | 401 |
V3, V1 | (empty body) |
| Auth error expired token | 401 |
V3 | (empty body) |
| Auth error revoked token | 401 |
V3 | (empty body) |
| Auth error deactivated user | 401 |
V3, V1 | {"error":"Your account is archived, Please contact your admin to active your account.","status":401} |
| Auth error mcp token | 403 |
V3 | (empty body) |
| Auth error missing company | 401 |
V3, V1 | {"error":"App company id is nil","status":401} |
| Auth error not a member | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
| Auth error membership archived | 401 |
V3, V1 | {"error":"Your account is archived, Please contact your admin to active your account.","status":401} |
| Auth error company blocked | 402 |
V3, V1 | {"error":"Billing Details Required","status":402} |
| Auth error otp required | 401 |
V3, V1 | {"error":"Two-factor authentication is required for this company. Sign in again with your one-time code.","otp_required":true,"status":401} |
Requests and Responses
Headers
curl "https://app.procurementexpress.com/api/v3/budgets" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Accept: application/json" \
-H "locale: es"
curl "https://app.procurementexpress.com/api/v1/budgets" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Accept: application/json" \
-H "locale: es"
require "rest-client"
RestClient.get(
"https://app.procurementexpress.com/api/v3/budgets",
{
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Accept" => "application/json",
"locale" => "es"
}
)
require "rest-client"
RestClient.get(
"https://app.procurementexpress.com/api/v1/budgets",
{
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Accept" => "application/json",
"locale" => "es"
}
)
| Header | Required | Description |
|---|---|---|
Authorization |
V3 | Bearer <access token>. See Authentication. |
authentication_token |
V1 | The user's V1 API token. |
app_company_id |
Yes, except where noted | Id of the company the request acts in. The user must be an active member of it. A user who belongs to several companies switches company by changing this header. |
Content-Type |
For bodies | application/json for JSON bodies (recommended), multipart/form-data for file uploads. |
Accept |
No | Responses are JSON (application/json) unless the endpoint returns a file. |
locale |
No | Language of error and status messages, for example es or fr. See Authentication for the supported values. |
All traffic must use HTTPS. Every response carries an X-Request-Id header;
include it when you contact support about a specific call.
Request bodies
Send JSON with Content-Type: application/json. Most write endpoints expect
their fields wrapped in the resource name, Rails style, and nested records
as *_attributes arrays:
{
"purchase_order": {
"supplier_id": 2,
"purchase_order_items_attributes": [
{ "description": "27-inch 4K monitor", "quantity": 2, "unit_price": 329.99 }
]
}
}
- A missing wrapper (for example posting
{"name": "x"}instead of{"department": {"name": "x"}}) answers400with{"error": "Required parameter are missing.", "status": 400}. - Unknown or misspelt fields are silently ignored, not rejected. If a value you sent does not appear in the response, check the field name against the endpoint's parameter table.
- A body that is not valid JSON answers
400with an empty body. - The same fields can also be sent form-encoded
(
purchase_order[supplier_id]=2), which is what file-upload endpoints use withmultipart/form-data.
Pagination
List endpoints are paginated with page (1-based) and per_page. Where an
endpoint accepts per_page, the allowed values are usually 10, 20, 50
and 100; anything else falls back to the user's own results per page
setting (10 by default). Each endpoint's parameter table says what it accepts.
Paginated V3 responses wrap the list and add a meta object:
{
"budgets": [ { "id": 1, "name": "Engineering Hardware FY26" } ],
"meta": {
"current_page": 1,
"next_page": 2,
"prev_page": null,
"total_pages": 4,
"total_count": 37
}
}
Keep requesting page = meta.next_page until it is null. Several V1
list endpoints return a bare JSON array with no meta; the endpoint pages show
the exact shape for each version. When an endpoint pages without meta, stop
when a page returns fewer items than per_page.
Data types
| Kind | Format | Example |
|---|---|---|
| Ids | Integers | 20000 |
| Money and quantities | JSON numbers, a few fields as decimal strings | 329.99, "base_rate": "1.0" |
| Dates | Either a Unix timestamp in seconds or an ISO date, depending on the field | "start_date": 1767225600 |
| Timestamps | ISO 8601 with offset, rendered in US Central Time | "created_at": "2026-10-21T04:48:43.335-05:00" |
| Currencies | ISO 4217 codes, plus a currency_id you can resolve with Currencies |
"USD" |
- Parse money with a decimal type, not binary floating point.
- Always honour the offset in ISO timestamps; do not assume UTC.
- Field types are given in each endpoint's tables. A field documented as a Unix timestamp is seconds since the epoch in UTC.
Records and tenancy
- Everything you read or write belongs to the company in
app_company_id. Ids of records that are not in that company answer404 Invalid id passed, and so does anapp_company_idthe user is not a member of. - What a user can see and do depends on their roles in that company
(
companyadmin,approver,finance,teammember). Each endpoint's Permissions section says who may call it and what others get back. Make integration calls as a user whose roles cover the data you need. - Most resources are archived rather than deleted. Archived records drop out of lists by default and can usually be listed again with a filter.
Retries and idempotency
The API has no idempotency keys. If a POST times out you cannot tell whether
it was applied, and retrying it can create a duplicate (for example a second
purchase order). Before retrying a create, look the record up (for example list
purchase orders filtered by your own reference) and only retry when it is not
there. GET requests are always safe to retry. Back off on 429 and 5xx
responses (see Rate Limits).
Errors
Error body (V1, and V3 once authenticated)
{
"error": "Invalid id passed",
"status": 404
}
V3 with a missing, invalid, expired or revoked access token: empty body
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="Doorkeeper", error="invalid_token", error_description="The access token is invalid"
Content-Length: 0
ProcurementExpress uses conventional HTTP status codes. Errors raised by the
API itself have a JSON body with a human-readable error message (translated
when you send a locale header) and the status repeated. Some validation
failures return the model's messages instead; each endpoint lists the error
bodies it was observed to return.
| Status | Meaning | Typical causes |
|---|---|---|
400 Bad Request |
The request could not be processed | Missing resource wrapper or required parameter (Required parameter are missing.), invalid values, a state transition that is not allowed, malformed JSON (empty body) |
401 Unauthorized |
Not authenticated, or not allowed | Missing/invalid token, missing app_company_id, archived membership, deactivated user, company requires two-factor sign-in, and role/permission denials (Unauthorized access) |
402 Payment Required |
The company cannot use the API | Subscription unpaid or company blocked (Billing Details Required) |
403 Forbidden |
Refused before reaching the API | Token issued to an MCP (AI assistant) client used on /api/v3 (empty body); IP blocked (see Rate Limits); a feature not enabled for the company on some endpoints |
404 Not Found |
No such record or route | Unknown id or a record of another company (Invalid id passed), an app_company_id the user is not a member of (Invalid id passed), unknown path (Invalid URL), endpoint not available in this API version |
410 Gone |
Endpoint retired | A retired endpoint that is still routed |
422 Unprocessable Entity |
Validation failed | The record was understood but is invalid |
429 Too Many Requests |
Rate limited | See Rate Limits |
500, 502, 503, 504 |
Server error | Something went wrong on our side; retry GETs with backoff and contact support with the X-Request-Id if it persists |
The authentication and company errors every endpoint can return are listed under Authentication errors.
Rate Limits
A throttled request is answered with
429and plain text:
HTTP/1.1 429 Too Many Requests
Content-Type: text/plain
Retry-After: 3600
Retry later
Requests are rate limited per client IP address. All /api/v1 and /api/v3
calls from one IP share one budget, whichever user or token makes them, so
integrations that run several jobs behind one egress IP should coordinate.
| What | Limit | Window |
|---|---|---|
All /api/... requests |
1,000 requests per IP | fixed 24-hour window |
POST /api/v1/login (password sign-in, shared with web sign-in) |
60 attempts per IP, and 20 attempts per account (successful and failed both count) | 1 minute / 15 minutes |
POST /api/v1/reset_password, /api/v3/reset_password |
10 per IP, 3 per email address | 15 minutes / 1 hour |
POST /api/v1/register, /api/v3/register |
10 per IP | 15 minutes |
When you are throttled the response is 429 Too Many Requests with a
Retry-After header giving the seconds until the window resets. For the
general API limit the body is the plain text Retry later; for sign-in limits
it is an empty JSON object {}. Separately, an account is locked after six
failed password attempts; it unlocks automatically after one hour or through the
unlock link emailed to the user.
If your integration needs a higher limit, contact support@procurementexpress.com with the IP addresses you call from.
Staying within the limits
- Use Webhooks to learn about new and changed purchase orders and invoices instead of polling lists.
- Cache reference data that rarely changes (departments, budgets, suppliers, tax rates, currencies) and refresh it on a schedule.
- Ask for
per_page=100when walking a list. - Reuse a V3 access token for its full two-hour lifetime instead of requesting a new one per call.
- On
429, wait forRetry-Afterseconds. On5xx, retryGETrequests with exponential backoff and jitter; see Retries and idempotency before retrying writes.
Blocked clients
Requests that look like vulnerability scans (for example paths containing
wp-admin or /etc/passwd) count against the caller's IP. After 10 of them in
a day the IP is blocked for 24 hours and every request gets 403 Forbidden.
V3 and V1
Both versions are served side by side from the same application and the same data: a purchase order created through V1 is immediately visible through V3. They differ in how you authenticate and, for some endpoints, in the shape of the response.
| V3 | V1 | |
|---|---|---|
| Authentication | OAuth2 bearer token | authentication_token header |
| Acts as | The user who registered the OAuth application | The user who signed in |
| List responses | Often {"<resource>": [...], "meta": {...}} |
Often a bare array |
| Decision endpoints (approve / reject) | POST or PUT only |
POST or PUT; GET still answers for old mobile clients and is deprecated |
| Status | Current | Supported; new endpoints are added to V3 |
Only in V1: Compliance (policies, scans, evidence packs), Custom Fields management, Suppliers: Approvals, publishing, unpublishing and exporting Approval Flows, and rerunning an invoice's approval flow.
Only in V3: OAuth2 token management, archiving budgets and QuickBooks Online: Item Budget Mappings.
Choosing a version. Use V3 unless you need an endpoint that only exists in
V1. An integration can use both, for example V3 for orders and V1 for
compliance checks, with the same app_company_id; the V1 token and the V3
OAuth application should then belong to the same user.
Migrating from V1 to V3. Register an OAuth application under the same user
your V1 integration signs in as and swap the authentication_token header for
Authorization: Bearer …. Then re-check each call against the endpoint's
V3 and V1 differences section: records mostly have the same fields, but list
envelopes, pagination parameters, filters and some status codes differ.
The table lists every public endpoint and the versions it is available in.
Endpoint availability
Approval Flows
An approval flow is a rule that decides who has to approve a document and in which order. Each flow targets one document type, says which documents it applies to (conditions) and lists one or more steps, each with its own approvers and an approval rule (any one approver, or all of them). When a matching document is submitted, the flow is attached to it and approval requests are sent step by step. Each document a flow was attached to is a run of that flow.
Approval flows replace the classic routing (approvers picked on the PO, budget approvers and approval limits) - see Enabling approval flows below, the switch is company-wide.
Document types
document_type |
Documents | Notes |
|---|---|---|
purchase_order |
Purchase orders | Matched when a PO is submitted (commit: "Send") or a pending PO is edited. |
invoice |
Invoices | Matched automatically after every create/update of an invoice whose status is awaiting_review or outstanding. |
rfq |
Requests for quotation | Used by the RFQ module (company RFQ workflows); not covered here. |
Anatomy of a flow
- Flow conditions (
approval_conditionsat the top level) decide whether the flow applies to a document. All of them must match (AND). A custom flow must have at least one. - Steps (
approval_steps, ordered bystep_no) run one after another. A flow must have at least one step and every step at least one approver. - Step conditions (
approval_steps[].approval_conditions) are optional. A step that has conditions is skipped (treated as completed) unless all of its conditions match - e.g. "add the CFO as a second step only when the gross amount is 10,000 or more". On a PO split into allocations, step conditions are evaluated against the same department/budget allocation that made the flow match; amount conditions always look at the whole PO. - Approvers (
approval_steps[].approval_step_approvers) are individual users - there are no role or group approvers. A user must be an active (non-archived) member of the company, otherwise the save fails with"approval_steps.approval_step_approvers.user_id": ["is invalid"]. The API does not require the approver role. - Approval rule (
all_should_approve):false- any one can approve: every approver of the step is asked at once and the first approval completes the step.true- all must approve: approvers are asked one at a time, in the order they were added, and the step completes when everyone has approved.
- Self approval (
self_approval_allowed): whenfalse, the document's creator is skipped if they are an approver in a step. Whentrueand the company settingauto_approve_self_requestsis on, the creator's own request is approved automatically. - Approvers on holiday are covered by their holiday delegate (
user.on_holidayin responses).
A rejection by any approver rejects the document and marks all of its runs rejected. When
every request of every attached flow is approved, the PO becomes approved.
Conditions
A condition is property + operator + value. Values are always sent as strings.
property |
Applies to | value |
|---|---|---|
department |
PO, invoice | Department id(s). On an invoice: the departments of the linked POs. |
budget |
PO, invoice | Budget id(s) of the line items / allocations. |
budget_name, cost_code, cost_type |
PO, invoice | Text, used with contains / not_contains (case-insensitive) against the budgets' name / cost code / cost type. |
supplier |
PO, invoice | Supplier id(s). |
requester |
PO, invoice | User id(s) of the document creator (on invoices: the uploader - same as created_by). |
po_requester |
invoice | User id(s) of the creator(s) of the PO(s) linked to the invoice. |
created_by |
invoice | User id(s) of the invoice uploader. |
currency |
PO, invoice | Currency id(s). |
gross_amount, net_amount |
PO, invoice | A number, compared with the document total in the company's base currency. |
chart_of_account_id, qbo_customer_id, quickbooks_class_id |
PO, invoice | QuickBooks account / customer / class id(s) on the line items (offered by the web app only when the company codes with them). |
custom_field_<id> |
PO, invoice | Custom field value, e.g. custom_field_12 = "LAB-001". Dates as YYYY-MM-DD, checkboxes as "1"/"0". |
invoice_number |
invoice | Text (the invoice reference). |
issue_date, due_date |
invoice | Date YYYY-MM-DD. |
standalone_invoice |
invoice | "1" (invoice without PO) or "0". |
invoice_status |
invoice | awaiting_review or outstanding. |
payment_term_id |
invoice | Payment term id. |
operator |
Meaning |
|---|---|
equals / not_equals |
Exact match. For list-like properties (budgets, QuickBooks coding, an invoice's departments) it means "one of the document's values equals the value". |
is_any_of / is_none_of |
value is a comma-separated list, e.g. "1,2,3". |
greater_than / less_than |
Inclusive (>= and <=) numeric or date comparison - the web app labels them "Greater Than Or Equal" / "Less Than Or Equal". |
contains / not_contains |
Case-insensitive substring match; a comma-separated value matches any of the substrings. |
exists / not_exists |
Only for RFQ document conditions. |
property is not validated: an unknown name evaluates as an empty value (so equals never
matches while not_equals / is_none_of / not_contains always do). An unknown operator or
document_type is not validated either and makes the request fail with HTTP 500.
Lifecycle
| State | How | Effect |
|---|---|---|
unpublished |
Every new flow (status cannot be set on create/update) |
Never attached to documents. |
published |
PATCH /approval_flows/:id/publish (V1, company admins only) |
Attached to matching documents submitted from now on (and to pending POs when they are next edited). Documents already in approval are not re-routed. |
unpublished again |
PATCH /approval_flows/:id/unpublish (V1) |
No new documents; existing runs carry on. |
archived: true |
PUT /approval_flows/:id/archive |
Hidden from the list and never attached to new documents, even if status is still published. There is no unarchive. |
| deleted | DELETE /approval_flows/:id |
Soft-deleted; only when the flow has no in-progress runs. |
Publish and unpublish exist only in V1; V3 routes them but answers 404.
Enabling approval flows
- The feature is always on for companies created on or after 31 July 2025; older companies
need it enabled for them (feature flag
enable_approval_flow). When it is off every endpoint in this section answers403with{"error": ["Approval Flows feature is not enabled"], "status": 403}. - Routing switches per document type, company-wide: as soon as one published,
non-archived
purchase_orderflow exists, every PO in the company is routed by approval flows. The classic routing and its "please select approvers" check are bypassed, and a PO that matches no published flow is submitted aspendingwith no approvers at all. Make sure your published flows cover every PO (e.g. keep a catch-all flow) before you publish the first one. Unpublishing or archiving the last PO flow switches back. - The same applies to invoices once a published
invoiceflow exists, with one safety net: when an invoice matches no custom invoice flow, the system creates (once) and uses a default invoice flow -is_default: true, named "Default Invoice Approval Flow", one step with the company's finance users (or admins) and the conditioninvoice_status equals outstanding. The default flow can be edited but not unpublished, archived or deleted.
How a flow is selected for a document
When a document is submitted, every published, non-archived flow of its type whose flow
conditions all match is attached - several flows can run on the same document at once, and
the document is approved only when all of them are complete. Each attachment is a run
(approval_entity) with status in_progress, completed, rejected or cancelled; the
flow keeps counters of its runs (in_progress_entities_count, ...). List them with
GET /approval_flows/:id/runs. To preview which flow and approvers a draft PO would get, use
POST /purchase_orders/approver_list (it can also test an unpublished flow with
test_mode + approval_flow_id).
Editing a flow, versions and reruns
- Every save of a flow is recorded;
version_no(aYYYYMMDDHHMMSSstring) is stamped on each update.GET /approval_flows/:id/versionslists the earlier states andGET /approval_flows/:id/version_detailsreturns one of them. - Editing a flow does not re-route documents already in approval. If the edit changes the
steps, approvers or conditions (not just the name or
self_approval_allowed), every in-progress PO/invoice of the flow is flagged as "flow changed".POST /approval_flows/rerun_approval_flowsthen re-evaluates flagged documents in the background: runs that still match keep their approved requests and drop pending requests of approvers who are no longer in the active step; runs that no longer match are removed; newly matching published flows are attached; a comment is added to the PO history. Documents that were not flagged are left alone. - A single invoice can also be rerun with
POST /invoices/:id/rerun_approval_flow.
Permissions
| Action | Roles |
|---|---|
| list, show, runs, show_entity, versions, version_details, export | company admin or finance |
| create, update, archive, rerun | company admin or finance |
| delete | company admin or finance; not the default flow; no in-progress runs |
| publish / unpublish | company admin only |
Any other caller gets 401 {"error": "Unauthorized access", "status": 401} (also returned
when publishing a flow that is already published, unpublishing one that is not, or
deleting/archiving/unpublishing the default flow).
API versions
V1 and V3 share the request and response formats. V3 lacks publish, unpublish and
export, does not hide runs of cancelled/archived POs, and its rerun_approval_flows
accepts purchase orders only - see each endpoint.
Endpoints in this section
| Endpoint | Method | Path | V3 | V1 |
|---|---|---|---|---|
| List approval flows | GET |
/approval_flows |
✓ | ✓ |
| Get an approval flow | GET |
/approval_flows/:id |
✓ | ✓ |
| Create an approval flow | POST |
/approval_flows |
✓ | ✓ |
| Update an approval flow | PUT |
/approval_flows/:id |
✓ | ✓ |
| Delete an approval flow | DELETE |
/approval_flows/:id |
✓ | ✓ |
| Archive an approval flow | PUT |
/approval_flows/:id/archive |
✓ | ✓ |
| Publish an approval flow | PATCH |
/approval_flows/:id/publish |
— | ✓ |
| Unpublish an approval flow | PATCH |
/approval_flows/:id/unpublish |
— | ✓ |
| List the runs of an approval flow | GET |
/approval_flows/:id/runs |
✓ | ✓ |
| Get one run of an approval flow | GET |
/approval_flows/:id/show_entity |
✓ | ✓ |
| List the versions of an approval flow | GET |
/approval_flows/:id/versions |
✓ | ✓ |
| Get a version of an approval flow | GET |
/approval_flows/:id/version_details |
✓ | ✓ |
| Rerun approval flows for documents | POST |
/approval_flows/rerun_approval_flows |
✓ | ✓ |
| Export approval flows to Excel | GET |
/approval_flows/export |
— | ✓ |
The approval flow object
| Field | Type | Description |
|---|---|---|
id |
integer | Flow id. |
name |
string | Name, required, max 255 characters. Not unique. |
document_type |
string | purchase_order, invoice or rfq. Defaults to purchase_order. |
self_approval_allowed |
boolean | Whether the document's creator may be one of its approvers (see intro). Defaults to false. |
company_id |
integer | Owning company. |
version_no |
string | null until the first update, then the time of the last update as YYYYMMDDHHMMSS (server time zone). Not changed by publish/unpublish/archive. |
archived |
boolean | true once archived. Archived flows are excluded from the list and from matching. |
status |
string | unpublished or published. Read-only - use publish/unpublish. |
in_progress_entities_count |
integer | Number of runs (documents) currently in approval. |
completed_entities_count |
integer | Runs that ended approved. |
rejected_entities_count |
integer | Runs that ended rejected. |
cancelled_entities_count |
integer | Runs whose document was cancelled. |
total_entities_count |
integer | All runs of the flow. |
is_default |
boolean | true only for the system-created default invoice flow. |
created_at |
timestamp | Unix seconds. |
updated_at |
timestamp | Unix seconds. Also changes when run counters change. |
approval_steps |
array[object] | Steps ordered by step_no (detail responses only - not in the list). See Get an approval flow for the step, approver and condition objects. |
approval_conditions |
array[object] | Flow-level conditions (detail responses only). |
List approval flows
curl "https://app.procurementexpress.com/api/v3/approval_flows?per_page=10" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/approval_flows?per_page=10" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/approval_flows",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
params: {
per_page: "10"
}
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/approval_flows",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
params: {
per_page: "10"
}
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1 (lists shortened to 2 items):
{
"approval_flows": [
{
"id": 4,
"name": "Large outstanding invoices",
"document_type": "invoice",
"self_approval_allowed": true,
"company_id": 20000,
"version_no": null,
"archived": false,
"status": "unpublished",
"in_progress_entities_count": 0,
"completed_entities_count": 0,
"rejected_entities_count": 0,
"cancelled_entities_count": 0,
"total_entities_count": 0,
"is_default": false,
"created_at": 1792576869,
"updated_at": 1792576869
},
{
"id": 3,
"name": "Large outstanding invoices",
"document_type": "invoice",
"self_approval_allowed": true,
"company_id": 20000,
"version_no": null,
"archived": false,
"status": "unpublished",
"in_progress_entities_count": 0,
"completed_entities_count": 0,
"rejected_entities_count": 0,
"cancelled_entities_count": 0,
"total_entities_count": 0,
"is_default": false,
"created_at": 1792576869,
"updated_at": 1792576869
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 4
}
}
Example: search (V3,
200 OK):
{
"approval_flows": [
{
"id": 1,
"name": "Lab PO routing v1",
"document_type": "purchase_order",
"self_approval_allowed": false,
"company_id": 20000,
"version_no": null,
"archived": false,
"status": "unpublished",
"in_progress_entities_count": 0,
"completed_entities_count": 0,
"rejected_entities_count": 0,
"cancelled_entities_count": 0,
"total_entities_count": 0,
"is_default": false,
"created_at": 1792576772,
"updated_at": 1792576772
},
{
"id": 2,
"name": "Lab PO routing v3",
"document_type": "purchase_order",
"self_approval_allowed": false,
"company_id": 20000,
"version_no": null,
"archived": false,
"status": "unpublished",
"in_progress_entities_count": 0,
"completed_entities_count": 0,
"rejected_entities_count": 0,
"cancelled_entities_count": 0,
"total_entities_count": 0,
"is_default": false,
"created_at": 1792576781,
"updated_at": 1792576781
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 2
}
}
V3 V1
Returns the company's non-archived approval flows, newest first, without their steps and conditions.
Lists all approval flows of every document type for the company in the app_company_id
header. Archived and deleted flows are not included (an archived flow can still be fetched by
id). Items are the flow summary only - fetch a flow by id to get its steps and conditions.
searchfilters by name (case-insensitive substring).- Sorting defaults to
iddescending.sortmust be a table-qualified column,approval_flows.<column>(e.g.approval_flows.name,approval_flows.created_at,approval_flows.total_entities_count); anything else - including a barename- silently falls back toid. The special valueapproval_flows.completed_entities_count+approval_flows.rejected_entities_countsorts by finished runs (URL-encode the+as%2B). - Paginated with
page/per_page.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/approval_flows |
| V1 | GET /api/v1/approval_flows |
Permissions
Company admin or finance role in the header company. Others: 401 Unauthorized access.
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
search |
string | No | Case-insensitive substring of the flow name. |
sort |
string | No | approval_flows.<column> or approval_flows.completed_entities_count+approval_flows.rejected_entities_count. Invalid values fall back to id. Default: id. |
direction |
string | No | asc or desc. Default: desc. |
page |
integer | No | Page number. Default: 1. |
per_page |
integer | No | 10, 20, 50 or 100. Defaults to the user's results-per-page setting for the company; any other value means 10. |
Response
200 with {"approval_flows": [...], "meta": {...}}. Each item is an approval flow summary
(see the object table, without approval_steps / approval_conditions). meta holds
current_page, next_page, prev_page, total_pages, total_count.
| Field | Type | Description |
|---|---|---|
meta.current_page |
integer | Current page. |
meta.next_page |
integer | Next page or null. |
meta.prev_page |
integer | Previous page or null. |
meta.total_pages |
integer | Number of pages. |
meta.total_count |
integer | Number of matching flows. |
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Unauthorized | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
Get an approval flow
curl "https://app.procurementexpress.com/api/v3/approval_flows/1" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/approval_flows/1" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/approval_flows/1",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/approval_flows/1",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 1,
"name": "Lab PO routing v1",
"document_type": "purchase_order",
"self_approval_allowed": false,
"company_id": 20000,
"version_no": null,
"archived": false,
"status": "unpublished",
"in_progress_entities_count": 0,
"completed_entities_count": 0,
"rejected_entities_count": 0,
"cancelled_entities_count": 0,
"total_entities_count": 0,
"is_default": false,
"created_at": 1792576772,
"updated_at": 1792576772,
"approval_steps": [
{
"id": 1,
"step_no": 1,
"all_should_approve": false,
"approval_flow_id": 1,
"created_at": 1792576772,
"updated_at": 1792576772,
"approval_step_approvers": [
{
"id": 1,
"approval_step_id": 1,
"user_id": 50000,
"user": {
"id": 50000,
"name": "Priya Patel",
"email": "priya.patel@example.com",
"on_holiday": false
},
"created_at": 1792576772,
"updated_at": 1792576772
}
],
"approval_conditions": [
]
},
{
"id": 2,
"step_no": 2,
"all_should_approve": true,
"approval_flow_id": 1,
"created_at": 1792576772,
"updated_at": 1792576772,
"approval_step_approvers": [
{
"id": 2,
"approval_step_id": 2,
"user_id": 40000,
"user": {
"id": 40000,
"name": "Jane Doe",
"email": "jane.doe@example.com",
"on_holiday": false
},
"created_at": 1792576772,
"updated_at": 1792576772
},
{
"id": 3,
"approval_step_id": 2,
"user_id": 50000,
"user": {
"id": 50000,
"name": "Priya Patel",
"email": "priya.patel@example.com",
"on_holiday": false
},
"created_at": 1792576772,
"updated_at": 1792576772
}
],
"approval_conditions": [
{
"id": 1,
"property": "gross_amount",
"operator": "greater_than",
"value": "1000",
"custom_field_id": null,
"approval_flow_id": null,
"approval_step_id": 2,
"created_at": 1792576772,
"updated_at": 1792576772,
"human_readable_description": "Gross amount greater than 1000",
"property_label": "Gross amount",
"value_label": "1000"
}
]
}
],
"approval_conditions": [
{
"id": 2,
"property": "department",
"operator": "equals",
"value": "5",
"custom_field_id": null,
"approval_flow_id": 1,
"approval_step_id": null,
"created_at": 1792576772,
"updated_at": 1792576772,
"human_readable_description": "Department equals Approval Flow Lab",
"property_label": "Department",
"value_label": "Approval Flow Lab"
}
]
}
V3 V1
Returns one approval flow with its steps, approvers and conditions.
Returns the flow with its steps (ordered by step_no), each step's approvers (in the order
they were added) and step conditions, and the flow-level conditions. Archived flows can be
fetched; deleted ones return 404.
Conditions are returned with three helper fields resolved for display:
property_label (e.g. "Department", or the custom field's name), value_label (ids turned
into names, e.g. "Approval Flow Lab") and human_readable_description.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/approval_flows/:id |
| V1 | GET /api/v1/approval_flows/:id |
Permissions
Company admin or finance. Others: 401 Unauthorized access.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Approval flow id. |
Response
200 with the approval flow object (no root key), including approval_steps and
approval_conditions. Unknown id (or another company's flow): 404
{"error": ["Invalid id passed"], "status": 404}.
| Field | Type | Description |
|---|---|---|
approval_steps[ |
integer | Step id (use it to update or _destroy the step). |
approval_steps[ |
integer | Position of the step; steps run in ascending step_no. |
approval_steps[ |
boolean | false = any one approver completes the step; true = all approvers, asked one at a time. |
approval_steps[ |
integer | Parent flow. |
approval_steps[ |
timestamp | Unix seconds. |
approval_steps[ |
timestamp | Unix seconds. |
approval_steps[ |
integer | Approver assignment id (use it to _destroy the assignment). |
approval_steps[ |
integer | Parent step. |
approval_steps[ |
integer | Approver's user id. |
approval_steps[ |
object | id, name, email, on_holiday (true while the user's holiday mode is on in this company - their delegate is asked instead). |
approval_steps[ |
timestamp | Unix seconds. |
approval_steps[ |
timestamp | Unix seconds. |
approval_steps[ |
array[object] | Step conditions - same object as approval_conditions[], with approval_step_id set and approval_flow_id null. |
approval_conditions[ |
integer | Condition id. |
approval_conditions[ |
string | Property, see the Conditions table in the intro. |
approval_conditions[ |
string | equals, not_equals, greater_than, less_than, is_any_of, is_none_of, contains, not_contains, exists, not_exists. |
approval_conditions[ |
string | Value as sent (ids are comma-separated for lists). |
approval_conditions[ |
integer | Legacy link to a custom field; null when the property is written as custom_field_<id>. When set, a custom_field object is also returned. |
approval_conditions[ |
integer | Set for flow-level conditions, null for step conditions. |
approval_conditions[ |
integer | Set for step conditions, null for flow-level conditions. |
approval_conditions[ |
string | e.g. Supplier is any of Northwind Office Supplies, Contoso Hardware. Note greater_than reads "greater than" but is inclusive. |
approval_conditions[ |
string | Display name of the property. |
approval_conditions[ |
string | Display value (record names for id properties). |
approval_conditions[ |
timestamp | Unix seconds. |
approval_conditions[ |
timestamp | Unix seconds. |
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 404 |
V3, V1 | {"error":["Invalid id passed"],"status":404} |
| Unauthorized | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
Create an approval flow
curl -X POST "https://app.procurementexpress.com/api/v3/approval_flows" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"approval_flow": {
"name": "Lab PO routing v3",
"document_type": "purchase_order",
"self_approval_allowed": false,
"approval_conditions_attributes": [
{
"property": "department",
"operator": "equals",
"value": "5"
}
],
"approval_steps_attributes": [
{
"step_no": 1,
"all_should_approve": false,
"approval_step_approvers_attributes": [
{
"user_id": 50000
}
]
},
{
"step_no": 2,
"all_should_approve": true,
"approval_step_approvers_attributes": [
{
"user_id": 40000
},
{
"user_id": 50000
}
],
"approval_conditions_attributes": [
{
"property": "gross_amount",
"operator": "greater_than",
"value": "1000"
}
]
}
]
}
}'
curl -X POST "https://app.procurementexpress.com/api/v1/approval_flows" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"approval_flow": {
"name": "Lab PO routing v1",
"document_type": "purchase_order",
"self_approval_allowed": false,
"approval_conditions_attributes": [
{
"property": "department",
"operator": "equals",
"value": "5"
}
],
"approval_steps_attributes": [
{
"step_no": 1,
"all_should_approve": false,
"approval_step_approvers_attributes": [
{
"user_id": 50000
}
]
},
{
"step_no": 2,
"all_should_approve": true,
"approval_step_approvers_attributes": [
{
"user_id": 40000
},
{
"user_id": 50000
}
],
"approval_conditions_attributes": [
{
"property": "gross_amount",
"operator": "greater_than",
"value": "1000"
}
]
}
]
}
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/approval_flows",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
approval_flow: {
name: "Lab PO routing v3",
document_type: "purchase_order",
self_approval_allowed: false,
approval_conditions_attributes: [
{
property: "department",
operator: "equals",
value: "5"
}
],
approval_steps_attributes: [
{
step_no: 1,
all_should_approve: false,
approval_step_approvers_attributes: [
{
user_id: 50000
}
]
},
{
step_no: 2,
all_should_approve: true,
approval_step_approvers_attributes: [
{
user_id: 40000
},
{
user_id: 50000
}
],
approval_conditions_attributes: [
{
property: "gross_amount",
operator: "greater_than",
value: "1000"
}
]
}
]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/approval_flows",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
approval_flow: {
name: "Lab PO routing v1",
document_type: "purchase_order",
self_approval_allowed: false,
approval_conditions_attributes: [
{
property: "department",
operator: "equals",
value: "5"
}
],
approval_steps_attributes: [
{
step_no: 1,
all_should_approve: false,
approval_step_approvers_attributes: [
{
user_id: 50000
}
]
},
{
step_no: 2,
all_should_approve: true,
approval_step_approvers_attributes: [
{
user_id: 40000
},
{
user_id: 50000
}
],
approval_conditions_attributes: [
{
property: "gross_amount",
operator: "greater_than",
value: "1000"
}
]
}
]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 2,
"name": "Lab PO routing v3",
"document_type": "purchase_order",
"self_approval_allowed": false,
"company_id": 20000,
"version_no": null,
"archived": false,
"status": "unpublished",
"in_progress_entities_count": 0,
"completed_entities_count": 0,
"rejected_entities_count": 0,
"cancelled_entities_count": 0,
"total_entities_count": 0,
"is_default": false,
"created_at": 1792576781,
"updated_at": 1792576781,
"approval_steps": [
{
"id": 3,
"step_no": 1,
"all_should_approve": false,
"approval_flow_id": 2,
"created_at": 1792576781,
"updated_at": 1792576781,
"approval_step_approvers": [
{
"id": 4,
"approval_step_id": 3,
"user_id": 50000,
"user": {
"id": 50000,
"name": "Priya Patel",
"email": "priya.patel@example.com",
"on_holiday": false
},
"created_at": 1792576781,
"updated_at": 1792576781
}
],
"approval_conditions": [
]
},
{
"id": 4,
"step_no": 2,
"all_should_approve": true,
"approval_flow_id": 2,
"created_at": 1792576781,
"updated_at": 1792576781,
"approval_step_approvers": [
{
"id": 5,
"approval_step_id": 4,
"user_id": 40000,
"user": {
"id": 40000,
"name": "Jane Doe",
"email": "jane.doe@example.com",
"on_holiday": false
},
"created_at": 1792576781,
"updated_at": 1792576781
},
{
"id": 6,
"approval_step_id": 4,
"user_id": 50000,
"user": {
"id": 50000,
"name": "Priya Patel",
"email": "priya.patel@example.com",
"on_holiday": false
},
"created_at": 1792576781,
"updated_at": 1792576781
}
],
"approval_conditions": [
{
"id": 3,
"property": "gross_amount",
"operator": "greater_than",
"value": "1000",
"custom_field_id": null,
"approval_flow_id": null,
"approval_step_id": 4,
"created_at": 1792576781,
"updated_at": 1792576781,
"human_readable_description": "Gross amount greater than 1000",
"property_label": "Gross amount",
"value_label": "1000"
}
]
}
],
"approval_conditions": [
{
"id": 4,
"property": "department",
"operator": "equals",
"value": "5",
"custom_field_id": null,
"approval_flow_id": 2,
"approval_step_id": null,
"created_at": 1792576781,
"updated_at": 1792576781,
"human_readable_description": "Department equals Approval Flow Lab",
"property_label": "Department",
"value_label": "Approval Flow Lab"
}
]
}
Example: invoice (V3,
200 OK) (lists shortened to 2 items):
{
"id": 4,
"name": "Large outstanding invoices",
"document_type": "invoice",
"self_approval_allowed": true,
"company_id": 20000,
"version_no": null,
"archived": false,
"status": "unpublished",
"in_progress_entities_count": 0,
"completed_entities_count": 0,
"rejected_entities_count": 0,
"cancelled_entities_count": 0,
"total_entities_count": 0,
"is_default": false,
"created_at": 1792576869,
"updated_at": 1792576869,
"approval_steps": [
{
"id": 6,
"step_no": 1,
"all_should_approve": false,
"approval_flow_id": 4,
"created_at": 1792576869,
"updated_at": 1792576869,
"approval_step_approvers": [
{
"id": 9,
"approval_step_id": 6,
"user_id": 60000,
"user": {
"id": 60000,
"name": "Marco Rossi",
"email": "marco.rossi@example.com",
"on_holiday": false
},
"created_at": 1792576869,
"updated_at": 1792576869
},
{
"id": 10,
"approval_step_id": 6,
"user_id": 40000,
"user": {
"id": 40000,
"name": "Jane Doe",
"email": "jane.doe@example.com",
"on_holiday": false
},
"created_at": 1792576869,
"updated_at": 1792576869
}
],
"approval_conditions": [
]
}
],
"approval_conditions": [
{
"id": 9,
"property": "invoice_status",
"operator": "equals",
"value": "outstanding",
"custom_field_id": null,
"approval_flow_id": 4,
"approval_step_id": null,
"created_at": 1792576869,
"updated_at": 1792576869,
"human_readable_description": "Invoice Status equals Outstanding",
"property_label": "Invoice Status",
"value_label": "Outstanding"
},
{
"id": 10,
"property": "gross_amount",
"operator": "greater_than",
"value": "5000",
"custom_field_id": null,
"approval_flow_id": 4,
"approval_step_id": null,
"created_at": 1792576869,
"updated_at": 1792576869,
"human_readable_description": "Gross amount greater than 5000",
"property_label": "Gross amount",
"value_label": "5000"
}
]
}
V3 V1
Creates an unpublished approval flow with its steps, approvers and conditions in one request.
Creates the flow and all nested records atomically (nested attributes). The new flow is
always unpublished - publish it with PATCH /approval_flows/:id/publish (V1) once it has
been reviewed.
Validation:
* name required (max 255).
* At least one flow-level condition and at least one step.
* Each step needs step_no and at least one approver; each approver must be an active member
of the company.
* Each condition needs property, operator and value.
* Nested entries whose fields are all blank are ignored.
The request source (app_source header, default api) is stored on the flow.
HTTP request
| Version | Request |
|---|---|
| V3 | POST /api/v3/approval_flows |
| V1 | POST /api/v1/approval_flows |
Permissions
Company admin or finance. Others: 401 Unauthorized access.
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
approval_flow[ |
string | Yes | Flow name, max 255 characters. |
approval_flow[ |
string | No | purchase_order (default), invoice or rfq. Any other value causes HTTP 500. |
approval_flow[ |
boolean | No | Default false. See intro. |
approval_flow[ |
array[object] | Yes | Flow conditions (all must match). At least one. |
approval_flow[ |
string | Yes | See the Conditions table, e.g. department, gross_amount, custom_field_12. |
approval_flow[ |
string | Yes | One of the operators in the intro. Any other value causes HTTP 500. |
approval_flow[ |
string | Yes | Value as a string; comma-separated ids for is_any_of / is_none_of. |
approval_flow[ |
integer | No | Legacy; prefer property: "custom_field_<id>" (matching uses the property). |
approval_flow[ |
integer | No | Ignored on create (set automatically). |
approval_flow[ |
array[object] | Yes | Steps. At least one. |
approval_flow[ |
integer | Yes | Order of the step (1, 2, 3 ...). |
approval_flow[ |
boolean | No | Default false (any one approver). |
approval_flow[ |
array[object] | Yes | Approvers of the step. At least one. |
approval_flow[ |
integer | Yes | An active member of the company (any role). |
approval_flow[ |
array[object] | No | Step conditions (property, operator, value, custom_field_id) - the step is skipped unless all match. |
Response
200 (not 201) with the created flow, same shape as Get an approval flow.
Errors:
* 400 with {"error": {"<attribute>": ["message", ...]}, "status": 400} for validation
failures - nested errors are keyed like approval_steps.approval_step_approvers.user_id.
* 400 {"error": "Required parameter are missing.", "status": 400} when the body has no
approval_flow object. (A body with top-level flow attributes such as {"name": "..."}
is wrapped into approval_flow automatically by Rails.)
* 500 for an unknown document_type or operator (ArgumentError: 'x' is not a valid operator).
Side effects
Creates a version record (the create event is not listed by versions).
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Validation error | 400 |
V3, V1 | {"error":{"name":["can't be blank"],"approval_steps":["must have at least one step"],"approval_conditions":["must have at least one condition"]},"status":400} |
| Invalid approver | 400 |
V3, V1 | {"error":{"approval_steps.approval_step_approvers.user_id":["is invalid"],"approval_steps.approval_step_approvers.user":["must exist"],"approval_steps.approval_step_approvers":["must have at least one approver"]},"status… |
| Missing params | 400 |
V3, V1 | {"error":"Required parameter are missing.","status":400} |
| Unauthorized | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
Update an approval flow
curl -X PATCH "https://app.procurementexpress.com/api/v3/approval_flows/2" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"approval_flow": {
"name": "Lab PO routing v3 - updated",
"approval_steps_attributes": [
{
"id": 4,
"approval_step_approvers_attributes": [
{
"id": 6,
"_destroy": true
}
],
"approval_conditions_attributes": [
{
"id": 3,
"value": "2500"
}
]
},
{
"step_no": 3,
"all_should_approve": false,
"approval_step_approvers_attributes": [
{
"user_id": 50000
}
],
"approval_conditions_attributes": [
{
"property": "gross_amount",
"operator": "greater_than",
"value": "10000"
}
]
}
],
"approval_conditions_attributes": [
{
"property": "supplier",
"operator": "is_any_of",
"value": "1,2,3"
}
]
}
}'
curl -X PUT "https://app.procurementexpress.com/api/v1/approval_flows/1" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"approval_flow": {
"name": "Lab PO routing v1 - updated",
"approval_steps_attributes": [
{
"id": 2,
"approval_step_approvers_attributes": [
{
"id": 3,
"_destroy": true
}
],
"approval_conditions_attributes": [
{
"id": 1,
"value": "2500"
}
]
},
{
"step_no": 3,
"all_should_approve": false,
"approval_step_approvers_attributes": [
{
"user_id": 50000
}
],
"approval_conditions_attributes": [
{
"property": "gross_amount",
"operator": "greater_than",
"value": "10000"
}
]
}
],
"approval_conditions_attributes": [
{
"property": "supplier",
"operator": "is_any_of",
"value": "1,2,3"
}
]
}
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :patch,
url: "https://app.procurementexpress.com/api/v3/approval_flows/2",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
approval_flow: {
name: "Lab PO routing v3 - updated",
approval_steps_attributes: [
{
id: 4,
approval_step_approvers_attributes: [
{
id: 6,
_destroy: true
}
],
approval_conditions_attributes: [
{
id: 3,
value: "2500"
}
]
},
{
step_no: 3,
all_should_approve: false,
approval_step_approvers_attributes: [
{
user_id: 50000
}
],
approval_conditions_attributes: [
{
property: "gross_amount",
operator: "greater_than",
value: "10000"
}
]
}
],
approval_conditions_attributes: [
{
property: "supplier",
operator: "is_any_of",
value: "1,2,3"
}
]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v1/approval_flows/1",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
approval_flow: {
name: "Lab PO routing v1 - updated",
approval_steps_attributes: [
{
id: 2,
approval_step_approvers_attributes: [
{
id: 3,
_destroy: true
}
],
approval_conditions_attributes: [
{
id: 1,
value: "2500"
}
]
},
{
step_no: 3,
all_should_approve: false,
approval_step_approvers_attributes: [
{
user_id: 50000
}
],
approval_conditions_attributes: [
{
property: "gross_amount",
operator: "greater_than",
value: "10000"
}
]
}
],
approval_conditions_attributes: [
{
property: "supplier",
operator: "is_any_of",
value: "1,2,3"
}
]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1 (lists shortened to 2 items):
{
"id": 2,
"name": "Lab PO routing v3 - updated",
"document_type": "purchase_order",
"self_approval_allowed": false,
"company_id": 20000,
"version_no": "20261021050206",
"archived": false,
"status": "unpublished",
"in_progress_entities_count": 0,
"completed_entities_count": 0,
"rejected_entities_count": 0,
"cancelled_entities_count": 0,
"total_entities_count": 0,
"is_default": false,
"created_at": 1792576781,
"updated_at": 1792576926,
"approval_steps": [
{
"id": 3,
"step_no": 1,
"all_should_approve": false,
"approval_flow_id": 2,
"created_at": 1792576781,
"updated_at": 1792576781,
"approval_step_approvers": [
{
"id": 4,
"approval_step_id": 3,
"user_id": 50000,
"user": {
"id": 50000,
"name": "Priya Patel",
"email": "priya.patel@example.com",
"on_holiday": false
},
"created_at": 1792576781,
"updated_at": 1792576781
}
],
"approval_conditions": [
]
},
{
"id": 4,
"step_no": 2,
"all_should_approve": true,
"approval_flow_id": 2,
"created_at": 1792576781,
"updated_at": 1792576781,
"approval_step_approvers": [
{
"id": 5,
"approval_step_id": 4,
"user_id": 40000,
"user": {
"id": 40000,
"name": "Jane Doe",
"email": "jane.doe@example.com",
"on_holiday": false
},
"created_at": 1792576781,
"updated_at": 1792576781
}
],
"approval_conditions": [
{
"id": 3,
"property": "gross_amount",
"operator": "greater_than",
"value": "2500",
"custom_field_id": null,
"approval_flow_id": null,
"approval_step_id": 4,
"created_at": 1792576781,
"updated_at": 1792576926,
"human_readable_description": "Gross amount greater than 2500",
"property_label": "Gross amount",
"value_label": "2500"
}
]
}
],
"approval_conditions": [
{
"id": 4,
"property": "department",
"operator": "equals",
"value": "5",
"custom_field_id": null,
"approval_flow_id": 2,
"approval_step_id": null,
"created_at": 1792576781,
"updated_at": 1792576781,
"human_readable_description": "Department equals Approval Flow Lab",
"property_label": "Department",
"value_label": "Approval Flow Lab"
},
{
"id": 16,
"property": "supplier",
"operator": "is_any_of",
"value": "1,2,3",
"custom_field_id": null,
"approval_flow_id": 2,
"approval_step_id": null,
"created_at": 1792576926,
"updated_at": 1792576926,
"human_readable_description": "Supplier is any of Northwind Office Supplies, Contoso Hardware, Globex Logistics",
"property_label": "Supplier",
"value_label": "Northwind Office Supplies, Contoso Hardware, Globex Logistics"
}
]
}
V3 V1
Renames a flow or adds, changes and removes its steps, approvers and conditions.
Accepts the same approval_flow body as create; send only what changes. Nested records:
- include
idto change an existing step / approver / condition; - omit
idto add a new one; - include
idand"_destroy": true(or1) to remove it. Destroying a step removes its approvers and step conditions.
The flow must still be valid afterwards (at least one step, one flow condition, one approver per step), otherwise nothing is saved.
Every update stamps a new version_no and records a version. Updating does not change
status and does not re-route documents already in approval. When steps, approvers or
conditions change, all in-progress runs whose document is still active are flagged
(flow_changed_at) so they can be re-evaluated with
POST /approval_flows/rerun_approval_flows. Archived flows can be updated too.
HTTP request
| Version | Request |
|---|---|
| V3 | `PUT |
| V1 | `PUT |
Permissions
Company admin or finance. Others: 401 Unauthorized access.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Approval flow id. |
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
approval_flow[ |
string | No | New name. |
approval_flow[ |
string | No | purchase_order, invoice or rfq. Changing the type of a flow that has runs is not prevented - avoid it. |
approval_flow[ |
boolean | No | See intro. |
approval_flow[ |
integer | No | Existing flow condition to change or remove. |
approval_flow[ |
string | No | Required for a new condition. |
approval_flow[ |
string | No | Required for a new condition. |
approval_flow[ |
string | No | Required for a new condition. |
approval_flow[ |
integer | No | Legacy custom field link. |
approval_flow[ |
boolean | No | true removes the condition. |
approval_flow[ |
integer | No | Existing step to change or remove. |
approval_flow[ |
integer | No | Required for a new step. |
approval_flow[ |
boolean | No | Approval rule. |
approval_flow[ |
boolean | No | true removes the step with its approvers and conditions. |
approval_flow[ |
integer | No | Existing approver assignment. |
approval_flow[ |
integer | No | Approver (active company member). |
approval_flow[ |
boolean | No | true removes the approver from the step. |
approval_flow[ |
array[object] | No | Step conditions: id, property, operator, value, custom_field_id, _destroy. |
Response
200 with the updated flow (same shape as Get an approval flow). Validation errors: 400
with the attribute-keyed error object; unknown id: 404 ["Invalid id passed"].
Side effects
Records a version; flags in-progress runs for rerun when the structure changed (see above).
V3 and V1 differences
None - identical request and response. Both versions accept PUT and PATCH (the V1 example uses PUT, the V3 example PATCH).
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Validation error | 400 |
V3, V1 | {"error":{"approval_conditions":["must have at least one condition"],"approval_steps.approval_step_approvers":["must have at least one approver"]},"status":400} |
| Not found | 404 |
V3, V1 | {"error":["Invalid id passed"],"status":404} |
| Unauthorized | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
Delete an approval flow
curl -X DELETE "https://app.procurementexpress.com/api/v3/approval_flows/6" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl -X DELETE "https://app.procurementexpress.com/api/v1/approval_flows/5" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :delete,
url: "https://app.procurementexpress.com/api/v3/approval_flows/6",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :delete,
url: "https://app.procurementexpress.com/api/v1/approval_flows/5",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"message": "Approval Flow has been successfully deleted"
}
V3 V1
Soft-deletes an approval flow that has no documents in approval.
Deletes the flow (soft delete - it disappears from every endpoint) together with its steps and conditions. Its runs (the links to the documents it routed) are removed permanently; the documents and their approval requests/history are kept.
Refused (401) for the system default invoice flow and while the flow has in-progress runs -
archive the flow instead if documents are still in approval.
HTTP request
| Version | Request |
|---|---|
| V3 | DELETE /api/v3/approval_flows/:id |
| V1 | DELETE /api/v1/approval_flows/:id |
Permissions
Company admin or finance, flow not is_default, in_progress_entities_count = 0. Otherwise
401 Unauthorized access.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Approval flow id. |
Response
200 {"message": "Approval Flow has been successfully deleted"}. Deleting again or an
unknown id: 404 ["Invalid id passed"].
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 404 |
V3, V1 | {"error":["Invalid id passed"],"status":404} |
| Unauthorized | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
Archive an approval flow
curl -X PUT "https://app.procurementexpress.com/api/v3/approval_flows/8/archive" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl -X PUT "https://app.procurementexpress.com/api/v1/approval_flows/7/archive" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v3/approval_flows/8/archive",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v1/approval_flows/7/archive",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"message": "Approval Flow has been successfully archived"
}
V3 V1
Archives a flow so it is hidden from the list and never attached to new documents.
Sets archived: true. Archived flows are skipped by matching (even if status is still
published), no longer count towards "approval flows enabled" for the company, and are
excluded from the list; GET /approval_flows/:id still returns them and existing runs
continue. Archiving is idempotent and cannot be undone through the API (there is no
unarchive and archived is not an accepted update param). Only PUT is routed (PATCH
returns 404).
HTTP request
| Version | Request |
|---|---|
| V3 | PUT /api/v3/approval_flows/:id/archive |
| V1 | PUT /api/v1/approval_flows/:id/archive |
Permissions
Company admin or finance; not the default invoice flow. Others: 401 Unauthorized access.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Approval flow id. |
Response
200 {"message": "Approval Flow has been successfully archived"} (also when it was already
archived).
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 404 |
V3, V1 | {"error":["Invalid id passed"],"status":404} |
| Unauthorized | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
Publish an approval flow
# V1 only: this endpoint has no V3 equivalent.
curl -X PATCH "https://app.procurementexpress.com/api/v1/approval_flows/1/publish" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl -X PATCH "https://app.procurementexpress.com/api/v1/approval_flows/1/publish" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
# V1 only: this endpoint has no V3 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :patch,
url: "https://app.procurementexpress.com/api/v1/approval_flows/1/publish",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :patch,
url: "https://app.procurementexpress.com/api/v1/approval_flows/1/publish",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
V1 response
200 OK(lists shortened to 2 items):
{
"id": 1,
"name": "Lab PO routing v1 - updated",
"document_type": "purchase_order",
"self_approval_allowed": false,
"company_id": 20000,
"version_no": "20261021050157",
"archived": false,
"status": "published",
"in_progress_entities_count": 0,
"completed_entities_count": 0,
"rejected_entities_count": 0,
"cancelled_entities_count": 0,
"total_entities_count": 0,
"is_default": false,
"created_at": 1792576772,
"updated_at": 1792577229,
"approval_steps": [
{
"id": 1,
"step_no": 1,
"all_should_approve": false,
"approval_flow_id": 1,
"created_at": 1792576772,
"updated_at": 1792576772
},
{
"id": 2,
"step_no": 2,
"all_should_approve": true,
"approval_flow_id": 1,
"created_at": 1792576772,
"updated_at": 1792576772
}
],
"approval_conditions": [
{
"id": 2,
"property": "department",
"operator": "equals",
"value": "5",
"custom_field_id": null,
"approval_flow_id": 1,
"approval_step_id": null,
"created_at": 1792576772,
"updated_at": 1792576772,
"human_readable_description": "Department equals Approval Flow Lab",
"property_label": "Department",
"value_label": "Approval Flow Lab"
},
{
"id": 14,
"property": "supplier",
"operator": "is_any_of",
"value": "1,2,3",
"custom_field_id": null,
"approval_flow_id": 1,
"approval_step_id": null,
"created_at": 1792576917,
"updated_at": 1792576917,
"human_readable_description": "Supplier is any of Northwind Office Supplies, Contoso Hardware, Globex Logistics",
"property_label": "Supplier",
"value_label": "Northwind Office Supplies, Contoso Hardware, Globex Logistics"
}
]
}
V3 — V1
Makes an unpublished flow live so it is attached to matching documents from now on (V1 only).
Changes status from unpublished to published. From the next submission on, documents
of the flow's type whose flow conditions match get this flow. Documents already in approval
are not affected.
HTTP request
| Version | Request |
|---|---|
| V1 | PATCH /api/v1/approval_flows/:id/publish |
Permissions
Company admin only, and the flow must currently be unpublished. Finance users, publishing a
published flow, and every other role get 401 Unauthorized access.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Approval flow id. |
Response
200 with the flow, status: "published". Unlike the detail endpoints, approval_steps
items do not include their approvers and conditions (only the step's own fields) -
fetch the flow again if you need them.
Side effects
Records a version (status change) and a server log entry.
V3 and V1 differences
V1 only. V3 routes PATCH /api/v3/approval_flows/:id/publish but the V3 controller has no
publish action: it answers 404 with a non-JSON body (an HTML "Unknown action" page). Only PATCH is routed in V1 (PUT returns 404).
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Unauthorized | 401 |
V1 | {"error":"Unauthorized access","status":401} |
| Not found | 404 |
V1 | {"error":["Invalid id passed"],"status":404} |
Unpublish an approval flow
# V1 only: this endpoint has no V3 equivalent.
curl -X PATCH "https://app.procurementexpress.com/api/v1/approval_flows/1/unpublish" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl -X PATCH "https://app.procurementexpress.com/api/v1/approval_flows/1/unpublish" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
# V1 only: this endpoint has no V3 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :patch,
url: "https://app.procurementexpress.com/api/v1/approval_flows/1/unpublish",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :patch,
url: "https://app.procurementexpress.com/api/v1/approval_flows/1/unpublish",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
V1 response
200 OK(lists shortened to 2 items):
{
"id": 1,
"name": "Lab PO routing v1 - updated",
"document_type": "purchase_order",
"self_approval_allowed": false,
"company_id": 20000,
"version_no": "20261021050157",
"archived": false,
"status": "unpublished",
"in_progress_entities_count": 1,
"completed_entities_count": 0,
"rejected_entities_count": 0,
"cancelled_entities_count": 0,
"total_entities_count": 1,
"is_default": false,
"created_at": 1792576772,
"updated_at": 1792577231,
"approval_steps": [
{
"id": 1,
"step_no": 1,
"all_should_approve": false,
"approval_flow_id": 1,
"created_at": 1792576772,
"updated_at": 1792576772
},
{
"id": 2,
"step_no": 2,
"all_should_approve": true,
"approval_flow_id": 1,
"created_at": 1792576772,
"updated_at": 1792576772
}
],
"approval_conditions": [
{
"id": 2,
"property": "department",
"operator": "equals",
"value": "5",
"custom_field_id": null,
"approval_flow_id": 1,
"approval_step_id": null,
"created_at": 1792576772,
"updated_at": 1792576772,
"human_readable_description": "Department equals Approval Flow Lab",
"property_label": "Department",
"value_label": "Approval Flow Lab"
},
{
"id": 14,
"property": "supplier",
"operator": "is_any_of",
"value": "1,2,3",
"custom_field_id": null,
"approval_flow_id": 1,
"approval_step_id": null,
"created_at": 1792576917,
"updated_at": 1792576917,
"human_readable_description": "Supplier is any of Northwind Office Supplies, Contoso Hardware, Globex Logistics",
"property_label": "Supplier",
"value_label": "Northwind Office Supplies, Contoso Hardware, Globex Logistics"
}
]
}
V3 — V1
Takes a published flow out of matching; documents already routed by it carry on (V1 only).
Changes status from published to unpublished. New documents are no longer matched to the
flow; in-progress runs keep their approval requests and finish normally. If this was the
company's last published, non-archived flow of its document type, that document type goes
back to classic routing.
HTTP request
| Version | Request |
|---|---|
| V1 | PATCH /api/v1/approval_flows/:id/unpublish |
Permissions
Company admin only; the flow must be published and not the default invoice flow. Otherwise
401 Unauthorized access.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Approval flow id. |
Response
200 with the flow, status: "unpublished"; as with publish, steps are returned without
approvers and conditions.
Side effects
Records a version (status change) and a server log entry.
V3 and V1 differences
V1 only. V3 routes PATCH /api/v3/approval_flows/:id/unpublish but has no action: 404 with
a non-JSON "Unknown action" body.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not published | 401 |
V1 | {"error":"Unauthorized access","status":401} |
List the runs of an approval flow
curl "https://app.procurementexpress.com/api/v3/approval_flows/1/runs" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/approval_flows/1/runs" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/approval_flows/1/runs",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/approval_flows/1/runs",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"approval_entities": [
{
"id": 1,
"approval_flow_id": 1,
"entity_id": 47,
"entity_type": "PurchaseOrder",
"status": "in_progress",
"created_at": 1792577230,
"updated_at": 1792577230,
"approval_flow": {
"id": 1,
"name": "Lab PO routing v1 - updated",
"document_type": "purchase_order",
"self_approval_allowed": false,
"company_id": 20000,
"version_no": "20261021050157",
"archived": false,
"status": "unpublished",
"in_progress_entities_count": 1,
"completed_entities_count": 0,
"rejected_entities_count": 0,
"cancelled_entities_count": 0,
"total_entities_count": 1,
"is_default": false,
"created_at": 1792576772,
"updated_at": 1792577231
},
"entity": {
"type": "purchase_order",
"purchase_order": {
"id": 47,
"approval_key": null,
"creator_name": "Sam Smith",
"amount": "3000.0",
"status": "pending",
"supplier_name": "Northwind Office Supplies",
"supplier_id": 1,
"keywords": "47 Northwind Office Supplies Sam Smith pending 3000.00 Lab Budget Lab test kit 3000.0 3000.0 ",
"created_at": 1792577230000,
"currency_id": 2000,
"currency_symbol": "$",
"currency_iso_code": "USD",
"total_gross_amount": "3000.0",
"total_net_amount": "3000.0",
"base_gross_amount": 3000.0,
"tax_amount_overridden": false,
"tax_amount_override": null,
"effective_tax_amount": 0.0,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"submitted_on": 1792577230000,
"po_date": null,
"share_key": "7DA09AB02D4ACBA7B76305FF0439D283288D5A2F",
"department_id": 5,
"department_name": "Approval Flow Lab",
"budget_name": "Lab Budget",
"compliance_status": null,
"delivered_on": null,
"delivery_status": "not_delivered",
"payment_status": "unpaid",
"payment_progress": "unpaid",
"xero_export_status": null,
"synced_with_xero": false,
"xero_is_changed": false
}
}
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 1
}
}
Example: filtered (V3,
200 OK):
{
"approval_entities": [
{
"id": 1,
"approval_flow_id": 1,
"entity_id": 47,
"entity_type": "PurchaseOrder",
"status": "in_progress",
"created_at": 1792577230,
"updated_at": 1792577230,
"approval_flow": {
"id": 1,
"name": "Lab PO routing v1 - updated",
"document_type": "purchase_order",
"self_approval_allowed": false,
"company_id": 20000,
"version_no": "20261021050157",
"archived": false,
"status": "unpublished",
"in_progress_entities_count": 1,
"completed_entities_count": 0,
"rejected_entities_count": 0,
"cancelled_entities_count": 0,
"total_entities_count": 1,
"is_default": false,
"created_at": 1792576772,
"updated_at": 1792577231
},
"entity": {
"type": "purchase_order",
"purchase_order": {
"id": 47,
"approval_key": null,
"creator_name": "Sam Smith",
"amount": "3000.0",
"status": "pending",
"supplier_name": "Northwind Office Supplies",
"supplier_id": 1,
"keywords": "47 Northwind Office Supplies Sam Smith pending 3000.00 Lab Budget Lab test kit 3000.0 3000.0 ",
"created_at": 1792577230000,
"currency_id": 2000,
"currency_symbol": "$",
"currency_iso_code": "USD",
"total_gross_amount": "3000.0",
"total_net_amount": "3000.0",
"base_gross_amount": 3000.0,
"tax_amount_overridden": false,
"tax_amount_override": null,
"effective_tax_amount": 0.0,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"submitted_on": 1792577230000,
"po_date": null,
"share_key": "7DA09AB02D4ACBA7B76305FF0439D283288D5A2F",
"department_id": 5,
"department_name": "Approval Flow Lab",
"budget_name": "Lab Budget",
"compliance_status": null,
"delivered_on": null,
"delivery_status": "not_delivered",
"payment_status": "unpaid",
"payment_progress": "unpaid",
"xero_export_status": null,
"synced_with_xero": false,
"xero_is_changed": false
}
}
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 1
}
}
Example: cancelled po (V3,
200 OK):
{
"approval_entities": [
{
"id": 1,
"approval_flow_id": 1,
"entity_id": 47,
"entity_type": "PurchaseOrder",
"status": "cancelled",
"created_at": 1792577230,
"updated_at": 1792577440,
"approval_flow": {
"id": 1,
"name": "Lab PO routing v1 - updated",
"document_type": "purchase_order",
"self_approval_allowed": false,
"company_id": 20000,
"version_no": "20261021050157",
"archived": false,
"status": "unpublished",
"in_progress_entities_count": 0,
"completed_entities_count": 0,
"rejected_entities_count": 0,
"cancelled_entities_count": 1,
"total_entities_count": 1,
"is_default": false,
"created_at": 1792576772,
"updated_at": 1792577440
},
"entity": {
"type": "purchase_order",
"purchase_order": {
"id": 47,
"approval_key": null,
"creator_name": "Sam Smith",
"amount": "3000.0",
"status": "cancelled",
"supplier_name": "Northwind Office Supplies",
"supplier_id": 1,
"keywords": "47 Northwind Office Supplies Sam Smith cancelled 3000.00 Lab Budget Lab test kit 3000.0 3000.0 Not Set Not Set ",
"created_at": 1792577230000,
"currency_id": 2000,
"currency_symbol": "$",
"currency_iso_code": "USD",
"total_gross_amount": "3000.0",
"total_net_amount": "3000.0",
"base_gross_amount": 3000.0,
"tax_amount_overridden": false,
"tax_amount_override": null,
"effective_tax_amount": 0.0,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"submitted_on": 1792577230000,
"po_date": null,
"share_key": "7DA09AB02D4ACBA7B76305FF0439D283288D5A2F",
"department_id": 5,
"department_name": "Approval Flow Lab",
"budget_name": "Lab Budget",
"compliance_status": null,
"delivered_on": null,
"delivery_status": "not_delivered",
"payment_status": "unpaid",
"payment_progress": "unpaid",
"xero_export_status": null,
"synced_with_xero": false,
"xero_is_changed": false
}
}
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 1
}
}
V3 V1
Lists the documents a flow was attached to (its runs), newest first, with their status.
Each run (approval_entity) links the flow to one document (entity_type PurchaseOrder
or Invoice) and tracks that flow's progress on it. Works for unpublished and archived flows
too. Newest first, paginated.
Filters:
* status - in_progress, completed, rejected or cancelled. An unknown value returns
an empty list.
* date_range - 24h, 7d, 30d, 60d (run created within that period) or custom with
date_from and date_to in MM/DD/YYYY (whole days, inclusive). custom without both
dates is ignored; other values are ignored.
* keyword - matches a run id exactly, or a PO approval_key / invoice number exactly
(case-insensitive, no partial match). Only numeric keywords work today: any keyword that
is not an integer (such as an approval key 3-10/21/2026 or an invoice number with letters)
makes the request fail with HTTP 500.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/approval_flows/:id/runs |
| V1 | GET /api/v1/approval_flows/:id/runs |
Permissions
Company admin or finance. Others: 401 Unauthorized access.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Approval flow id. |
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
status |
string | No | in_progress, completed, rejected, cancelled. |
date_range |
string | No | 24h, 7d, 30d, 60d or custom. |
date_from |
string | No | With date_range=custom: start date MM/DD/YYYY. |
date_to |
string | No | With date_range=custom: end date MM/DD/YYYY (inclusive). |
keyword |
string | No | Run id (see the warning above). |
page |
integer | No | Page number. Default: 1. |
per_page |
integer | No | 10, 20, 50 or 100 (default: user setting, else 10). |
Response
200 with {"approval_entities": [...], "meta": {...}} (same meta as the flow list).
Each run embeds the flow summary (approval_flow) and the document (entity), which is
wrapped by type: {"type": "purchase_order", "purchase_order": {...}} or
{"type": "invoice", "invoice": {...}}, rendered with the document's standard list
serializer (note that the PO's own created_at / submitted_on are in milliseconds).
| Field | Type | Description |
|---|---|---|
approval_entities[ |
integer | Run id (use with show_entity). |
approval_entities[ |
integer | The flow. |
approval_entities[ |
integer | Id of the purchase order or invoice. |
approval_entities[ |
string | PurchaseOrder or Invoice. |
approval_entities[ |
string | in_progress, completed (approved), rejected, cancelled (document cancelled). |
approval_entities[ |
timestamp | Unix seconds - when the flow was attached. |
approval_entities[ |
timestamp | Unix seconds. |
approval_entities[ |
object | Flow summary (list format). |
approval_entities[ |
object | type plus the document under a key named after the type. |
V3 and V1 differences
V1 hides runs whose purchase order is cancelled, deleted or archived; V3 returns them (with
run status cancelled). Verified: after cancelling PO 47, V1 returned 0 runs and V3 1
(approval_flows_runs_cancelled_po). Otherwise identical.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Keyword error | 500 |
V3, V1 | HTML error page (no JSON body) |
| Not found | 404 |
V3, V1 | {"error":["Invalid id passed"],"status":404} |
| Unauthorized | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
Get one run of an approval flow
curl "https://app.procurementexpress.com/api/v3/approval_flows/1/show_entity?entity_id=1" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/approval_flows/1/show_entity?entity_id=1" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/approval_flows/1/show_entity",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
params: {
entity_id: "1"
}
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/approval_flows/1/show_entity",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
params: {
entity_id: "1"
}
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 1,
"approval_flow_id": 1,
"entity_id": 47,
"entity_type": "PurchaseOrder",
"status": "in_progress",
"created_at": 1792577230,
"updated_at": 1792577230,
"entity": {
"type": "purchase_order",
"purchase_order": {
"id": 47,
"approval_key": null,
"creator_name": "Sam Smith",
"amount": "3000.0",
"status": "pending",
"supplier_name": "Northwind Office Supplies",
"supplier_id": 1,
"keywords": "47 Northwind Office Supplies Sam Smith pending 3000.00 Lab Budget Lab test kit 3000.0 3000.0 ",
"created_at": 1792577230000,
"currency_id": 2000,
"currency_symbol": "$",
"currency_iso_code": "USD",
"total_gross_amount": "3000.0",
"total_net_amount": "3000.0",
"base_gross_amount": 3000.0,
"tax_amount_overridden": false,
"tax_amount_override": null,
"effective_tax_amount": 0.0,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"submitted_on": 1792577230000,
"po_date": null,
"share_key": "7DA09AB02D4ACBA7B76305FF0439D283288D5A2F",
"department_id": 5,
"department_name": "Approval Flow Lab",
"budget_name": "Lab Budget",
"compliance_status": null,
"delivered_on": null,
"delivery_status": "not_delivered",
"payment_status": "unpaid",
"payment_progress": "unpaid",
"xero_export_status": null,
"synced_with_xero": false,
"xero_is_changed": false
}
}
}
V3 V1
Returns one run of the flow with its document.
entity_id is the run id (approval_entities[].id from the runs list), not the purchase
order or invoice id. Runs of cancelled POs are returned by both versions.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/approval_flows/:id/show_entity |
| V1 | GET /api/v1/approval_flows/:id/show_entity |
Permissions
Company admin or finance. Others: 401 Unauthorized access.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Approval flow id. |
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
entity_id |
integer | Yes | Run id. A document id, a run of another flow or a missing value returns 404. |
Response
200 with the run object (id, approval_flow_id, entity_id, entity_type, status,
created_at, updated_at) and the entity wrapper as in the runs list. The flow itself is
not embedded. Unknown run: 404 {"error": "Invalid id passed", "status": 404}.
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
List the versions of an approval flow
curl "https://app.procurementexpress.com/api/v3/approval_flows/1/versions" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/approval_flows/1/versions" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/approval_flows/1/versions",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/approval_flows/1/versions",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1 (lists shortened to 2 items):
{
"versions": [
{
"id": 154,
"name": "Lab PO routing v1 - updated",
"created_at": 1792577440,
"conditions_count": 2,
"steps_count": 3
},
{
"id": 113,
"name": "Lab PO routing v1 - updated",
"created_at": 1792577231,
"conditions_count": 2,
"steps_count": 3
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 8
}
}
V3 V1
Lists earlier states of a flow, newest first.
Every change to the flow record creates a version: updates, publish/unpublish, and also the
automatic run-counter updates when documents are routed, approved or cancelled. Each entry is
the flow as it was before that change, and created_at is when the change happened. The
create event has no earlier state and is not listed.
Use an entry's id with GET /approval_flows/:id/version_details.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/approval_flows/:id/versions |
| V1 | GET /api/v1/approval_flows/:id/versions |
Permissions
Company admin or finance. Others: 401 Unauthorized access.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Approval flow id. |
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
page |
integer | No | Page number. Default: 1. |
per_page |
integer | No | 10, 20, 50 or 100 (default: user setting, else 10). |
Response
200 with {"versions": [...], "meta": {...}}. meta.total_count includes the unlisted
create event, so it is one higher than the number of entries you can page through.
| Field | Type | Description |
|---|---|---|
versions[ |
integer | Version id. |
versions[ |
string | Flow name before the change. |
versions[ |
timestamp | Unix seconds - when the change was made. |
versions[ |
integer | Number of flow-level conditions before the change. |
versions[ |
integer | Number of steps before the change. |
V3 and V1 differences
None - identical request and response.
Get a version of an approval flow
curl "https://app.procurementexpress.com/api/v3/approval_flows/1/version_details?version_id=55" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/approval_flows/1/version_details?version_id=55" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/approval_flows/1/version_details",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
params: {
version_id: "55"
}
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/approval_flows/1/version_details",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
params: {
version_id: "55"
}
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1 (lists shortened to 2 items):
{
"id": 1,
"name": "Lab PO routing v1",
"document_type": "purchase_order",
"self_approval_allowed": false,
"company_id": 20000,
"version_no": null,
"archived": false,
"status": "unpublished",
"in_progress_entities_count": 0,
"completed_entities_count": 0,
"rejected_entities_count": 0,
"cancelled_entities_count": 0,
"total_entities_count": 0,
"is_default": false,
"created_at": 1792576772,
"updated_at": 1792576772,
"approval_steps": [
{
"id": 1,
"step_no": 1,
"all_should_approve": false,
"approval_flow_id": 1,
"created_at": 1792576772,
"updated_at": 1792576772,
"approval_step_approvers": [
{
"id": 1,
"approval_step_id": 1,
"user_id": 50000,
"user": {
"id": 50000,
"name": "Priya Patel",
"email": "priya.patel@example.com",
"on_holiday": false
},
"created_at": 1792576772,
"updated_at": 1792576772
}
],
"approval_conditions": [
]
},
{
"id": 2,
"step_no": 2,
"all_should_approve": true,
"approval_flow_id": 1,
"created_at": 1792576772,
"updated_at": 1792576772,
"approval_step_approvers": [
{
"id": 2,
"approval_step_id": 2,
"user_id": 40000,
"user": {
"id": 40000,
"name": "Jane Doe",
"email": "jane.doe@example.com",
"on_holiday": false
},
"created_at": 1792576772,
"updated_at": 1792576772
}
],
"approval_conditions": [
{
"id": 1,
"property": "gross_amount",
"operator": "greater_than",
"value": "2500",
"custom_field_id": null,
"approval_flow_id": null,
"approval_step_id": 2,
"created_at": 1792576772,
"updated_at": 1792576917,
"human_readable_description": "Gross amount greater than 2500",
"property_label": "Gross amount",
"value_label": "2500"
}
]
}
],
"approval_conditions": [
{
"id": 2,
"property": "department",
"operator": "equals",
"value": "5",
"custom_field_id": null,
"approval_flow_id": 1,
"approval_step_id": null,
"created_at": 1792576772,
"updated_at": 1792576772,
"human_readable_description": "Department equals Approval Flow Lab",
"property_label": "Department",
"value_label": "Approval Flow Lab"
}
]
}
V3 V1
Returns the flow as it was before the change recorded by a version.
Returns a historical state in the same shape as Get an approval flow. The flow's own
attributes (name, status, version_no, counters, updated_at) and its flow-level
approval_conditions are the historical values.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/approval_flows/:id/version_details |
| V1 | GET /api/v1/approval_flows/:id/version_details |
Permissions
Company admin or finance. Others: 401 Unauthorized access.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Approval flow id. |
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
version_id |
integer | Yes | A version id of this flow (from versions). Another flow's version or a missing value returns 404. |
Response
200 with the flow object. Unknown version: 404 {"error": "Invalid id passed", "status": 404}.
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
Rerun approval flows for documents
curl -X POST "https://app.procurementexpress.com/api/v3/approval_flows/rerun_approval_flows" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"order_ids": [
47
]
}'
curl -X POST "https://app.procurementexpress.com/api/v1/approval_flows/rerun_approval_flows" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"order_ids": [
47
],
"invoice_ids": [
22
]
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/approval_flows/rerun_approval_flows",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
order_ids: [47]
}.to_json
)
puts response.code
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/approval_flows/rerun_approval_flows",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
order_ids: [47],
invoice_ids: [22]
}.to_json
)
puts response.code
Response
500 Internal Server Error, same structure in V3 and V1:
HTML error page (no JSON body)
Example: draft po (V3,
500 Internal Server Error):
HTML error page (no JSON body)
Example: invoice only (V3,
404 Not Found):
{
"error": [
"Invalid id passed"
],
"status": 404
}
V3 V1
Queues a background re-evaluation of documents whose approval flow was edited while they were in approval.
Use after editing a flow's steps, approvers or conditions to bring documents that are already in approval in line with the new definition. The request only enqueues a background job; the response does not say what changed.
The job processes each purchase order that is still pending (approved, rejected, cancelled, deleted, draft and archived POs are skipped) and only its runs that were flagged as "flow changed" by a flow update (POs with no flagged run are left as they are): * if the PO still matches the edited flow, approved requests are kept and pending requests of approvers who are no longer in the active step are removed; * if it no longer matches, the run and its pending requests are removed; * published flows that now match are attached, approvers are re-assigned (new requests are sent), and a "was reconfigured and manually restarted" comment is added to the PO.
Invoices (V1 invoice_ids) go through the single-invoice rerun service (same rules as
POST /invoices/:id/rerun_approval_flow).
Ids that do not belong to the company are ignored. In V1, POs that are draft, cancelled, deleted or archived are dropped before queueing.
HTTP request
| Version | Request |
|---|---|
| V3 | POST /api/v3/approval_flows/rerun_approval_flows |
| V1 | POST /api/v1/approval_flows/rerun_approval_flows |
Permissions
Company admin or finance in the header company. Others: 401 Unauthorized access.
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
order_ids |
array[integer] | No | Purchase order ids. Required in V3. |
invoice_ids |
array[integer] | No | V1 only: invoice ids. Ignored by V3. |
Response
Intended success response: 200, V1 {"message": "...", "job_ids": ["<sidekiq jid>", ...]}
(one job for the POs and one for the invoices), V3 {"message": "...", "job_id": "<sidekiq jid>"}.
Because the success message's translation key is missing, the message text is
"Translation missing: en.approval_flow.rerun_initiated" where missing translations are not
fatal; where it is fatal the endpoint answers HTTP 500
after the job has already been queued - do not retry a 500 from this endpoint blindly.
No eligible document: 404 {"error": ["Invalid id passed"], "status": 404}.
Side effects
Queues a background job that reruns the flows. The job may send new approval requests and adds PO comments.
V3 and V1 differences
- V1 accepts
order_idsandinvoice_idsand returnsjob_ids(array); V3 acceptsorder_idsonly and returnsjob_id(string). - V1 drops draft, cancelled, deleted and archived POs before queueing (a draft-only request is
a 404); V3 queues any PO of the company (draft PO 39: V1 404, V3 queued - captures
approval_flows_rerun_draft_po). The job itself skips such POs. - Invoice-only request: V1 queues an invoice job, V3 answers 404 (
approval_flows_rerun_invoice_only).
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 404 |
V3, V1 | {"error":["Invalid id passed"],"status":404} |
| Unauthorized | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
Export approval flows to Excel
# V1 only: this endpoint has no V3 equivalent.
curl "https://app.procurementexpress.com/api/v1/approval_flows/export" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/approval_flows/export" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
# V1 only: this endpoint has no V3 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/approval_flows/export",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/approval_flows/export",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
V1 response
200 OK:
Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
Content-Disposition: attachment; filename="approval-flows-2026-10-21.xlsx"; filename*=UTF-8''approval-flows-2026-10-21.xlsx
<6748 bytes of binary data>
V3 — V1
Downloads all approval flows of the company as an .xlsx workbook (V1 only).
Returns an Excel workbook with one sheet per document type ("PO Approval Flows",
"RFQ Approval Flows", "Invoice Approval Flows"). Each row is a flow - published first, then
unpublished, then archived, alphabetically within each group - with the columns Name,
Status (Published / Unpublished / Archived), Self-Approval Allowed, Flow Conditions
(human-readable, ;-separated), Total, In Progress, Completed (approved + rejected), and
for each step: Approvers, Approval Rule ("Any one can approve" / "All must approve") and
Conditions. Archived flows are included; deleted flows are not.
HTTP request
| Version | Request |
|---|---|
| V1 | GET /api/v1/approval_flows/export |
Permissions
Company admin or finance. Others: 401 Unauthorized access (JSON).
Response
200 binary body, Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,
Content-Disposition: attachment; filename="approval-flows-YYYY-MM-DD.xlsx".
V3 and V1 differences
V1 only. In V3 GET /approval_flows/export is matched by the show route with id export and
returns 404 {"error": ["Invalid id passed"], "status": 404}.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Unauthorized | 401 |
V1 | {"error":"Unauthorized access","status":401} |
Budgets
A budget is a pot of money that purchase order line items (and invoice
line items) are coded against. Every line item on a purchase order points at
one budget, and the budget keeps running totals of what has been requested,
approved and paid against it, so remaining_amount always shows how much is
left to spend.
Budgets can be:
- Company-wide or department-specific - a budget linked to one or more
departments (
department_ids) is offered on POs for those departments; a budget with no department is available to every department. - Periodic or open-ended - a budget with a
start_dateandend_dateis periodic and only counts as active between those dates (inclusive). A budget with neither date never expires. Both dates must be given together. - Linked to approvers -
approver_idsare the budget's approvers used by the classic (non approval-flow) routing for POs coded to the budget.allow_anyone_to_approve_a_polets a requester who is only a team member self-approve a PO on this budget (within their approval limit) instead of sending it to the budget's approvers. - Linked to accounting -
chart_of_account_idlinks the budget to a chart of account. With the company settinglink_budgets_to_qbo_accountsenabled, POs/imports use that link to pick the account (or the budget) automatically. The budget's QuickBooks class (quickbooks_class) is shown but cannot be set through the API. - Archived - archived budgets are hidden from lists and cannot be
selected on new POs. Use the V3
archive / unarchive endpoints;
V1 has no way to archive a budget (
budget[archived]is not accepted).
Dates use the company date format
start_date and end_date are sent as strings in the company's date
format (company_setting.date_format, e.g. %m/%d/%Y -> "10/01/2026";
read it from GET /companies/details) and are returned as
Unix timestamps (seconds, midnight UTC of the date). A date that does not
match the company format is silently dropped on create (the budget is
created without dates) and cleared on update - always format dates with
the company's date_format.
Currency
A budget is always created in the company's base currency: currency_id is
ignored on create. base_amount and base_rate are the amount in, and the
exchange rate to, the base currency at creation time; they are not
recalculated when the amount or currency is later updated.
`remaining_amount`
remaining_amount = amount - pending - approved (unpaid) - paid, where the
three spend figures are the sums of the PO line items coded to the budget
(net or gross, depending on the company's net/gross setting).
API versions
| V1 | V3 | V2 (legacy, internal) | |
|---|---|---|---|
| Auth | authentication_token |
OAuth2 bearer | authentication_token |
GET /budgets envelope |
bare array, unpaginated | {budgets, meta}, paginated (max 20) |
{budgets, meta}, 20 per page |
| Which budgets are listed | non-expired only (open-ended + periodic valid today) | all (use only_active=true to exclude expired / future) |
non-expired only |
department_id filter |
department's budgets plus company-wide budgets | only budgets linked to the department | department's budgets plus company-wide budgets |
search |
ignored | name / cost code / cost type | name / cost code / cost type |
| archive / unarchive | not available | POST /budgets/:id/archive, /unarchive |
not available |
/api/v2/budgets is an older, undocumented variant used by legacy clients.
It authenticates exactly like V1 (authentication_token + app_company_id
headers; an OAuth bearer token is rejected with 401) but answers the list in
the V3 envelope. New integrations should use V3.
Endpoints in this section
| Endpoint | Method | Path | V3 | V1 |
|---|---|---|---|---|
| List budgets | GET |
/budgets |
✓ | ✓ |
| Get a budget | GET |
/budgets/:id |
✓ | ✓ |
| Create a budget | POST |
/budgets |
✓ | ✓ |
| Update a budget | PUT |
/budgets/:id |
✓ | ✓ |
| Archive a budget | POST |
/budgets/:id/archive |
✓ | — |
| Unarchive a budget | POST |
/budgets/:id/unarchive |
✓ | — |
The budget object
| Field | Type | Description |
|---|---|---|
id |
integer | Budget id. |
company_id |
integer | Owning company (the app_company_id the budget was created in). |
name |
string | Budget name (1-255 characters). |
amount |
number | Budget amount, in the budget's currency. |
cost_code |
string | Optional cost code (leading/trailing whitespace stripped). null when not set. |
cost_type |
string | Optional cost type. null when not set. |
archived |
boolean | true when the budget is archived. |
currency_id |
integer | Currency of amount. Always the company's base currency at creation; see the intro. |
base_amount |
number | amount converted to the company base currency at creation (not recalculated on update). |
base_rate |
string | Exchange rate to the base currency at creation, as a decimal string (e.g. "1.0"). |
allow_anyone_to_approve_a_po |
boolean | When true, a team-member-only requester may self-approve POs coded to this budget within their approval limit. |
start_date |
timestamp | Start of a periodic budget as Unix seconds (midnight UTC of the date), or null for an open-ended budget. |
end_date |
timestamp | End of a periodic budget (inclusive) as Unix seconds, or null. |
summary |
string | Display label: name : cost_code : cost_type, blank parts omitted. |
remaining_amount |
number | Amount still available: amount minus pending, approved-but-unpaid and paid line-item spend. |
creator_id |
integer | User who created the budget. |
qbo_class |
string | Legacy free-text class label. Not linked to QuickBooks classes; see quickbooks_class. |
department_ids |
array[integer] | Departments the budget is linked to. Empty array = company-wide budget. |
approver_ids |
array[integer] | User ids of the budget's approvers. |
created_at |
timestamp | ISO-8601 with UTC offset (server time zone). |
updated_at |
timestamp | ISO-8601 with UTC offset (server time zone). |
chart_of_account_id |
integer | Linked chart of account id, or null. |
chart_of_account_name |
string | Name of the linked chart of account, or null. |
chart_of_account |
object | {id, name} of the linked chart of account. Always present; both values null when no account is linked. |
quickbooks_class |
object | {id, name} of the linked QuickBooks class (name = fully qualified name). Always present; null values when none. Read-only via the API. |
third_party_id_mappings |
array[object] | External ids of the budget in integrated systems (service, third_party_id, realm_id, synced, ...). Only in list responses: always in V3/V2 lists, in V1 lists only with show_mappings. Never in show/create/update responses. |
List budgets
curl "https://app.procurementexpress.com/api/v3/budgets?department_id=3" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/budgets?department_id=3" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/budgets",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
params: {
department_id: "3"
}
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/budgets",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
params: {
department_id: "3"
}
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
V3 response
200 OK(lists shortened to 2 items):
{
"budgets": [
{
"id": 11,
"company_id": 20000,
"name": "Customer Events Q4",
"amount": 2500.0,
"cost_code": "EVT-Q4",
"cost_type": "Opex",
"archived": false,
"currency_id": 2000,
"base_amount": 2500.0,
"base_rate": "1.0",
"allow_anyone_to_approve_a_po": false,
"start_date": null,
"end_date": null,
"summary": "Customer Events Q4 : EVT-Q4 : Opex",
"remaining_amount": 2500.0,
"creator_id": 40000,
"qbo_class": null,
"department_ids": [
3
],
"approver_ids": [
],
"created_at": "2026-10-21T05:02:17.243-05:00",
"updated_at": "2026-10-21T05:02:27.319-05:00",
"chart_of_account_id": null,
"chart_of_account_name": null,
"third_party_id_mappings": [
],
"chart_of_account": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
}
},
{
"id": 9,
"company_id": 20000,
"name": "Expired FY25",
"amount": 3000.0,
"cost_code": "EXP-25",
"cost_type": null,
"archived": false,
"currency_id": 2000,
"base_amount": 3000.0,
"base_rate": "1.0",
"allow_anyone_to_approve_a_po": false,
"start_date": 1735689600,
"end_date": 1767139200,
"summary": "Expired FY25 : EXP-25",
"remaining_amount": 3000.0,
"creator_id": 40000,
"qbo_class": null,
"department_ids": [
3
],
"approver_ids": [
],
"created_at": "2026-10-21T05:01:46.123-05:00",
"updated_at": "2026-10-21T05:01:46.123-05:00",
"chart_of_account_id": null,
"chart_of_account_name": null,
"third_party_id_mappings": [
],
"chart_of_account": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
}
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 4
}
}
V1 response
200 OK(lists shortened to 2 items):
[
{
"id": 11,
"company_id": 20000,
"name": "Customer Events Q4",
"amount": 2500.0,
"cost_code": "EVT-Q4",
"cost_type": "Opex",
"archived": false,
"currency_id": 2000,
"base_amount": 2500.0,
"base_rate": "1.0",
"allow_anyone_to_approve_a_po": false,
"start_date": null,
"end_date": null,
"summary": "Customer Events Q4 : EVT-Q4 : Opex",
"remaining_amount": 2500.0,
"creator_id": 40000,
"qbo_class": null,
"department_ids": [
3
],
"approver_ids": [
],
"created_at": "2026-10-21T05:02:17.243-05:00",
"updated_at": "2026-10-21T05:02:27.319-05:00",
"chart_of_account_id": null,
"chart_of_account_name": null,
"chart_of_account": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
}
},
{
"id": 6,
"company_id": 20000,
"name": "Field Marketing FY27",
"amount": 9500.0,
"cost_code": "FMK-27",
"cost_type": "Opex",
"archived": false,
"currency_id": 2000,
"base_amount": 8000.0,
"base_rate": "1.0",
"allow_anyone_to_approve_a_po": false,
"start_date": 1790812800,
"end_date": 1830211200,
"summary": "Field Marketing FY27 : FMK-27 : Opex",
"remaining_amount": 9500.0,
"creator_id": 40000,
"qbo_class": null,
"department_ids": [
2,
3
],
"approver_ids": [
40000
],
"created_at": "2026-10-21T04:59:50.497-05:00",
"updated_at": "2026-10-21T05:02:55.998-05:00",
"chart_of_account_id": 3,
"chart_of_account_name": "Advertising & Marketing",
"chart_of_account": {
"id": 3,
"name": "Advertising & Marketing"
},
"quickbooks_class": {
"id": null,
"name": null
}
}
]
Example: only active (V3,
200 OK) (lists shortened to 2 items):
{
"budgets": [
{
"id": 11,
"company_id": 20000,
"name": "Customer Events Q4",
"amount": 2500.0,
"cost_code": "EVT-Q4",
"cost_type": "Opex",
"archived": false,
"currency_id": 2000,
"base_amount": 2500.0,
"base_rate": "1.0",
"allow_anyone_to_approve_a_po": false,
"start_date": null,
"end_date": null,
"summary": "Customer Events Q4 : EVT-Q4 : Opex",
"remaining_amount": 2500.0,
"creator_id": 40000,
"qbo_class": null,
"department_ids": [
3
],
"approver_ids": [
],
"created_at": "2026-10-21T05:02:17.243-05:00",
"updated_at": "2026-10-21T05:02:27.319-05:00",
"chart_of_account_id": null,
"chart_of_account_name": null,
"third_party_id_mappings": [
],
"chart_of_account": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
}
},
{
"id": 6,
"company_id": 20000,
"name": "Field Marketing FY27",
"amount": 9500.0,
"cost_code": "FMK-27",
"cost_type": "Opex",
"archived": false,
"currency_id": 2000,
"base_amount": 8000.0,
"base_rate": "1.0",
"allow_anyone_to_approve_a_po": false,
"start_date": 1790812800,
"end_date": 1830211200,
"summary": "Field Marketing FY27 : FMK-27 : Opex",
"remaining_amount": 9500.0,
"creator_id": 40000,
"qbo_class": null,
"department_ids": [
2,
3
],
"approver_ids": [
40000
],
"created_at": "2026-10-21T04:59:50.497-05:00",
"updated_at": "2026-10-21T05:02:55.998-05:00",
"chart_of_account_id": 3,
"chart_of_account_name": "Advertising & Marketing",
"third_party_id_mappings": [
],
"chart_of_account": {
"id": 3,
"name": "Advertising & Marketing"
},
"quickbooks_class": {
"id": null,
"name": null
}
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 3
}
}
Example: search paginated (V3,
200 OK):
{
"budgets": [
{
"id": 2,
"company_id": 20000,
"name": "Marketing Q4 Campaigns",
"amount": 20000.0,
"cost_code": "MKT-Q4",
"cost_type": "Opex",
"archived": false,
"currency_id": 2000,
"base_amount": 20000.0,
"base_rate": "1.0",
"allow_anyone_to_approve_a_po": false,
"start_date": 1767225600,
"end_date": 1798675200,
"summary": "Marketing Q4 Campaigns : MKT-Q4 : Opex",
"remaining_amount": 20000.0,
"creator_id": 40000,
"qbo_class": null,
"department_ids": [
3
],
"approver_ids": [
40000,
50000
],
"created_at": "2026-10-21T04:48:43.390-05:00",
"updated_at": "2026-10-21T04:48:43.390-05:00",
"chart_of_account_id": 3,
"chart_of_account_name": "Advertising & Marketing",
"third_party_id_mappings": [
],
"chart_of_account": {
"id": 3,
"name": "Advertising & Marketing"
},
"quickbooks_class": {
"id": 2,
"name": "West Coast"
}
}
],
"meta": {
"current_page": 2,
"next_page": null,
"prev_page": 1,
"total_pages": 2,
"total_count": 2
}
}
Example: archived (V3,
200 OK) (lists shortened to 2 items):
{
"budgets": [
{
"id": 7,
"company_id": 20000,
"name": "ISO dates test",
"amount": 100.0,
"cost_code": null,
"cost_type": null,
"archived": true,
"currency_id": 2000,
"base_amount": 100.0,
"base_rate": "1.0",
"allow_anyone_to_approve_a_po": false,
"start_date": null,
"end_date": null,
"summary": "ISO dates test",
"remaining_amount": 100.0,
"creator_id": 40000,
"qbo_class": null,
"department_ids": [
],
"approver_ids": [
],
"created_at": "2026-10-21T05:00:16.000-05:00",
"updated_at": "2026-10-21T05:02:29.466-05:00",
"chart_of_account_id": null,
"chart_of_account_name": null,
"third_party_id_mappings": [
],
"chart_of_account": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
}
},
{
"id": 20000,
"company_id": 20000,
"name": "Tech",
"amount": 10000.0,
"cost_code": "100",
"cost_type": "200",
"archived": true,
"currency_id": 1000,
"base_amount": 10000.0,
"base_rate": "1.0",
"allow_anyone_to_approve_a_po": true,
"start_date": null,
"end_date": null,
"summary": "Tech : 100 : 200",
"remaining_amount": 10000.0,
"creator_id": 40000,
"qbo_class": null,
"department_ids": [
],
"approver_ids": [
],
"created_at": "2026-10-21T04:44:45.630-05:00",
"updated_at": "2026-10-21T04:44:45.630-05:00",
"chart_of_account_id": null,
"chart_of_account_name": null,
"third_party_id_mappings": [
],
"chart_of_account": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
}
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 3
}
}
Example: show mappings (V1,
200 OK) (lists shortened to 2 items):
[
{
"id": 11,
"company_id": 20000,
"name": "Customer Events Q4",
"amount": 2500.0,
"cost_code": "EVT-Q4",
"cost_type": "Opex",
"archived": false,
"currency_id": 2000,
"base_amount": 2500.0,
"base_rate": "1.0",
"allow_anyone_to_approve_a_po": false,
"start_date": null,
"end_date": null,
"summary": "Customer Events Q4 : EVT-Q4 : Opex",
"remaining_amount": 2500.0,
"creator_id": 40000,
"qbo_class": null,
"department_ids": [
3
],
"approver_ids": [
],
"created_at": "2026-10-21T05:02:17.243-05:00",
"updated_at": "2026-10-21T05:02:27.319-05:00",
"chart_of_account_id": null,
"chart_of_account_name": null,
"third_party_id_mappings": [
],
"chart_of_account": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
}
},
{
"id": 6,
"company_id": 20000,
"name": "Field Marketing FY27",
"amount": 9500.0,
"cost_code": "FMK-27",
"cost_type": "Opex",
"archived": false,
"currency_id": 2000,
"base_amount": 8000.0,
"base_rate": "1.0",
"allow_anyone_to_approve_a_po": false,
"start_date": 1790812800,
"end_date": 1830211200,
"summary": "Field Marketing FY27 : FMK-27 : Opex",
"remaining_amount": 9500.0,
"creator_id": 40000,
"qbo_class": null,
"department_ids": [
2,
3
],
"approver_ids": [
40000
],
"created_at": "2026-10-21T04:59:50.497-05:00",
"updated_at": "2026-10-21T05:02:55.998-05:00",
"chart_of_account_id": 3,
"chart_of_account_name": "Advertising & Marketing",
"third_party_id_mappings": [
{
"id": 3,
"created_at": "2026-10-21T05:04:32.659-05:00",
"invoice_id": null,
"meta": {
},
"migrated": false,
"purchase_order_id": null,
"realm_id": null,
"service": "zapier",
"synced": false,
"third_party_id": "zap-6001",
"third_party_id_mappable_id": 6,
"third_party_id_mappable_type": "Budget",
"updated_at": "2026-10-21T05:04:32.659-05:00"
}
],
"chart_of_account": {
"id": 3,
"name": "Advertising & Marketing"
},
"quickbooks_class": {
"id": null,
"name": null
}
}
]
V3 V1
Returns the company's budgets; V3 paginates and filters, V1 returns every active, non-expired budget.
Lists budgets of the company in the app_company_id header.
V3 (OAuth) - paginated, ordered by name:
archived-false(default) returns active budgets,truearchived ones.department_id- only budgets linked to that department (company-wide budgets are not included). An unknown id answers 404.only_active=true- only budgets usable today: open-ended budgets plus periodic budgets whosestart_date <= today <= end_date. Without it, expired and future periodic budgets are included.search- case-insensitive substring match on name, cost code or cost type.page/per_page-per_pagedefaults to 20 and is capped at 20.
V1 (authentication token) - returns a bare JSON array of every matching
budget, without pagination (page, per_page, search and
only_active are ignored):
- Only budgets usable today are returned (open-ended + periodic valid today); expired or future periodic budgets are never listed.
department_idreturns the department's budgets plus all company-wide budgets (no department), i.e. what that department can pick on a PO.archived=truereturns archived budgets (same date rule).show_mappings(any value) addsthird_party_id_mappingsto every budget.- Order: open-ended budgets by name, then periodic budgets by name.
The list is not narrowed to the caller's own departments: every member of the company sees all budgets here (the PO form needs them), even team members who cannot open a budget with GET /budgets/:id.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/budgets |
| V1 | GET /api/v1/budgets |
Permissions
Any live member of the company (company admin, approver, finance or team
member). Callers without any of these roles get 401 Unauthorized access.
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
archived |
boolean | No | true lists archived budgets instead of active ones. Default: false. |
department_id |
integer | No | Department filter. V3: only budgets linked to the department. V1: the department's budgets plus company-wide budgets. Unknown id -> 404. |
only_active |
boolean | No | V3 only. true excludes expired and not-yet-started periodic budgets (V1 always excludes them). Default: false. |
search |
string | No | V3 only. Case-insensitive substring of name, cost code or cost type. |
page |
integer | No | V3 only. Page number. Default: 1. |
per_page |
integer | No | V3 only. Page size, capped at 20. Default: 20. |
show_mappings |
string | No | V1 only. When present (any value) each budget includes third_party_id_mappings. V3 always includes them. |
Response
200. V3: {"budgets": [Budget, ...], "meta": {...}}; every budget
includes third_party_id_mappings. V1: a bare array of Budget objects
(third_party_id_mappings only with show_mappings).
In the examples (department_id=3, Marketing) V3 also lists
"Expired FY25", a periodic budget that ended in 2025; V1 omits it.
| Field | Type | Description |
|---|---|---|
meta.current_page |
integer | V3 only. Current page. |
meta.next_page |
integer | V3 only. Next page number, null on the last page. |
meta.prev_page |
integer | V3 only. Previous page number, null on the first page. |
meta.total_pages |
integer | V3 only. Number of pages. |
meta.total_count |
integer | V3 only. Total number of matching budgets. |
V3 and V1 differences
- Envelope: V1 returns a bare array; V3 returns
{budgets, meta}. - Pagination: V1 none (all rows); V3
page/per_page(max 20). - Expired / future periodic budgets: V1 never lists them; V3 lists them
unless
only_active=true. department_id: V1 adds company-wide (no-department) budgets; V3 returns only budgets linked to the department.search: V3 only (ignored by V1).third_party_id_mappings: always in V3; V1 only withshow_mappings.- Order: V3 by name; V1 open-ended budgets first, then periodic ones.
- Internal: a V3 request with header
app_source: mobile_ios,mobile_androidorpush_cart(our own apps) uses the V1 selection rules (non-expired only, department + company-wide) but keeps the V3 envelope.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Unknown department | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
Get a budget
curl "https://app.procurementexpress.com/api/v3/budgets/1" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/budgets/1" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/budgets/1",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/budgets/1",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 1,
"company_id": 20000,
"name": "Engineering Hardware FY26",
"amount": 50000.0,
"cost_code": "ENG-HW",
"cost_type": "Capex",
"archived": false,
"currency_id": 2000,
"base_amount": 50000.0,
"base_rate": "1.0",
"allow_anyone_to_approve_a_po": false,
"start_date": 1767225600,
"end_date": 1798675200,
"summary": "Engineering Hardware FY26 : ENG-HW : Capex",
"remaining_amount": 25845.78,
"creator_id": 40000,
"qbo_class": null,
"department_ids": [
2
],
"approver_ids": [
40000,
50000
],
"created_at": "2026-10-21T04:48:43.335-05:00",
"updated_at": "2026-10-21T05:02:00.107-05:00",
"chart_of_account_id": 2,
"chart_of_account_name": "Computer Equipment",
"chart_of_account": {
"id": 2,
"name": "Computer Equipment"
},
"quickbooks_class": {
"id": 1,
"name": "Northeast"
}
}
V3 V1
Returns one budget of the company, archived budgets included.
Returns a single budget by id. Archived budgets are returned too. The
response never includes third_party_id_mappings (use the list for those).
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/budgets/:id |
| V1 | GET /api/v1/budgets/:id |
Permissions
Company admins, approvers and finance users of the budget's company may read
any budget. Team members may read a budget only when the company setting
allow_members_to_see_budgets is on and the budget is linked to one of
their departments. Otherwise 401 Unauthorized access. A budget of another
company answers 404.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Budget id. |
Response
200 with the Budget object (no envelope).
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
| Forbidden | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
Create a budget
curl -X POST "https://app.procurementexpress.com/api/v3/budgets" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"budget": {
"name": "Field Marketing FY27",
"amount": 8000,
"cost_code": "FMK-27",
"cost_type": "Opex",
"start_date": "10/01/2026",
"end_date": "09/30/2027",
"department_ids": [
3
],
"approver_ids": [
40000,
50000
],
"chart_of_account_id": 3,
"allow_anyone_to_approve_a_po": false
}
}'
curl -X POST "https://app.procurementexpress.com/api/v1/budgets" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"budget": {
"name": "Facilities FY27",
"amount": 12000,
"cost_code": "FAC-27",
"cost_type": "Opex",
"start_date": "10/01/2026",
"end_date": "09/30/2027",
"department_ids": [
4
],
"approver_ids": [
40000,
50000
],
"chart_of_account_id": 1,
"allow_anyone_to_approve_a_po": false
}
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/budgets",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
budget: {
name: "Field Marketing FY27",
amount: 8000,
cost_code: "FMK-27",
cost_type: "Opex",
start_date: "10/01/2026",
end_date: "09/30/2027",
department_ids: [3],
approver_ids: [40000, 50000],
chart_of_account_id: 3,
allow_anyone_to_approve_a_po: false
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/budgets",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
budget: {
name: "Facilities FY27",
amount: 12000,
cost_code: "FAC-27",
cost_type: "Opex",
start_date: "10/01/2026",
end_date: "09/30/2027",
department_ids: [4],
approver_ids: [40000, 50000],
chart_of_account_id: 1,
allow_anyone_to_approve_a_po: false
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 6,
"company_id": 20000,
"name": "Field Marketing FY27",
"amount": 8000.0,
"cost_code": "FMK-27",
"cost_type": "Opex",
"archived": false,
"currency_id": 2000,
"base_amount": 8000.0,
"base_rate": "1.0",
"allow_anyone_to_approve_a_po": false,
"start_date": 1790812800,
"end_date": 1822262400,
"summary": "Field Marketing FY27 : FMK-27 : Opex",
"remaining_amount": 8000.0,
"creator_id": 40000,
"qbo_class": null,
"department_ids": [
3
],
"approver_ids": [
40000,
50000
],
"created_at": "2026-10-21T04:59:50.497-05:00",
"updated_at": "2026-10-21T04:59:50.497-05:00",
"chart_of_account_id": 3,
"chart_of_account_name": "Advertising & Marketing",
"chart_of_account": {
"id": 3,
"name": "Advertising & Marketing"
},
"quickbooks_class": {
"id": null,
"name": null
}
}
Example: iso dates (V3,
200 OK):
{
"id": 7,
"company_id": 20000,
"name": "ISO dates test",
"amount": 100.0,
"cost_code": null,
"cost_type": null,
"archived": false,
"currency_id": 2000,
"base_amount": 100.0,
"base_rate": "1.0",
"allow_anyone_to_approve_a_po": false,
"start_date": null,
"end_date": null,
"summary": "ISO dates test",
"remaining_amount": 100.0,
"creator_id": 40000,
"qbo_class": null,
"department_ids": [
],
"approver_ids": [
],
"created_at": "2026-10-21T05:00:16.000-05:00",
"updated_at": "2026-10-21T05:00:16.000-05:00",
"chart_of_account_id": null,
"chart_of_account_name": null,
"chart_of_account": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
}
}
V3 V1
Creates a budget in the company's base currency.
Creates a budget owned by the caller (creator_id = caller) in the
company's base currency (currency_id is ignored). Send dates in the
company date_format; both or neither must be present.
department_ids and approver_ids set the budget's departments and
approvers (ids must belong to the company; an id that does not exist at all
answers 404, an approver who is not a live member of the company -> 400
Approver ids is invalid). When the company setting
make_department_required is on, at least one department is required.
Integration ids: xero_id, zapier_id and quickbooks_id store the
budget's id in that system (a third_party_id_mappings row with service
xero / zapier / quickbooks); third_party_id_mappings_attributes sets
mappings explicitly (service must be one of xero, zapier, quickbooks,
sage50, sage200, sage200_api, mindee, llm, greatplains,
sage_intacct, lightyear).
The JSON body may also be sent without the budget wrapper for plain
attributes (Rails wraps known column names), but array params such as
department_ids are only read inside budget - always send the wrapper.
Optional header app_source records where the budget came from (api when
absent); it must be one of website, mobile_ios, mobile_android,
mobile_other, zapier, api, sage50, sage200, sage200_api,
po_generator, xero_marketplace (and a few internal values) - an unknown
value makes the request fail with 500.
HTTP request
| Version | Request |
|---|---|
| V3 | POST /api/v3/budgets |
| V1 | POST /api/v1/budgets |
Permissions
Company admin or finance role in the company. Approvers and team members get
401 Unauthorized access.
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
budget[ |
string | Yes | 1-255 characters. |
budget[ |
number | Yes | Budget amount, at most 10,000,000,000,000. |
budget[ |
string | No | Max 255 characters; whitespace is stripped. |
budget[ |
string | No | Max 255 characters; whitespace is stripped. |
budget[ |
string | No | Start date in the company date format (e.g. 10/01/2026 for %m/%d/%Y). Required when end_date is given; must not be after end_date. A value in another format is silently ignored. |
budget[ |
string | No | End date (inclusive) in the company date format. Required when start_date is given. |
budget[ |
array[integer] | No | Departments of the company. Empty / omitted = company-wide budget. Required when make_department_required is on. |
budget[ |
array[integer] | No | User ids of live members of the company who approve POs on this budget (classic routing). |
budget[ |
boolean | No | Let team-member-only requesters self-approve POs on this budget within their limit. Default: false. |
budget[ |
integer | No | Chart of account of the company to link. |
budget[ |
string | No | Legacy free-text class label. Does not link a QuickBooks class (quickbooks_class_id is not accepted). |
budget[ |
array[object] | No | Budget custom field values: {custom_field_id, value} (plus id to update an existing value). The custom field must belong to the company. |
budget[ |
string | No | Budget's id in Xero (creates a xero third-party mapping). |
budget[ |
string | No | Id in Zapier (creates a zapier mapping). |
budget[ |
string | No | Id in QuickBooks (creates a quickbooks mapping). |
budget[ |
array[object] | No | Explicit mappings {service, third_party_id, realm_id}. |
budget[ |
integer | No | Ignored on create - the company base currency is always used. |
budget[ |
integer | No | Ignored on create - the caller becomes the creator. |
Response
200 (not 201) with the created Budget object. remaining_amount equals
amount for a new budget. Validation failures answer 400 with
{"error": ["<message>", ...], "status": 400} (an array of messages); a
missing budget object answers 400 Required parameter are missing.
The budget_create_iso_dates example shows a request with ISO dates and
currency_id: 1000 (EUR): the budget is created without dates and in USD.
Side effects
Writes an audit-trail entry; re-indexes global search; touches the linked
departments so offline/mobile clients refetch them. source is set from the
app_source header (api by default).
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Validation error | 400 |
V3, V1 | {"error":["Name can't be blank","Name is too short (minimum is 1 character)"],"status":400} |
| Dates error | 400 |
V3, V1 | {"error":["Start date Must be less than end date","End date Must be greater than start date"],"status":400} |
| Missing end date | 400 |
V3, V1 | {"error":["End date can't be blank"],"status":400} |
| Invalid refs | 400 |
V3, V1 | {"error":["Approver ids is invalid","Chart of account is invalid"],"status":400} |
| Unknown department | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
| Missing wrapper | 400 |
V3, V1 | {"error":"Required parameter are missing.","status":400} |
| Forbidden | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
Update a budget
curl -X PUT "https://app.procurementexpress.com/api/v3/budgets/6" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"budget": {
"amount": 9500,
"end_date": "12/31/2027",
"approver_ids": [
40000
],
"department_ids": [
3,
2
]
}
}'
curl -X PUT "https://app.procurementexpress.com/api/v1/budgets/5" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"budget": {
"amount": 15000,
"end_date": "12/31/2027",
"approver_ids": [
40000
],
"department_ids": [
4,
2
]
}
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v3/budgets/6",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
budget: {
amount: 9500,
end_date: "12/31/2027",
approver_ids: [40000],
department_ids: [3, 2]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v1/budgets/5",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
budget: {
amount: 15000,
end_date: "12/31/2027",
approver_ids: [40000],
department_ids: [4, 2]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 6,
"company_id": 20000,
"name": "Field Marketing FY27",
"amount": 9500.0,
"cost_code": "FMK-27",
"cost_type": "Opex",
"archived": false,
"currency_id": 2000,
"base_amount": 8000.0,
"base_rate": "1.0",
"allow_anyone_to_approve_a_po": false,
"start_date": 1790812800,
"end_date": 1830211200,
"summary": "Field Marketing FY27 : FMK-27 : Opex",
"remaining_amount": 9500.0,
"creator_id": 40000,
"qbo_class": null,
"department_ids": [
3,
2
],
"approver_ids": [
40000
],
"created_at": "2026-10-21T04:59:50.497-05:00",
"updated_at": "2026-10-21T05:02:55.998-05:00",
"chart_of_account_id": 3,
"chart_of_account_name": "Advertising & Marketing",
"chart_of_account": {
"id": 3,
"name": "Advertising & Marketing"
},
"quickbooks_class": {
"id": null,
"name": null
}
}
V3 V1
Updates the given attributes of a budget; omitted attributes are unchanged.
Partial update: only the attributes you send change. In particular a date
that is not sent is kept, and an amount-only update keeps the dates. Sending
start_date / end_date as an empty string clears it (clear both to make
the budget open-ended); a date in the wrong format also clears it, which
usually fails with Start date can't be blank / End date can't be blank.
department_ids / approver_ids replace the whole set. currency_id
can be changed here (unlike create), but base_amount and base_rate keep
their creation values. creator_id may be reassigned to a live member of
the company. archived is not accepted - use V3
archive / unarchive. Archived
budgets can still be updated.
HTTP request
| Version | Request |
|---|---|
| V3 | `PUT |
| V1 | `PUT |
Permissions
Company admin or finance role in the company; otherwise 401
Unauthorized access. Unknown id or another company's budget -> 404.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Budget id. |
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
budget[ |
string | No | 1-255 characters. |
budget[ |
number | No | At most 10,000,000,000,000. base_amount is not recalculated. |
budget[ |
string | No | Max 255 characters. |
budget[ |
string | No | Max 255 characters. |
budget[ |
string | No | Company date format; empty string clears. Keep start <= end and both-or-neither. |
budget[ |
string | No | Company date format; empty string clears. |
budget[ |
array[integer] | No | Replaces the budget's departments. |
budget[ |
array[integer] | No | Replaces the budget's approvers (live members of the company). |
budget[ |
boolean | No | See create. |
budget[ |
integer | No | Chart of account of the company. |
budget[ |
integer | No | Changes the budget currency (base_amount / base_rate are not recalculated). |
budget[ |
integer | No | Reassign the creator; must be a live member of the company (Creator is invalid). |
budget[ |
string | No | Legacy free-text label. |
budget[ |
array[object] | No | {id, custom_field_id, value}. |
budget[ |
string | No | Adds a xero mapping. |
budget[ |
string | No | Adds a zapier mapping. |
budget[ |
string | No | Adds a quickbooks mapping. |
budget[ |
array[object] | No | {service, third_party_id, realm_id}. |
Response
200 with the updated Budget object. Validation failures -> 400 with an
array of messages. In the examples the amount was raised (12,000 -> 15,000
and 8,000 -> 9,500) while base_amount stayed at the creation value.
Side effects
Audit-trail entries for changed fields, departments and approvers; search re-index; linked departments are touched when the department set changes.
V3 and V1 differences
None - identical request and response; PUT and PATCH behave the same in both.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Validation error | 400 |
V3, V1 | {"error":["Name can't be blank","Name is too short (minimum is 1 character)","Start date Must be less than end date","End date Must be greater than start date"],"status":400} |
| Wrong date format | 400 |
V3, V1 | {"error":["Start date can't be blank"],"status":400} |
| Not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
| Forbidden | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
Archive a budget
curl -X POST "https://app.procurementexpress.com/api/v3/budgets/11/archive" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
# V3 only: this endpoint has no V1 equivalent.
curl -X POST "https://app.procurementexpress.com/api/v3/budgets/11/archive" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/budgets/11/archive",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
# V3 only: this endpoint has no V1 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/budgets/11/archive",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
V3 response
200 OK:
{
"id": 11,
"company_id": 20000,
"name": "Customer Events Q4",
"amount": 2500.0,
"cost_code": "EVT-Q4",
"cost_type": "Opex",
"archived": true,
"currency_id": 2000,
"base_amount": 2500.0,
"base_rate": "1.0",
"allow_anyone_to_approve_a_po": false,
"start_date": null,
"end_date": null,
"summary": "Customer Events Q4 : EVT-Q4 : Opex",
"remaining_amount": 2500.0,
"creator_id": 40000,
"qbo_class": null,
"department_ids": [
3
],
"approver_ids": [
],
"created_at": "2026-10-21T05:02:17.243-05:00",
"updated_at": "2026-10-21T05:02:26.400-05:00",
"chart_of_account_id": null,
"chart_of_account_name": null,
"chart_of_account": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
}
}
Example: already archived (V3,
200 OK):
{
"id": 11,
"company_id": 20000,
"name": "Customer Events Q4",
"amount": 2500.0,
"cost_code": "EVT-Q4",
"cost_type": "Opex",
"archived": true,
"currency_id": 2000,
"base_amount": 2500.0,
"base_rate": "1.0",
"allow_anyone_to_approve_a_po": false,
"start_date": null,
"end_date": null,
"summary": "Customer Events Q4 : EVT-Q4 : Opex",
"remaining_amount": 2500.0,
"creator_id": 40000,
"qbo_class": null,
"department_ids": [
3
],
"approver_ids": [
],
"created_at": "2026-10-21T05:02:17.243-05:00",
"updated_at": "2026-10-21T05:02:26.400-05:00",
"chart_of_account_id": null,
"chart_of_account_name": null,
"chart_of_account": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
}
}
V3 V1 —
Archives a budget (idempotent).
Sets archived: true. Archived budgets disappear from the default lists and
can no longer be chosen on new POs; existing POs keep them. Idempotent:
archiving an archived budget succeeds and changes nothing (unlike the web
app's toggle, so a retried request cannot undo itself).
HTTP request
| Version | Request |
|---|---|
| V3 | POST /api/v3/budgets/:id/archive |
Permissions
Company admin or finance role; otherwise 401 Unauthorized access. Unknown
id -> 404.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Budget id. |
Response
200 with the Budget object (archived: true).
Side effects
Audit-trail entry; linked departments are touched (offline clients refetch).
V3 and V1 differences
V3 only. V1 has no archive route: POST /api/v1/budgets/:id/archive answers
404 {"error": "Invalid URL"}, and V1 update does not accept archived.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 404 |
V3 | {"error":"Invalid id passed","status":404} |
| Forbidden | 401 |
V3 | {"error":"Unauthorized access","status":401} |
Unarchive a budget
curl -X POST "https://app.procurementexpress.com/api/v3/budgets/11/unarchive" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
# V3 only: this endpoint has no V1 equivalent.
curl -X POST "https://app.procurementexpress.com/api/v3/budgets/11/unarchive" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/budgets/11/unarchive",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
# V3 only: this endpoint has no V1 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/budgets/11/unarchive",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
V3 response
200 OK:
{
"id": 11,
"company_id": 20000,
"name": "Customer Events Q4",
"amount": 2500.0,
"cost_code": "EVT-Q4",
"cost_type": "Opex",
"archived": false,
"currency_id": 2000,
"base_amount": 2500.0,
"base_rate": "1.0",
"allow_anyone_to_approve_a_po": false,
"start_date": null,
"end_date": null,
"summary": "Customer Events Q4 : EVT-Q4 : Opex",
"remaining_amount": 2500.0,
"creator_id": 40000,
"qbo_class": null,
"department_ids": [
3
],
"approver_ids": [
],
"created_at": "2026-10-21T05:02:17.243-05:00",
"updated_at": "2026-10-21T05:02:27.319-05:00",
"chart_of_account_id": null,
"chart_of_account_name": null,
"chart_of_account": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
}
}
V3 V1 —
Restores an archived budget (idempotent).
Sets archived: false, making the budget selectable again. Idempotent.
HTTP request
| Version | Request |
|---|---|
| V3 | POST /api/v3/budgets/:id/unarchive |
Permissions
Company admin or finance role; otherwise 401 Unauthorized access. Unknown
id -> 404.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Budget id. |
Response
200 with the Budget object (archived: false).
V3 and V1 differences
V3 only. POST /api/v1/budgets/:id/unarchive answers 404 Invalid URL.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 404 |
V3 | {"error":"Invalid id passed","status":404} |
| Forbidden | 401 |
V3 | {"error":"Unauthorized access","status":401} |
Companies
A company is a ProcurementExpress account (tenant). A user can belong to
several companies; each membership carries its own roles (companyadmin,
approver, finance, teammember, ...), approval limit, department
assignments and archived flag. Every API request except
List companies and Create a company
acts inside the company named by the app_company_id header, and roles are
always read from the caller's membership of that company.
The endpoints in this section let an integration:
- discover the companies the caller belongs to (
GET /companies) and pick theidto send asapp_company_id; - read the company's configuration - company settings, custom fields,
supported currencies, feature switches (
GET /companies/details,GET /companies/:id); - list approvers and employees, and the company's chart of accounts;
- invite new users and check how many seats are left;
- create an additional company owned by the caller.
There is no API to update or delete a company: PUT|PATCH|DELETE /companies/:id
are not routed and answer 404 {"error": "Invalid URL"}. Company settings
are changed in the web app.
Header errors shared by every endpoint that needs a company.
| Situation | Status | error |
|---|---|---|
app_company_id header missing |
401 | App company id is nil |
app_company_id is not a company the caller belongs to |
404 | Invalid id passed |
| caller's membership of that company is archived | 401 | the api.account_archived message |
| company locked for non-payment or deactivated | 402 | the api.company_blocked message |
Seats. A company may have at most allowed_users active members
(from its subscription plan; the trial plan allows 5). Active members are
counted across the whole parent/child company structure (only paid-up
child companies count, unless a child has its own subscription).
Pending invitations do not use a seat - only accepted ones do. See
Get the invite limit and
Invite a user.
Endpoints in this section
| Endpoint | Method | Path | V3 | V1 |
|---|---|---|---|---|
| List companies | GET |
/companies |
✓ | ✓ |
| Get the current company | GET |
/companies/details |
✓ | ✓ |
| Get a company | GET |
/companies/:id |
✓ | ✓ |
| Create a company | POST |
/companies |
✓ | ✓ |
| List approvers | GET |
/companies/approvers |
✓ | ✓ |
| List all approvers | GET |
/companies/all_approvers |
✓ | ✓ |
| List employees | GET |
/companies/employees |
✓ | ✓ |
| Get the invite limit | GET |
/companies/invite_limit_left |
✓ | ✓ |
| List the company's chart of accounts | GET |
/companies/chart_of_accounts |
✓ | ✓ |
| Invite a user | POST |
/companies/send_user_invite |
✓ | ✓ |
The company (summary) object
| Field | Type | Description |
|---|---|---|
id |
integer | Company id. Send it as the app_company_id header. |
name |
string | Company name (max 80 characters). |
external_user_id |
string | The caller's external user id in this company (set by an integration such as an ERP mapping), or null. |
membership_archived |
boolean | true when the caller's membership of this company is archived (the company is still listed but every company-scoped request answers 401). null in the create response. |
is_locked |
boolean | true when the company is not paid up (locked for non-payment). Company-scoped requests then answer 402. |
is_removed |
boolean | true when the company has been deactivated. |
approval_limit |
number | The caller's approval limit in this company. Defaults to 1000000.0 when no limit has been set. null in the create response. |
in_trial |
boolean | true while the company is on its free trial plan. |
trial_expired |
boolean | true when the trial has ended without a paid subscription. |
remaining_trial_days |
integer | Days until the current subscription's expiry date (can be negative after expiry). |
roles |
array[string] | The caller's roles in this company, e.g. companyadmin, approver, finance, teammember. null in the create response. |
has_unassigned_budgets |
boolean | true when at least one non-archived budget has no approvers assigned, i.e. purchase orders in this company are not fully auto-routed by budget and the requester picks an approver (see List approvers). |
List companies
curl "https://app.procurementexpress.com/api/v3/companies" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/companies" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/companies",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/companies",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1 (lists shortened to 2 items):
[
{
"id": 20000,
"name": "Acme Corporation",
"external_user_id": null,
"membership_archived": false,
"is_locked": false,
"is_removed": false,
"approval_limit": 1000000.0,
"in_trial": true,
"trial_expired": false,
"remaining_trial_days": 365,
"roles": [
"companyadmin",
"approver",
"finance",
"teammember"
],
"has_unassigned_budgets": true
},
{
"id": 9,
"name": "Globex Subsidiary",
"external_user_id": null,
"membership_archived": false,
"is_locked": false,
"is_removed": false,
"approval_limit": 1000000.0,
"in_trial": true,
"trial_expired": false,
"remaining_trial_days": 365,
"roles": [
"companyadmin",
"approver",
"finance",
"teammember"
],
"has_unassigned_budgets": false
}
]
Example: fields (V3,
200 OK) (lists shortened to 2 items):
[
{
"id": 20000,
"name": "Acme Corporation",
"roles": [
"companyadmin",
"approver",
"finance",
"teammember"
]
},
{
"id": 9,
"name": "Globex Subsidiary",
"roles": [
"companyadmin",
"approver",
"finance",
"teammember"
]
}
]
Example: no company (V3,
200 OK) (lists shortened to 2 items):
[
{
"id": 20000,
"name": "Acme Corporation",
"external_user_id": null,
"membership_archived": false,
"is_locked": false,
"is_removed": false,
"approval_limit": 1000000.0,
"in_trial": true,
"trial_expired": false,
"remaining_trial_days": 365,
"roles": [
"companyadmin",
"approver",
"finance",
"teammember"
],
"has_unassigned_budgets": true
},
{
"id": 9,
"name": "Globex Subsidiary",
"external_user_id": null,
"membership_archived": false,
"is_locked": false,
"is_removed": false,
"approval_limit": 1000000.0,
"in_trial": true,
"trial_expired": false,
"remaining_trial_days": 365,
"roles": [
"companyadmin",
"approver",
"finance",
"teammember"
],
"has_unassigned_budgets": false
}
]
V3 V1
Lists every company the caller is a member of, with the caller's roles and approval limit in each.
Returns all companies the authenticated user has a membership in,
including companies where that membership is archived
(membership_archived: true) and companies that are locked or removed
(is_locked, is_removed). Use it after authenticating to choose the
app_company_id to send on every other request.
The app_company_id header is optional here (the request succeeds
without it). Results are not paginated and not filterable; order is
company creation order.
V3 only - sparse fieldsets. Pass fields with a comma-separated list
of company attributes to receive only those (plus id, always included).
This also skips the per-company work behind the omitted attributes, so it
is noticeably faster for users with many companies. Whitespace around
names is ignored; an unknown name answers 400. Use the comma form -
the array form fields[]=name is rejected as unknown.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/companies |
| V1 | GET /api/v1/companies |
Permissions
Any authenticated user. Only the caller's own memberships are returned.
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
fields |
string | No | V3 only. Comma-separated attribute names to return, e.g. name,roles,in_trial. Allowed: id, name, external_user_id, membership_archived, is_locked, is_removed, approval_limit, in_trial, trial_expired, remaining_trial_days, roles, has_unassigned_budgets. Ignored by V1. |
Response
200 with a bare JSON array of Company (summary) objects -
no envelope, no pagination metadata, in both versions.
V3 and V1 differences
- V3 accepts
fields(sparse fieldsets) and answers400Unknown fields: <names>. Allowed fields are: ...for unknown names. V1 ignoresfieldsand always returns every attribute. - Otherwise identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Fields unknown | 400 |
V3 | {"error":"Unknown fields: colour. Allowed fields are: id, name, external_user_id, membership_archived, is_locked, is_removed, approval_limit, in_trial, trial_expired, remaining_trial_days, roles, has_unassigned_budgets."… |
Get the current company
curl "https://app.procurementexpress.com/api/v3/companies/details" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/companies/details" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/companies/details",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/companies/details",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1 (lists shortened to 2 items):
{
"id": 20000,
"name": "Acme Corporation",
"employees_count": 5,
"default_tax_rate": 0.0,
"prepaid_subscription": false,
"multicompany_pack": false,
"payment_term_ff_enabled": null,
"scan_and_match_ff_enabled": null,
"approval_flow_ff_enabled": true,
"policy_ff_enabled": true,
"sam_gov_enabled": false,
"company_setting": {
"id": 2,
"show_po_item_number": false,
"gross_or_net": "net",
"company_id": 20000,
"fixed_supplier_list": false,
"currency_id": 2000,
"javascript": null,
"departments_alias": null,
"display_remaining_budget_amount": true,
"date_format": "%m/%d/%Y",
"tax_label": "Tax",
"approvals_require_login": false,
"fixed_product_list": false,
"fixed_product_price": false,
"show_tax_column": true,
"show_company_name": true,
"allow_budget_overruns": true,
"budgets_alias": null,
"enable_detailed_allocations_for_purchase_order_line_items": false,
"allocations_enabled": false,
"report_name": null,
"user_can_add_supplier": true,
"user_can_add_product": false,
"po_fields": [
],
"reserve_po_number": false,
"default_template_id": null,
"default_tax_rate_id": null,
"can_invite_user": true,
"show_remaining_budget_to_teammember": false,
"allow_open_draft_access": false,
"link_budgets_to_qbo_accounts": false,
"link_budgets_to_qbo_class": false,
"enable_offline_mode": true,
"is_quickbooks_connected": null,
"is_sage_connected": false,
"show_currency_symbol": true,
"enable_approval_override": true,
"can_upload_invoice": true,
"invoice_reference_mandatory": false,
"manually_approve_by_lowest_available_approvers": false,
"default_payment_term_id": 1,
"qbo_customer_required": false,
"qbo_customer_active": false,
"qbo_auto_select_new_customer": true,
"qbo_class_required": false,
"qbo_class_active": false,
"qbo_class_alias": "",
"qbo_customer_alias": "",
"qbo_description_required": false,
"show_qbo_chart_of_account_number": false,
"is_xero_connected": null,
"allow_members_to_see_budgets": false
},
"supported_currencies": [
{
"id": 1000,
"iso_code": "EUR",
"iso_numeric": "978",
"name": "Euro",
"symbol": "€",
"created_at": "2026-10-21T04:44:44.790-05:00",
"updated_at": "2026-10-21T04:44:44.790-05:00"
},
{
"id": 3000,
"iso_code": "GBP",
"iso_numeric": "826",
"name": "British Pound",
"symbol": "£",
"created_at": "2026-10-21T04:44:44.794-05:00",
"updated_at": "2026-10-21T04:44:44.794-05:00"
}
],
"custom_fields": [
{
"id": 1,
"company_id": 20000,
"name": "Project code",
"field_type": "string",
"active": true,
"required": false,
"options": [
],
"access_level": "everyone",
"position": 0,
"on_line_item": false,
"on_budget": false,
"on_invoice_item": false,
"on_invoice": false,
"on_supplier": false,
"on_rfq": false,
"on_product": false,
"display_on_pdf": true,
"default_value": null,
"created_at": "2026-10-21T04:48:43.644-05:00",
"updated_at": "2026-10-21T04:48:43.644-05:00",
"editable_after_approval": null,
"readonly": false,
"webhook_enabled": false,
"response_populated": false,
"payload_included": false,
"is_auto_populated": false,
"show_on_receiving": false
},
{
"id": 2,
"company_id": 20000,
"name": "Cost center",
"field_type": "select",
"active": true,
"required": false,
"options": [
"CC-100 Engineering",
"CC-200 Marketing",
"CC-300 Operations"
],
"access_level": "everyone",
"position": 1,
"on_line_item": false,
"on_budget": false,
"on_invoice_item": false,
"on_invoice": false,
"on_supplier": false,
"on_rfq": false,
"on_product": false,
"display_on_pdf": true,
"default_value": null,
"created_at": "2026-10-21T04:49:01.957-05:00",
"updated_at": "2026-10-21T04:49:01.957-05:00",
"editable_after_approval": null,
"readonly": false,
"webhook_enabled": false,
"response_populated": false,
"payload_included": false,
"is_auto_populated": false,
"show_on_receiving": false
}
]
}
V3 V1
Returns the full configuration of the company named in the app_company_id header.
Returns the company selected by app_company_id with its settings, the
custom fields the caller may see, its supported currencies and a set of
feature switches. This is the call an integration makes to learn how the
company is configured (gross or net amounts, mandatory QuickBooks
fields, enabled modules, which custom fields to send on a purchase
order) before creating documents.
The body is the same as Get a company for the header company.
How to find active/required QuickBooks class and customer fields
company_setting.qbo_customer_required- the QuickBooks customer field is mandatory on purchase orders and invoices.company_setting.qbo_customer_active- the customer field is shown on the forms. Show it anyway whenqbo_customer_requiredistrue.company_setting.qbo_class_required- the QuickBooks class field is mandatory.company_setting.qbo_class_active- the class field is shown on the forms. Show it anyway whenqbo_class_requiredistrue.
Custom fields are filtered by the caller's role in the company:
companyadmin and finance see every field; approver sees fields with
access_level approvers_and_finance or everyone; other members see
only everyone fields. Budget custom fields (on_budget) and RFQ
custom fields (on_rfq) are never included.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/companies/details |
| V1 | GET /api/v1/companies/details |
Permissions
Any active member of the header company.
Response
200 with a single company detail object (fields below). company_setting
is a nested object, custom_fields and supported_currencies are arrays.
default_payment_term (object) and payment_terms (array of
{id, name, due_days, day_of_month, term_type, archived}) are only
present when payment_term_ff_enabled is true - they are absent from the
example because payment terms are not enabled for that company.
| Field | Type | Description |
|---|---|---|
id |
integer | Company id. |
name |
string | Company name. |
employees_count |
integer | Number of memberships in the company, archived members included. |
default_tax_rate |
number | Legacy company-level default tax rate (percent). See company_setting.default_tax_rate_id for the default tax rate record. |
prepaid_subscription |
boolean | true when the latest subscription is prepaid. Computed for the header company (see findings). |
multicompany_pack |
boolean | true when the subscription includes the multi-company pack (needed to create further companies from the web app). |
payment_term_ff_enabled |
boolean | Payment terms feature is on. null means off. |
scan_and_match_ff_enabled |
boolean | Scan & Match (digital invoice) feature is on. null means off. |
approval_flow_ff_enabled |
boolean | Approval flows feature is on (always true for companies created on or after 2025-07-31). null/false means off. |
policy_ff_enabled |
boolean | Spend policies feature is on. null means off. |
sam_gov_enabled |
boolean | SAM.gov supplier screening is active (policies feature on, company opted in and an API key configured). |
company_setting |
object | Company settings, see the company_setting.* rows. |
company_setting.id |
integer | Settings record id. |
company_setting.company_id |
integer | Company id. |
company_setting.gross_or_net |
string | gross or net - whether line amounts are entered including or excluding tax. |
company_setting.currency_id |
integer | Default currency id. |
company_setting.date_format |
string | strftime pattern used by the company, e.g. %m/%d/%Y. |
company_setting.tax_label |
string | Label used for tax (e.g. Tax, VAT, GST). |
company_setting.departments_alias |
string | Custom label for departments, or null. |
company_setting.budgets_alias |
string | Custom label for budgets, or null. |
company_setting.report_name |
string | Custom report name, or null. |
company_setting.javascript |
string | Custom JavaScript configured for the web app, or null. |
company_setting.po_fields |
array[string] | Purchase order fields chosen for the PO list/exports. |
company_setting.show_po_item_number |
boolean | Show item numbers on PO lines. |
company_setting.fixed_supplier_list |
boolean | Requesters must pick an existing supplier. |
company_setting.user_can_add_supplier |
boolean | Non-admin users may add suppliers. |
company_setting.fixed_product_list |
boolean | PO lines must use catalogue products. |
company_setting.fixed_product_price |
boolean | Product prices cannot be changed on PO lines. |
company_setting.user_can_add_product |
boolean | Non-admin users may add products. |
company_setting.show_tax_column |
boolean | Show the tax column on POs. |
company_setting.show_company_name |
boolean | Show the company name on PO PDFs. |
company_setting.show_currency_symbol |
boolean | Show currency symbols on amounts. |
company_setting.display_remaining_budget_amount |
boolean | Show remaining budget amounts. |
company_setting.show_remaining_budget_to_teammember |
boolean | Team members may see remaining budget amounts. |
company_setting.allow_members_to_see_budgets |
boolean | Team members may see budgets. |
company_setting.allow_budget_overruns |
boolean | POs may exceed the remaining budget. |
company_setting.allocations_enabled |
boolean | Budget allocations are enabled. |
company_setting.enable_detailed_allocations_for_purchase_order_line_items |
boolean | Per-line budget allocations are enabled. |
company_setting.approvals_require_login |
boolean | Approvers must log in to approve (email one-click approval disabled). |
company_setting.manually_approve_by_lowest_available_approvers |
boolean | Manual approver selection is restricted to the lowest sufficient approvers. |
company_setting.enable_approval_override |
boolean | Approval override is allowed. |
company_setting.reserve_po_number |
boolean | PO numbers are reserved when a draft is created. |
company_setting.allow_open_draft_access |
boolean | Drafts are visible to other users. |
company_setting.enable_offline_mode |
boolean | Mobile offline mode is enabled. |
company_setting.default_template_id |
integer | Default PDF template id, or null. |
company_setting.default_tax_rate_id |
integer | Default tax rate id, or null. |
company_setting.default_payment_term_id |
integer | Default payment term id, or null. |
company_setting.invoice_reference_mandatory |
boolean | An invoice reference is required on invoices. |
company_setting.can_invite_user |
boolean | Computed: true when the caller is a companyadmin (read from the caller's primary company - see findings). Does not consider seats; use Get the invite limit. |
company_setting.can_upload_invoice |
boolean | Computed: the caller may upload invoices (per-role invoice-upload settings or role permissions). |
company_setting.is_quickbooks_connected |
boolean | Computed: an active QuickBooks Online connection exists. null when none. |
company_setting.is_xero_connected |
boolean | Computed: an active Xero connection exists. null when none. |
company_setting.is_sage_connected |
boolean | Computed: a Sage 50 or Sage 200 integration is connected. |
company_setting.link_budgets_to_qbo_accounts |
boolean | Budgets are linked to QuickBooks accounts. |
company_setting.link_budgets_to_qbo_class |
boolean | Budgets are linked to QuickBooks classes. |
company_setting.qbo_customer_required |
boolean | QuickBooks customer is mandatory on POs/invoices. |
company_setting.qbo_customer_active |
boolean | QuickBooks customer field is shown. |
company_setting.qbo_auto_select_new_customer |
boolean | A newly created QuickBooks customer is selected automatically. |
company_setting.qbo_class_required |
boolean | QuickBooks class is mandatory on POs/invoices. |
company_setting.qbo_class_active |
boolean | QuickBooks class field is shown. |
company_setting.qbo_class_alias |
string | Custom label for the QuickBooks class field. |
company_setting.qbo_customer_alias |
string | Custom label for the QuickBooks customer field. |
company_setting.qbo_description_required |
boolean | A description is mandatory for QuickBooks sync. |
company_setting.show_qbo_chart_of_account_number |
boolean | Show account numbers next to chart of account names. |
supported_currencies[ |
object | Currencies enabled for the company: id, iso_code, iso_numeric, name, symbol, created_at, updated_at (ISO-8601). Always includes the default currency. |
custom_fields[ |
object | Custom fields visible to the caller (see description): id, company_id, name, field_type (long_list fields are reported as select with readonly: true), active, required, options, access_level, position, on_line_item, on_budget, on_invoice_item, on_invoice, on_supplier, on_rfq, on_product, display_on_pdf, default_value, created_at, updated_at, editable_after_approval, readonly, webhook_enabled, response_populated, payload_included, is_auto_populated, show_on_receiving. |
default_payment_term |
object | Only when payment_term_ff_enabled: the default payment term {id, name, due_days, day_of_month, term_type, archived}. |
payment_terms[ |
object | Only when payment_term_ff_enabled: all payment terms, same shape as default_payment_term. |
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| No company | 401 |
V3, V1 | {"error":"App company id is nil","status":401} |
| Invalid company | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
Get a company
curl "https://app.procurementexpress.com/api/v3/companies/20000" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/companies/20000" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/companies/20000",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/companies/20000",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1 (lists shortened to 2 items):
{
"id": 20000,
"name": "Acme Corporation",
"employees_count": 5,
"default_tax_rate": 0.0,
"prepaid_subscription": false,
"multicompany_pack": false,
"payment_term_ff_enabled": null,
"scan_and_match_ff_enabled": null,
"approval_flow_ff_enabled": true,
"policy_ff_enabled": true,
"sam_gov_enabled": false,
"company_setting": {
"id": 2,
"show_po_item_number": false,
"gross_or_net": "net",
"company_id": 20000,
"fixed_supplier_list": false,
"currency_id": 2000,
"javascript": null,
"departments_alias": null,
"display_remaining_budget_amount": true,
"date_format": "%m/%d/%Y",
"tax_label": "Tax",
"approvals_require_login": false,
"fixed_product_list": false,
"fixed_product_price": false,
"show_tax_column": true,
"show_company_name": true,
"allow_budget_overruns": true,
"budgets_alias": null,
"enable_detailed_allocations_for_purchase_order_line_items": false,
"allocations_enabled": false,
"report_name": null,
"user_can_add_supplier": true,
"user_can_add_product": false,
"po_fields": [
],
"reserve_po_number": false,
"default_template_id": null,
"default_tax_rate_id": null,
"can_invite_user": true,
"show_remaining_budget_to_teammember": false,
"allow_open_draft_access": false,
"link_budgets_to_qbo_accounts": false,
"link_budgets_to_qbo_class": false,
"enable_offline_mode": true,
"is_quickbooks_connected": null,
"is_sage_connected": false,
"show_currency_symbol": true,
"enable_approval_override": true,
"can_upload_invoice": true,
"invoice_reference_mandatory": false,
"manually_approve_by_lowest_available_approvers": false,
"default_payment_term_id": 1,
"qbo_customer_required": false,
"qbo_customer_active": false,
"qbo_auto_select_new_customer": true,
"qbo_class_required": false,
"qbo_class_active": false,
"qbo_class_alias": "",
"qbo_customer_alias": "",
"qbo_description_required": false,
"show_qbo_chart_of_account_number": false,
"is_xero_connected": null,
"allow_members_to_see_budgets": false
},
"supported_currencies": [
{
"id": 1000,
"iso_code": "EUR",
"iso_numeric": "978",
"name": "Euro",
"symbol": "€",
"created_at": "2026-10-21T04:44:44.790-05:00",
"updated_at": "2026-10-21T04:44:44.790-05:00"
},
{
"id": 3000,
"iso_code": "GBP",
"iso_numeric": "826",
"name": "British Pound",
"symbol": "£",
"created_at": "2026-10-21T04:44:44.794-05:00",
"updated_at": "2026-10-21T04:44:44.794-05:00"
}
],
"custom_fields": [
{
"id": 1,
"company_id": 20000,
"name": "Project code",
"field_type": "string",
"active": true,
"required": false,
"options": [
],
"access_level": "everyone",
"position": 0,
"on_line_item": false,
"on_budget": false,
"on_invoice_item": false,
"on_invoice": false,
"on_supplier": false,
"on_rfq": false,
"on_product": false,
"display_on_pdf": true,
"default_value": null,
"created_at": "2026-10-21T04:48:43.644-05:00",
"updated_at": "2026-10-21T04:48:43.644-05:00",
"editable_after_approval": null,
"readonly": false,
"webhook_enabled": false,
"response_populated": false,
"payload_included": false,
"is_auto_populated": false,
"show_on_receiving": false
},
{
"id": 2,
"company_id": 20000,
"name": "Cost center",
"field_type": "select",
"active": true,
"required": false,
"options": [
"CC-100 Engineering",
"CC-200 Marketing",
"CC-300 Operations"
],
"access_level": "everyone",
"position": 1,
"on_line_item": false,
"on_budget": false,
"on_invoice_item": false,
"on_invoice": false,
"on_supplier": false,
"on_rfq": false,
"on_product": false,
"display_on_pdf": true,
"default_value": null,
"created_at": "2026-10-21T04:49:01.957-05:00",
"updated_at": "2026-10-21T04:49:01.957-05:00",
"editable_after_approval": null,
"readonly": false,
"webhook_enabled": false,
"response_populated": false,
"payload_included": false,
"is_auto_populated": false,
"show_on_receiving": false
}
]
}
Example: other (V3,
200 OK):
{
"id": 9,
"name": "Globex Subsidiary",
"employees_count": 1,
"default_tax_rate": 0.0,
"prepaid_subscription": false,
"multicompany_pack": false,
"payment_term_ff_enabled": null,
"scan_and_match_ff_enabled": null,
"approval_flow_ff_enabled": true,
"policy_ff_enabled": null,
"sam_gov_enabled": false,
"company_setting": {
"id": 10,
"show_po_item_number": false,
"gross_or_net": "net",
"company_id": 9,
"fixed_supplier_list": false,
"currency_id": 2000,
"javascript": null,
"departments_alias": null,
"display_remaining_budget_amount": true,
"date_format": "%Y-%m-%d",
"tax_label": "VAT",
"approvals_require_login": false,
"fixed_product_list": false,
"fixed_product_price": false,
"show_tax_column": true,
"show_company_name": true,
"allow_budget_overruns": false,
"budgets_alias": null,
"enable_detailed_allocations_for_purchase_order_line_items": false,
"allocations_enabled": false,
"report_name": null,
"user_can_add_supplier": true,
"user_can_add_product": false,
"po_fields": [
],
"reserve_po_number": false,
"default_template_id": 10,
"default_tax_rate_id": 22,
"can_invite_user": true,
"show_remaining_budget_to_teammember": false,
"allow_open_draft_access": false,
"link_budgets_to_qbo_accounts": false,
"link_budgets_to_qbo_class": false,
"enable_offline_mode": false,
"is_quickbooks_connected": null,
"is_sage_connected": false,
"show_currency_symbol": true,
"enable_approval_override": true,
"can_upload_invoice": true,
"invoice_reference_mandatory": true,
"manually_approve_by_lowest_available_approvers": false,
"default_payment_term_id": 37,
"qbo_customer_required": false,
"qbo_customer_active": false,
"qbo_auto_select_new_customer": true,
"qbo_class_required": false,
"qbo_class_active": false,
"qbo_class_alias": "",
"qbo_customer_alias": "",
"qbo_description_required": false,
"show_qbo_chart_of_account_number": false,
"is_xero_connected": null,
"allow_members_to_see_budgets": false
},
"supported_currencies": [
{
"id": 1000,
"iso_code": "EUR",
"iso_numeric": "978",
"name": "Euro",
"symbol": "€",
"created_at": "2026-10-21T04:44:44.790-05:00",
"updated_at": "2026-10-21T04:44:44.790-05:00"
},
{
"id": 2000,
"iso_code": "USD",
"iso_numeric": "840",
"name": "United States Dollar",
"symbol": "$",
"created_at": "2026-10-21T04:44:44.793-05:00",
"updated_at": "2026-10-21T04:44:44.793-05:00"
}
],
"custom_fields": [
]
}
V3 V1
Returns the full configuration of one of the caller's companies by id.
Same body as Get the current company, for
any company in which the caller has an active (non-archived)
membership and which is not deactivated - it need not be the
app_company_id company. A valid app_company_id header is still
required.
Role-dependent parts (custom field visibility) are evaluated in the requested company.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/companies/:id |
| V1 | GET /api/v1/companies/:id |
Permissions
Any user with an active membership of company :id. Any other id
(unknown, a company you are not a member of, an archived membership or
a deactivated company) answers 404 {"error": "Invalid id passed"}.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Company id (from List companies). |
Response
200 with the company detail object described under
Get the current company.
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
| Not member | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
Create a company
curl -X POST "https://app.procurementexpress.com/api/v3/companies" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"company": {
"name": "Globex Subsidiary",
"department": "Finance",
"gross_or_net_config": "net",
"default_tax_rate": 0,
"company_setting_attributes": {
"currency_id": 2000,
"date_format": "%Y-%m-%d",
"tax_label": "VAT"
},
"supported_currency_ids": [
2000,
1000
]
}
}'
curl -X POST "https://app.procurementexpress.com/api/v1/companies" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"company": {
"name": "Globex Subsidiary",
"department": "Finance",
"gross_or_net_config": "net",
"default_tax_rate": 0,
"company_setting_attributes": {
"currency_id": 2000,
"date_format": "%Y-%m-%d",
"tax_label": "VAT"
},
"supported_currency_ids": [
2000,
1000
]
}
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/companies",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
company: {
name: "Globex Subsidiary",
department: "Finance",
gross_or_net_config: "net",
default_tax_rate: 0,
company_setting_attributes: {
currency_id: 2000,
date_format: "%Y-%m-%d",
tax_label: "VAT"
},
supported_currency_ids: [2000, 1000]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/companies",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
company: {
name: "Globex Subsidiary",
department: "Finance",
gross_or_net_config: "net",
default_tax_rate: 0,
company_setting_attributes: {
currency_id: 2000,
date_format: "%Y-%m-%d",
tax_label: "VAT"
},
supported_currency_ids: [2000, 1000]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 10,
"name": "Globex Subsidiary",
"external_user_id": null,
"membership_archived": null,
"is_locked": false,
"is_removed": false,
"approval_limit": null,
"in_trial": true,
"trial_expired": false,
"remaining_trial_days": 365,
"roles": null,
"has_unassigned_budgets": false
}
V3 V1
Creates a new company owned by the caller, on a free trial, with the caller as its administrator.
Creates a company and makes the caller its creator and first member with
the roles companyadmin, approver, finance and teammember. The
app_company_id header is not required.
What is created with the company:
- company settings from
company_setting_attributes- a currency is mandatory. Some settings are always forced on create:allow_budget_overruns= false (the request value is ignored), approval required after PO edit, invoice upload off, supplier rating on, PDF attachment in supplier emails on, auto-approval of own requests off; - a budget named Sample Budget (10,000,000 in the company currency),
in addition to any
budgets_attributesyou send; - a trial subscription on the trial plan (5 seats);
- tax rates
NoneandTax exempt(Nonebecomes the default whendefault_tax_rateis 0), default payment terms and default reports; - the default currency is added to
supported_currency_idsautomatically.
Department. When the caller originally signed up through the website
(the usual case), company[department] is mandatory: a department with
that name is created, linked to the Sample Budget, the caller is made the
Sample Budget's approver and is assigned to the department. For users
created another way (mobile app, integrations) company[department] is
ignored and no department is created.
Side effects. A Slack notification to the sales team is queued. The
caller's primary company (employer_id, used e.g. by GET /currentuser)
is switched to the new company. If the caller's profile country requires
payment before a trial, the company is created locked (is_locked: true)
and every request in it answers 402 until a subscription is bought.
The optional app_source request header records where the company came
from (api by default; one of website, mobile_ios,
mobile_android, mobile_other, mobile_android_offline,
mobile_ios_offline, push_cart, zapier, api, sage50, sage200,
po_generator, xero_marketplace, sage200_api).
The call is not idempotent: repeating it creates another company with the same name.
HTTP request
| Version | Request |
|---|---|
| V3 | POST /api/v3/companies |
| V1 | POST /api/v1/companies |
Permissions
Any authenticated user.
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
company[ |
string | Yes | Company name, max 80 characters. |
company[ |
string | No | Name of the first department. Required when the caller signed up on the website (Department can't be blank otherwise); ignored for other users. |
company[ |
integer | Yes | Default currency id. Without it: Company setting currency can't be blank and Budgets currency can't be blank. |
company[ |
string | No | gross or net (or 0 / 1). Defaults to gross. Any other value answers 500. |
company[ |
number | No | Legacy default tax rate percent, greater than -1 and less than 101. Defaults to 0. |
company[ |
array[integer] | No | Currency ids to enable. The default currency is added automatically. |
company[ |
string | No | Additional budget to create (the Sample Budget is always created too). |
company[ |
number | No | Additional budget amount. |
company[ |
integer | No | Ignored - the caller is always the creator. |
company[ |
integer | No | Ignored - the company currency is used. Sending any budget also forces tax_label to Tax. |
company[ |
string | No | strftime pattern, e.g. %Y-%m-%d. |
company[ |
string | No | Tax label, e.g. VAT (overridden to Tax when budgets_attributes are sent). |
company[ |
string | No | Custom label for departments. |
company[ |
string | No | Custom label for budgets. |
company[ |
integer | No | PDF font size. |
company[ |
integer | No | Custom field whose value is appended to PO numbers. |
company[ |
boolean | No | Approvers must log in to approve. |
company[ |
boolean | No | Show item numbers on PO lines. |
company[ |
boolean | No | Requesters must pick an existing supplier. |
company[ |
boolean | No | Show remaining budget amounts. |
company[ |
boolean | No | Print the budget on PO PDFs. |
company[ |
string | No | PDF background. |
company[ |
string | No | PDF footer text. |
company[ |
string | No | Logo. |
company[ |
string | No | Logo alignment on PDFs. |
company[ |
string | No | Logo MIME type. |
company[ |
boolean | No | PO lines must use catalogue products. |
company[ |
boolean | No | Show the tax column. |
company[ |
boolean | No | Show the company name on PDFs. |
company[ |
boolean | No | Show the approver name on PDFs. |
company[ |
boolean | No | Non-admin users may add suppliers. |
company[ |
boolean | No | Restrict manual approver choice to the lowest sufficient approvers. |
company[ |
boolean | No | Users see POs of their departments. |
company[ |
boolean | No | Ignored on create - always set to false. |
company[ |
boolean | No | Expire idle web sessions. |
company[ |
boolean | No | Enable budget allocations. |
company[ |
boolean | No | Link budgets to QuickBooks classes. |
company[ |
array[string] | No | PO list/export fields. |
company[ |
array[string] | No | Supplier fields that must be filled in. |
company[ |
integer | No | Permitted but meaningless on create; do not send. |
company[ |
array[string] | No | Permitted by the controller but not a Company attribute - sending it answers 500. Do not send. |
Response
200 (not 201) with the new company as a
Company (summary) object. Because the response is not
rendered for a particular user, membership_archived, approval_limit
and roles are null here; call List companies to
see the caller's roles in the new company.
Validation failures answer 400 with error as an array of
messages, e.g. ["Department can't be blank"],
["Name can't be blank"], ["Default tax rate must be less than 101"].
A body without a company object answers 400
Required parameter are missing.
Side effects
Queues a Slack message to the sales channel; switches the caller's
primary company (employer_id) to the new company; creates a trial
subscription, default tax rates, payment terms, reports and the Sample
Budget (and, for website-signup users, the first department).
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Missing department | 400 |
V3, V1 | {"error":["Department can't be blank"],"status":400} |
| Invalid | 400 |
V3, V1 | {"error":["Budgets currency can't be blank","Company setting currency can't be blank","Name can't be blank"],"status":400} |
| Missing params | 400 |
V3, V1 | {"error":"Required parameter are missing.","status":400} |
List approvers
curl "https://app.procurementexpress.com/api/v3/companies/approvers" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/companies/approvers" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/companies/approvers",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/companies/approvers",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
[
{
"id": 50000,
"email": "priya.patel@example.com",
"name": "Priya Patel",
"approver_name": "Priya Patel",
"phone_number": "+1 555 0150",
"setup_incomplete": false,
"employer_id": 20000,
"approval_limit": 50000.0,
"companies": [
{
"id": 20000,
"approval_limit": 50000.0
}
],
"roles": [
"approver",
"teammember"
],
"external_user_id": null
},
{
"id": 40000,
"email": "jane.doe@example.com",
"name": "Jane Doe",
"approver_name": "Jane Doe",
"phone_number": "+1 555 0140",
"setup_incomplete": false,
"employer_id": 20000,
"approval_limit": 1000000.0,
"companies": [
{
"id": 20000,
"approval_limit": 1000000.0
}
],
"roles": [
"companyadmin",
"approver",
"finance",
"teammember"
],
"external_user_id": null
}
]
Example: department (V3,
200 OK):
[
{
"id": 40000,
"email": "jane.doe@example.com",
"name": "Jane Doe",
"approver_name": "Jane Doe",
"phone_number": "+1 555 0140",
"setup_incomplete": false,
"employer_id": 20000,
"approval_limit": 1000000.0,
"companies": [
{
"id": 20000,
"approval_limit": 1000000.0
}
],
"roles": [
"companyadmin",
"approver",
"finance",
"teammember"
],
"external_user_id": null
}
]
Example: as approver (V3,
200 OK):
[
{
"id": 40000,
"email": "jane.doe@example.com",
"name": "Jane Doe",
"approver_name": "Jane Doe",
"phone_number": "+1 555 0140",
"setup_incomplete": false,
"employer_id": 20000,
"approval_limit": 1000000.0,
"companies": [
{
"id": 20000,
"approval_limit": 1000000.0
}
],
"roles": [
"companyadmin",
"approver",
"finance",
"teammember"
],
"external_user_id": null
}
]
V3 V1
Lists the approvers a requester may choose for a new purchase order, optionally limited to a department.
Returns the users a requester can pick as the approver of a new purchase order in the header company, in ascending order of approval limit.
Empty when routing is automatic. The list is [] when every
non-archived budget of the company has approvers assigned
(has_unassigned_budgets: false in List companies) -
purchase orders are then routed by budget - and also whenever a
published, active purchase-order approval flow exists.
Department filter. With department_id, only approvers explicitly
assigned to that department are considered (approvers with no
department assignments are not included). An unknown department_id
answers 404.
Holiday mode. Approvers who are on holiday (out of office) and have no delegate are left out.
Approving your own request. Unless the company setting Allow approvers to approve their own request is on, the caller is removed from the list - except when the caller is the only approver or the one with the highest approval limit (so a PO above everyone else's limit can still be routed).
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/companies/approvers |
| V1 | GET /api/v1/companies/approvers |
Permissions
Any active member of the header company.
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
department_id |
integer | No | Only approvers assigned to this department of the header company. Unknown id -> 404 Invalid id passed. |
Response
200 with a bare array of user objects (not paginated). For each user:
| Field | Type | Description |
|---|---|---|
id |
integer | User id. |
email |
string | Email address. |
name |
string | Full name. |
approver_name |
string | Name to display as approver: <delegate> (on behalf of <name>) while the user is on holiday with a delegate, otherwise name. |
phone_number |
string | Phone number, or null. |
setup_incomplete |
boolean | true when the user has no primary company. |
employer_id |
integer | The user's primary company id (not necessarily the header company). |
approval_limit |
number | Approval limit in the header company (1000000.0 when none set). |
companies[ |
object | {id, approval_limit} for every company the user belongs to. |
roles |
array[string] | Roles in the header company. |
external_user_id |
string | External user id, or null. |
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Department not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
List all approvers
curl "https://app.procurementexpress.com/api/v3/companies/all_approvers" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/companies/all_approvers" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/companies/all_approvers",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/companies/all_approvers",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
[
{
"id": 40000,
"email": "jane.doe@example.com",
"name": "Jane Doe",
"approver_name": "Jane Doe",
"phone_number": "+1 555 0140",
"setup_incomplete": false,
"employer_id": 20000,
"approval_limit": 1000000.0,
"companies": [
{
"id": 20000,
"approval_limit": 1000000.0
}
],
"roles": [
"companyadmin",
"approver",
"finance",
"teammember"
],
"external_user_id": null
},
{
"id": 50000,
"email": "priya.patel@example.com",
"name": "Priya Patel",
"approver_name": "Priya Patel",
"phone_number": "+1 555 0150",
"setup_incomplete": false,
"employer_id": 20000,
"approval_limit": 50000.0,
"companies": [
{
"id": 20000,
"approval_limit": 50000.0
}
],
"roles": [
"approver",
"teammember"
],
"external_user_id": null
}
]
V3 V1
Lists every active member with the approver role, regardless of routing.
Returns all active (non-archived) members of the header company who
have the approver role, ignoring budget routing, approval flows,
departments, holiday mode and self-approval rules. Use it to populate
approver pickers outside the PO-creation flow (e.g. reports, filters).
Not paginated, not filterable, no guaranteed order.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/companies/all_approvers |
| V1 | GET /api/v1/companies/all_approvers |
Permissions
Any active member of the header company.
Response
200 with a bare array of user objects (same shape as
List approvers).
V3 and V1 differences
None - identical request and response.
List employees
curl "https://app.procurementexpress.com/api/v3/companies/employees" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/companies/employees" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/companies/employees",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/companies/employees",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1 (lists shortened to 2 items):
[
{
"id": 40000,
"email": "jane.doe@example.com",
"name": "Jane Doe",
"approver_name": "Jane Doe",
"phone_number": "+1 555 0140",
"setup_incomplete": false,
"employer_id": 20000,
"approval_limit": 1000000.0,
"companies": [
{
"id": 20000,
"approval_limit": 1000000.0
}
],
"roles": [
"companyadmin",
"approver",
"finance",
"teammember"
],
"external_user_id": null
},
{
"id": 41000,
"email": "sam.smith@example.com",
"name": "Sam Smith",
"approver_name": "Sam Smith",
"phone_number": "+1 555 0141",
"setup_incomplete": false,
"employer_id": 20000,
"approval_limit": 0.0,
"companies": [
{
"id": 20000,
"approval_limit": 0.0
}
],
"roles": [
"teammember"
],
"external_user_id": null
}
]
Example: non admin (V3,
200 OK):
[
]
V3 V1
Lists every active member of the company (company administrators only).
Returns all active (non-archived) members of the header company with their roles and approval limits. Not paginated, not filterable.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/companies/employees |
| V1 | GET /api/v1/companies/employees |
Permissions
Only companyadmin. Any other caller gets 200 with an empty array
[] (not an error).
Response
200 with a bare array of user objects (same shape as
List approvers); [] for non-admins.
V3 and V1 differences
None - identical request and response.
Get the invite limit
curl "https://app.procurementexpress.com/api/v3/companies/invite_limit_left" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/companies/invite_limit_left" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/companies/invite_limit_left",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/companies/invite_limit_left",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"invite_limit_left": 1,
"active_users": 4,
"allowed_users": 5
}
V3 V1
Returns how many more users the company's plan allows.
Reports the seat usage of the header company. active_users counts
distinct users with a non-archived membership across the company's
parent/child structure; allowed_users comes from the subscription
(the trial plan allows 5). Pending invitations are not counted.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/companies/invite_limit_left |
| V1 | GET /api/v1/companies/invite_limit_left |
Permissions
Any active member of the header company.
Response
200 with an object (no envelope).
| Field | Type | Description |
|---|---|---|
invite_limit_left |
integer | allowed_users - active_users, never below 0. |
active_users |
integer | Distinct active users counted against the plan. |
allowed_users |
integer | Seats allowed by the subscription. |
V3 and V1 differences
None - identical request and response.
List the company's chart of accounts
curl "https://app.procurementexpress.com/api/v3/companies/chart_of_accounts" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/companies/chart_of_accounts" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/companies/chart_of_accounts",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/companies/chart_of_accounts",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1 (lists shortened to 2 items):
[
{
"id": 1,
"name": "Office Expenses",
"classification": "Expense",
"account_type": "Expense",
"currency_code": "USD",
"account_number": "6000",
"display_name": "Office Expenses - Expense",
"archived": false,
"company_id": 20000
},
{
"id": 2,
"name": "Computer Equipment",
"classification": "Expense",
"account_type": "Expense",
"currency_code": "USD",
"account_number": "6100",
"display_name": "Computer Equipment - Expense",
"archived": false,
"company_id": 20000
}
]
V3 V1
Returns every chart-of-accounts entry of the company, archived ones included.
Returns all chart-of-accounts entries (imported from the accounting
integration or created manually) of the header company, including
archived entries (archived: true) and without the per-user
access-rights filtering. Not paginated or filterable. For a filterable
list use the Chart of accounts endpoints.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/companies/chart_of_accounts |
| V1 | GET /api/v1/companies/chart_of_accounts |
Permissions
Any active member of the header company.
Response
200 with a bare array of chart-of-account objects: id, name,
classification, account_type, currency_code, account_number,
display_name, archived, company_id.
V3 and V1 differences
None - identical request and response.
Invite a user
curl -X POST "https://app.procurementexpress.com/api/v3/companies/send_user_invite" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"invite_user": {
"email": "new.hire+v3@example.com",
"name": "Nora Hire",
"roles": [
"approver",
"teammember"
],
"approval_limit": 2500,
"department_ids": [
2,
3
]
}
}'
curl -X POST "https://app.procurementexpress.com/api/v1/companies/send_user_invite" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"invite_user": {
"email": "new.hire+v1@example.com",
"name": "Nora Hire",
"roles": [
"approver",
"teammember"
],
"approval_limit": 2500,
"department_ids": [
2,
3
]
}
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/companies/send_user_invite",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
invite_user: {
email: "new.hire+v3@example.com",
name: "Nora Hire",
roles: ["approver", "teammember"],
approval_limit: 2500,
department_ids: [2, 3]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/companies/send_user_invite",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
invite_user: {
email: "new.hire+v1@example.com",
name: "Nora Hire",
roles: ["approver", "teammember"],
approval_limit: 2500,
department_ids: [2, 3]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"status": true
}
V3 V1
Invites a person by email to join the company with the given roles, departments and approval limit.
Creates a pending invitation in the header company and emails the invitee (a "join company" email if they already have a ProcurementExpress account, otherwise a sign-up invitation). The user only becomes a member - and only uses a seat - once they accept.
rolesmay containcompanyadmin,approver,finance,teammember,rfqcaseofficer,batch_payment_admin,batch_payment_approver,supplier_admin,supplier_approver.sysadminand unknown names are dropped; if nothing valid remains the invitee getsteammember.department_idsthat are not departments of the header company are dropped silently.approval_limitdefaults to 0.- The email is stored lower-case.
Checks run in this order: permission, seats, already a member, already invited (a pending or accepted invitation for the same email in this company), then field validation.
Seat limit. The company must have a free seat
(invite_limit_left > 0). In V1 a company on its free trial may invite
beyond the limit; in V3 the seat check also applies during the trial.
When there is no free seat: V1 answers 400 You need to upgrade plan;
V3 answers 400 You need to upgrade plan during a trial and 401
Unauthorized access otherwise.
HTTP request
| Version | Request |
|---|---|
| V3 | POST /api/v3/companies/send_user_invite |
| V1 | POST /api/v1/companies/send_user_invite |
Permissions
Only companyadmin of the header company. Others get 401
{"error": "Unauthorized access"}.
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
invite_user[ |
string | Yes | Invitee email; must be a valid address. |
invite_user[ |
string | Yes | Invitee full name - at least two words (Please provide a full name otherwise). |
invite_user[ |
array[string] | No | Roles to grant on acceptance (see description). Default ["teammember"]. |
invite_user[ |
array[integer] | No | Departments of the header company to assign on acceptance. Other ids are ignored. |
invite_user[ |
number | No | Approval limit to set on acceptance, >= 0. Default 0. |
Response
200 {"status": true}. The invitation itself is not returned.
| Error | Status | error |
|---|---|---|
caller is not companyadmin |
401 | Unauthorized access |
| no free seat | 400 (V3 outside trial: 401) | You need to upgrade plan |
| email belongs to a member of the company | 400 | Email already exist |
| a pending/accepted invitation exists for the email | 400 | User already invited by this company. |
| validation failed | 400 | array, e.g. ["Email is invalid", "Approval limit must be greater than or equal to 0", "Email does not appear to be a valid e-mail address", "Name Please provide a full name"] |
no invite_user object |
400 | Required parameter are missing. |
Side effects
Sends the invitation email (queued). Clears the company's cached invitation counters.
V3 and V1 differences
Same request and success response. Seat-limit handling differs (code
read): V1 refuses only when the company is out of seats and not in
trial (400 You need to upgrade plan); V3 runs the policy check (which
exempts trials) and then a strict seat check, so a trial company at its
limit gets 400 You need to upgrade plan from V3 but is allowed by V1,
and a paid company at its limit gets 401 Unauthorized access from V3
but 400 You need to upgrade plan from V1. All other errors verified
identical.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Already invited | 400 |
V3, V1 | {"error":"User already invited by this company.","status":400} |
| Existing employee | 400 |
V3, V1 | {"error":"Email already exist","status":400} |
| Invalid | 400 |
V3, V1 | {"error":["Email is invalid","Approval limit must be greater than or equal to 0","Email does not appear to be a valid e-mail address","Name Please provide a full name"],"status":400} |
| Not admin | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
| Missing params | 400 |
V3, V1 | {"error":"Required parameter are missing.","status":400} |
Compliance
Compliance lets a company write its procurement rules down as policies, scan purchase orders against them with an AI reviewer, resolve whatever the scan finds (by fixing the order or by justifying the finding), and hand auditors an evidence pack ZIP for each purchase order.
Compliance scans run on purchase orders only. Invoices are not scanned.
Turning it on
| Requirement | What it controls |
|---|---|
Feature flag enable_policies (per company, switched on by ProcurementExpress support) |
Required by every /policies endpoint. Without it they answer 403 {"error": ["Policies feature is not enabled"], "status": 403}. The flag also gates the SAM.gov exclusion part of a scan. GET /companies/details shows it as policy_ff_enabled. |
Company setting sam_gov_opted_in plus a SAM.gov API key |
Optional. Adds a supplier debarment check against SAM.gov to every scan (sam_gov_enabled in GET /companies/details). |
No subscription plan check exists in the code. /policy_templates, /compliance/* and the
evidence pack routes do not check the flag, so they answer even when it is off.
Without active policies, though, a scan has nothing to check.
Who can do what
| Action | Roles |
|---|---|
Manage policies (/policies) |
companyadmin, finance in the company named by app_company_id. Others get 401 Unauthorized access. |
| List policy templates | Any member |
Scan a purchase order (/compliance/check, /compliance/bulk_check) |
Anyone who can collaborate on that order: its creator, a user mentioned on it, or companyadmin/approver/finance for a non-draft order. A draft can only be scanned by its creator, by a mentioned user, or by a companyadmin when open draft access is on. With department sharing on, members of the order's department also qualify. Everyone else gets 403. |
| Read your own bulk scan progress | Any member |
| Company-wide scan history, AI memos | companyadmin, finance (401 otherwise) |
| Justify a violation | companyadmin, finance (403 otherwise) |
| Evidence packs | Anyone who can collaborate on the pack's purchase order, using the same rule as scanning (401 otherwise) |
Policies
A policy is a named rule written in plain language (content). The AI reviewer reads
that text, so write it the way you would brief an auditor. Only policies that are
status: active and archived: false are evaluated. A draft policy is a work in progress.
- Scope. A
globalpolicy applies to every purchase order. Abudget_specificpolicy applies only when one of the order's line items is charged to one of itsbudget_ids. If no line item has a budget, thebudget_idsent toPOST /compliance/checkis used. Abudget_specificpolicy with no budgets never applies. - Category (
federal_regulation,threshold,exception,custom) is a label. It is also passed to the AI as context. - Amount range (
min_amount/max_amount) is only used by the built-in quote-count rule described below. It does not stop the AI from evaluating the policy against orders outside the range, and the AI is not told the range. If an amount limit matters to the AI's verdict, state it incontentas well. - Quote count rule. The checker looks for a phrase such as "three written quotes" or
"3 competitive bids" (digits, or the words one to six) in
content. If it finds one and the order amount is withinmin_amount..max_amount, it counts the attachments that the AI identified as quotes. Too few raises ahighinsufficient_quotesviolation, whatever the AI concluded. required_attachmentsandmin_quotes_requiredare stored and returned for your own UI, but the checker does not read them. Put the requirement incontent.- Every change is versioned.
GET /policies/:idreturns the change history. - Deleting is a soft delete. A policy that has produced violations cannot currently be
deleted (the request fails with
500). Archive it witharchived: trueinstead.
Policy templates
GET /policy_templates returns seven ready-made policies: 2 CFR 200.318 general
procurement standards, SAM.gov debarment check, micro-purchase (under $10k), small purchase
($10k to $250k, three quotes), sealed bids (over $250k), sole source, and cost/price
reasonableness. To use one, copy its name, category, description, content and
default_thresholds into POST /policies. The API does not link the new policy to its
template (source_template_id is read-only).
How a scan works
- Request (synchronous).
POST /compliance/checkorPOST /compliance/bulk_checkchecks your access to each order. It sets the order'scompliance_statustorunning, adds a scan initiated comment to the order, queues a background job and answers202 Acceptedstraight away. A second scan of an order that is alreadyrunningis refused with409. - Evaluation (background). The job loads the applicable policies. For each policy
with
content, every PDF or image attached to the order is first described by a vision model (the description is cached for 24 hours per file version). A second model call then returns a JSON verdict on the policy text, the order facts (amount, supplier, budgets, line items, plus per-line tax and shipping when the order uses the advanced tax and shipping model) and the document descriptions. The primary model isgemini-2.5-flashand the fallback isgpt-5.4. For an order-bound scan the stored purchase order is evaluated. Values you send in the request body are only compared with it, and any difference is recorded. - Outcome. A verdict of compliant, with nothing rated
highorcritical, passes the policy. Low and medium remarks are kept as observations. Anything else becomes a violation. The checker adds its own violations in these cases:
| violation_type | When |
|---|---|
| insufficient_quotes | Fewer quotes attached than the policy text requires (see above) |
| compliance_check_unavailable | Both AI models failed (for example, the AI service is unreachable) |
| compliance_check_inconclusive | The AI answer could not be validated as a verdict |
| document_analysis_unavailable | An attachment could not be read for the person running the scan |
| suspected_prompt_injection | The order or its documents contain text that tries to instruct the reviewer |
| sam_gov_exclusion | SAM.gov reports active exclusions for the supplier (critical), or a SAM.gov Debarment Check policy exists but no API key is configured (medium) |
| policy_violation | Fallback when the AI returns an unusable type name |
The result is stored as a compliance check. The order's compliance_status
becomes passed or failed. A passed scan of a purchase order also creates an
evidence pack and starts building its ZIP.
4. Delivery. The result is pushed over ActionCable (see below). Afterwards you can read
it from GET /purchase_orders/:id: latest_compliance_check, compliance_checks (the
last 5), can_justify and has_global_policies. The purchase order list includes
compliance_status and accepts the filter compliance_status=passed|failed|running|not_run.
A scan without purchase_order_id ("metadata only") evaluates the amount, supplier and
items you send. Its result is stored, but it cannot be received over ActionCable, read
back through the API, justified, or turned into an evidence pack.
Real-time results (ActionCable)
Connect to wss://app.procurementexpress.com/cable?token=<authentication_token> and subscribe to:
ComplianceCheckChannelwith{ "purchase_order_id": 21 }. Message types:compliance_result(datais the compliance check result below),compliance_error(the job gave up after its retries),evidence_pack_generating,evidence_pack_ready(dataholdsevidence_pack_id,download_url,file_name,file_size) andevidence_pack_failed. Only messages for scans you started reach you.BulkComplianceCheckChannel(no parameters). Message types:bulk_scan_progress,bulk_scan_completed(same fields asGET /compliance/bulk_check_status) andbulk_scan_error. During a bulk scan, each order's owncompliance_resultalso arrives on itsComplianceCheckChannelstream.
Here is a compliance_result message for a scan during which the AI service was
unreachable (one violation shown):
{
"type": "compliance_result",
"data": {
"id": 6,
"status": "failed",
"violations": [
{
"id": 5,
"policy_id": 1,
"policy_name": "Competitive quotes over $2,500",
"policy_reference": "Competitive quotes over $2,500",
"violation_type": "compliance_check_unavailable",
"risk_level": "high",
"impact": "Compliance check could not be performed — LLM service unreachable",
"details": "Policy \"Competitive quotes over $2,500\" could not be evaluated because the AI compliance engine is unavailable (...). This purchase order cannot be marked as compliant until the check completes successfully.",
"remediation_options": [
{"type": "retry_check", "label": "Retry Compliance Check", "description": "Re-run the compliance check once the AI service is restored"},
{"type": "add_justification", "label": "Add Manual Justification", "description": "Provide manual justification while the automated check is unavailable"}
],
"sam_gov_check_id": null
}
],
"passed_policies": [],
"evidence_pack_id": null,
"checked_at": "2026-10-21T05:09:14-05:00"
}
}
Compliance check result
| Field | Type | Description |
|---|---|---|
id |
integer | Compliance check id. Use it with POST /compliance/evidence_packs. |
status |
string | passed or failed |
violations |
array[object] | Violations found (fields below, without the resolution fields) |
passed_policies |
array[integer] | Ids of the evaluated policies that produced no violation |
evidence_pack_id |
integer or null | Set when the scan passed and an evidence pack was created |
checked_at |
timestamp | ISO-8601 time of the scan |
observations |
array[object] | Only present when the AI made low or medium remarks on a passing policy (policy_id, violation_type, risk_level, impact, details) |
injection_signals |
integer | Only present when the order text contained instruction-like phrases |
request_mismatch |
object | Only present when the request body disagreed with the stored order (total_amount, supplier, item_count, each {submitted, persisted}) |
Violation
| Field | Type | Description |
|---|---|---|
id |
integer | Violation id. Use it with POST /compliance/justify. |
policy_id |
integer | Policy that was violated |
policy_name |
string | Name of that policy |
policy_reference |
string | Currently always the policy name |
violation_type |
string | Snake-case type: one of the checker types above, or a type chosen by the AI such as missing_price_documentation |
risk_level |
string | low, medium, high or critical. high and critical block the order from being audit-ready until they are justified. |
impact |
string | One-line consequence |
details |
string | Explanation of what is missing or wrong |
remediation_options |
array[object] | Suggested next steps, each {type, label, description}. type is one of upload_documents, generate_memo, add_justification, retry_check, select_different_supplier, configure_api_key. |
resolved |
boolean | true once justified |
resolved_at |
timestamp or null | ISO-8601 |
resolution_method |
string or null | manual_justification when justified through the API |
justified_by_id / justified_by_name |
integer / string, or null | Who justified it |
justification_reason |
string or null | The justification text (10 to 2,000 characters) |
sam_gov_check_id |
integer or null | SAM.gov check behind a sam_gov_exclusion violation |
sam_gov_exclusion_details |
object or null | For sam_gov_exclusion only: supplier_name, uei, status, checked_at, exclusions |
Violations, justification and memos
A companyadmin or finance user can justify an unresolved violation with a written
reason (POST /compliance/justify). This marks it resolved. When no high or critical
violation of that scan is left unresolved, the order is audit-ready: an audit ready
comment is added and an evidence pack is created or rebuilt. The order's
compliance_status stays failed. Audit-readiness is recorded in the comments and the
evidence pack, not in the status.
POST /compliance/generate_memo asks the AI to draft a Price Reasonableness Memo
(2 CFR 200.404 style) from the vendor, price, alternatives and rationale you supply. The
memo is returned as text and is not stored.
Bulk scans and scan history
POST /compliance/bulk_check scans several orders in one background job. Orders that
already passed, are already running, or that you cannot access are skipped. Each user can
have only one bulk scan in progress per company. Follow it with
GET /compliance/bulk_check_status or BulkComplianceCheckChannel. Finished and failed bulk
scans of the whole company appear in GET /compliance/scan_history (admins and finance only).
Evidence packs
An evidence pack belongs to one compliance check of one purchase order. It records a snapshot of the order and a ZIP archive containing:
purchase_order_<number or id>.pdf, an audit PDF of the order and its compliance statussam_gov_verification_screenshot.png, when a SAM.gov check with a screenshot exists for the supplierattachments/001_<file name>and so on, one per order attachment (an order with more than 50 attachments cannot be packed)metadata.md, covering order details, SAM.gov status, the history of every scan of the order with the violations each one triggered, the included files, and any skipped attachments
Packs are built in the background: zip_pending → zip_generating → zip_ready (or
zip_failed). Download links are short-lived signed URLs (8 hours).
Audit trail
Every compliance event is written to the purchase order as a system comment, so it shows
in the order's activity and in GET /purchase_orders/:id comments:
| Event | Comment | category |
|---|---|---|
| Scan requested | Jane Doe initiated a compliance scan |
compliance_scan_initiated |
| Scan passed | Compliance scan passed. 2 policies checked — no violations found. PO is audit-ready. |
compliance_scan_passed |
| Scan failed | Compliance scan completed. Found 2 policy violations in: Policy A, Policy B |
compliance_scan_failed |
| Violation justified | Jane Doe justified violation for "Policy A": "<reason>" |
compliance_violation_justified |
| Last high/critical violation justified | All compliance violations have been resolved or justified. PO is now audit-ready. |
compliance_audit_ready |
Policies, compliance checks, violations and evidence packs are also versioned (PaperTrail).
Timestamps in this section are ISO-8601 strings, except the policy created_at /
updated_at, which are unix seconds.
Endpoints in this section
| Endpoint | Method | Path | V3 | V1 |
|---|---|---|---|---|
| List policies | GET |
/policies |
— | ✓ |
| Get a policy | GET |
/policies/:id |
— | ✓ |
| Create a policy | POST |
/policies |
— | ✓ |
| Update a policy | PUT |
/policies/:id |
— | ✓ |
| Delete a policy | DELETE |
/policies/:id |
— | ✓ |
| List policy templates | GET |
/policy_templates |
— | ✓ |
| Scan a purchase order | POST |
/compliance/check |
— | ✓ |
| Scan several purchase orders | POST |
/compliance/bulk_check |
— | ✓ |
| Get your latest bulk scan | GET |
/compliance/bulk_check_status |
— | ✓ |
| List bulk scan history | GET |
/compliance/scan_history |
— | ✓ |
| Get a bulk scan | GET |
/compliance/scan_history/:id |
— | ✓ |
| Justify a violation | POST |
/compliance/justify |
— | ✓ |
| Generate a price reasonableness memo | POST |
/compliance/generate_memo |
— | ✓ |
| Create an evidence pack | POST |
/compliance/evidence_packs |
— | ✓ |
| Get an evidence pack | GET |
/compliance/evidence_packs/:id |
— | ✓ |
| Download an evidence pack | GET |
/compliance/evidence_packs/:id/download |
— | ✓ |
| Rebuild an evidence pack | POST |
/compliance/evidence_packs/:id/regenerate |
— | ✓ |
The policy object
| Field | Type | Description |
|---|---|---|
id |
integer | Policy id |
name |
string | Name, required, at most 255 characters. Not unique. |
description |
string | Short summary for people, or null. Not sent to the AI. |
status |
string | draft (default) or active. Only active, non-archived policies are evaluated. |
archived |
boolean | Archived policies are kept for history but never evaluated. Default false. |
category |
string | federal_regulation, threshold, exception or custom (default). Passed to the AI as context. |
scope |
string | global (default): applies to every purchase order. budget_specific: applies only to orders with a line item charged to one of budget_ids. |
budget_ids |
array[integer] | Budgets a budget_specific policy applies to. Always empty for global policies. |
budgets |
array[object] | The same budgets as {id, name} |
min_amount |
string | Decimal as a string (for example "2500.0"), or null. Lower bound of the order amount for the quote-count rule. |
max_amount |
string | Decimal as a string, or null. Must be greater than or equal to min_amount. |
required_attachments |
array[string] | Free-form labels, for example quote. Stored for display only, not evaluated. |
min_quotes_required |
integer | Whole number of 0 or more, or null. Stored for display only. The checker reads the quote count from content. |
source_template_id |
string | Read-only. Set only on the SAM.gov policy that the system creates (sam_gov_debarment). null otherwise. |
versions_count |
integer | Number of recorded versions (create plus every update) |
content |
string | The policy text the AI evaluates. Returned by show, create and update, but not by the list endpoint. |
created_at |
timestamp | Unix seconds |
updated_at |
timestamp | Unix seconds |
List policies
# V1 only: this endpoint has no V3 equivalent.
curl "https://app.procurementexpress.com/api/v1/policies" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/policies" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
# V1 only: this endpoint has no V3 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/policies",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/policies",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
V1 response
200 OK(lists shortened to 2 items):
{
"policies": [
{
"id": 3,
"name": "Sole source justification",
"description": null,
"status": "draft",
"archived": false,
"category": "exception",
"budget_ids": [
],
"min_amount": null,
"max_amount": null,
"required_attachments": [
],
"min_quotes_required": null,
"source_template_id": null,
"versions_count": 1,
"created_at": 1792576752,
"updated_at": 1792576752,
"scope": "global",
"budgets": [
]
},
{
"id": 2,
"name": "Price reasonableness documentation",
"description": "Every purchase needs evidence that the price is reasonable.",
"status": "active",
"archived": false,
"category": "federal_regulation",
"budget_ids": [
],
"min_amount": null,
"max_amount": null,
"required_attachments": [
],
"min_quotes_required": null,
"source_template_id": null,
"versions_count": 1,
"created_at": 1792576752,
"updated_at": 1792576752,
"scope": "global",
"budgets": [
]
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 3
}
}
Example: filtered (V1,
200 OK):
{
"policies": [
{
"id": 1,
"name": "Competitive quotes over $5,000",
"description": "Purchases between $5,000 and $250,000 need three written quotes.",
"status": "active",
"archived": false,
"category": "threshold",
"budget_ids": [
1
],
"min_amount": "5000.0",
"max_amount": "250000.0",
"required_attachments": [
"quote"
],
"min_quotes_required": 3,
"source_template_id": null,
"versions_count": 1,
"created_at": 1792576744,
"updated_at": 1792576744,
"scope": "budget_specific",
"budgets": [
{
"id": 1,
"name": "Engineering Hardware FY26"
}
]
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 1
}
}
V3 — V1
Returns the company's policies, most recently updated first, with optional filters.
Lists the policies of the company named by app_company_id, sorted by updated_at
descending. Archived policies are included unless you filter them out. Each item is
a policy object without content. Use GET /policies/:id for the text.
Filters can be combined. An unknown status, scope or category value returns an
empty list, not an error.
HTTP request
| Version | Request |
|---|---|
| V1 | GET /api/v1/policies |
Permissions
companyadmin or finance in the request company. Other roles get
401 {"error": "Unauthorized access"}. If the company does not have the enable_policies
feature, every user gets 403 {"error": ["Policies feature is not enabled"]}. The flag is
checked before the role.
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
status |
string | No | draft or active |
archived |
boolean | No | true returns only archived policies. Any other value returns only non-archived ones. |
scope |
string | No | global or budget_specific |
category |
string | No | federal_regulation, threshold, exception or custom |
budget_id |
integer | No | Policies that would apply to a purchase order on this budget: every global policy plus the budget-specific policies linked to it |
search |
string | No | Case-insensitive substring match on name or description |
page |
integer | No | Page number Default: 1. |
per_page |
integer | No | 10, 20, 50 or 100. Any other value falls back to 10. Default: your results-per-page setting, else 10. |
Response
200 OK with {"policies": [...], "meta": {...}}. Each policy has the fields of the
Policy object except content.
| Field | Type | Description |
|---|---|---|
meta.current_page |
integer | Current page |
meta.next_page |
integer | Next page, or null on the last page |
meta.prev_page |
integer | Previous page, or null on the first page |
meta.total_pages |
integer | Number of pages |
meta.total_count |
integer | Number of matching policies |
V3 and V1 differences
V1 only. GET /api/v3/policies does not exist and returns 404 {"error": "Invalid URL"}
.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Forbidden | 401 |
V1 | {"error":"Unauthorized access","status":401} |
| Feature disabled | 403 |
V1 | {"error":["Policies feature is not enabled"],"status":403} |
Get a policy
# V1 only: this endpoint has no V3 equivalent.
curl "https://app.procurementexpress.com/api/v1/policies/1" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/policies/1" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
# V1 only: this endpoint has no V3 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/policies/1",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/policies/1",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
V1 response
200 OK(lists shortened to 2 items):
{
"policy": {
"id": 1,
"name": "Competitive quotes over $2,500",
"description": "Purchases between $2,500 and $250,000 need three written quotes.",
"status": "active",
"archived": false,
"category": "threshold",
"budget_ids": [
1,
3
],
"min_amount": "2500.0",
"max_amount": "250000.0",
"required_attachments": [
"quote"
],
"min_quotes_required": 3,
"source_template_id": null,
"versions_count": 3,
"created_at": 1792576744,
"updated_at": 1792576799,
"scope": "budget_specific",
"content": "Any purchase of $2,500 or more charged to an engineering hardware or office supplies budget must be supported by three written quotes from independent suppliers. Each quote must show the supplier name, item descriptions, unit prices and a validity date.",
"budgets": [
{
"id": 1,
"name": "Engineering Hardware FY26"
},
{
"id": 3,
"name": "Office Supplies"
}
]
},
"versions": [
{
"id": 4,
"item_type": "Policy",
"item_id": 1,
"event": "create",
"whodunnit": "40000",
"whodunnit_name": "Jane Doe",
"object": null,
"object_changes": {
"id": [
null,
1
],
"category": [
"custom",
"threshold"
],
"company_id": [
null,
20000
],
"content": [
null,
"Any purchase of $5,000 or more charged to an engineering hardware budget must be supported by three written quotes from independent suppliers. Each quote must show the supplier name, item descriptions, unit prices and a validity date."
],
"created_at": [
null,
"2026-10-21T04:59:04.070-05:00"
],
"description": [
null,
"Purchases between $5,000 and $250,000 need three written quotes."
],
"max_amount": [
null,
"250000.0"
],
"min_amount": [
null,
"5000.0"
],
"min_quotes_required": [
null,
3
],
"name": [
null,
"Competitive quotes over $5,000"
],
"required_attachments": [
[
],
[
"quote"
]
],
"scope": [
"global",
"budget_specific"
],
"status": [
"draft",
"active"
],
"updated_at": [
null,
"2026-10-21T04:59:04.070-05:00"
]
},
"created_at": "2026-10-21T04:59:04.070-05:00"
},
{
"id": 25,
"item_type": "Policy",
"item_id": 1,
"event": "update",
"whodunnit": "40000",
"whodunnit_name": "Jane Doe",
"object": "---\ncompany_id: 20000\ndescription: Purchases between $5,000 and $250,000 need three written quotes.\nmin_amount: !ruby/object:BigDecimal 9:0.5e4\nmax_amount: !ruby/object:BigDecimal 9:0.25e6\nmin_quotes_required: 3\nscope: 1\nid: 1\narchived: false\ncategory: 1\ncontent: Any purchase of $5,000 or more charged to an engineering hardware budget\n must be supported by three written quotes from independent suppliers. Each quote\n must show the supplier name, item descriptions, unit prices and a validity date.\ncreated_at: 2026-10-21 09:59:04.070066000 Z\ndeleted_at:\nname: Competitive quotes over $5,000\nrequired_attachments:\n- quote\nsource_template_id:\nstatus: 1\nupdated_at: 2026-10-21 09:59:04.070066000 Z\nversions_count: 1\n",
"object_changes": {
"description": [
"Purchases between $5,000 and $250,000 need three written quotes.",
"Purchases between $2,500 and $250,000 need three written quotes."
],
"min_amount": [
"5000.0",
"2500.0"
],
"updated_at": [
"2026-10-21T04:59:04.070-05:00",
"2026-10-21T04:59:51.606-05:00"
]
},
"created_at": "2026-10-21T04:59:51.606-05:00"
}
]
}
V3 — V1
Returns one policy, including its text and its change history.
Returns the full policy, including content, plus versions: the change history
recorded by PaperTrail, oldest first. Each create and update adds a version that lists
the changed attributes. Changes to budget_ids are not recorded, because they live
in a join table.
HTTP request
| Version | Request |
|---|---|
| V1 | GET /api/v1/policies/:id |
Permissions
companyadmin or finance (401 otherwise). The enable_policies feature is required
(403 otherwise). Policies of other companies return 404.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Policy id |
Response
200 OK with {"policy": {...}, "versions": [...]}. policy is the full Policy object.
| Field | Type | Description |
|---|---|---|
versions[ |
integer | Version id |
versions[ |
string | Always Policy |
versions[ |
integer | Policy id |
versions[ |
string | create, update or destroy |
versions[ |
string | Id of the user who made the change, as a string, or null |
versions[ |
string | That user's name, or null |
versions[ |
string | The policy as it was before this change, as a YAML string (null for create). Treat it as opaque. |
versions[ |
object | Changed attributes as {attribute: [old, new]}. Enum attributes show their names (for example "status": ["draft", "active"]). |
versions[ |
timestamp | ISO-8601 time of the change |
V3 and V1 differences
V1 only. GET /api/v3/policies/:id returns 404 {"error": "Invalid URL"}.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 404 |
V1 | {"error":"Invalid id passed","status":404} |
| Forbidden | 401 |
V1 | {"error":"Unauthorized access","status":401} |
Create a policy
# V1 only: this endpoint has no V3 equivalent.
curl -X POST "https://app.procurementexpress.com/api/v1/policies" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"policy": {
"name": "Competitive quotes over $5,000",
"description": "Purchases between $5,000 and $250,000 need three written quotes.",
"status": "active",
"scope": "budget_specific",
"category": "threshold",
"budget_ids": [
1
],
"min_amount": 5000,
"max_amount": 250000,
"min_quotes_required": 3,
"required_attachments": [
"quote"
],
"content": "Any purchase of $5,000 or more charged to an engineering hardware budget must be supported by three written quotes from independent suppliers. Each quote must show the supplier name, item descriptions, unit prices and a validity date."
}
}'
curl -X POST "https://app.procurementexpress.com/api/v1/policies" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"policy": {
"name": "Competitive quotes over $5,000",
"description": "Purchases between $5,000 and $250,000 need three written quotes.",
"status": "active",
"scope": "budget_specific",
"category": "threshold",
"budget_ids": [
1
],
"min_amount": 5000,
"max_amount": 250000,
"min_quotes_required": 3,
"required_attachments": [
"quote"
],
"content": "Any purchase of $5,000 or more charged to an engineering hardware budget must be supported by three written quotes from independent suppliers. Each quote must show the supplier name, item descriptions, unit prices and a validity date."
}
}'
# V1 only: this endpoint has no V3 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/policies",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
policy: {
name: "Competitive quotes over $5,000",
description: "Purchases between $5,000 and $250,000 need three written quotes.",
status: "active",
scope: "budget_specific",
category: "threshold",
budget_ids: [1],
min_amount: 5000,
max_amount: 250000,
min_quotes_required: 3,
required_attachments: ["quote"],
content: "Any purchase of $5,000 or more charged to an engineering hardware budget must be supported by three written quotes from independent suppliers. Each quote must show the supplier name, item descriptions, unit prices and a validity date."
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/policies",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
policy: {
name: "Competitive quotes over $5,000",
description: "Purchases between $5,000 and $250,000 need three written quotes.",
status: "active",
scope: "budget_specific",
category: "threshold",
budget_ids: [1],
min_amount: 5000,
max_amount: 250000,
min_quotes_required: 3,
required_attachments: ["quote"],
content: "Any purchase of $5,000 or more charged to an engineering hardware budget must be supported by three written quotes from independent suppliers. Each quote must show the supplier name, item descriptions, unit prices and a validity date."
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
V1 response
201 Created:
{
"policy": {
"id": 1,
"name": "Competitive quotes over $5,000",
"description": "Purchases between $5,000 and $250,000 need three written quotes.",
"status": "active",
"archived": false,
"category": "threshold",
"budget_ids": [
1
],
"min_amount": "5000.0",
"max_amount": "250000.0",
"required_attachments": [
"quote"
],
"min_quotes_required": 3,
"source_template_id": null,
"versions_count": 1,
"created_at": 1792576744,
"updated_at": 1792576744,
"scope": "budget_specific",
"content": "Any purchase of $5,000 or more charged to an engineering hardware budget must be supported by three written quotes from independent suppliers. Each quote must show the supplier name, item descriptions, unit prices and a validity date.",
"budgets": [
{
"id": 1,
"name": "Engineering Hardware FY26"
}
]
}
}
V3 — V1
Creates a policy for the company.
Creates a policy in the company named by app_company_id. A policy starts evaluating
purchase orders as soon as it is active. New scans pick it up, but existing scan results
are not re-evaluated.
To start from a template, copy the fields of an item from GET /policy_templates
(default_thresholds maps to min_amount, max_amount and min_quotes_required).
Validation:
* name is required (at most 255 characters).
* min_amount and max_amount must be 0 or more, and max_amount must be at least
min_amount when both are set.
* min_quotes_required must be a whole number of 0 or more.
* status, scope and category must be one of the listed values. Any other value
currently fails with a 500 error, not a 422.
Send budget_ids only with scope: budget_specific. Sending budget_ids with a global
scope, including the default scope, currently fails with 500. Budget ids are not checked
against your company, so only send your own.
HTTP request
| Version | Request |
|---|---|
| V1 | POST /api/v1/policies |
Permissions
companyadmin or finance (401 otherwise). The enable_policies feature is required (403 otherwise).
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
policy[ |
string | Yes | Policy name, at most 255 characters |
policy[ |
string | No | The rule text the AI evaluates. A policy without content is never sent to the AI and so never fails a scan, apart from the built-in SAM.gov check. |
policy[ |
string | No | Short summary for people. Not sent to the AI. |
policy[ |
string | No | draft or active Default: draft. |
policy[ |
string | No | global or budget_specific Default: global. |
policy[ |
string | No | federal_regulation, threshold, exception or custom Default: custom. |
policy[ |
array[integer] | No | Budgets of your company that a budget_specific policy applies to. Do not send with global scope. |
policy[ |
number | No | Lower bound (0 or more) of the order amount for the quote-count rule |
policy[ |
number | No | Upper bound for the quote-count rule. Must be at least min_amount. |
policy[ |
integer | No | Whole number of 0 or more. Stored only. State the quote requirement in content for it to be checked. |
policy[ |
array[string] | No | Labels such as quote or invoice. Stored only, not evaluated. |
policy[ |
boolean | No | Create the policy already archived Default: false. |
Response
201 Created with {"policy": {...}}, the full Policy object including content.
Validation errors return 422 with {"errors": ["Name can't be blank", ...]}, a list of
full messages.
Side effects
Records a create version. No email, webhook or audit comment is sent.
V3 and V1 differences
V1 only. POST /api/v3/policies returns 404 {"error": "Invalid URL"}.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Invalid | 422 |
V1 | {"errors":["Name can't be blank","Max amount must be greater than or equal to minimum amount"]} |
Update a policy
# V1 only: this endpoint has no V3 equivalent.
curl -X PUT "https://app.procurementexpress.com/api/v1/policies/1" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"policy": {
"name": "Competitive quotes over $2,500",
"min_amount": 2500,
"description": "Purchases between $2,500 and $250,000 need three written quotes.",
"content": "Any purchase of $2,500 or more charged to an engineering hardware or office supplies budget must be supported by three written quotes from independent suppliers. Each quote must show the supplier name, item descriptions, unit prices and a validity date.",
"budget_ids": [
1,
3
]
}
}'
curl -X PUT "https://app.procurementexpress.com/api/v1/policies/1" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"policy": {
"name": "Competitive quotes over $2,500",
"min_amount": 2500,
"description": "Purchases between $2,500 and $250,000 need three written quotes.",
"content": "Any purchase of $2,500 or more charged to an engineering hardware or office supplies budget must be supported by three written quotes from independent suppliers. Each quote must show the supplier name, item descriptions, unit prices and a validity date.",
"budget_ids": [
1,
3
]
}
}'
# V1 only: this endpoint has no V3 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v1/policies/1",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
policy: {
name: "Competitive quotes over $2,500",
min_amount: 2500,
description: "Purchases between $2,500 and $250,000 need three written quotes.",
content: "Any purchase of $2,500 or more charged to an engineering hardware or office supplies budget must be supported by three written quotes from independent suppliers. Each quote must show the supplier name, item descriptions, unit prices and a validity date.",
budget_ids: [1, 3]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v1/policies/1",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
policy: {
name: "Competitive quotes over $2,500",
min_amount: 2500,
description: "Purchases between $2,500 and $250,000 need three written quotes.",
content: "Any purchase of $2,500 or more charged to an engineering hardware or office supplies budget must be supported by three written quotes from independent suppliers. Each quote must show the supplier name, item descriptions, unit prices and a validity date.",
budget_ids: [1, 3]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
V1 response
200 OK:
{
"policy": {
"id": 1,
"name": "Competitive quotes over $2,500",
"description": "Purchases between $2,500 and $250,000 need three written quotes.",
"status": "active",
"archived": false,
"category": "threshold",
"budget_ids": [
1,
3
],
"min_amount": "2500.0",
"max_amount": "250000.0",
"required_attachments": [
"quote"
],
"min_quotes_required": 3,
"source_template_id": null,
"versions_count": 3,
"created_at": 1792576744,
"updated_at": 1792576799,
"scope": "budget_specific",
"content": "Any purchase of $2,500 or more charged to an engineering hardware or office supplies budget must be supported by three written quotes from independent suppliers. Each quote must show the supplier name, item descriptions, unit prices and a validity date.",
"budgets": [
{
"id": 1,
"name": "Engineering Hardware FY26"
},
{
"id": 3,
"name": "Office Supplies"
}
]
}
}
V3 — V1
Changes any attribute of a policy; only the attributes you send are changed.
Partial update: send only the attributes you want to change. They take the same names and validation as Create a policy.
budget_idsreplaces the whole list ([]removes every budget).- Changing
scopetoglobalremoves the policy's budgets. - Set
archived: trueto retire a policy. This is the only option for a policy that already has violations, because such a policy cannot be deleted. - The change affects future scans only.
HTTP request
| Version | Request |
|---|---|
| V1 | `PUT |
Permissions
companyadmin or finance (401 otherwise). The enable_policies feature is required
(403 otherwise). Unknown ids, or policies of other companies, return 404.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Policy id |
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
policy[ |
string | No | At most 255 characters, not blank |
policy[ |
string | No | Rule text evaluated by the AI |
policy[ |
string | No | Short summary |
policy[ |
string | No | draft or active |
policy[ |
string | No | global or budget_specific |
policy[ |
string | No | federal_regulation, threshold, exception or custom |
policy[ |
array[integer] | No | Replaces the linked budgets. Ignored for global policies. |
policy[ |
number | No | 0 or more, or null |
policy[ |
number | No | At least min_amount, or null |
policy[ |
integer | No | Whole number of 0 or more, or null |
policy[ |
array[string] | No | Replaces the list |
policy[ |
boolean | No | Archive or restore the policy |
Response
200 OK with {"policy": {...}}, the full updated Policy object. Validation errors
return 422 with {"errors": [...]}.
Side effects
Records an update version (budget changes are not versioned).
V3 and V1 differences
V1 only. PUT /api/v3/policies/:id returns 404 {"error": "Invalid URL"}.
PUT and PATCH behave the same (the 422 example was sent with PATCH).
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Invalid | 422 |
V1 | {"errors":["Max amount must be greater than or equal to minimum amount"]} |
| Not found | 404 |
V1 | {"error":"Invalid id passed","status":404} |
Delete a policy
# V1 only: this endpoint has no V3 equivalent.
curl -X DELETE "https://app.procurementexpress.com/api/v1/policies/5" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl -X DELETE "https://app.procurementexpress.com/api/v1/policies/5" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
# V1 only: this endpoint has no V3 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :delete,
url: "https://app.procurementexpress.com/api/v1/policies/5",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts response.code
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :delete,
url: "https://app.procurementexpress.com/api/v1/policies/5",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts response.code
V1 response
204 No Content(empty body)
V3 — V1
Soft-deletes a policy so it is no longer listed or evaluated.
Soft-deletes the policy. It disappears from the list and is never evaluated again, and its budget links are removed.
A policy that has produced violations in past scans currently cannot be deleted: the
request fails with 500. Archive it instead (PUT /policies/:id with archived: true).
HTTP request
| Version | Request |
|---|---|
| V1 | DELETE /api/v1/policies/:id |
Permissions
companyadmin or finance (401 otherwise). The enable_policies feature is required (403 otherwise).
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Policy id |
Response
204 No Content with an empty body. Deleting the same id again returns 404.
Side effects
Records a destroy version.
V3 and V1 differences
V1 only. DELETE /api/v3/policies/:id returns 404 {"error": "Invalid URL"}.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 404 |
V1 | {"error":"Invalid id passed","status":404} |
| Forbidden | 401 |
V1 | {"error":"Unauthorized access","status":401} |
List policy templates
# V1 only: this endpoint has no V3 equivalent.
curl "https://app.procurementexpress.com/api/v1/policy_templates" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/policy_templates" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
# V1 only: this endpoint has no V3 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/policy_templates",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/policy_templates",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
V1 response
200 OK(lists shortened to 2 items):
{
"templates": [
{
"id": "2cfr200318",
"name": "2 CFR 200.318 - General Procurement Standards",
"category": "federal_regulation",
"description": "Uniform Administrative Requirements for Federal Awards.",
"content": "§ 200.318 General procurement standards.\n\n(a) The non-Federal entity must use its own documented procurement procedures\nwhich reflect applicable State, local, and tribal laws and regulations,\nprovided that the procurements conform to applicable Federal law and the\nstandards identified in this part.\n\n(b) Non-Federal entities must maintain oversight to ensure that contractors\nperform in accordance with the terms, conditions, and specifications of\ntheir contracts or purchase orders.\n\n(c) The non-Federal entity must maintain written standards of conduct\ncovering conflicts of interest and governing the actions of its employees\nengaged in the selection, award and administration of contracts.\n\n(d) The non-Federal entity's procedures must avoid acquisition of unnecessary\nor duplicative items. Consideration should be given to consolidating or\nbreaking out procurements to obtain a more economical purchase.\n\n(e) To foster greater economy and efficiency, and in accordance with efforts\nto promote cost-effective use of shared services, the non-Federal entity is\nencouraged to enter into state and local intergovernmental agreements or\ninter-entity agreements for procurement or use of common or shared goods\nand services.\n",
"default_thresholds": null
},
{
"id": "sam_gov_debarment",
"name": "SAM.gov Debarment Check",
"category": "federal_regulation",
"scope": "global",
"description": "Automated check against the SAM.gov Exclusions API to verify suppliers are not debarred, suspended, or otherwise excluded from federal procurement.",
"content": "All purchase orders must include a SAM.gov exclusion check for the\nselected supplier per FAR 9.4. Orders with flagged suppliers require\nwritten justification and manager-level approval before proceeding.\n\nReferences:\n- FAR 9.4 - Debarment, Suspension, and Ineligibility\n- 2 CFR 180 - OMB Guidelines to Agencies on Governmentwide\n Debarment and Suspension\n",
"default_thresholds": null
}
]
}
V3 — V1
Returns the built-in policy templates that can be copied into new policies.
Returns the seven built-in templates (the same for every company). The list is static
and not paginated. Template ids: 2cfr200318, sam_gov_debarment, micro_purchase,
small_purchase, sealed_bids, sole_source and cost_reasonableness.
To use a template, send its name, category, description and content, plus
default_thresholds as min_amount, max_amount and min_quotes_required, to
POST /policies.
There is no need to create the sam_gov_debarment policy by hand. SAM.gov exclusions are
checked automatically once SAM.gov is enabled. If you do create a policy named
SAM.gov Debarment Check while no SAM.gov API key is configured, every scan reports a
medium sam_gov_exclusion warning.
HTTP request
| Version | Request |
|---|---|
| V1 | GET /api/v1/policy_templates |
Permissions
Any authenticated member of the company. This endpoint does not require the enable_policies feature.
Response
200 OK with {"templates": [...]}.
| Field | Type | Description |
|---|---|---|
templates[ |
string | Stable template key |
templates[ |
string | Suggested policy name |
templates[ |
string | federal_regulation, threshold or exception |
templates[ |
string | Short summary |
templates[ |
string | Policy text, ready to use as content |
templates[ |
object | {min_amount, max_amount, min_quotes_required} for the threshold templates (max_amount may be null), otherwise null |
templates[ |
string | Only on sam_gov_debarment: global |
V3 and V1 differences
V1 only. GET /api/v3/policy_templates returns 404 {"error": "Invalid URL"}.
Scan a purchase order
# V1 only: this endpoint has no V3 equivalent.
curl -X POST "https://app.procurementexpress.com/api/v1/compliance/check" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"compliance_check": {
"purchase_order_id": 21,
"budget_id": 1,
"budget_name": "Engineering Hardware FY26",
"total_amount": 12800,
"supplier": "Contoso Hardware",
"items": [
{
"description": "14-inch developer laptop, 32GB RAM",
"quantity": 4,
"unit_price": 3200
}
]
}
}'
curl -X POST "https://app.procurementexpress.com/api/v1/compliance/check" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"compliance_check": {
"purchase_order_id": 21,
"budget_id": 1,
"budget_name": "Engineering Hardware FY26",
"total_amount": 12800,
"supplier": "Contoso Hardware",
"items": [
{
"description": "14-inch developer laptop, 32GB RAM",
"quantity": 4,
"unit_price": 3200
}
]
}
}'
# V1 only: this endpoint has no V3 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/compliance/check",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
compliance_check: {
purchase_order_id: 21,
budget_id: 1,
budget_name: "Engineering Hardware FY26",
total_amount: 12800,
supplier: "Contoso Hardware",
items: [
{
description: "14-inch developer laptop, 32GB RAM",
quantity: 4,
unit_price: 3200
}
]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/compliance/check",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
compliance_check: {
purchase_order_id: 21,
budget_id: 1,
budget_name: "Engineering Hardware FY26",
total_amount: 12800,
supplier: "Contoso Hardware",
items: [
{
description: "14-inch developer laptop, 32GB RAM",
quantity: 4,
unit_price: 3200
}
]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
V1 response
202 Accepted:
{
"status": "processing",
"job_id": "e336af984fd90146fc4b9114",
"purchase_order_id": 21,
"message": "Compliance check started"
}
Example: without po (V1,
202 Accepted):
{
"status": "processing",
"job_id": "fa849166d3e3ebe6bf882be0",
"purchase_order_id": null,
"message": "Compliance check started"
}
V3 — V1
Starts an asynchronous compliance scan of one purchase order against the active policies.
Queues a compliance scan and returns 202 Accepted immediately. The result arrives on
ComplianceCheckChannel as a compliance_result message, and afterwards in
GET /purchase_orders/:id (latest_compliance_check). See How a scan works above.
With purchase_order_id (the normal case):
* The order must belong to the request company (404 otherwise), and you must be able to
collaborate on it (403 otherwise).
* If a scan of the order is already running, the request is refused with 409.
* The order's compliance_status is set to running, and a scan initiated comment is
added to it, before the job is queued.
* The stored order is evaluated: its amount, supplier, line items, line-item budgets
and attachments. Other body fields are optional. When they disagree with the stored
order, the difference is reported in the result's request_mismatch and the stored
values are still the ones used.
Without purchase_order_id ("metadata only"): the amount, supplier and items you send
are evaluated against the policies that apply to budget_id. This mode has no documents,
and its result cannot be received or read back through the API. It is mainly useful
inside the web app while an order is being drafted.
If the background job keeps failing and runs out of retries (3), the order's
compliance_status is reset to null ("not run") and a compliance_error message is pushed.
HTTP request
| Version | Request |
|---|---|
| V1 | POST /api/v1/compliance/check |
Permissions
Any member of the company. With purchase_order_id, you must be able to collaborate on
the order: its creator, a mentioned user, or companyadmin/approver/finance for a
non-draft order (drafts: creator, mentioned users, or companyadmin with open draft
access). Otherwise 403 {"error": "Not authorized to run a compliance check on this purchase order"}.
The enable_policies feature is not checked.
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
compliance_check[ |
integer | No | Purchase order to scan. Echoed back in the response as sent. |
compliance_check[ |
integer | No | Budget used to pick budget-specific policies when the order has no budgeted line items, or when no order is given |
compliance_check[ |
string | No | Budget name shown to the AI when no budget can be read from the order |
compliance_check[ |
number | No | Metadata-only mode: amount to evaluate. With an order: compared with the stored amount only. |
compliance_check[ |
string | No | Metadata-only mode: supplier name. With an order: compared with the stored supplier only. |
compliance_check[ |
string | No | Line item description (metadata-only mode) |
compliance_check[ |
number | No | Line item quantity |
compliance_check[ |
number | No | Line item unit price |
compliance_check[ |
string | No | Accepted and stored with the request, but not evaluated. Documents are read from the order's real attachments. |
compliance_check[ |
string | No | Accepted, not evaluated |
compliance_check[ |
integer | No | Accepted, not evaluated |
Response
202 Accepted with status: "processing", the background job_id, the
purchase_order_id you sent (or null) and message: "Compliance check started".
The compliance_check root object is required. A body without it returns
400 {"error": "Required parameter are missing."}. Error bodies are
{"error": "...", "status": "<reason>"}, where status is a word such as conflict or
forbidden.
| Field | Type | Description |
|---|---|---|
status |
string | Always processing |
job_id |
string | Background job id (for support) |
purchase_order_id |
integer | As sent, or null |
message |
string | Compliance check started |
Side effects
Sets the order's compliance_status to running, then passed or failed. Adds
scan initiated and scan passed or scan failed comments to the order. On a passed
scan, creates an evidence pack and builds its ZIP. Pushes compliance_result on
ActionCable. Calls the AI provider (Gemini, with OpenAI as fallback) and, if enabled,
SAM.gov.
V3 and V1 differences
V1 only. POST /api/v3/compliance/check returns 404 {"error": "Invalid URL"}.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Already running | 409 |
V1 | {"error":"Compliance scan is already running for this purchase order","status":"conflict"} |
| Po not found | 404 |
V1 | {"error":"Purchase order not found","status":"not_found"} |
| Forbidden | 403 |
V1 | {"error":"Not authorized to run a compliance check on this purchase order","status":"forbidden"} |
| Missing params | 400 |
V1 | {"error":"Required parameter are missing.","status":400} |
Scan several purchase orders
# V1 only: this endpoint has no V3 equivalent.
curl -X POST "https://app.procurementexpress.com/api/v1/compliance/bulk_check" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"purchase_order_ids": [
23,
24,
25,
26
]
}'
curl -X POST "https://app.procurementexpress.com/api/v1/compliance/bulk_check" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"purchase_order_ids": [
23,
24,
25,
26
]
}'
# V1 only: this endpoint has no V3 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/compliance/bulk_check",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
purchase_order_ids: [23, 24, 25, 26]
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/compliance/bulk_check",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
purchase_order_ids: [23, 24, 25, 26]
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
V1 response
202 Accepted:
{
"status": "processing",
"bulk_scan_id": 1,
"total_count": 2,
"skipped": {
"already_passed": [
23
],
"already_scanning": [
24
],
"not_permitted": [
]
}
}
Example: partially permitted (V1,
202 Accepted):
{
"status": "processing",
"bulk_scan_id": 4,
"total_count": 1,
"skipped": {
"already_passed": [
],
"already_scanning": [
],
"not_permitted": [
22
]
}
}
V3 — V1
Starts one background bulk scan over a list of purchase orders, skipping orders that cannot or need not be scanned.
Sorts the requested orders of the request company into:
* already_passed: compliance_status is passed. These are skipped. Passed orders are
never re-scanned in bulk; use POST /compliance/check to re-scan one.
* already_scanning: compliance_status is running. These are skipped.
* not_permitted: you cannot collaborate on the order (same rule as a single scan). These are skipped.
* the rest (never scanned, or failed), which are scanned.
Ids that do not exist or belong to another company are ignored silently. The scanned
orders are set to running, each gets a scan initiated comment, and one bulk scan
record is queued. Orders are then scanned one after another, exactly like
POST /compliance/check with a purchase order. Progress is pushed on
BulkComplianceCheckChannel and can be polled with GET /compliance/bulk_check_status.
You can have only one bulk scan pending or in progress per company. Wait for it to finish
before starting another (409).
HTTP request
| Version | Request |
|---|---|
| V1 | POST /api/v1/compliance/bulk_check |
Permissions
Any member of the company, per order as for a single scan. If none of the scannable
orders is accessible, the response is
403 {"error": "Not authorized to run a compliance check on the selected purchase orders"}.
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
purchase_order_ids |
array[integer] | Yes | Purchase order ids, as a JSON array (not nested under a root key) |
Response
202 Accepted. total_count is the number of orders actually queued.
| Field | Type | Description |
|---|---|---|
status |
string | Always processing |
bulk_scan_id |
integer | Bulk scan id (see Scan history) |
total_count |
integer | Orders queued for scanning |
skipped.already_passed |
array[integer] | Requested orders skipped because they already passed |
skipped.already_scanning |
array[integer] | Requested orders skipped because a scan is running |
skipped.not_permitted |
array[integer] | Scannable orders skipped because you cannot access them |
Side effects
The same per-order effects as Scan a purchase order, plus the bulk scan record and
bulk_scan_progress / bulk_scan_completed / bulk_scan_error ActionCable messages
for the initiating user.
V3 and V1 differences
V1 only. POST /api/v3/compliance/bulk_check returns 404 {"error": "Invalid URL"}.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Missing ids | 400 |
V1 | {"error":"purchase_order_ids must be a non-empty array of IDs","status":"bad_request"} |
| Nothing to scan | 422 |
V1 | {"error":"No purchase orders available to scan. All are either passed or already scanning.","status":"unprocessable_entity"} |
| Forbidden | 403 |
V1 | {"error":"Not authorized to run a compliance check on the selected purchase orders","status":"forbidden"} |
| Conflict | 409 |
V1 | {"error":"A bulk compliance scan is already in progress","status":"conflict"} |
Get your latest bulk scan
# V1 only: this endpoint has no V3 equivalent.
curl "https://app.procurementexpress.com/api/v1/compliance/bulk_check_status" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/compliance/bulk_check_status" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
# V1 only: this endpoint has no V3 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/compliance/bulk_check_status",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/compliance/bulk_check_status",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
V1 response
200 OK:
{
"bulk_scan_id": 1,
"status": "in_progress",
"total_count": 2,
"scanned_count": 1,
"passed_count": 1,
"failed_count": 0,
"error_count": 0,
"progress_percent": 50,
"results_data": [
{
"amount": "2250.0",
"status": "passed",
"scanned_at": "2026-10-21T05:06:00-05:00",
"approval_key": null,
"supplier_name": "Contoso Hardware",
"purchase_order_id": 25,
"compliance_check_id": 3,
"passed_policy_count": 2
}
],
"started_at": "2026-10-21T05:05:58.928-05:00",
"completed_at": null,
"initiated_by": "Jane Doe"
}
Example: pending (V1,
200 OK):
{
"bulk_scan_id": 1,
"status": "pending",
"total_count": 2,
"scanned_count": 0,
"passed_count": 0,
"failed_count": 0,
"error_count": 0,
"progress_percent": 0,
"results_data": [
],
"started_at": null,
"completed_at": null,
"initiated_by": "Jane Doe"
}
Example: completed (V1,
200 OK):
{
"bulk_scan_id": 4,
"status": "completed",
"total_count": 1,
"scanned_count": 1,
"passed_count": 0,
"failed_count": 1,
"error_count": 0,
"progress_percent": 100,
"results_data": [
{
"amount": "3600.0",
"status": "failed",
"scanned_at": "2026-10-21T05:11:35-05:00",
"risk_levels": [
"high"
],
"approval_key": null,
"policy_names": [
"Competitive quotes over $2,500",
"Price reasonableness documentation"
],
"supplier_name": "Contoso Hardware",
"violation_count": 2,
"purchase_order_id": 24,
"compliance_check_id": 7
}
],
"started_at": "2026-10-21T05:11:30.771-05:00",
"completed_at": "2026-10-21T05:11:35.399-05:00",
"initiated_by": "Marco Rossi"
}
V3 — V1
Returns progress and per-order results of the most recent bulk scan you started in this company.
Returns your most recent bulk scan in the request company, whatever its status. Poll it
while status is pending or in_progress, or use BulkComplianceCheckChannel
instead. Bulk scans started by other users are not returned; see Scan history.
HTTP request
| Version | Request |
|---|---|
| V1 | GET /api/v1/compliance/bulk_check_status |
Permissions
Any member of the company (only your own scans).
Response
200 OK with the bulk scan. 404 {"error": "No bulk compliance scan found"} if you have
never started one in this company.
| Field | Type | Description |
|---|---|---|
bulk_scan_id |
integer | Bulk scan id |
status |
string | pending (queued), in_progress, completed or failed (the job itself crashed) |
total_count |
integer | Orders queued |
scanned_count |
integer | Orders finished so far |
passed_count |
integer | Orders that passed |
failed_count |
integer | Orders with at least one violation |
error_count |
integer | Orders that could not be scanned |
progress_percent |
integer | scanned_count / total_count as a whole percentage |
results_data[ |
array[object] | One entry per finished order, in completion order |
results_data[ |
integer | Order |
results_data[ |
string | passed, failed or error |
results_data[ |
timestamp | ISO-8601 |
results_data[ |
integer | Compliance check created for the order (passed and failed only) |
results_data[ |
string | Order number, or null if none has been assigned yet |
results_data[ |
string | Supplier |
results_data[ |
string | Order amount as a decimal string |
results_data[ |
integer | Passed only: number of policies checked |
results_data[ |
integer | Failed only: number of violations |
results_data[ |
array[string] | Failed only: up to 3 violated policy names |
results_data[ |
array[string] | Failed only: distinct risk levels found |
results_data[ |
string | Error only: why the order could not be scanned (for example Compliance check unavailable (Compliance::BulkCheckWorker::ScanRefused) when your access was revoked mid-scan). When the whole job crashes, a final entry {error, failed_at} is appended. |
started_at |
timestamp | ISO-8601 time the job started, or null while pending |
completed_at |
timestamp | ISO-8601 time the job finished, or null |
initiated_by |
string | Name of the user who started the scan |
V3 and V1 differences
V1 only. GET /api/v3/compliance/bulk_check_status returns 404 {"error": "Invalid URL"}.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 404 |
V1 | {"error":"No bulk compliance scan found","status":"not_found"} |
List bulk scan history
# V1 only: this endpoint has no V3 equivalent.
curl "https://app.procurementexpress.com/api/v1/compliance/scan_history" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/compliance/scan_history" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
# V1 only: this endpoint has no V3 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/compliance/scan_history",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/compliance/scan_history",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
V1 response
200 OK(lists shortened to 2 items):
{
"scans": [
{
"id": 4,
"status": "completed",
"total_count": 1,
"passed_count": 0,
"failed_count": 1,
"error_count": 0,
"completed_at": "2026-10-21T05:11:35.399-05:00",
"initiated_by": "Marco Rossi"
},
{
"id": 1,
"status": "completed",
"total_count": 2,
"passed_count": 1,
"failed_count": 1,
"error_count": 0,
"completed_at": "2026-10-21T05:06:11.218-05:00",
"initiated_by": "Jane Doe"
}
],
"meta": {
"current_page": 1,
"total_pages": 1,
"total_count": 3,
"next_page": null
}
}
V3 — V1
Lists the company's finished bulk scans, newest first.
Lists the completed and failed bulk scans of the request company, started by any
user, newest first. Scans still pending or in progress are not listed. Single-order scans
(POST /compliance/check) are not bulk scans and never appear here.
HTTP request
| Version | Request |
|---|---|
| V1 | GET /api/v1/compliance/scan_history |
Permissions
companyadmin or finance in the request company. Other roles get 401 {"error": "Unauthorized access"}.
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
page |
integer | No | Page number. 0 or less is treated as 1. Default: 1. |
per_page |
integer | No | 10, 20, 50 or 100 Default: your results-per-page setting, else 10. |
Response
200 OK with {"scans": [...], "meta": {...}}.
| Field | Type | Description |
|---|---|---|
scans[ |
integer | Bulk scan id |
scans[ |
string | completed or failed |
scans[ |
integer | Orders queued |
scans[ |
integer | Orders that passed |
scans[ |
integer | Orders with violations |
scans[ |
integer | Orders that could not be scanned |
scans[ |
timestamp | ISO-8601 |
scans[ |
string | Name of the user who started it |
meta.current_page |
integer | Current page |
meta.total_pages |
integer | Number of pages |
meta.total_count |
integer | Number of finished scans |
meta.next_page |
integer | Next page, or null on the last page |
V3 and V1 differences
V1 only. GET /api/v3/compliance/scan_history returns 404 {"error": "Invalid URL"}.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Forbidden | 401 |
V1 | {"error":"Unauthorized access","status":401} |
Get a bulk scan
# V1 only: this endpoint has no V3 equivalent.
curl "https://app.procurementexpress.com/api/v1/compliance/scan_history/1" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/compliance/scan_history/1" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
# V1 only: this endpoint has no V3 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/compliance/scan_history/1",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/compliance/scan_history/1",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
V1 response
200 OK:
{
"id": 1,
"bulk_scan_id": 1,
"status": "completed",
"total_count": 2,
"scanned_count": 2,
"passed_count": 1,
"failed_count": 1,
"error_count": 0,
"progress_percent": 100,
"results_data": [
{
"amount": "2250.0",
"status": "passed",
"scanned_at": "2026-10-21T05:06:00-05:00",
"approval_key": null,
"supplier_name": "Contoso Hardware",
"purchase_order_id": 25,
"compliance_check_id": 3,
"passed_policy_count": 2
},
{
"amount": "6000.0",
"status": "failed",
"scanned_at": "2026-10-21T05:06:11-05:00",
"risk_levels": [
"high"
],
"approval_key": null,
"policy_names": [
"Competitive quotes over $2,500"
],
"supplier_name": "Northwind Office Supplies",
"violation_count": 1,
"purchase_order_id": 26,
"compliance_check_id": 4
}
],
"started_at": "2026-10-21T05:05:58.928-05:00",
"completed_at": "2026-10-21T05:06:11.218-05:00",
"initiated_by": "Jane Doe"
}
V3 — V1
Returns one finished bulk scan of the company with its per-order results.
Returns a completed or failed bulk scan of the request company, whoever started it.
A scan that is still pending or in_progress returns 404. Use
GET /compliance/bulk_check_status to follow a running scan.
HTTP request
| Version | Request |
|---|---|
| V1 | GET /api/v1/compliance/scan_history/:id |
Permissions
companyadmin or finance (401 otherwise).
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Bulk scan id |
Response
200 OK with the fields of Get your latest bulk scan, plus id (the same value as
bulk_scan_id). 404 {"error": "Scan not found"} for unknown, unfinished or other
companies' scans.
V3 and V1 differences
V1 only. GET /api/v3/compliance/scan_history/:id returns 404 {"error": "Invalid URL"}.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 404 |
V1 | {"error":"Scan not found","status":"not_found"} |
| In progress | 404 |
V1 | {"error":"Scan not found","status":"not_found"} |
| Forbidden | 401 |
V1 | {"error":"Unauthorized access","status":401} |
Justify a violation
# V1 only: this endpoint has no V3 equivalent.
curl -X POST "https://app.procurementexpress.com/api/v1/compliance/justify" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"violation_id": 1,
"justification_reason": "Contoso is our sole authorised reseller for this laptop model under the 2026 hardware framework agreement, so competing quotes are not available."
}'
curl -X POST "https://app.procurementexpress.com/api/v1/compliance/justify" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"violation_id": 1,
"justification_reason": "Contoso is our sole authorised reseller for this laptop model under the 2026 hardware framework agreement, so competing quotes are not available."
}'
# V1 only: this endpoint has no V3 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/compliance/justify",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
violation_id: 1,
justification_reason: "Contoso is our sole authorised reseller for this laptop model under the 2026 hardware framework agreement, so competing quotes are not available."
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/compliance/justify",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
violation_id: 1,
justification_reason: "Contoso is our sole authorised reseller for this laptop model under the 2026 hardware framework agreement, so competing quotes are not available."
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
V1 response
200 OK(lists shortened to 2 items):
{
"violation": {
"id": 1,
"policy_id": 1,
"policy_name": "Competitive quotes over $2,500",
"policy_reference": "Competitive quotes over $2,500",
"violation_type": "insufficient_quotes",
"risk_level": "high",
"impact": "Fewer quotes attached than the policy requires",
"details": "Policy \"Competitive quotes over $2,500\" requires 3 quote(s); 0 attached document(s) were identified as quotes.",
"remediation_options": [
{
"type": "upload_documents",
"label": "Upload Supporting Documents",
"description": "Upload documents that satisfy the policy requirements"
},
{
"type": "generate_memo",
"label": "Generate Justification Memo",
"description": "AI will help generate documentation to address this violation"
}
],
"resolved": true,
"resolved_at": "2026-10-21T05:06:35-05:00",
"resolution_method": "manual_justification",
"justified_by_id": 40000,
"justified_by_name": "Jane Doe",
"justification_reason": "Contoso is our sole authorised reseller for this laptop model under the 2026 hardware framework agreement, so competing quotes are not available.",
"sam_gov_check_id": null,
"sam_gov_exclusion_details": null
},
"all_justified": false,
"evidence_pack_id": null
}
Example: all resolved (V1,
200 OK) (lists shortened to 2 items):
{
"violation": {
"id": 2,
"policy_id": 2,
"policy_name": "Price reasonableness documentation",
"policy_reference": "Price reasonableness documentation",
"violation_type": "missing_price_documentation",
"risk_level": "high",
"impact": "No evidence that the laptop price is reasonable",
"details": "The purchase order has no attachments. The policy requires a supplier quote, a catalog price list or a price comparison against at least one other supplier.",
"remediation_options": [
{
"type": "upload_documents",
"label": "Upload Supporting Documents",
"description": "Upload documents that satisfy the policy requirements"
},
{
"type": "generate_memo",
"label": "Generate Justification Memo",
"description": "AI will help generate documentation to address this violation"
}
],
"resolved": true,
"resolved_at": "2026-10-21T05:06:35-05:00",
"resolution_method": "manual_justification",
"justified_by_id": 60000,
"justified_by_name": "Marco Rossi",
"justification_reason": "Price matches the framework agreement rate card (USD 3,200 per unit), attached to the PO as the price-reasonableness evidence.",
"sam_gov_check_id": null,
"sam_gov_exclusion_details": null
},
"all_justified": true,
"evidence_pack_id": 3
}
V3 — V1
Resolves a compliance violation with a written justification.
Marks a violation as resolved (resolution_method: "manual_justification") and records
who justified it, when, and why. The reason is also written to the order as a
violation justified comment.
After saving, the endpoint counts the high and critical violations of the same scan
that are still unresolved. When none are left (all_justified: true):
* an audit ready comment is added to the order, and
* the scan's evidence pack is created (or reset to zip_pending and rebuilt) and its ID
returned as evidence_pack_id.
Justifying low or medium violations is allowed but is not needed for audit-readiness.
The order's compliance_status stays failed.
Only violations from scans of a purchase order can be justified. Violations from
metadata-only scans return 404.
HTTP request
| Version | Request |
|---|---|
| V1 | POST /api/v1/compliance/justify |
Permissions
companyadmin or finance in the request company. Other roles get
403 {"error": "Not authorized to justify violations"}. Access to the purchase order
itself is not checked.
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
violation_id |
integer | Yes | Violation id (top level, no root key) |
justification_reason |
string | Yes | Why the purchase may proceed. Leading and trailing whitespace is trimmed. At least 10 characters (422 JSON error otherwise) and at most 2,000 (longer reasons are rejected with a non-JSON 422 error page). |
Response
200 OK with the updated violation (see the Violation fields in the intro).
| Field | Type | Description |
|---|---|---|
violation |
object | The justified violation |
all_justified |
boolean | true when no high or critical violation of this scan remains unresolved |
evidence_pack_id |
integer | Evidence pack created or rebuilt because the scan became audit-ready, otherwise null |
Side effects
Adds violation justified (and, when the last blocking violation is resolved, audit
ready) comments to the order. Starts the evidence pack build, with
evidence_pack_generating and evidence_pack_ready pushed to the justifying user on
ComplianceCheckChannel.
V3 and V1 differences
V1 only. POST /api/v3/compliance/justify returns 404 {"error": "Invalid URL"}.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Missing violation | 400 |
V1 | {"error":"violation_id parameter is required","status":"bad_request"} |
| Reason too short | 422 |
V1 | {"error":"Justification reason must be provided and at least 10 characters long","status":"unprocessable_entity"} |
| Not found | 404 |
V1 | {"error":"Violation not found","status":"not_found"} |
| Forbidden | 403 |
V1 | {"error":"Not authorized to justify violations","status":"forbidden"} |
| Already resolved | 422 |
V1 | {"error":"Violation is already resolved","status":"unprocessable_entity"} |
Generate a price reasonableness memo
# V1 only: this endpoint has no V3 equivalent.
curl -X POST "https://app.procurementexpress.com/api/v1/compliance/generate_memo" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"memo_request": {
"violation_id": 1,
"item_description": "4 x 14-inch developer laptops, 32GB RAM",
"selected_vendor": "Contoso Hardware",
"selected_price": 12800,
"selection_rationale": "best_value",
"market_basis": "competitive_quotes",
"alternatives_considered": [
{
"vendor": "Northwind Office Supplies",
"price": 13600,
"notes": "Same model, 3-week lead time"
},
{
"vendor": "Globex Logistics",
"price": 12950,
"notes": "Refurbished units only"
}
]
}
}'
curl -X POST "https://app.procurementexpress.com/api/v1/compliance/generate_memo" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"memo_request": {
"violation_id": 1,
"item_description": "4 x 14-inch developer laptops, 32GB RAM",
"selected_vendor": "Contoso Hardware",
"selected_price": 12800,
"selection_rationale": "best_value",
"market_basis": "competitive_quotes",
"alternatives_considered": [
{
"vendor": "Northwind Office Supplies",
"price": 13600,
"notes": "Same model, 3-week lead time"
},
{
"vendor": "Globex Logistics",
"price": 12950,
"notes": "Refurbished units only"
}
]
}
}'
# V1 only: this endpoint has no V3 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/compliance/generate_memo",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
memo_request: {
violation_id: 1,
item_description: "4 x 14-inch developer laptops, 32GB RAM",
selected_vendor: "Contoso Hardware",
selected_price: 12800,
selection_rationale: "best_value",
market_basis: "competitive_quotes",
alternatives_considered: [
{
vendor: "Northwind Office Supplies",
price: 13600,
notes: "Same model, 3-week lead time"
},
{
vendor: "Globex Logistics",
price: 12950,
notes: "Refurbished units only"
}
]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/compliance/generate_memo",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
memo_request: {
violation_id: 1,
item_description: "4 x 14-inch developer laptops, 32GB RAM",
selected_vendor: "Contoso Hardware",
selected_price: 12800,
selection_rationale: "best_value",
market_basis: "competitive_quotes",
alternatives_considered: [
{
vendor: "Northwind Office Supplies",
price: 13600,
notes: "Same model, 3-week lead time"
},
{
vendor: "Globex Logistics",
price: 12950,
notes: "Refurbished units only"
}
]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
V1 response
422 Unprocessable Entity:
{
"error": "AI service temporarily unavailable. Please try again."
}
V3 — V1
Uses AI to draft a Price Reasonableness Memo from the purchase details you supply.
Sends the details to the AI (Gemini, with OpenAI as fallback) and returns a formal Price Reasonableness Memo in plain text, suitable for federal grant audit files (2 CFR 200.404). The call is synchronous and typically takes several seconds.
The memo is not stored: it is not attached to a violation, an order or an evidence pack. To keep it, upload it to the order or paste it into a justification.
selection_rationale and market_basis accept fixed keys, which become standard
phrases, or free text. Prices are parsed as numbers ($, , and spaces are stripped) and
dropped if they cannot be parsed. At most 10 alternatives are used, and each text value is
cut to 1,000 characters.
HTTP request
| Version | Request |
|---|---|
| V1 | POST /api/v1/compliance/generate_memo |
Permissions
companyadmin or finance in the request company. Other roles get 401 {"error": "Unauthorized access"}.
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
memo_request[ |
string | No | What is being bought |
memo_request[ |
string | No | Chosen supplier |
memo_request[ |
number | No | Chosen supplier's price |
memo_request[ |
string | No | lowest_price, best_value, sole_source, emergency, or free text Default: best_value. |
memo_request[ |
string | No | catalog_pricing, historical_pricing, competitive_quotes, market_research, or free text Default: market_research. |
memo_request[ |
string | No | Alternative supplier |
memo_request[ |
number | No | Its price |
memo_request[ |
string | No | Why it was not chosen |
memo_request[ |
integer | No | Accepted but currently ignored |
Response
On success, 200 OK with:
{
"memo": {
"id": "5b0f0d8e-4c1a-4f37-9a43-0c6a3c2f9e11",
"title": "Price Reasonableness Memo",
"content": "MEMORANDUM\n\nSUBJECT: Price Reasonableness Determination ...",
"pdf_url": null,
"generated_at": "2026-10-21T05:15:02-05:00"
}
}
When the AI cannot produce a memo, the endpoint returns 422 {"error": "..."}. The message
is AI service temporarily unavailable. Please try again. when both models fail, as in
the example. Without the memo_request root the response is
400 {"error": "Required parameter are missing."}.
| Field | Type | Description |
|---|---|---|
memo.id |
string | Random UUID for this response. Nothing is stored under it. |
memo.title |
string | Always Price Reasonableness Memo |
memo.content |
string | The memo text |
memo.pdf_url |
string | Always null |
memo.generated_at |
timestamp | ISO-8601 |
Side effects
One or two AI calls, recorded in the company's AI usage.
V3 and V1 differences
V1 only. POST /api/v3/compliance/generate_memo returns 404 {"error": "Invalid URL"}.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Forbidden | 401 |
V1 | {"error":"Unauthorized access","status":401} |
| Missing params | 400 |
V1 | {"error":"Required parameter are missing.","status":400} |
Create an evidence pack
# V1 only: this endpoint has no V3 equivalent.
curl -X POST "https://app.procurementexpress.com/api/v1/compliance/evidence_packs" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"compliance_check_id": 4
}'
curl -X POST "https://app.procurementexpress.com/api/v1/compliance/evidence_packs" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"compliance_check_id": 4
}'
# V1 only: this endpoint has no V3 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/compliance/evidence_packs",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
compliance_check_id: 4
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/compliance/evidence_packs",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
compliance_check_id: 4
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
V1 response
200 OK:
{
"message": "Evidence pack generation started",
"evidence_pack": {
"id": 4,
"compliance_check_id": 4,
"purchase_order_id": 26,
"po_snapshot": {
},
"attachments_metadata": [
],
"audit_log": [
],
"zip_status": "zip_pending",
"zip_error": null,
"zip_file_name": null,
"zip_file_size": null,
"zip_updated_at": null,
"download_url": null,
"created_at": "2026-10-21T05:07:15.146-05:00",
"updated_at": "2026-10-21T05:07:15.146-05:00"
}
}
Example: existing (V1,
200 OK):
{
"message": "Evidence pack generation started",
"evidence_pack": {
"id": 1,
"compliance_check_id": 2,
"purchase_order_id": 23,
"po_snapshot": {
},
"attachments_metadata": [
],
"audit_log": [
],
"zip_status": "zip_pending",
"zip_error": null,
"zip_file_name": null,
"zip_file_size": null,
"zip_updated_at": null,
"download_url": null,
"created_at": "2026-10-21T05:04:32.504-05:00",
"updated_at": "2026-10-21T05:04:32.504-05:00"
}
}
V3 — V1
Creates (or rebuilds) the evidence pack of a compliance check and starts building its ZIP.
Creates the evidence pack for a compliance check and queues the ZIP build. If the check
already has a pack that is not currently being built, the pack is reset to zip_pending
and rebuilt. If it is being built, the pack is returned unchanged.
The check must belong to a purchase order (422 for metadata-only checks). The endpoint
does not require the check to have passed. A pack can be built for a failed check too,
for example to document an exception.
Packs are created automatically when a scan passes, and when the last blocking violation
is justified. You mainly need this endpoint to build a pack on demand or to refresh one.
Poll GET /compliance/evidence_packs/:id until zip_status is zip_ready, or listen for
evidence_pack_ready on ComplianceCheckChannel.
HTTP request
| Version | Request |
|---|---|
| V1 | POST /api/v1/compliance/evidence_packs |
Permissions
Anyone who can collaborate on the check's purchase order (creator, mentioned user, or
companyadmin/approver/finance for non-drafts). Otherwise 401 {"error": "Unauthorized access"}.
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
compliance_check_id |
integer | Yes | Compliance check id (top level). Found in the scan result id, results_data[].compliance_check_id or latest_compliance_check.id on the order. |
Response
200 OK with {"message": "Evidence pack generation started", "evidence_pack": {...}}.
This response is returned both for a new pack and for an existing one. The evidence_pack
object is described under Get an evidence pack. A new pack starts with an empty
po_snapshot and attachments_metadata, which are filled in when the ZIP is built.
An unknown compliance_check_id (or none) returns 404 {"error": "Invalid id passed"}.
Side effects
Queues the ZIP build in the background.
V3 and V1 differences
V1 only. POST /api/v3/compliance/evidence_packs returns 404 {"error": "Invalid URL"}.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 404 |
V1 | {"error":"Invalid id passed","status":404} |
| No purchase order | 422 |
V1 | {"error":"Cannot create evidence pack: no associated purchase order"} |
| Forbidden | 401 |
V1 | {"error":"Unauthorized access","status":401} |
Get an evidence pack
# V1 only: this endpoint has no V3 equivalent.
curl "https://app.procurementexpress.com/api/v1/compliance/evidence_packs/1" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/compliance/evidence_packs/1" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
# V1 only: this endpoint has no V3 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/compliance/evidence_packs/1",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/compliance/evidence_packs/1",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
V1 response
200 OK:
{
"evidence_pack": {
"id": 1,
"compliance_check_id": 2,
"purchase_order_id": 23,
"po_snapshot": {
"id": 23,
"items": [
{
"total": 1800.0,
"quantity": 6.0,
"unit_price": 300.0,
"description": "Ergonomic office chair"
}
],
"status": "pending",
"creator": "Jane Doe",
"currency": "USD",
"supplier": "Northwind Office Supplies",
"po_number": null,
"created_at": "2026-10-21T05:01:58-05:00",
"department": "Operations",
"net_amount": 1800.0,
"approved_at": "2026-10-21T05:08:18-05:00",
"budget_name": "Office Supplies",
"supplier_id": 1,
"total_amount": 1800.0
},
"attachments_metadata": [
],
"audit_log": [
],
"zip_status": "zip_ready",
"zip_error": null,
"zip_file_name": "PO-23-20000-20261021_050818-compliance-evidence-pack.zip",
"zip_file_size": 47577,
"zip_updated_at": "2026-10-21T05:08:18.603-05:00",
"download_url": "/api/v1/compliance/evidence_packs/1/download",
"created_at": "2026-10-21T05:04:32.504-05:00",
"updated_at": "2026-10-21T05:08:19.621-05:00"
}
}
V3 — V1
Returns an evidence pack with its purchase order snapshot and ZIP status.
Returns the pack. po_snapshot and attachments_metadata describe the order at the
time the ZIP was last built. When zip_status is zip_ready, download_url is the API
path to call for a signed download link.
HTTP request
| Version | Request |
|---|---|
| V1 | GET /api/v1/compliance/evidence_packs/:id |
Permissions
Anyone who can collaborate on the pack's purchase order (401 otherwise). Packs of other companies return 404.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Evidence pack id |
Response
200 OK with {"evidence_pack": {...}}.
| Field | Type | Description |
|---|---|---|
evidence_pack.id |
integer | Evidence pack id |
evidence_pack.compliance_check_id |
integer | Compliance check it documents |
evidence_pack.purchase_order_id |
integer | Purchase order |
evidence_pack.po_snapshot |
object | Order at build time: id, po_number (order number or null), supplier, supplier_id, total_amount (gross), net_amount, currency, status, department, budget_name, creator, created_at, approved_at (currently the build time, not the approval time), items[] (description, quantity, unit_price, total), and sam_gov_check (status, checked_at, has_active_exclusions, total_records) when the supplier has been checked. Empty {} until the first build. |
evidence_pack.attachments_metadata |
array[object] | Order attachments at build time: id, name, type, size, uploaded_at |
evidence_pack.audit_log |
array[object] | Reserved. Currently always empty. The audit trail is in the order's comments and in metadata.md inside the ZIP. |
evidence_pack.zip_status |
string | zip_pending (queued), zip_generating, zip_ready or zip_failed |
evidence_pack.zip_error |
string | Why the last build failed (at most 255 characters), otherwise null |
evidence_pack.zip_file_name |
string | PO-<number or id>-<company id>-<YYYYMMDD_HHMMSS>-compliance-evidence-pack.zip, or null before the first build |
evidence_pack.zip_file_size |
integer | Bytes, or null |
evidence_pack.zip_updated_at |
timestamp | ISO-8601 time the ZIP was stored, or null |
evidence_pack.download_url |
string | When zip_ready, the relative API path /api/v1/compliance/evidence_packs/:id/download. Otherwise null. It is not a file URL. |
evidence_pack.created_at |
timestamp | ISO-8601 |
evidence_pack.updated_at |
timestamp | ISO-8601 |
V3 and V1 differences
V1 only. GET /api/v3/compliance/evidence_packs/:id returns 404 {"error": "Invalid URL"}.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 404 |
V1 | {"error":"Invalid id passed","status":404} |
| Forbidden | 401 |
V1 | {"error":"Unauthorized access","status":401} |
Download an evidence pack
# V1 only: this endpoint has no V3 equivalent.
curl "https://app.procurementexpress.com/api/v1/compliance/evidence_packs/1/download" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/compliance/evidence_packs/1/download" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
# V1 only: this endpoint has no V3 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/compliance/evidence_packs/1/download",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/compliance/evidence_packs/1/download",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
V1 response
200 OK:
{
"download_url": "https://pex-files.s3.amazonaws.com/evidence_packs/1/PO-23-20000-20261021_050818-compliance-evidence-pack.zip?response-content-disposition=attachment%3B%20filename%3D%22PO-23-20000-20261021_050818-compliance-evidence-pack.zip%22%3B%20filename%2A%3DUTF-8%27%27PO-23-20000-20261021_050818-compliance-evidence-pack.zip&response-content-type=application%2Foctet-stream&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20261021%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20261021T100825Z&X-Amz-Expires=28800&X-Amz-SignedHeaders=host&X-Amz-Signature=d4a6d0e44027b962f7dca889a568d62b75bc114ce6fda6a17e26e2ad303ab582",
"file_name": "PO-23-20000-20261021_050818-compliance-evidence-pack.zip",
"file_size": 47577
}
V3 — V1
Returns a short-lived signed URL for the evidence pack ZIP.
Returns a pre-signed storage URL for the ZIP, valid for 8 hours. Fetch it with a
plain GET, without the API headers. The file is served as an attachment
(application/octet-stream).
The ZIP must be zip_ready. While it is pending, building or failed, the endpoint
answers 422, with the current zip_status in the status field. A rebuild
(regenerate, or a new justification) also makes the previous ZIP unavailable until the
new one is ready.
HTTP request
| Version | Request |
|---|---|
| V1 | GET /api/v1/compliance/evidence_packs/:id/download |
Permissions
Anyone who can collaborate on the pack's purchase order (401 otherwise).
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Evidence pack id |
Response
200 OK with download_url (a pre-signed S3 URL), file_name and file_size (bytes).
| Field | Type | Description |
|---|---|---|
download_url |
string | Pre-signed URL, valid for 8 hours |
file_name |
string | ZIP file name |
file_size |
integer | Size in bytes |
V3 and V1 differences
V1 only. GET /api/v3/compliance/evidence_packs/:id/download returns 404 {"error": "Invalid URL"}.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not ready | 422 |
V1 | {"error":"Evidence pack is not ready for download","status":"zip_pending"} |
| Generating | 422 |
V1 | {"error":"Evidence pack is not ready for download","status":"zip_generating"} |
| Not found | 404 |
V1 | {"error":"Invalid id passed","status":404} |
Rebuild an evidence pack
# V1 only: this endpoint has no V3 equivalent.
curl -X POST "https://app.procurementexpress.com/api/v1/compliance/evidence_packs/1/regenerate" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl -X POST "https://app.procurementexpress.com/api/v1/compliance/evidence_packs/1/regenerate" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
# V1 only: this endpoint has no V3 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/compliance/evidence_packs/1/regenerate",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/compliance/evidence_packs/1/regenerate",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
V1 response
200 OK:
{
"message": "Evidence pack regeneration started",
"evidence_pack": {
"id": 1,
"compliance_check_id": 2,
"purchase_order_id": 23,
"po_snapshot": {
"id": 23,
"items": [
{
"total": 1800.0,
"quantity": 6.0,
"unit_price": 300.0,
"description": "Ergonomic office chair"
}
],
"status": "pending",
"creator": "Jane Doe",
"currency": "USD",
"supplier": "Northwind Office Supplies",
"po_number": null,
"created_at": "2026-10-21T05:01:58-05:00",
"department": "Operations",
"net_amount": 1800.0,
"approved_at": "2026-10-21T05:08:18-05:00",
"budget_name": "Office Supplies",
"supplier_id": 1,
"total_amount": 1800.0
},
"attachments_metadata": [
],
"audit_log": [
],
"zip_status": "zip_pending",
"zip_error": null,
"zip_file_name": "PO-23-20000-20261021_050818-compliance-evidence-pack.zip",
"zip_file_size": 47577,
"zip_updated_at": "2026-10-21T05:08:18.603-05:00",
"download_url": null,
"created_at": "2026-10-21T05:04:32.504-05:00",
"updated_at": "2026-10-21T05:08:40.304-05:00"
}
}
V3 — V1
Rebuilds an evidence pack's snapshot and ZIP from the current state of the purchase order.
Resets the pack to zip_pending and queues a rebuild. The rebuild takes a new snapshot
of the order and its attachments, re-renders the PDF, includes the full scan history and
replaces the ZIP. Use it after attachments were added or violations were justified. No
request body is needed.
The previous ZIP stays in the pack record (zip_file_name, zip_file_size), but
download_url is null and the download endpoint answers 422 until the new ZIP is ready.
HTTP request
| Version | Request |
|---|---|
| V1 | POST /api/v1/compliance/evidence_packs/:id/regenerate |
Permissions
Anyone who can collaborate on the pack's purchase order (401 otherwise).
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Evidence pack id |
Response
200 OK with {"message": "Evidence pack regeneration started", "evidence_pack": {...}}.
A pack that is currently zip_generating returns
422 {"error": "Evidence pack is currently being generated"}. A pack whose purchase order
is gone returns 422.
Side effects
Queues the ZIP build. When it runs, evidence_pack_generating and then
evidence_pack_ready (or evidence_pack_failed) are pushed to the caller on ComplianceCheckChannel.
V3 and V1 differences
V1 only. POST /api/v3/compliance/evidence_packs/:id/regenerate returns 404 {"error": "Invalid URL"}.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| In progress | 422 |
V1 | {"error":"Evidence pack is currently being generated"} |
| Not found | 404 |
V1 | {"error":"Invalid id passed","status":404} |
| Forbidden | 401 |
V1 | {"error":"Unauthorized access","status":401} |
Currencies
Currencies are global reference data (ISO 4217). Each company has a default
currency and a list of supported currencies that can be used on purchase orders,
budgets and invoices. Use GET /currencies for the currencies a company may use
and GET /all_currencies for the full list (for example to map an ISO code to the
currency id that other endpoints expect).
Endpoints in this section
| Endpoint | Method | Path | V3 | V1 |
|---|---|---|---|---|
| List the company's currencies | GET |
/currencies |
✓ | ✓ |
| List all currencies | GET |
/all_currencies |
✓ | ✓ |
The currency object
| Field | Type | Description |
|---|---|---|
id |
integer | Currency id, used as currency_id elsewhere. |
iso_code |
string | ISO 4217 alphabetic code, e.g. USD. |
iso_numeric |
string | ISO 4217 numeric code as a string, e.g. 840. |
name |
string | English currency name. |
symbol |
string | Display symbol (A$ for AUD and C$ for CAD). |
created_at |
timestamp | ISO-8601 with offset. |
updated_at |
timestamp | ISO-8601 with offset. |
List the company's currencies
curl "https://app.procurementexpress.com/api/v3/currencies" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/currencies" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/currencies",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/currencies",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1 (lists shortened to 2 items):
[
{
"id": 2000,
"iso_code": "USD",
"iso_numeric": "840",
"name": "United States Dollar",
"symbol": "$",
"created_at": "2026-10-21T04:44:44.793-05:00",
"updated_at": "2026-10-21T04:44:44.793-05:00"
},
{
"id": 1000,
"iso_code": "EUR",
"iso_numeric": "978",
"name": "Euro",
"symbol": "€",
"created_at": "2026-10-21T04:44:44.790-05:00",
"updated_at": "2026-10-21T04:44:44.790-05:00"
}
]
V3 V1
Currencies enabled for the company in app_company_id, default currency first.
Returns the company's default currency followed by its other supported currencies (company settings). These are the currencies users can pick on purchase orders and budgets in that company.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/currencies |
| V1 | GET /api/v1/currencies |
Permissions
Any member of the company (all roles).
Response
200 with an array of Currency objects; the first element is the company's default currency.
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Missing company | 401 |
V3, V1 | {"error":"App company id is nil","status":401} |
List all currencies
curl "https://app.procurementexpress.com/api/v3/all_currencies" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/all_currencies" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/all_currencies",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/all_currencies",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1 (lists shortened to 2 items):
[
{
"id": 1000,
"iso_code": "EUR",
"iso_numeric": "978",
"name": "Euro",
"symbol": "€",
"created_at": "2026-10-21T04:44:44.790-05:00",
"updated_at": "2026-10-21T04:44:44.790-05:00"
},
{
"id": 2000,
"iso_code": "USD",
"iso_numeric": "840",
"name": "United States Dollar",
"symbol": "$",
"created_at": "2026-10-21T04:44:44.793-05:00",
"updated_at": "2026-10-21T04:44:44.793-05:00"
}
]
Example: anonymous (V3,
200 OK) (lists shortened to 2 items):
[
{
"id": 6000,
"iso_code": "AUD",
"iso_numeric": "036",
"name": "Australian Dollar",
"symbol": "A$",
"created_at": "2026-10-21T04:44:44.798-05:00",
"updated_at": "2026-10-21T04:44:44.798-05:00"
},
{
"id": 4000,
"iso_code": "CAD",
"iso_numeric": "124",
"name": "Canadian Dollar",
"symbol": "C$",
"created_at": "2026-10-21T04:44:44.796-05:00",
"updated_at": "2026-10-21T04:44:44.796-05:00"
}
]
V3 V1
Every currency known to ProcurementExpress; works without authentication.
Returns all currencies. No credential or company header is needed.
- Anonymous (or not a live member of the
app_company_idcompany): every currency, ordered by ISO code. - Authenticated and
app_company_idnames a company you are a live member of: the (up to four) currencies most used on that company's purchase orders come first, then the rest; both groups are ordered by ISO code.
The answer never reveals whether a company id exists.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/all_currencies |
| V1 | GET /api/v1/all_currencies |
Permissions
Public.
Response
200 with an array of Currency objects.
V3 and V1 differences
None - identical request and response. (V3 additionally ignores an expired or revoked bearer token, treating the request as anonymous.)
Custom Fields
Custom fields are company-defined extra fields shown on purchase orders, PO line items,
invoices, budgets, suppliers, products and RFQs. The definitions are managed with the
V1-only /custom_fields endpoints (company admins); the values are written when you
create or update a purchase order, or one at a time with
Set a custom field value (V1 and V3).
Field types (field_type):
| Type | Value |
|---|---|
string |
Single line of text, max 255 characters. |
text |
Multi-line text. |
select |
One of the field's options (set with option_list, one option per line). |
date |
A date written in the company date format (company_setting.date_format, e.g. 10/21/2026). Read back as value = unix seconds plus value_str (as stored) and date_format. |
decimal |
Number; precision_display sets the decimals shown. |
boolean |
Yes/No: send "1"/"0" (the web form) or "true"/"false". |
tel |
Phone number (free text). |
url |
Link. |
label |
Display-only heading/instructions; holds no value. |
long_list |
Long option list synchronised from QuickBooks; reported by the API as field_type: "select" with readonly: true. |
computed |
Read-only number calculated from formula_builder (numbers, + - * / ( ) and {{Field name}} references to numeric budget fields). |
Where a field applies. A field with no scope flag is a PO header field;
on_line_item: true makes it a PO line item field. Invoice (on_invoice,
on_invoice_item), budget (on_budget), supplier (on_supplier), product (on_product) and
RFQ (on_rfq) fields are configured in the web app; the V1 API cannot set those flags and
the definition responses only expose on_line_item (use context=all to list them).
Access level (access_level): everyone; approvers_and_finance (hidden from users who
are only team members); finance_only.
Sending values when creating a purchase order. Every active PO header field gets a
value row when the PO is saved (the field's default_value, or "Not Set"), and every
line gets rows for the active line item fields. Supply your values in the PO create/update
body:
{
"commit": "Send",
"purchase_order": {
"supplier_id": 1,
"department_id": 4,
"custom_field_values_attributes": [
{ "custom_field_id": 1, "value": "PRJ-2041" },
{ "custom_field_id": 2, "value": "CC-300 Operations" }
],
"purchase_order_items_attributes": [
{
"description": "Label printer",
"quantity": 1,
"unit_price": 149.0,
"budget_id": 3,
"custom_field_values_attributes": [
{ "custom_field_id": 3, "value": "AT-001200" }
]
}
]
}
}
When updating a PO, include each value's id (from custom_field_values[].id in the PO
details) to change it in place. Required (required: true), active, everyone fields must
have a value when the PO is submitted (commit: "Send"); drafts may leave them empty.
The older purchase_order[custom_fields] hash is a legacy free-form store and does not
fill these fields.
The values of a PO are returned in custom_field_values[] (header) and
purchase_order_items[].custom_field_values[] (lines) of
Get purchase order details.
Endpoints in this section
| Endpoint | Method | Path | V3 | V1 |
|---|---|---|---|---|
| List custom fields | GET |
/custom_fields |
— | ✓ |
| Get a custom field | GET |
/custom_fields/:id |
— | ✓ |
| Create a custom field | POST |
/custom_fields |
— | ✓ |
| Update a custom field | PUT |
/custom_fields/:id |
— | ✓ |
| Delete (archive) a custom field | DELETE |
/custom_fields/:id |
— | ✓ |
| Reorder custom fields | PATCH |
/custom_fields/update_positions |
— | ✓ |
| Set a custom field value | POST |
/custom_field_values |
✓ | ✓ |
The custom field object
| Field | Type | Description |
|---|---|---|
id |
integer | Custom field id (custom_field_id in values). |
company_id |
integer | Owning company. |
name |
string | Field label, max 255 characters; unique (case-insensitive) within its scope (PO header + line items, invoice, budget, ...). |
field_type |
string | One of label, string, select, date, tel, text, decimal, boolean, url, computed (long_list fields are reported as select). |
active |
boolean | Inactive fields are hidden from forms and get no default values on new POs. |
required |
boolean | Value required when a PO is submitted (only enforced for active everyone fields; ignored for label/boolean). |
options |
array[string] | Options of a select (or long_list) field. |
option_list |
string | The same options as one newline-separated string (this is what you write). |
default_value |
string | Value given to new POs; for select it must be one of the options. |
access_level |
string | everyone, approvers_and_finance or finance_only. |
position |
integer | Sort order (ascending). |
on_line_item |
boolean | true for PO line item fields, false for PO header (and other scope) fields. |
display_on_pdf |
boolean | Print the field on the PO PDF. |
display_on_pdf_even_if_value_is_nil |
boolean | Print it even when empty. |
editable_after_approval |
boolean | Value may still be changed after the PO is approved (null = no). |
readonly |
boolean | true for long_list (QuickBooks-managed) fields. |
archived |
boolean | Archived (deleted) fields are hidden from the list unless include_archived=true. |
formula_builder |
string | Formula of a computed field. |
precision_display |
integer | Decimals shown for numeric fields (default 2). |
show_on_receiving |
boolean | Show the (line item) field on the receiving screen. |
webhook_enabled |
boolean | Value populated by an inbound webhook (Great Plains); read-only. |
response_populated |
boolean | Value populated from an outbound export response; read-only. |
payload_included |
boolean | Value included in the outbound export payload. |
is_auto_populated |
boolean | webhook_enabled or response_populated. |
created_at |
timestamp | ISO-8601 with offset. |
updated_at |
timestamp | ISO-8601 with offset. |
List custom fields
# V1 only: this endpoint has no V3 equivalent.
curl "https://app.procurementexpress.com/api/v1/custom_fields" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/custom_fields" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
# V1 only: this endpoint has no V3 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/custom_fields",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/custom_fields",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
V1 response
200 OK:
[
{
"id": 1,
"company_id": 20000,
"name": "Project code",
"field_type": "string",
"active": true,
"required": false,
"options": [
],
"access_level": "everyone",
"position": 0,
"on_line_item": false,
"display_on_pdf": true,
"default_value": null,
"editable_after_approval": null,
"readonly": false,
"archived": false,
"formula_builder": null,
"precision_display": 2,
"display_on_pdf_even_if_value_is_nil": true,
"option_list": "",
"created_at": "2026-10-21T04:48:43.644-05:00",
"updated_at": "2026-10-21T04:48:43.644-05:00",
"webhook_enabled": false,
"response_populated": false,
"payload_included": false,
"is_auto_populated": false,
"show_on_receiving": false
},
{
"id": 2,
"company_id": 20000,
"name": "Cost center",
"field_type": "select",
"active": true,
"required": false,
"options": [
"CC-100 Engineering",
"CC-200 Marketing",
"CC-300 Operations"
],
"access_level": "everyone",
"position": 1,
"on_line_item": false,
"display_on_pdf": true,
"default_value": null,
"editable_after_approval": null,
"readonly": false,
"archived": false,
"formula_builder": null,
"precision_display": 2,
"display_on_pdf_even_if_value_is_nil": true,
"option_list": "CC-100 Engineering\nCC-200 Marketing\nCC-300 Operations",
"created_at": "2026-10-21T04:49:01.957-05:00",
"updated_at": "2026-10-21T04:49:01.957-05:00",
"webhook_enabled": false,
"response_populated": false,
"payload_included": false,
"is_auto_populated": false,
"show_on_receiving": false
}
]
Example: line item (V1,
200 OK):
[
{
"id": 3,
"company_id": 20000,
"name": "Asset tag",
"field_type": "string",
"active": true,
"required": false,
"options": [
],
"access_level": "everyone",
"position": 2,
"on_line_item": true,
"display_on_pdf": true,
"default_value": null,
"editable_after_approval": null,
"readonly": false,
"archived": false,
"formula_builder": null,
"precision_display": 2,
"display_on_pdf_even_if_value_is_nil": true,
"option_list": "",
"created_at": "2026-10-21T04:49:01.986-05:00",
"updated_at": "2026-10-21T04:49:01.986-05:00",
"webhook_enabled": false,
"response_populated": false,
"payload_included": false,
"is_auto_populated": false,
"show_on_receiving": false
}
]
Example: include archived (V1,
200 OK) (lists shortened to 2 items):
[
{
"id": 1,
"company_id": 20000,
"name": "Project code",
"field_type": "string",
"active": true,
"required": false,
"options": [
],
"access_level": "everyone",
"position": 0,
"on_line_item": false,
"display_on_pdf": true,
"default_value": null,
"editable_after_approval": null,
"readonly": false,
"archived": false,
"formula_builder": null,
"precision_display": 2,
"display_on_pdf_even_if_value_is_nil": true,
"option_list": "",
"created_at": "2026-10-21T04:48:43.644-05:00",
"updated_at": "2026-10-21T04:48:43.644-05:00",
"webhook_enabled": false,
"response_populated": false,
"payload_included": false,
"is_auto_populated": false,
"show_on_receiving": false
},
{
"id": 2,
"company_id": 20000,
"name": "Cost center",
"field_type": "select",
"active": true,
"required": false,
"options": [
"CC-100 Engineering",
"CC-200 Marketing",
"CC-300 Operations"
],
"access_level": "everyone",
"position": 1,
"on_line_item": false,
"display_on_pdf": true,
"default_value": null,
"editable_after_approval": null,
"readonly": false,
"archived": false,
"formula_builder": null,
"precision_display": 2,
"display_on_pdf_even_if_value_is_nil": true,
"option_list": "CC-100 Engineering\nCC-200 Marketing\nCC-300 Operations",
"created_at": "2026-10-21T04:49:01.957-05:00",
"updated_at": "2026-10-21T04:49:01.957-05:00",
"webhook_enabled": false,
"response_populated": false,
"payload_included": false,
"is_auto_populated": false,
"show_on_receiving": false
}
]
V3 — V1
List the company's custom field definitions.
Returns the company's custom field definitions ordered by position, without pagination.
By default only active and inactive PO header fields that are not archived are returned.
HTTP request
| Version | Request |
|---|---|
| V1 | GET /api/v1/custom_fields |
Permissions
Any user of the company.
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
context |
string | No | purchase_order (default): PO header fields; line_item: PO line item fields; any other value (e.g. all): every field of the company, including invoice, budget, supplier, product and RFQ fields. Default: purchase_order. |
include_archived |
boolean | No | true also returns archived (deleted) fields. Default: false. |
Response
200 with a JSON array of Custom field objects.
V3 and V1 differences
V1 only. There is no V3 route: GET /api/v3/custom_fields returns
404 {"error": "Invalid URL"}.
Get a custom field
# V1 only: this endpoint has no V3 equivalent.
curl "https://app.procurementexpress.com/api/v1/custom_fields/2" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/custom_fields/2" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
# V1 only: this endpoint has no V3 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/custom_fields/2",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/custom_fields/2",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
V1 response
200 OK:
{
"id": 2,
"company_id": 20000,
"name": "Cost center",
"field_type": "select",
"active": true,
"required": false,
"options": [
"CC-100 Engineering",
"CC-200 Marketing",
"CC-300 Operations"
],
"access_level": "everyone",
"position": 1,
"on_line_item": false,
"display_on_pdf": true,
"default_value": null,
"editable_after_approval": null,
"readonly": false,
"archived": false,
"formula_builder": null,
"precision_display": 2,
"display_on_pdf_even_if_value_is_nil": true,
"option_list": "CC-100 Engineering\nCC-200 Marketing\nCC-300 Operations",
"created_at": "2026-10-21T04:49:01.957-05:00",
"updated_at": "2026-10-21T04:49:01.957-05:00",
"webhook_enabled": false,
"response_populated": false,
"payload_included": false,
"is_auto_populated": false,
"show_on_receiving": false
}
V3 — V1
Get one custom field definition (archived fields included).
Returns one custom field of the company, also when it is archived.
HTTP request
| Version | Request |
|---|---|
| V1 | GET /api/v1/custom_fields/:id |
Permissions
Any user of the company.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Custom field id. |
Response
200 with the Custom field object; 404 {"error": "Invalid id passed"} for an unknown id
or a field of another company.
V3 and V1 differences
V1 only; GET /api/v3/custom_fields/:id returns 404 {"error": "Invalid URL"}
.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 404 |
V1 | {"error":"Invalid id passed","status":404} |
Create a custom field
# V1 only: this endpoint has no V3 equivalent.
curl -X POST "https://app.procurementexpress.com/api/v1/custom_fields" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"custom_field": {
"name": "Delivery priority",
"field_type": "select",
"option_list": "Standard\nExpress\nOvernight",
"default_value": "Standard",
"required": true,
"access_level": "everyone",
"on_line_item": false,
"display_on_pdf": true,
"editable_after_approval": true
}
}'
curl -X POST "https://app.procurementexpress.com/api/v1/custom_fields" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"custom_field": {
"name": "Delivery priority",
"field_type": "select",
"option_list": "Standard\nExpress\nOvernight",
"default_value": "Standard",
"required": true,
"access_level": "everyone",
"on_line_item": false,
"display_on_pdf": true,
"editable_after_approval": true
}
}'
# V1 only: this endpoint has no V3 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/custom_fields",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
custom_field: {
name: "Delivery priority",
field_type: "select",
option_list: "Standard\nExpress\nOvernight",
default_value: "Standard",
required: true,
access_level: "everyone",
on_line_item: false,
display_on_pdf: true,
editable_after_approval: true
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/custom_fields",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
custom_field: {
name: "Delivery priority",
field_type: "select",
option_list: "Standard\nExpress\nOvernight",
default_value: "Standard",
required: true,
access_level: "everyone",
on_line_item: false,
display_on_pdf: true,
editable_after_approval: true
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
V1 response
201 Created:
{
"id": 5,
"company_id": 20000,
"name": "Delivery priority",
"field_type": "select",
"active": true,
"required": true,
"options": [
"Standard",
"Express",
"Overnight"
],
"access_level": "everyone",
"position": 5,
"on_line_item": false,
"display_on_pdf": true,
"default_value": "Standard",
"editable_after_approval": true,
"readonly": false,
"archived": false,
"formula_builder": null,
"precision_display": 2,
"display_on_pdf_even_if_value_is_nil": true,
"option_list": "Standard\nExpress\nOvernight",
"created_at": "2026-10-21T05:08:04.628-05:00",
"updated_at": "2026-10-21T05:08:04.628-05:00",
"webhook_enabled": false,
"response_populated": false,
"payload_included": false,
"is_auto_populated": false,
"show_on_receiving": false
}
V3 — V1
Create a PO header or line item custom field.
Creates a custom field at the end of the list (position = number of fields). Only PO header
fields and PO line item fields (on_line_item: true) can be created through the API; scope
flags such as on_budget or on_invoice are ignored.
Validation (400 with an array of messages): name present and unique within the scope
("Name Already exist"), field_type one of the types listed above
("Field type is not included in the list"), option_list required for select, each option
max 255 characters, default_value must be one of the options, formula_builder required
and valid for computed.
A new active header or line item field is immediately added (with its default value or
"Not Set") to every purchase order created afterwards; a required one blocks submitting
POs that do not supply it.
HTTP request
| Version | Request |
|---|---|
| V1 | POST /api/v1/custom_fields |
Permissions
Company admins only; others get 401 Unauthorized access.
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
custom_field[ |
string | Yes | Field label (truncated to 255 characters). |
custom_field[ |
string | Yes | label, string, select, date, tel, text, decimal, boolean, url, long_list or computed. |
custom_field[ |
string | No | Options of a select, one per line ("Standard\nExpress\nOvernight"). Required for select. |
custom_field[ |
string | No | Default for new POs; must be one of the options for select. |
custom_field[ |
boolean | No | Value required on submit. Default: false. |
custom_field[ |
boolean | No | Inactive fields are hidden and not added to new POs. Default: true. |
custom_field[ |
string | No | everyone, approvers_and_finance or finance_only; any other value is ignored. Default: everyone. |
custom_field[ |
boolean | No | true for a PO line item field. Default: false. |
custom_field[ |
boolean | No | Print on the PO PDF. Default: true. |
custom_field[ |
boolean | No | Print even when empty. Default: true. |
custom_field[ |
boolean | No | Allow changing the value after approval. |
custom_field[ |
string | No | Formula for computed fields, e.g. {{Annual budget}} / 12. |
custom_field[ |
integer | No | Decimals displayed for numeric fields. Default: 2. |
custom_field[ |
boolean | No | Show on the receiving screen. Default: false. |
custom_field[ |
boolean | No | Create the field archived (rarely useful). Default: false. |
Response
201 Created with the Custom field object.
V3 and V1 differences
V1 only; there is no V3 route (/api/v3/custom_fields -> 404 Invalid URL).
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Missing options | 400 |
V1 | {"error":["Option list can't be blank (you may be seeing placeholder text instead of actual content)"],"status":400} |
| Duplicate | 400 |
V1 | {"error":["Name Already exist"],"status":400} |
| Invalid type | 400 |
V1 | {"error":["Field type is not included in the list"],"status":400} |
| Bad default | 400 |
V1 | {"error":["Default value must be one of the options in the option list"],"status":400} |
| Missing root | 400 |
V1 | {"error":"Required parameter are missing.","status":400} |
| Unauthorized | 401 |
V1 | {"error":"Unauthorized access","status":401} |
Update a custom field
# V1 only: this endpoint has no V3 equivalent.
curl -X PUT "https://app.procurementexpress.com/api/v1/custom_fields/6" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"custom_field": {
"name": "Requested delivery window",
"field_type": "select",
"option_list": "Morning\nAfternoon\nEvening",
"default_value": "Morning",
"access_level": "approvers_and_finance",
"display_on_pdf": false
}
}'
curl -X PUT "https://app.procurementexpress.com/api/v1/custom_fields/6" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"custom_field": {
"name": "Requested delivery window",
"field_type": "select",
"option_list": "Morning\nAfternoon\nEvening",
"default_value": "Morning",
"access_level": "approvers_and_finance",
"display_on_pdf": false
}
}'
# V1 only: this endpoint has no V3 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v1/custom_fields/6",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
custom_field: {
name: "Requested delivery window",
field_type: "select",
option_list: "Morning\nAfternoon\nEvening",
default_value: "Morning",
access_level: "approvers_and_finance",
display_on_pdf: false
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v1/custom_fields/6",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
custom_field: {
name: "Requested delivery window",
field_type: "select",
option_list: "Morning\nAfternoon\nEvening",
default_value: "Morning",
access_level: "approvers_and_finance",
display_on_pdf: false
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
V1 response
200 OK:
{
"id": 6,
"company_id": 20000,
"name": "Requested delivery window",
"field_type": "select",
"active": false,
"required": false,
"options": [
"Morning",
"Afternoon",
"Evening"
],
"access_level": "approvers_and_finance",
"position": 6,
"on_line_item": false,
"display_on_pdf": false,
"default_value": "Morning",
"editable_after_approval": null,
"readonly": false,
"archived": false,
"formula_builder": null,
"precision_display": 2,
"display_on_pdf_even_if_value_is_nil": true,
"option_list": "Morning\nAfternoon\nEvening",
"created_at": "2026-10-21T05:08:40.605-05:00",
"updated_at": "2026-10-21T05:09:05.856-05:00",
"webhook_enabled": false,
"response_populated": false,
"payload_included": false,
"is_auto_populated": false,
"show_on_receiving": false
}
V3 — V1
Change a custom field definition.
Updates the given attributes (same parameters and validation as create; omitted attributes
are unchanged). Archived fields can be updated too - send archived: false to restore one.
Renaming a field also renames it in the company's PO list column settings. A field used in a
computed formula cannot be renamed, deactivated or change type or access level, and a field
used in the PO number sequence cannot be made optional or inactive.
HTTP request
| Version | Request |
|---|---|
| V1 | `PUT |
Permissions
Company admins only; others get 401 Unauthorized access.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Custom field id. |
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
custom_field[ |
object | Yes | Any of the create parameters (name, field_type, option_list, default_value, required, active, access_level, on_line_item, display_on_pdf, display_on_pdf_even_if_value_is_nil, editable_after_approval, formula_builder, precision_display, show_on_receiving, archived). |
Response
200 with the updated Custom field object.
V3 and V1 differences
V1 only; there is no V3 route.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Invalid | 400 |
V1 | {"error":["Default value must be one of the options in the option list"],"status":400} |
| Unauthorized | 401 |
V1 | {"error":"Unauthorized access","status":401} |
Delete (archive) a custom field
# V1 only: this endpoint has no V3 equivalent.
curl -X DELETE "https://app.procurementexpress.com/api/v1/custom_fields/6" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl -X DELETE "https://app.procurementexpress.com/api/v1/custom_fields/6" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
# V1 only: this endpoint has no V3 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :delete,
url: "https://app.procurementexpress.com/api/v1/custom_fields/6",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :delete,
url: "https://app.procurementexpress.com/api/v1/custom_fields/6",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
V1 response
200 OK:
{
"archived": true
}
V3 — V1
Archive a custom field.
Soft-deletes the field by setting archived: true: it disappears from the list (unless
include_archived=true) and from forms, while values already stored on purchase orders are
kept. Restore it with Update a custom field (archived: false).
HTTP request
| Version | Request |
|---|---|
| V1 | DELETE /api/v1/custom_fields/:id |
Permissions
Company admins only; others get 401 Unauthorized access.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Custom field id. |
Response
200 {"archived": true}.
V3 and V1 differences
V1 only; DELETE /api/v3/custom_fields/:id returns 404 {"error": "Invalid URL"}
.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 404 |
V1 | {"error":"Invalid id passed","status":404} |
| Unauthorized | 401 |
V1 | {"error":"Unauthorized access","status":401} |
Reorder custom fields
# V1 only: this endpoint has no V3 equivalent.
curl -X PATCH "https://app.procurementexpress.com/api/v1/custom_fields/update_positions" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"positions": {
"6": 5,
"5": 6
}
}'
curl -X PATCH "https://app.procurementexpress.com/api/v1/custom_fields/update_positions" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"positions": {
"6": 5,
"5": 6
}
}'
# V1 only: this endpoint has no V3 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :patch,
url: "https://app.procurementexpress.com/api/v1/custom_fields/update_positions",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
positions: {
"6": 5,
"5": 6
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :patch,
url: "https://app.procurementexpress.com/api/v1/custom_fields/update_positions",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
positions: {
"6": 5,
"5": 6
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
V1 response
200 OK:
{
"success": true
}
V3 — V1
Set the display position of several custom fields at once.
Sets position for each field id in positions. Ids that are not fields of the company are
ignored; fields not listed keep their position. Only PATCH works - PUT is routed to
Update a custom field with id update_positions and returns 404.
HTTP request
| Version | Request |
|---|---|
| V1 | PATCH /api/v1/custom_fields/update_positions |
Permissions
Company admins only; others get 401 Unauthorized access.
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
positions |
object | Yes | Map of custom field id to new position, e.g. {"6": 5, "5": 6}. |
Response
200 {"success": true} (also when positions is empty).
V3 and V1 differences
V1 only; PATCH /api/v3/custom_fields/update_positions returns 404 Invalid URL
.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Unauthorized | 401 |
V1 | {"error":"Unauthorized access","status":401} |
Set a custom field value
curl -X POST "https://app.procurementexpress.com/api/v3/custom_field_values" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"custom_field_value": {
"id": 47,
"custom_field_id": 2,
"purchase_order_id": 12,
"value": "CC-300 Operations"
}
}'
curl -X POST "https://app.procurementexpress.com/api/v1/custom_field_values" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"custom_field_value": {
"id": 43,
"custom_field_id": 2,
"purchase_order_id": 11,
"value": "CC-300 Operations"
}
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/custom_field_values",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
custom_field_value: {
id: 47,
custom_field_id: 2,
purchase_order_id: 12,
value: "CC-300 Operations"
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/custom_field_values",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
custom_field_value: {
id: 43,
custom_field_id: 2,
purchase_order_id: 11,
value: "CC-300 Operations"
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 47,
"purchase_order_id": 12,
"value": "CC-300 Operations",
"custom_field_id": 2,
"purchase_order_item_id": null,
"date_format": null,
"budget_id": null
}
Example: line item (V3,
200 OK):
{
"id": 44,
"purchase_order_id": null,
"value": "AT-000981",
"custom_field_id": 3,
"purchase_order_item_id": 22,
"date_format": null,
"budget_id": null
}
Example: new (V3,
200 OK):
{
"id": 191,
"purchase_order_id": 14,
"value": "Express",
"custom_field_id": 5,
"purchase_order_item_id": null,
"date_format": null,
"budget_id": null
}
Example: date (V3,
200 OK):
{
"id": 194,
"purchase_order_id": 16,
"value": 1794700800,
"custom_field_id": 7,
"purchase_order_item_id": null,
"date_format": "%m/%d/%Y",
"budget_id": null,
"value_str": "11/15/2026"
}
V3 V1
Set the value of one custom field on a purchase order or a PO line.
Writes a single custom field value without editing the whole purchase order (the web app's
inline editor). For a PO header field send purchase_order_id; for a line item field send
purchase_order_item_id.
Pass the value's id. Purchase orders already hold a value row for every active field
(created with the PO), so in practice you update an existing row: take its id from
custom_field_values[] / purchase_order_items[].custom_field_values[] in the PO details.
Without id a new row is created - this only works for a header field the PO has no value for
yet (for example a field added after the PO was created); otherwise the request fails with a
500. An id belonging to another PO or line returns 404.
Values are stored as strings; select values are not checked against the options. A changed
value adds an audit comment to the PO ("Cost center changed from Not Set to CC-300 Operations").
HTTP request
| Version | Request |
|---|---|
| V3 | POST /api/v3/custom_field_values |
| V1 | POST /api/v1/custom_field_values |
Permissions
The caller must be able to view the PO, have access to the field's access_level, the PO
must not be cancelled, and either the field is editable_after_approval or the caller may
edit the PO (finance users; approvers of a pending PO; the creator of a draft). Otherwise
403 {"error": "Unauthorized access"}.
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
custom_field_value[ |
integer | Yes | Custom field id (400 "custom_field_id is required" when missing/unknown). |
custom_field_value[ |
integer | No | Purchase order id - required for header fields. |
custom_field_value[ |
integer | No | Purchase order item id - required for line item fields. |
custom_field_value[ |
integer | No | Existing value id to update (recommended). |
custom_field_value[ |
string | Yes | New value. string max 255 characters; dates in the company date format; booleans 1/0 or true/false. |
Response
200 with the value object (not wrapped). For date fields value is returned as unix
seconds, with the stored text in value_str and the company format in date_format.
| Field | Type | Description |
|---|---|---|
id |
integer | Value id. |
custom_field_id |
integer | Field. |
purchase_order_id |
integer | Set for header values, otherwise null. |
purchase_order_item_id |
integer | Set for line item values, otherwise null. |
value |
string | Stored value (unix seconds integer for date fields). |
value_str |
string | Only for date fields: the stored text. |
date_format |
string | Only for date fields: the company date format, else null. |
budget_id |
integer | Always null for PO values. |
Side effects
Audit comment on the PO when the value changed; PO search keywords refreshed for header values.
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Missing field | 400 |
V3, V1 | {"error":"custom_field_id is required","status":400} |
| Missing po | 400 |
V3, V1 | {"error":"Purchase order custom field requires purchase_order_id","status":400} |
| Missing item | 400 |
V3, V1 | {"error":"Purchase order item custom field requires purchase_order_item_id","status":400} |
| Too long | 400 |
V3, V1 | {"error":["Value Should not exceed 255 characters"],"status":400} |
| Forbidden | 403 |
V3, V1 | {"error":"Unauthorized access","status":403} |
| Foreign value | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
Departments
Departments (the label can be renamed per company with the
departments_alias company setting, e.g. "Cost centres") group users,
budgets and suppliers. A user's department assignments decide which
departments they can pick on a purchase order, and departments filter the
approver list (List approvers with department_id).
Department names are unique within a company (case-sensitive). There is no
delete: DELETE /departments/:id is not routed (404 Invalid URL).
Retire a department by updating it with archived: true; archived
departments are hidden from the list unless you ask for archived=true.
Permissions
| Action | Who may call it |
|---|---|
List (your own departments, or company_specific=true) |
any active member |
| List with full details for the whole company | companyadmin, finance (other roles get id/name/archived only) |
| Show a department you are assigned to | any active member |
| Show any other department | companyadmin, finance |
| Create | companyadmin only |
| Update / archive | companyadmin, finance |
Refusals answer 401 {"error": "Unauthorized access", "status": 401}.
Assigning users. user_ids on create/update sets the complete list of
users assigned to the department (it is written to each member's
department list). It is not returned in the department object, and
on update an omitted user_ids is treated as an empty list - see
Update a department.
Endpoints in this section
| Endpoint | Method | Path | V3 | V1 |
|---|---|---|---|---|
| List departments | GET |
/departments |
✓ | ✓ |
| Get a department | GET |
/departments/:id |
✓ | ✓ |
| Create a department | POST |
/departments |
✓ | ✓ |
| Update a department | PUT |
/departments/:id |
✓ | ✓ |
The department object
| Field | Type | Description |
|---|---|---|
id |
integer | Department id. |
name |
string | Department name, 1-255 characters, unique in the company (max 60 when a Sage integration is connected). |
company_id |
integer | Owning company id. |
archived |
boolean | true when the department has been archived. |
contact_person |
string | Free-text contact name, or null. |
tax_number |
string | Free-text tax/VAT number, or null. |
phone_number |
string | Free-text phone number, or null. Not format-validated (max 30 characters when a Sage integration is connected). |
address |
string | Free-text address, or null. |
email |
string | Free-text email, or null. Not format-validated. |
created_at |
timestamp | ISO-8601 timestamp with the server's UTC offset. |
updated_at |
timestamp | ISO-8601 timestamp with the server's UTC offset. |
supplier_ids |
array[integer] | Ids of suppliers linked to the department. |
budget_ids |
array[integer] | Ids of budgets linked to the department. |
List departments
curl "https://app.procurementexpress.com/api/v3/departments" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/departments" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/departments",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/departments",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1 (lists shortened to 2 items):
[
{
"id": 5,
"name": "Approval Flow Lab",
"company_id": 20000,
"archived": false,
"contact_person": null,
"tax_number": null,
"phone_number": null,
"address": null,
"email": null,
"created_at": "2026-10-21T04:58:54.413-05:00",
"updated_at": "2026-10-21T04:58:54.413-05:00",
"supplier_ids": [
],
"budget_ids": [
4
]
},
{
"id": 2,
"name": "Engineering",
"company_id": 20000,
"archived": false,
"contact_person": "Jane Doe",
"tax_number": "US-EN-001",
"phone_number": "+1 555 0110",
"address": "100 Main Street, Springfield, IL 62701",
"email": "engineering@example.com",
"created_at": "2026-10-21T04:48:42.702-05:00",
"updated_at": "2026-10-21T04:48:42.702-05:00",
"supplier_ids": [
4,
5
],
"budget_ids": [
1,
5,
6,
10
]
}
]
Example: own (V3,
200 OK):
[
{
"id": 5,
"name": "Approval Flow Lab",
"company_id": 20000,
"archived": false,
"contact_person": null,
"tax_number": null,
"phone_number": null,
"address": null,
"email": null,
"created_at": "2026-10-21T04:58:54.413-05:00",
"updated_at": "2026-10-21T04:58:54.413-05:00",
"supplier_ids": [
],
"budget_ids": [
4
]
},
{
"id": 2,
"name": "Engineering",
"company_id": 20000,
"archived": false,
"contact_person": "Jane Doe",
"tax_number": "US-EN-001",
"phone_number": "+1 555 0110",
"address": "100 Main Street, Springfield, IL 62701",
"email": "engineering@example.com",
"created_at": "2026-10-21T04:48:42.702-05:00",
"updated_at": "2026-10-21T04:48:42.702-05:00",
"supplier_ids": [
4,
5
],
"budget_ids": [
1,
5,
6,
10
]
}
]
Example: archived (V3,
200 OK):
[
{
"id": 1,
"name": "TEST",
"company_id": 20000,
"archived": true,
"contact_person": null,
"tax_number": null,
"phone_number": null,
"address": null,
"email": null,
"created_at": "2026-10-21T04:44:45.179-05:00",
"updated_at": "2026-10-21T04:44:45.179-05:00",
"supplier_ids": [
],
"budget_ids": [
]
},
{
"id": 10000,
"name": "tech department",
"company_id": 20000,
"archived": true,
"contact_person": null,
"tax_number": null,
"phone_number": null,
"address": null,
"email": null,
"created_at": "2026-10-21T04:44:45.680-05:00",
"updated_at": "2026-10-21T04:44:45.680-05:00",
"supplier_ids": [
],
"budget_ids": [
]
}
]
Example: company specific names (V3,
200 OK) (lists shortened to 2 items):
[
{
"id": 5,
"name": "Approval Flow Lab",
"archived": false
},
{
"id": 2,
"name": "Engineering",
"archived": false
}
]
V3 V1
Lists the caller's departments, or all departments of the company.
By default returns the departments the caller is assigned to in the
header company. With company_specific=true returns every department
of the company.
Archived departments are excluded unless archived=true, which returns
only archived departments (there is no "both" option).
With company_specific=true, callers who are not companyadmin or
finance receive only id, name and archived for each department.
Results are sorted by name and are not paginated. There is no search parameter.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/departments |
| V1 | GET /api/v1/departments |
Permissions
Any active member of the header company (detail level depends on role, see description).
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
company_specific |
boolean | No | true returns all departments of the company instead of only the caller's. Any other value means false. Default: false. |
archived |
boolean | No | true returns only archived departments; anything else returns only non-archived ones. Default: false. |
Response
200 with a bare array of Department objects (or
{id, name, archived} objects for non-admin/finance callers with
company_specific=true). No envelope, no pagination metadata.
V3 and V1 differences
None - identical request and response.
Get a department
curl "https://app.procurementexpress.com/api/v3/departments/2" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/departments/2" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/departments/2",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/departments/2",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 2,
"name": "Engineering",
"company_id": 20000,
"archived": false,
"contact_person": "Jane Doe",
"tax_number": "US-EN-001",
"phone_number": "+1 555 0110",
"address": "100 Main Street, Springfield, IL 62701",
"email": "engineering@example.com",
"created_at": "2026-10-21T04:48:42.702-05:00",
"updated_at": "2026-10-21T04:48:42.702-05:00",
"supplier_ids": [
4,
5
],
"budget_ids": [
1,
5,
6,
10
]
}
V3 V1
Returns one department of the header company.
Returns a department of the header company by id.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/departments/:id |
| V1 | GET /api/v1/departments/:id |
Permissions
Any active member for a department they are assigned to;
companyadmin or finance for any department. Otherwise 401
Unauthorized access. A department of another company, or an unknown
id, answers 404 Invalid id passed.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Department id. |
Response
200 with a Department object.
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
| Not permitted | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
Create a department
curl -X POST "https://app.procurementexpress.com/api/v3/departments" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"department": {
"name": "Procurement",
"contact_person": "Jane Doe",
"phone_number": "+1 555 0130",
"email": "procurement@example.com",
"address": "300 Supply Road, Springfield, IL 62701",
"tax_number": "US-PR-003",
"budget_ids": [
3
],
"user_ids": [
40000
]
}
}'
curl -X POST "https://app.procurementexpress.com/api/v1/departments" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"department": {
"name": "Procurement",
"contact_person": "Jane Doe",
"phone_number": "+1 555 0130",
"email": "procurement@example.com",
"address": "300 Supply Road, Springfield, IL 62701",
"tax_number": "US-PR-003",
"budget_ids": [
3
],
"user_ids": [
40000
]
}
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/departments",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
department: {
name: "Procurement",
contact_person: "Jane Doe",
phone_number: "+1 555 0130",
email: "procurement@example.com",
address: "300 Supply Road, Springfield, IL 62701",
tax_number: "US-PR-003",
budget_ids: [3],
user_ids: [40000]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/departments",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
department: {
name: "Procurement",
contact_person: "Jane Doe",
phone_number: "+1 555 0130",
email: "procurement@example.com",
address: "300 Supply Road, Springfield, IL 62701",
tax_number: "US-PR-003",
budget_ids: [3],
user_ids: [40000]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 17,
"name": "Procurement",
"company_id": 20000,
"archived": false,
"contact_person": "Jane Doe",
"tax_number": "US-PR-003",
"phone_number": "+1 555 0130",
"address": "300 Supply Road, Springfield, IL 62701",
"email": "procurement@example.com",
"created_at": "2026-10-21T05:05:36.987-05:00",
"updated_at": "2026-10-21T05:05:36.987-05:00",
"supplier_ids": [
],
"budget_ids": [
3
]
}
V3 V1
Creates a department in the header company and optionally links budgets and assigns users.
Creates a department. budget_ids links budgets of the same company;
user_ids assigns active members of the company to the new department
(added to each user's department list).
Contact fields (contact_person, phone_number, email, address,
tax_number) are free text - phone and email are not
format-validated (phone_number max 30 and name max 60 characters
when a Sage integration is connected).
HTTP request
| Version | Request |
|---|---|
| V3 | POST /api/v3/departments |
| V1 | POST /api/v1/departments |
Permissions
Only companyadmin of the header company; others get 401
Unauthorized access (finance users may update but not create).
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
department[ |
string | Yes | 1-255 characters, unique in the company (case-sensitive). |
department[ |
boolean | No | Create already archived. Default false. |
department[ |
string | No | Contact name. |
department[ |
string | No | Phone number (free text). |
department[ |
string | No | Email (free text, not validated). |
department[ |
string | No | Address. |
department[ |
string | No | Tax/VAT number. |
department[ |
array[integer] | No | Budgets to link; each must belong to the header company (Budget ids is invalid otherwise). |
department[ |
array[integer] | No | Users to assign; each must be an active member of the header company (User ids is invalid otherwise). Not returned in the response. |
Response
200 (not 201) with the new Department object.
| Error | Status | error |
|---|---|---|
| name already used in the company | 400 (422 if two requests race) | ["Name has already registered"] |
| blank name | 400 | ["Name can't be blank", "Name is too short (minimum is 1 character)"] |
| budget of another company / user not an active member | 400 | ["Budget ids is invalid", "User ids is invalid"] |
caller not companyadmin |
401 | Unauthorized access |
no department object |
400 | Required parameter are missing. |
Side effects
Writes the department id into the department list of each user in
user_ids.
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Duplicate name | 400 |
V3, V1 | {"error":["Name has already registered"],"status":400} |
| Blank name | 400 |
V3, V1 | {"error":["Name can't be blank","Name is too short (minimum is 1 character)"],"status":400} |
| Invalid associations | 400 |
V3, V1 | {"error":["Budget ids is invalid","User ids is invalid"],"status":400} |
| Not admin | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
| Missing params | 400 |
V3, V1 | {"error":"Required parameter are missing.","status":400} |
Update a department
curl -X PUT "https://app.procurementexpress.com/api/v3/departments/17" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"department": {
"name": "Procurement & Sourcing",
"contact_person": "Marco Rossi",
"phone_number": "+1 555 0131",
"budget_ids": [
],
"user_ids": [
40000
]
}
}'
curl -X PUT "https://app.procurementexpress.com/api/v1/departments/16" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"department": {
"name": "Procurement & Sourcing",
"contact_person": "Marco Rossi",
"phone_number": "+1 555 0131",
"budget_ids": [
],
"user_ids": [
40000
]
}
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v3/departments/17",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
department: {
name: "Procurement & Sourcing",
contact_person: "Marco Rossi",
phone_number: "+1 555 0131",
budget_ids: [],
user_ids: [40000]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v1/departments/16",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
department: {
name: "Procurement & Sourcing",
contact_person: "Marco Rossi",
phone_number: "+1 555 0131",
budget_ids: [],
user_ids: [40000]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 17,
"name": "Procurement & Sourcing",
"company_id": 20000,
"archived": false,
"contact_person": "Marco Rossi",
"tax_number": "US-PR-003",
"phone_number": "+1 555 0131",
"address": "300 Supply Road, Springfield, IL 62701",
"email": "procurement@example.com",
"created_at": "2026-10-21T05:05:36.987-05:00",
"updated_at": "2026-10-21T05:06:42.611-05:00",
"supplier_ids": [
],
"budget_ids": [
]
}
Example: archive (V3,
200 OK):
{
"id": 17,
"name": "Procurement & Sourcing",
"company_id": 20000,
"archived": true,
"contact_person": "Marco Rossi",
"tax_number": "US-PR-003A",
"phone_number": "+1 555 0131",
"address": "300 Supply Road, Springfield, IL 62701",
"email": "procurement@example.com",
"created_at": "2026-10-21T05:05:36.987-05:00",
"updated_at": "2026-10-21T05:08:56.237-05:00",
"supplier_ids": [
],
"budget_ids": [
]
}
Example: without user ids (V3,
200 OK):
{
"id": 17,
"name": "Procurement & Sourcing",
"company_id": 20000,
"archived": false,
"contact_person": "Marco Rossi",
"tax_number": "US-PR-003A",
"phone_number": "+1 555 0131",
"address": "300 Supply Road, Springfield, IL 62701",
"email": "procurement@example.com",
"created_at": "2026-10-21T05:05:36.987-05:00",
"updated_at": "2026-10-21T05:07:16.360-05:00",
"supplier_ids": [
],
"budget_ids": [
]
}
Example: unvalidated contact (V3,
200 OK):
{
"id": 17,
"name": "Procurement & Sourcing",
"company_id": 20000,
"archived": false,
"contact_person": "Marco Rossi",
"tax_number": "US-PR-003A",
"phone_number": "ext. 42 (ask reception)",
"address": "300 Supply Road, Springfield, IL 62701",
"email": "not-an-email",
"created_at": "2026-10-21T05:05:36.987-05:00",
"updated_at": "2026-10-21T05:07:36.689-05:00",
"supplier_ids": [
],
"budget_ids": [
]
}
V3 V1
Updates a department's details, linked budgets and assigned users, or archives it.
Updates the given fields of a department of the header company. Send
archived: true to archive it (there is no delete) and archived: false
to restore it.
budget_ids, when sent, replaces the complete set of linked budgets
(send [] to unlink all); when omitted, links are unchanged.
user_ids is not a partial field. Every update rewrites the
department's user assignments from user_ids; if you omit it, the
department is removed from every user's department list. Always
send the full user_ids list you want to keep (fetch it from the web
app - it is not returned by the API), or [] deliberately.
Same validation rules as Create a department.
HTTP request
| Version | Request |
|---|---|
| V3 | `PUT |
| V1 | `PUT |
Permissions
companyadmin or finance of the header company; others get 401
Unauthorized access. A department of another company or an unknown id
answers 404 Invalid id passed.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Department id. |
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
department[ |
string | No | New name, 1-255 characters, unique in the company. |
department[ |
boolean | No | true archives, false restores. |
department[ |
string | No | Contact name. |
department[ |
string | No | Phone number (free text). |
department[ |
string | No | Email (free text, not validated). |
department[ |
string | No | Address. |
department[ |
string | No | Tax/VAT number. |
department[ |
array[integer] | No | Replaces the linked budgets; each must belong to the header company. |
department[ |
array[integer] | No | Complete list of assigned users (active members of the header company). Omitting it unassigns every user. |
Response
200 with the updated Department object. Errors as for
create (400 with an array of messages, 401, 404).
Side effects
Rewrites the department list of every member of the company (adds the
department for users in user_ids, removes it for everyone else).
Replacing a non-empty set of linked budgets with a different non-empty
set queues a background job that refreshes related records for offline
(mobile) clients.
V3 and V1 differences
None - identical request and response; both accept PUT and PATCH.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
| Other company | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
| Not permitted | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
| Duplicate name | 400 |
V3, V1 | {"error":["Name has already registered"],"status":400} |
Invoices
An invoice is a supplier bill recorded in ProcurementExpress. Each invoice has
line items. A line item can point at a purchase order line
(purchase_order_id + purchase_order_item_id). That link is how an invoice is
matched to purchase orders. A line item with no PO link makes the invoice a
standalone invoice. Invoices are approved, then paid with
payments. Paying an invoice in full settles it and updates the
payment status of the linked purchase orders.
Status lifecycle
status holds one of these five values. There is no separate rejected or
pending status. Archived is a separate boolean flag (archived), not a
status.
| status | meaning (UI label) | how it is reached |
|---|---|---|
awaiting_review |
Awaiting review: a captured invoice that someone must check first | Created while the company setting can_create_invoice_in_awaiting_review is on, created by the PO-overrun guard (see below), or created by a scan |
outstanding |
Outstanding: reviewed, waiting for approval | The default status on create; accept moves awaiting_review here |
ready_to_pay |
Approved (ready to pay) | approve on an outstanding invoice. validation_date is set to the approval date |
settled |
Paid | Automatic: a ready_to_pay invoice whose balance_amount reaches 0 through payments |
cancelled |
Cancelled or rejected | reject (from awaiting_review / outstanding) or cancel (from any other status) |
awaiting_review --accept--> outstanding --approve--> ready_to_pay --(fully paid)--> settled
| | | |
+------reject/cancel------+-----reject/cancel------+----------cancel-----------+--> cancelled
A settled invoice moves back to ready_to_pay if a payment that covered it is
cancelled. payment_progress (unpaid, partially_paid, paid) tracks the money
separately from the workflow status. A partly paid invoice stays ready_to_pay
with payment_progress: partially_paid.
Every action checks the current state. Calling an action the invoice is not in a
state for (approving an archived or already approved invoice, archiving an archived
one, accepting an invoice that is not awaiting review...) returns 401
{"error": "Unauthorized access", "status": 401}, the same answer as a missing
permission. Use the can_accept, can_approve, can_reject, can_cancel,
can_archive and can_dearchive flags on the invoice to know in advance what the
current user may do.
Matching invoices to purchase orders
- Find approved POs of the supplier that are not fully invoiced yet:
GET /invoices/purchase_order_list
?supplier_id=.... - Get their open lines, with the remaining quantity and amount:
GET /invoices/purchase_order_item_list
?purchase_order_ids[]=.... - Create the invoice. Each line in
invoice_line_items_attributescarries thepurchase_order_idandpurchase_order_item_idit bills.
When an invoice is saved, the linked POs get payment_status: invoice_received.
An invoice can only be approved when none of its linked POs is draft,
pending, rejected or cancelled.
Amounts
Amounts come from the line items: net_amount = sum of line net_amount,
tax_amount = sum of net_amount x vat / 100 per line, and
gross_amount = net + tax + shipping_amount. A gross_amount sent in the request
is overwritten. The line's vat (a percentage) drives the tax. tax_rate_id is
only stored. balance_amount is gross_amount minus the payments allocated to the
invoice.
Dates
Send dates as ISO YYYY-MM-DD. Unlike most other endpoints, the invoice
endpoints do not parse the company date_format. A date such as 10/18/2026
is silently dropped, and issue_date / due_date / uploaded_date then default
to the current time. Invoice dates are returned as Unix timestamps in seconds
(midnight US Central time of the date, e.g. 2026-10-20 -> 1792472400). Note that
the nested purchase_orders[].created_at / submitted_on are in milliseconds.
Company settings that affect invoices
| setting | effect |
|---|---|
enable_admin_invoice_upload, enable_finance_invoice_upload, enable_approver_invoice_upload, enable_member_invoice_upload |
Which roles may create invoices (POST /invoices, POST /digital_invoices). Otherwise 401 |
enable_admin_invoice_review, ..._finance_..., ..._approver_..., ..._member_invoice_review |
Which roles may accept awaiting_review invoices |
enable_admin_invoice_approval, ..._finance_..., ..._approver_..., ..._member_invoice_approval |
Which roles may approve / reject. With enable_member_invoice_approval on, a team member can also open, approve and reject any invoice of the company, although GET /invoices lists only their own |
can_create_invoice_in_awaiting_review |
Every invoice created through the API starts as awaiting_review, and supplier_id becomes optional |
invoice_reference_mandatory |
invoice_number is required |
invoice_po_overrun_enabled + invoice_po_overrun_pct (default 10) |
PO overrun guard. On create, an invoice that takes the amount invoiced against a linked PO above the PO total is forced into awaiting_review. Accepting it then needs invoice[po_overrun_reason] when the overrun is within invoice_po_overrun_pct percent of the PO total. Above that (or with a pct of 0) acceptance is refused until the PO value is increased |
send_invoice_rejected_email_to_supplier |
Rejecting an invoice emails the supplier |
Companies using the role-permissions feature use the upload_invoice permission
instead of the upload, review and approval settings above. With an active
QuickBooks connection, invoice_number is limited to 21 characters and line
accounting fields are validated. An invoice exported to Sage 200 can no longer be
edited. Required invoice custom fields must be filled in.
Invoice approval flows
If the company has published invoice approval flows, a new or updated
outstanding / awaiting_review invoice is matched against them. When a flow is
assigned, only users with a pending approval request on the invoice may approve,
reject or accept it. If a flow is edited while invoices are in progress, a company
admin can re-apply it with
rerun approval flow (V1 only).
Duplicate detection
invoice_number must be unique per supplier among non-cancelled invoices: create
and update return Invoice reference already exists. If duplicates exist anyway
(for example after a supplier change elsewhere), duplicate_invoice_flag is
true and the invoice cannot be approved or accepted until it is resolved.
Digital invoices (scan and match)
POST /digital_invoices uploads a PDF or image for AI extraction. It needs the company feature Invoice Scan and Match. With Scan & Match v2 the document lands in the AP inbox for review and no invoice is created automatically.
Webhooks and side effects
Creating an invoice fires the new_invoice webhook. Approving it fires
invoice_approved, emails the approval notification and starts the accounting
export (QuickBooks, Xero, Sage...) when the company exports on approval. Accepting
an invoice notifies the suppliers of the linked POs. Every action writes a history
entry (the histories list of the invoice).
API versions
V1 and V3 accept the same requests and return the same responses, with these exceptions:
- V3 ignores
invoice[payment_term_id]. - V3 has an extra e-invoicing (Factur-X) intake path on create.
- Rerun approval flow exists only in V1. The V3 route answers 404.
Endpoints in this section
| Endpoint | Method | Path | V3 | V1 |
|---|---|---|---|---|
| List invoices | GET |
/invoices |
✓ | ✓ |
| Get an invoice | GET |
/invoices/:id |
✓ | ✓ |
| Create an invoice | POST |
/invoices |
✓ | ✓ |
| Update an invoice | PUT |
/invoices/:id |
✓ | ✓ |
| List purchase orders to invoice | GET |
/invoices/purchase_order_list |
✓ | ✓ |
| List purchase order lines to invoice | GET |
/invoices/purchase_order_item_list |
✓ | ✓ |
| Comment on an invoice | POST |
/invoices/:id/create_comment |
✓ | ✓ |
| Accept an invoice awaiting review | PUT |
/invoices/:id/accept |
✓ | ✓ |
| Approve an invoice | PUT |
/invoices/:id/approve |
✓ | ✓ |
| Reject an invoice | PUT |
/invoices/:id/reject |
✓ | ✓ |
| Cancel an invoice | PUT |
/invoices/:id/cancel |
✓ | ✓ |
| Archive an invoice | PUT |
/invoices/:id/archive |
✓ | ✓ |
| Unarchive an invoice | PUT |
/invoices/:id/dearchive |
✓ | ✓ |
| Rerun the approval flow of an invoice | POST |
/invoices/:id/rerun_approval_flow |
— | ✓ |
| Scan an invoice document | POST |
/digital_invoices |
✓ | ✓ |
The invoice object
| Field | Type | Description |
|---|---|---|
id |
integer | Invoice id. |
invoice_number |
string | Supplier's invoice reference. Unique per supplier among non-cancelled invoices. |
status |
string | awaiting_review, outstanding, ready_to_pay (approved), settled (paid) or cancelled (also used for rejected). See the lifecycle table above. |
issue_date |
timestamp | Invoice date, Unix seconds. Defaults to the creation time when not sent. |
validation_date |
timestamp | Date the invoice was approved (moved to ready_to_pay), Unix seconds, or null. |
uploaded_date |
timestamp | Date the invoice was recorded, Unix seconds. Defaults to the creation time. |
due_date |
timestamp | Payment due date, Unix seconds. Defaults to the creation time when not sent. |
net_amount |
string | Decimal string. Sum of the line net_amounts. |
tax_amount |
string | Decimal string. Effective tax: the sum of line tax (net_amount x vat / 100), or tax_amount_override when overridden. |
tax_amount_overridden |
boolean | True when a flat tax override is applied (set in the web app; not writable through the API). |
tax_amount_override |
string | The flat tax override, or null. |
shipping_amount |
string | Header shipping amount (not writable through the API). |
shipping_amount_overridden |
boolean | True when the shipping amount was overridden. |
gross_amount |
string | Decimal string. net_amount + tax_amount + shipping_amount, recalculated on every save. It can carry more than 2 decimals when the line tax does. |
balance_amount |
string | Amount still to pay: gross_amount minus the payments allocated to this invoice. |
payment_progress |
string | unpaid, partially_paid or paid, based on the payments recorded. |
standalone_invoice |
boolean | True for an invoice that is not linked to a purchase order. Sent by the client; also set automatically when all PO links are removed. |
digital_invoice |
boolean | True for invoices created by scanning (digital invoices). |
confidence_score |
string | Extraction confidence (0-100) of a scanned invoice; 0.0 otherwise. |
sage_exported |
boolean | Sage export marker. Clients may set it. |
duplicate_invoice_flag |
boolean | True when another non-cancelled invoice of the same supplier has the same invoice_number. Blocks approval and acceptance. |
payment_term_id |
integer | Payment term id (company payment terms), or null. |
payment_terms_list |
object | The payment term object (id, name, term_type, due_days, day_of_month, archived, company_id, created_at, updated_at), or null. |
xero_export_status |
string | xero_not_exported, xero_export_success, xero_export_failed, or null when Xero is not used. |
xero_is_changed |
boolean | True when the invoice changed after its last Xero export. |
submitted_with_unproven_supplier_email |
boolean | True when a supplier submitted the invoice from an account whose email was not verified. |
can_accept |
boolean | The current user may accept it. |
can_approve |
boolean | The current user may approve it. |
can_reject |
boolean | The current user may reject it. |
can_cancel |
boolean | The current user may cancel it. |
can_archive |
boolean | The current user may archive it. |
can_dearchive |
boolean | The current user may unarchive it. |
currency |
object | id, iso_code, iso_numeric, name, symbol, created_at, updated_at (ISO-8601). |
supplier_id |
integer | List only. Supplier id (null only for awaiting_review invoices). |
supplier_name |
string | List only. Supplier name. |
purchase_order_references |
array[string] | List only. approval_key of each linked PO. |
created_at |
timestamp | Detail only. Unix seconds. |
updated_at |
timestamp | Detail only. Unix seconds. |
gl_posting_date |
timestamp | Detail only. Sage Intacct GL posting date, Unix seconds, or null. |
selected_purchase_order_ids |
array[string] | Detail only. PO ids picked on the web invoice form. The PO links themselves come from the line items. |
xero_export_error_message |
string | Detail only. Last Xero export error, or null. |
xero_last_export_at |
timestamp | Detail only. Last Xero export, Unix seconds, or null. |
supplier |
object | Detail only. The full supplier (id, name, email, address, tax_number, payment_terms, currency_id, ...; timestamps ISO-8601). |
invoice_line_items |
array[object] | Detail only. Ordered by sequence_no. See the line item fields below. |
invoice_line_items[ |
string | Tax percentage of the line; drives tax_amount. |
invoice_line_items[ |
string | Line tax and line gross. |
invoice_line_items[ |
integer | The PO and PO line this invoice line bills, or null for a line that is not linked to a PO. |
invoice_line_items[ |
object | {id, name} pairs, with null values when not set. |
invoice_line_items[ |
boolean | True when the line tax was entered manually. |
purchase_orders |
array[object] | Detail only. The linked POs (summary format: approval_key, status, payment_status, payment_progress, totals...). Their created_at / submitted_on are Unix milliseconds. |
supplier_invoice_uploads |
array[object] | Detail only. Attached files: id, file_file_name, file_content_type, file_file_size, url (a signed download link that expires after 5 minutes). |
histories |
array[object] | Detail only. History entries written by the system (created, approved, edited...), in the comment format. |
comments |
array[object] | Detail only. User comments (see create a comment). |
npayments |
array[object] | Detail only. Payments that cover this invoice: id, reference, status, ptype, date (Unix seconds), amount. |
List invoices
curl "https://app.procurementexpress.com/api/v3/invoices?invoice_statuses_filter=settled&supplier_id%5B%5D=2&sort=invoices.invoice_number&direction=asc" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/invoices?invoice_statuses_filter=settled&supplier_id%5B%5D=2&sort=invoices.invoice_number&direction=asc" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/invoices",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
params: {
invoice_statuses_filter: "settled",
"supplier_id[]": "2",
sort: "invoices.invoice_number",
direction: "asc"
}
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/invoices",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
params: {
invoice_statuses_filter: "settled",
"supplier_id[]": "2",
sort: "invoices.invoice_number",
direction: "asc"
}
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1 (lists shortened to 2 items):
{
"invoices": [
{
"id": 6,
"invoice_number": "CH-APV-V1",
"status": "settled",
"issue_date": 1792472400,
"validation_date": 1792558800,
"uploaded_date": 1792577150,
"due_date": 1795068000,
"tax_amount": "15.0",
"tax_amount_overridden": false,
"tax_amount_override": null,
"shipping_amount": "0.0",
"shipping_amount_overridden": false,
"net_amount": "150.0",
"gross_amount": "165.0",
"balance_amount": "0.0",
"supplier_id": 2,
"supplier_name": "Contoso Hardware",
"purchase_order_references": [
"5-10/21/2026"
],
"can_accept": false,
"can_approve": false,
"can_reject": false,
"can_cancel": true,
"can_archive": true,
"can_dearchive": false,
"standalone_invoice": false,
"confidence_score": "0.0",
"digital_invoice": false,
"payment_term_id": null,
"payment_terms_list": null,
"xero_export_status": null,
"xero_is_changed": false,
"duplicate_invoice_flag": false,
"payment_progress": "paid",
"submitted_with_unproven_supplier_email": false,
"currency": {
"id": 2000,
"iso_code": "USD",
"iso_numeric": "840",
"name": "United States Dollar",
"symbol": "$",
"created_at": "2026-10-21T04:44:44.793-05:00",
"updated_at": "2026-10-21T04:44:44.793-05:00"
}
},
{
"id": 16,
"invoice_number": "CH-PAY-V1",
"status": "settled",
"issue_date": 1792472400,
"validation_date": 1792558800,
"uploaded_date": 1792577159,
"due_date": 1795068000,
"tax_amount": "15.0",
"tax_amount_overridden": false,
"tax_amount_override": null,
"shipping_amount": "0.0",
"shipping_amount_overridden": false,
"net_amount": "150.0",
"gross_amount": "165.0",
"balance_amount": "0.0",
"supplier_id": 2,
"supplier_name": "Contoso Hardware",
"purchase_order_references": [
"9-10/21/2026"
],
"can_accept": false,
"can_approve": false,
"can_reject": false,
"can_cancel": true,
"can_archive": true,
"can_dearchive": false,
"standalone_invoice": false,
"confidence_score": "0.0",
"digital_invoice": false,
"payment_term_id": null,
"payment_terms_list": null,
"xero_export_status": null,
"xero_is_changed": false,
"duplicate_invoice_flag": false,
"payment_progress": "paid",
"submitted_with_unproven_supplier_email": false,
"currency": {
"id": 2000,
"iso_code": "USD",
"iso_numeric": "840",
"name": "United States Dollar",
"symbol": "$",
"created_at": "2026-10-21T04:44:44.793-05:00",
"updated_at": "2026-10-21T04:44:44.793-05:00"
}
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 3
}
}
Example: search (V3,
200 OK) (lists shortened to 2 items):
{
"invoices": [
{
"id": 21,
"invoice_number": "CH-2026-1004",
"status": "outstanding",
"issue_date": 1792472400,
"validation_date": null,
"uploaded_date": 1792577293,
"due_date": 1795068000,
"tax_amount": "30.0",
"tax_amount_overridden": false,
"tax_amount_override": null,
"shipping_amount": "0.0",
"shipping_amount_overridden": false,
"net_amount": "300.0",
"gross_amount": "330.0",
"balance_amount": "330.0",
"supplier_id": 2,
"supplier_name": "Contoso Hardware",
"purchase_order_references": [
"10-10/21/2026"
],
"can_accept": false,
"can_approve": true,
"can_reject": true,
"can_cancel": true,
"can_archive": true,
"can_dearchive": false,
"standalone_invoice": false,
"confidence_score": "0.0",
"digital_invoice": false,
"payment_term_id": null,
"payment_terms_list": null,
"xero_export_status": null,
"xero_is_changed": false,
"duplicate_invoice_flag": false,
"payment_progress": "unpaid",
"submitted_with_unproven_supplier_email": false,
"currency": {
"id": 2000,
"iso_code": "USD",
"iso_numeric": "840",
"name": "United States Dollar",
"symbol": "$",
"created_at": "2026-10-21T04:44:44.793-05:00",
"updated_at": "2026-10-21T04:44:44.793-05:00"
}
},
{
"id": 20,
"invoice_number": "CH-2026-1003",
"status": "outstanding",
"issue_date": 1792472400,
"validation_date": null,
"uploaded_date": 1792577292,
"due_date": 1795068000,
"tax_amount": "30.0",
"tax_amount_overridden": false,
"tax_amount_override": null,
"shipping_amount": "0.0",
"shipping_amount_overridden": false,
"net_amount": "300.0",
"gross_amount": "330.0",
"balance_amount": "330.0",
"supplier_id": 2,
"supplier_name": "Contoso Hardware",
"purchase_order_references": [
"10-10/21/2026"
],
"can_accept": false,
"can_approve": true,
"can_reject": true,
"can_cancel": true,
"can_archive": true,
"can_dearchive": false,
"standalone_invoice": false,
"confidence_score": "0.0",
"digital_invoice": false,
"payment_term_id": null,
"payment_terms_list": null,
"xero_export_status": null,
"xero_is_changed": false,
"duplicate_invoice_flag": false,
"payment_progress": "unpaid",
"submitted_with_unproven_supplier_email": false,
"currency": {
"id": 2000,
"iso_code": "USD",
"iso_numeric": "840",
"name": "United States Dollar",
"symbol": "$",
"created_at": "2026-10-21T04:44:44.793-05:00",
"updated_at": "2026-10-21T04:44:44.793-05:00"
}
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 4
}
}
Example: awaiting review (V3,
200 OK) (lists shortened to 2 items):
{
"invoices": [
{
"id": 31,
"invoice_number": "CH-OVR-B-V3",
"status": "awaiting_review",
"issue_date": 1792472400,
"validation_date": null,
"uploaded_date": 1792577861,
"due_date": 1792577861,
"tax_amount": "50.0",
"tax_amount_overridden": false,
"tax_amount_override": null,
"shipping_amount": "0.0",
"shipping_amount_overridden": false,
"net_amount": "500.0",
"gross_amount": "550.0",
"balance_amount": "550.0",
"supplier_id": 2,
"supplier_name": "Contoso Hardware",
"purchase_order_references": [
"8-10/21/2026"
],
"can_accept": true,
"can_approve": false,
"can_reject": true,
"can_cancel": true,
"can_archive": true,
"can_dearchive": false,
"standalone_invoice": false,
"confidence_score": "0.0",
"digital_invoice": false,
"payment_term_id": null,
"payment_terms_list": null,
"xero_export_status": null,
"xero_is_changed": false,
"duplicate_invoice_flag": false,
"payment_progress": "unpaid",
"submitted_with_unproven_supplier_email": false,
"currency": {
"id": 2000,
"iso_code": "USD",
"iso_numeric": "840",
"name": "United States Dollar",
"symbol": "$",
"created_at": "2026-10-21T04:44:44.793-05:00",
"updated_at": "2026-10-21T04:44:44.793-05:00"
}
},
{
"id": 29,
"invoice_number": "CH-88305",
"status": "awaiting_review",
"issue_date": 1792558800,
"validation_date": null,
"uploaded_date": 1792577860,
"due_date": 1795154400,
"tax_amount": "101.175",
"tax_amount_overridden": false,
"tax_amount_override": null,
"shipping_amount": "0.0",
"shipping_amount_overridden": false,
"net_amount": "1140.0",
"gross_amount": "1241.175",
"balance_amount": "1241.18",
"supplier_id": 2,
"supplier_name": "Contoso Hardware",
"purchase_order_references": [
"74-10/21/2026"
],
"can_accept": true,
"can_approve": false,
"can_reject": true,
"can_cancel": true,
"can_archive": true,
"can_dearchive": false,
"standalone_invoice": false,
"confidence_score": "0.0",
"digital_invoice": false,
"payment_term_id": null,
"payment_terms_list": null,
"xero_export_status": null,
"xero_is_changed": false,
"duplicate_invoice_flag": false,
"payment_progress": "unpaid",
"submitted_with_unproven_supplier_email": false,
"currency": {
"id": 2000,
"iso_code": "USD",
"iso_numeric": "840",
"name": "United States Dollar",
"symbol": "$",
"created_at": "2026-10-21T04:44:44.793-05:00",
"updated_at": "2026-10-21T04:44:44.793-05:00"
}
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 3
}
}
Example: archived (V3,
200 OK):
{
"invoices": [
{
"id": 13,
"invoice_number": "CH-ARC-V3",
"status": "outstanding",
"issue_date": 1792472400,
"validation_date": null,
"uploaded_date": 1792577157,
"due_date": 1795068000,
"tax_amount": "15.0",
"tax_amount_overridden": false,
"tax_amount_override": null,
"shipping_amount": "0.0",
"shipping_amount_overridden": false,
"net_amount": "150.0",
"gross_amount": "165.0",
"balance_amount": "165.0",
"supplier_id": 2,
"supplier_name": "Contoso Hardware",
"purchase_order_references": [
"8-10/21/2026"
],
"can_accept": false,
"can_approve": false,
"can_reject": false,
"can_cancel": true,
"can_archive": false,
"can_dearchive": true,
"standalone_invoice": false,
"confidence_score": "0.0",
"digital_invoice": false,
"payment_term_id": null,
"payment_terms_list": null,
"xero_export_status": null,
"xero_is_changed": false,
"duplicate_invoice_flag": false,
"payment_progress": "unpaid",
"submitted_with_unproven_supplier_email": false,
"currency": {
"id": 2000,
"iso_code": "USD",
"iso_numeric": "840",
"name": "United States Dollar",
"symbol": "$",
"created_at": "2026-10-21T04:44:44.793-05:00",
"updated_at": "2026-10-21T04:44:44.793-05:00"
}
},
{
"id": 12,
"invoice_number": "CH-ARC-V1",
"status": "outstanding",
"issue_date": 1792472400,
"validation_date": null,
"uploaded_date": 1792577157,
"due_date": 1795068000,
"tax_amount": "15.0",
"tax_amount_overridden": false,
"tax_amount_override": null,
"shipping_amount": "0.0",
"shipping_amount_overridden": false,
"net_amount": "150.0",
"gross_amount": "165.0",
"balance_amount": "165.0",
"supplier_id": 2,
"supplier_name": "Contoso Hardware",
"purchase_order_references": [
"8-10/21/2026"
],
"can_accept": false,
"can_approve": false,
"can_reject": false,
"can_cancel": true,
"can_archive": false,
"can_dearchive": true,
"standalone_invoice": false,
"confidence_score": "0.0",
"digital_invoice": false,
"payment_term_id": null,
"payment_terms_list": null,
"xero_export_status": null,
"xero_is_changed": false,
"duplicate_invoice_flag": false,
"payment_progress": "unpaid",
"submitted_with_unproven_supplier_email": false,
"currency": {
"id": 2000,
"iso_code": "USD",
"iso_numeric": "840",
"name": "United States Dollar",
"symbol": "$",
"created_at": "2026-10-21T04:44:44.793-05:00",
"updated_at": "2026-10-21T04:44:44.793-05:00"
}
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 2
}
}
V3 V1
Paginated, filterable list of the invoices the current user can see.
Returns the invoices of the company in app_company_id. Archived invoices are
excluded unless archived=true is passed. Results are sorted by
invoices.created_at desc by default.
Which invoices the user sees depends on their role:
- Company admins and finance users see every invoice.
- Approvers see the invoices linked to POs they approve, plus invoices they own or are mentioned on.
- Team members see the invoices they own or are mentioned on.
- When the company shares PO access by department, department members see the invoices of POs in their departments.
- Admins and finance users also see every standalone
awaiting_reviewinvoice. Team members and approvers with review rights see their own.
Filters can be combined. Values that do not match an allowed value are ignored,
except invoice_statuses_filter: an unknown status returns an empty list.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/invoices |
| V1 | GET /api/v1/invoices |
Permissions
Any user of the company. The list is filtered by role as described above.
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
page |
integer | No | Page number. Default: 1. |
per_page |
integer | No | One of 10, 20, 50, 100. Any other value falls back to 10. Default: user's results-per-page setting (10). |
invoice_statuses_filter |
string | No | One status: awaiting_review, outstanding, ready_to_pay, settled or cancelled. An unknown value returns no invoices. |
archived |
boolean | No | true lists only archived invoices, false only active ones. Any other value is treated as false. Default: false. |
search[ |
string | No | Case-insensitive substring match on invoice_number. A bare search=... also works. |
supplier_id[ |
array[integer] | No | Only invoices of these suppliers. A single supplier_id=3 also works. |
supplier_exclude_id[ |
array[integer] | No | All invoices except those of these suppliers (invoices without a supplier stay visible). Ignored when supplier_id is given. |
supplier_external_id |
string | No | Only invoices whose supplier has this external_vendor_id. |
requester_id |
integer | No | Invoices linked to POs created by this user, plus the user's own standalone awaiting_review invoices. |
approver_id |
integer | No | Invoices linked to POs that this user is an approver of. This is about the PO approvers, not the invoice's own approvers. |
invoice_approver_id[ |
array[integer] | No | Invoices this user is (or was) asked to approve through an invoice approval flow. The tab matters: with ready_to_pay / settled it means the user approved it; with awaiting_review / outstanding it means the user is a pending approver. |
invoice_approver_exclude_id[ |
array[integer] | No | Opposite of invoice_approver_id[]. |
department_id |
integer | No | Invoices linked to POs of this department. |
invoice_date_filter |
string | No | last 7days, last 30days, last 60days, last 90days, last 180days, last 1year, current_month, current_year, last_month, last_year. The date column depends on invoice_statuses_filter: issue_date for ready_to_pay, settled_date for settled, created_at otherwise. |
cf_<custom_field_id>_filter |
string or array[string] | No | Invoices whose invoice-level custom field has one of these values, e.g. cf_4_filter=AP-2026-10-B. |
sage_exported |
boolean | No | Filter on sage_exported. |
sage300_statuses_filter |
string | No | not_exported or exported (Sage 300). |
synced_with_qbo_filter |
string | No | yes, no or all (QuickBooks). |
synced_with_gp_filter |
string | No | success, failure, not set or all (Great Plains). |
sage_intacct_statuses_filter |
string | No | si_not_started, si_syncing, si_exported, si_failed, si_update_failed, si_skipped. |
dynamics_statuses_filter |
string | No | Dynamics 365 export status, or all. |
sage200_api_statuses_filter |
string | No | Sage 200 export status, or all. |
sort |
string | No | A fully qualified invoice column such as invoices.invoice_number, invoices.due_date or invoices.gross_amount, or custom_field_<id>. Anything else (including a bare due_date) silently falls back to invoices.created_at. Default: invoices.created_at. |
direction |
string | No | asc or desc. Default: desc. |
Response
200 with {"invoices": [...], "meta": {...}}. Each invoice uses the list format
(see the invoice object). Line items and nested records are not included: use
GET /invoices/:id for those. meta holds current_page,
next_page, prev_page, total_pages and total_count.
| Field | Type | Description |
|---|---|---|
meta.current_page |
integer | Current page. |
meta.next_page |
integer | Next page, or null. |
meta.prev_page |
integer | Previous page, or null. |
meta.total_pages |
integer | Number of pages. |
meta.total_count |
integer | Number of matching invoices. |
V3 and V1 differences
None - identical request and response.
Get an invoice
curl "https://app.procurementexpress.com/api/v3/invoices/1" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/invoices/1" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/invoices/1",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/invoices/1",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 1,
"invoice_number": "CH-2026-1001",
"status": "outstanding",
"issue_date": 1792472400,
"validation_date": null,
"uploaded_date": 1792576873,
"due_date": 1796364000,
"tax_amount": "45.0",
"tax_amount_overridden": false,
"tax_amount_override": null,
"shipping_amount": "0.0",
"shipping_amount_overridden": false,
"net_amount": "450.0",
"gross_amount": "495.0",
"balance_amount": "495.0",
"created_at": 1792576873,
"updated_at": 1792577056,
"standalone_invoice": false,
"can_accept": false,
"can_approve": true,
"can_reject": true,
"can_cancel": true,
"can_archive": true,
"can_dearchive": false,
"sage_exported": false,
"confidence_score": "0.0",
"digital_invoice": false,
"selected_purchase_order_ids": [
],
"payment_term_id": 3,
"payment_terms_list": {
"id": 3,
"archived": false,
"company_id": 20000,
"created_at": "2026-10-21T04:44:45.145-05:00",
"day_of_month": 0,
"due_days": 30,
"name": "Net 30",
"term_type": "standard",
"updated_at": "2026-10-21T04:44:45.145-05:00"
},
"xero_export_status": null,
"xero_export_error_message": null,
"xero_last_export_at": null,
"xero_is_changed": false,
"duplicate_invoice_flag": false,
"payment_progress": "unpaid",
"gl_posting_date": null,
"submitted_with_unproven_supplier_email": false,
"currency": {
"id": 2000,
"iso_code": "USD",
"iso_numeric": "840",
"name": "United States Dollar",
"symbol": "$",
"created_at": "2026-10-21T04:44:44.793-05:00",
"updated_at": "2026-10-21T04:44:44.793-05:00"
},
"supplier": {
"id": 2,
"name": "Contoso Hardware",
"company_id": 20000,
"notes": null,
"phone_number": "+1 555 0300",
"address": "500 Tech Park, Austin, TX 78701",
"email": "sales@contoso.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Diego Ramos",
"tax_number": "74-7654321",
"payment_terms": "Net 45",
"created_at": "2026-10-21T04:48:43.563-05:00",
"updated_at": "2026-10-21T04:48:43.563-05:00",
"currency_id": 2000,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
},
"invoice_line_items": [
{
"id": 1,
"sequence_no": 1,
"description": "USB-C dock",
"unit_price": "150.0",
"quantity": "3.0",
"vat": "10.0",
"net_amount": "450.0",
"base_net_amount": "450.0",
"deleted_at": null,
"billable_status": null,
"tax_rate_id": 4,
"chart_of_account_id": null,
"qbo_customer_id": null,
"quickbooks_class_id": null,
"qbo_line_description": null,
"invoice_id": 1,
"purchase_order_id": 1,
"purchase_order_item_id": 1,
"product_id": null,
"markup_info": null,
"created_at": 1792576873,
"updated_at": 1792577056,
"tax_amount": "45.0",
"calculated_gross_amount": "495.0",
"tax_exception": false,
"chart_of_account": {
"id": null,
"name": null
},
"qbo_customer": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
}
}
],
"purchase_orders": [
{
"id": 1,
"approval_key": "1-10/21/2026",
"creator_name": "Jane Doe",
"amount": "1200.0",
"status": "approved",
"supplier_name": "Contoso Hardware",
"supplier_id": 2,
"keywords": "1 Contoso Hardware Jane Doe 1-10/21/2026 approved 1306.50 Engineering Hardware FY26 USB-C dock 653.25 600.0 Engineering Hardware FY26 27in monitor 653.25 600.0 Not Set Not Set ",
"created_at": 1792576723000,
"currency_id": 2000,
"currency_symbol": "$",
"currency_iso_code": "USD",
"total_gross_amount": "1306.5",
"total_net_amount": "1200.0",
"base_gross_amount": 1306.5,
"tax_amount_overridden": false,
"tax_amount_override": null,
"effective_tax_amount": 106.5,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"submitted_on": 1792576723000,
"po_date": null,
"share_key": "408EF0983EE977885C34631274E59C3B3AD1A461",
"department_id": 2,
"department_name": "Engineering",
"budget_name": "Engineering Hardware FY26",
"compliance_status": null,
"delivered_on": null,
"delivery_status": "not_delivered",
"payment_status": "invoice_received",
"payment_progress": "unpaid",
"xero_export_status": null,
"synced_with_xero": false,
"xero_is_changed": false
}
],
"supplier_invoice_uploads": [
],
"histories": [
{
"id": 1,
"comment": "has created an invoice ",
"invoice_id": 1,
"creator_id": 40000,
"creator_name": "Jane Doe",
"created_at": 1792576873,
"updated_at": 1792576873,
"comment_formatted_time": "05:01",
"comment_formatted_date": "2026-10-21",
"formatted_comment": "<p>has created an invoice </p>"
},
{
"id": 5,
"comment": "Changes made to invoice CH-2026-1001: <br>Line item 1 Quantity from <b>2.0</b> to <b>3.0</b><br>Line item 1 Net Amount from <b>300.0</b> to <b>450.0</b><br>Line item 1 Tax Amount from <b>30.0</b> to <b>45.0</b><br>Line item 1 Gross Amount from <b>330.0</b> to <b>495.0</b>",
"invoice_id": 1,
"creator_id": 40000,
"creator_name": "Jane Doe",
"created_at": 1792577056,
"updated_at": 1792577056,
"comment_formatted_time": "05:04",
"comment_formatted_date": "2026-10-21",
"formatted_comment": "<p>Changes made to invoice <a href=\"/companies/20000/invoices/1\" rel=\"noopener noreferrer nofollow\">CH-2026-1001</a>: <br>Line item 1 Quantity from <b>2.0</b> to <b>3.0</b><br>Line item 1 Net Amount from <b>300.0</b> to <b>450.0</b><br>Line item 1 Tax Amount from <b>30.0</b> to <b>45.0</b><br>Line item 1 Gross Amount from <b>330.0</b> to <b>495.0</b></p>"
}
],
"comments": [
],
"npayments": [
]
}
V3 V1
Full detail of one invoice, with line items, linked POs, files, history, comments and payments.
Returns the invoice in the detail format. Every field of the invoice object is
included, plus invoice_line_items, purchase_orders, supplier_invoice_uploads
(with signed download URLs that expire after 5 minutes), histories, comments
and npayments.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/invoices/:id |
| V1 | GET /api/v1/invoices/:id |
Permissions
- Company admins, finance users and approvers can open any invoice of the company.
- Team members can open invoices they own or are mentioned on. With the company
setting
enable_member_invoice_approvalthey can open any invoice of the company. - Department sharing also grants access to invoices of POs in the user's departments.
Otherwise 401 Unauthorized access. An unknown id, or an invoice of another
company, returns 404.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Invoice id. |
Response
200 with the invoice object (detail format). Invoice timestamps are Unix seconds;
nested purchase_orders[].created_at / submitted_on are Unix milliseconds;
currency and supplier timestamps are ISO-8601.
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
Create an invoice
curl -X POST "https://app.procurementexpress.com/api/v3/invoices" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"invoice": {
"invoice_number": "CH-2026-1002",
"supplier_id": 2,
"currency_id": 2000,
"issue_date": "2026-10-20",
"received_date": "2026-10-21",
"due_date": "2026-11-19",
"gross_amount": "990.00",
"invoice_line_items_attributes": [
{
"sequence_no": 1,
"description": "USB-C dock",
"quantity": 2,
"unit_price": 150,
"vat": 10,
"net_amount": 300,
"tax_rate_id": 4,
"purchase_order_id": 4,
"purchase_order_item_id": 6
},
{
"sequence_no": 2,
"description": "27in monitor",
"quantity": 2,
"unit_price": 300,
"vat": 10,
"net_amount": 600,
"tax_rate_id": 4,
"purchase_order_id": 4,
"purchase_order_item_id": 7
}
]
}
}'
curl -X POST "https://app.procurementexpress.com/api/v1/invoices" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"invoice": {
"invoice_number": "CH-2026-1001",
"supplier_id": 2,
"currency_id": 2000,
"issue_date": "2026-10-20",
"received_date": "2026-10-21",
"due_date": "2026-11-19",
"gross_amount": "990.00",
"invoice_line_items_attributes": [
{
"sequence_no": 1,
"description": "USB-C dock",
"quantity": 2,
"unit_price": 150,
"vat": 10,
"net_amount": 300,
"tax_rate_id": 4,
"purchase_order_id": 1,
"purchase_order_item_id": 1
},
{
"sequence_no": 2,
"description": "27in monitor",
"quantity": 2,
"unit_price": 300,
"vat": 10,
"net_amount": 600,
"tax_rate_id": 4,
"purchase_order_id": 1,
"purchase_order_item_id": 2
}
]
}
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/invoices",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
invoice: {
invoice_number: "CH-2026-1002",
supplier_id: 2,
currency_id: 2000,
issue_date: "2026-10-20",
received_date: "2026-10-21",
due_date: "2026-11-19",
gross_amount: "990.00",
invoice_line_items_attributes: [
{
sequence_no: 1,
description: "USB-C dock",
quantity: 2,
unit_price: 150,
vat: 10,
net_amount: 300,
tax_rate_id: 4,
purchase_order_id: 4,
purchase_order_item_id: 6
},
{
sequence_no: 2,
description: "27in monitor",
quantity: 2,
unit_price: 300,
vat: 10,
net_amount: 600,
tax_rate_id: 4,
purchase_order_id: 4,
purchase_order_item_id: 7
}
]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/invoices",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
invoice: {
invoice_number: "CH-2026-1001",
supplier_id: 2,
currency_id: 2000,
issue_date: "2026-10-20",
received_date: "2026-10-21",
due_date: "2026-11-19",
gross_amount: "990.00",
invoice_line_items_attributes: [
{
sequence_no: 1,
description: "USB-C dock",
quantity: 2,
unit_price: 150,
vat: 10,
net_amount: 300,
tax_rate_id: 4,
purchase_order_id: 1,
purchase_order_item_id: 1
},
{
sequence_no: 2,
description: "27in monitor",
quantity: 2,
unit_price: 300,
vat: 10,
net_amount: 600,
tax_rate_id: 4,
purchase_order_id: 1,
purchase_order_item_id: 2
}
]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 2,
"invoice_number": "CH-2026-1002",
"status": "outstanding",
"issue_date": 1792472400,
"validation_date": null,
"uploaded_date": 1792576882,
"due_date": 1795068000,
"tax_amount": "90.0",
"tax_amount_overridden": false,
"tax_amount_override": null,
"shipping_amount": "0.0",
"shipping_amount_overridden": false,
"net_amount": "900.0",
"gross_amount": "990.0",
"balance_amount": "990.0",
"created_at": 1792576882,
"updated_at": 1792576882,
"standalone_invoice": false,
"can_accept": false,
"can_approve": true,
"can_reject": true,
"can_cancel": true,
"can_archive": true,
"can_dearchive": false,
"sage_exported": false,
"confidence_score": "0.0",
"digital_invoice": false,
"selected_purchase_order_ids": [
],
"payment_term_id": null,
"payment_terms_list": null,
"xero_export_status": null,
"xero_export_error_message": null,
"xero_last_export_at": null,
"xero_is_changed": false,
"duplicate_invoice_flag": false,
"payment_progress": "unpaid",
"gl_posting_date": null,
"submitted_with_unproven_supplier_email": false,
"currency": {
"id": 2000,
"iso_code": "USD",
"iso_numeric": "840",
"name": "United States Dollar",
"symbol": "$",
"created_at": "2026-10-21T04:44:44.793-05:00",
"updated_at": "2026-10-21T04:44:44.793-05:00"
},
"supplier": {
"id": 2,
"name": "Contoso Hardware",
"company_id": 20000,
"notes": null,
"phone_number": "+1 555 0300",
"address": "500 Tech Park, Austin, TX 78701",
"email": "sales@contoso.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Diego Ramos",
"tax_number": "74-7654321",
"payment_terms": "Net 45",
"created_at": "2026-10-21T04:48:43.563-05:00",
"updated_at": "2026-10-21T04:48:43.563-05:00",
"currency_id": 2000,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
},
"invoice_line_items": [
{
"id": 3,
"sequence_no": 1,
"description": "USB-C dock",
"unit_price": "150.0",
"quantity": "2.0",
"vat": "10.0",
"net_amount": "300.0",
"base_net_amount": "300.0",
"deleted_at": null,
"billable_status": null,
"tax_rate_id": 4,
"chart_of_account_id": null,
"qbo_customer_id": null,
"quickbooks_class_id": null,
"qbo_line_description": null,
"invoice_id": 2,
"purchase_order_id": 4,
"purchase_order_item_id": 6,
"product_id": null,
"markup_info": null,
"created_at": 1792576882,
"updated_at": 1792576882,
"tax_amount": "30.0",
"calculated_gross_amount": "330.0",
"tax_exception": false,
"chart_of_account": {
"id": null,
"name": null
},
"qbo_customer": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
}
},
{
"id": 4,
"sequence_no": 2,
"description": "27in monitor",
"unit_price": "300.0",
"quantity": "2.0",
"vat": "10.0",
"net_amount": "600.0",
"base_net_amount": "600.0",
"deleted_at": null,
"billable_status": null,
"tax_rate_id": 4,
"chart_of_account_id": null,
"qbo_customer_id": null,
"quickbooks_class_id": null,
"qbo_line_description": null,
"invoice_id": 2,
"purchase_order_id": 4,
"purchase_order_item_id": 7,
"product_id": null,
"markup_info": null,
"created_at": 1792576882,
"updated_at": 1792576882,
"tax_amount": "60.0",
"calculated_gross_amount": "660.0",
"tax_exception": false,
"chart_of_account": {
"id": null,
"name": null
},
"qbo_customer": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
}
}
],
"purchase_orders": [
{
"id": 4,
"approval_key": "4-10/21/2026",
"creator_name": "Jane Doe",
"amount": "1200.0",
"status": "approved",
"supplier_name": "Contoso Hardware",
"supplier_id": 2,
"keywords": "4 Contoso Hardware Jane Doe 4-10/21/2026 approved 1306.50 Engineering Hardware FY26 USB-C dock 653.25 600.0 Engineering Hardware FY26 27in monitor 653.25 600.0 Not Set Not Set ",
"created_at": 1792576811000,
"currency_id": 2000,
"currency_symbol": "$",
"currency_iso_code": "USD",
"total_gross_amount": "1306.5",
"total_net_amount": "1200.0",
"base_gross_amount": 1306.5,
"tax_amount_overridden": false,
"tax_amount_override": null,
"effective_tax_amount": 106.5,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"submitted_on": 1792576811000,
"po_date": null,
"share_key": "BC85C70BB47874D5FE56B85FB6FF6F65B1D13753",
"department_id": 2,
"department_name": "Engineering",
"budget_name": "Engineering Hardware FY26",
"compliance_status": null,
"delivered_on": null,
"delivery_status": "not_delivered",
"payment_status": "invoice_received",
"payment_progress": "unpaid",
"xero_export_status": null,
"synced_with_xero": false,
"xero_is_changed": false
}
],
"supplier_invoice_uploads": [
],
"histories": [
{
"id": 2,
"comment": "has created an invoice ",
"invoice_id": 2,
"creator_id": 40000,
"creator_name": "Jane Doe",
"created_at": 1792576882,
"updated_at": 1792576882,
"comment_formatted_time": "05:01",
"comment_formatted_date": "2026-10-21",
"formatted_comment": "<p>has created an invoice </p>"
}
],
"comments": [
],
"npayments": [
]
}
Example: multipart (V3,
200 OK):
{
"id": 21,
"invoice_number": "CH-2026-1004",
"status": "outstanding",
"issue_date": 1792472400,
"validation_date": null,
"uploaded_date": 1792577293,
"due_date": 1795068000,
"tax_amount": "30.0",
"tax_amount_overridden": false,
"tax_amount_override": null,
"shipping_amount": "0.0",
"shipping_amount_overridden": false,
"net_amount": "300.0",
"gross_amount": "330.0",
"balance_amount": "330.0",
"created_at": 1792577293,
"updated_at": 1792577294,
"standalone_invoice": false,
"can_accept": false,
"can_approve": true,
"can_reject": true,
"can_cancel": true,
"can_archive": true,
"can_dearchive": false,
"sage_exported": false,
"confidence_score": "0.0",
"digital_invoice": false,
"selected_purchase_order_ids": [
],
"payment_term_id": null,
"payment_terms_list": null,
"xero_export_status": null,
"xero_export_error_message": null,
"xero_last_export_at": null,
"xero_is_changed": false,
"duplicate_invoice_flag": false,
"payment_progress": "unpaid",
"gl_posting_date": null,
"submitted_with_unproven_supplier_email": false,
"currency": {
"id": 2000,
"iso_code": "USD",
"iso_numeric": "840",
"name": "United States Dollar",
"symbol": "$",
"created_at": "2026-10-21T04:44:44.793-05:00",
"updated_at": "2026-10-21T04:44:44.793-05:00"
},
"supplier": {
"id": 2,
"name": "Contoso Hardware",
"company_id": 20000,
"notes": null,
"phone_number": "+1 555 0300",
"address": "500 Tech Park, Austin, TX 78701",
"email": "sales@contoso.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Diego Ramos",
"tax_number": "74-7654321",
"payment_terms": "Net 45",
"created_at": "2026-10-21T04:48:43.563-05:00",
"updated_at": "2026-10-21T04:48:43.563-05:00",
"currency_id": 2000,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
},
"invoice_line_items": [
{
"id": 22,
"sequence_no": 1,
"description": "USB-C dock",
"unit_price": "150.0",
"quantity": "2.0",
"vat": "10.0",
"net_amount": "300.0",
"base_net_amount": "300.0",
"deleted_at": null,
"billable_status": null,
"tax_rate_id": null,
"chart_of_account_id": null,
"qbo_customer_id": null,
"quickbooks_class_id": null,
"qbo_line_description": null,
"invoice_id": 21,
"purchase_order_id": 10,
"purchase_order_item_id": 18,
"product_id": null,
"markup_info": null,
"created_at": 1792577294,
"updated_at": 1792577294,
"tax_amount": "30.0",
"calculated_gross_amount": "330.0",
"tax_exception": false,
"chart_of_account": {
"id": null,
"name": null
},
"qbo_customer": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
}
}
],
"purchase_orders": [
{
"id": 10,
"approval_key": "10-10/21/2026",
"creator_name": "Jane Doe",
"amount": "1200.0",
"status": "approved",
"supplier_name": "Contoso Hardware",
"supplier_id": 2,
"keywords": "10 Contoso Hardware Jane Doe 10-10/21/2026 approved 1306.50 Engineering Hardware FY26 USB-C dock 653.25 600.0 Engineering Hardware FY26 27in monitor 653.25 600.0 Not Set Not Set ",
"created_at": 1792576822000,
"currency_id": 2000,
"currency_symbol": "$",
"currency_iso_code": "USD",
"total_gross_amount": "1306.5",
"total_net_amount": "1200.0",
"base_gross_amount": 1306.5,
"tax_amount_overridden": false,
"tax_amount_override": null,
"effective_tax_amount": 106.5,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"submitted_on": 1792576822000,
"po_date": null,
"share_key": "5731DC97332BF7369958FE78F1A077187CA1AD89",
"department_id": 2,
"department_name": "Engineering",
"budget_name": "Engineering Hardware FY26",
"compliance_status": null,
"delivered_on": null,
"delivery_status": "not_delivered",
"payment_status": "invoice_received",
"payment_progress": "unpaid",
"xero_export_status": null,
"synced_with_xero": false,
"xero_is_changed": false
}
],
"supplier_invoice_uploads": [
{
"id": 3,
"file_file_name": "invoice-CH-2026-1003.pdf",
"file_content_type": "application/pdf",
"file_file_size": 646,
"url": "https://pex-files.s3.amazonaws.com/supplier_invoice_uploads/files/000/000/003/original/invoice-CH-2026-1003.pdf?response-content-disposition=attachment%3B%20filename%3D%22invoice-CH-2026-1003.pdf%22%3B%20filename%2A%3DUTF-8%27%27invoice-CH-2026-1003.pdf&response-content-type=application%2Foctet-stream&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20261021%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20261021T100814Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=1b4b804e1663737a90e7d2d7350a12ee03ca5011e23b28724af4995eec873167"
}
],
"histories": [
{
"id": 38,
"comment": "has created an invoice invoice-CH-2026-1003.pdf",
"invoice_id": 21,
"creator_id": 40000,
"creator_name": "Jane Doe",
"created_at": 1792577294,
"updated_at": 1792577294,
"comment_formatted_time": "05:08",
"comment_formatted_date": "2026-10-21",
"formatted_comment": "<p>has created an invoice invoice-CH-2026-1003.pdf</p>"
}
],
"comments": [
],
"npayments": [
]
}
Example: standalone (V3,
200 OK):
{
"id": 5,
"invoice_number": "GL-77813",
"status": "outstanding",
"issue_date": 1792299600,
"validation_date": null,
"uploaded_date": 1792576999,
"due_date": 1794895200,
"tax_amount": "0.0",
"tax_amount_overridden": false,
"tax_amount_override": null,
"shipping_amount": "0.0",
"shipping_amount_overridden": false,
"net_amount": "120.0",
"gross_amount": "120.0",
"balance_amount": "120.0",
"created_at": 1792577000,
"updated_at": 1792577000,
"standalone_invoice": true,
"can_accept": false,
"can_approve": true,
"can_reject": true,
"can_cancel": true,
"can_archive": true,
"can_dearchive": false,
"sage_exported": false,
"confidence_score": "0.0",
"digital_invoice": false,
"selected_purchase_order_ids": [
],
"payment_term_id": null,
"payment_terms_list": null,
"xero_export_status": null,
"xero_export_error_message": null,
"xero_last_export_at": null,
"xero_is_changed": false,
"duplicate_invoice_flag": false,
"payment_progress": "unpaid",
"gl_posting_date": null,
"submitted_with_unproven_supplier_email": false,
"currency": {
"id": 2000,
"iso_code": "USD",
"iso_numeric": "840",
"name": "United States Dollar",
"symbol": "$",
"created_at": "2026-10-21T04:44:44.793-05:00",
"updated_at": "2026-10-21T04:44:44.793-05:00"
},
"supplier": {
"id": 3,
"name": "Globex Logistics",
"company_id": 20000,
"notes": null,
"phone_number": "+1 555 0400",
"address": "77 Harbor Blvd, Oakland, CA 94607",
"email": "billing@globex.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Hank Scorpio",
"tax_number": "94-1112223",
"payment_terms": "Due on receipt",
"created_at": "2026-10-21T04:48:43.570-05:00",
"updated_at": "2026-10-21T04:48:43.570-05:00",
"currency_id": 2000,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
},
"invoice_line_items": [
{
"id": 6,
"sequence_no": 1,
"description": "Courier fees October",
"unit_price": "120.0",
"quantity": "1.0",
"vat": "0.0",
"net_amount": "120.0",
"base_net_amount": "120.0",
"deleted_at": null,
"billable_status": null,
"tax_rate_id": null,
"chart_of_account_id": 1,
"qbo_customer_id": null,
"quickbooks_class_id": null,
"qbo_line_description": null,
"invoice_id": 5,
"purchase_order_id": null,
"purchase_order_item_id": null,
"product_id": null,
"markup_info": null,
"created_at": 1792577000,
"updated_at": 1792577000,
"tax_amount": "0.0",
"calculated_gross_amount": "120.0",
"tax_exception": false,
"chart_of_account": {
"id": 1,
"name": "Office Expenses"
},
"qbo_customer": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
}
}
],
"purchase_orders": [
],
"supplier_invoice_uploads": [
],
"histories": [
{
"id": 4,
"comment": "has created an invoice ",
"invoice_id": 5,
"creator_id": 40000,
"creator_name": "Jane Doe",
"created_at": 1792577000,
"updated_at": 1792577000,
"comment_formatted_time": "05:03",
"comment_formatted_date": "2026-10-21",
"formatted_comment": "<p>has created an invoice </p>"
}
],
"comments": [
],
"npayments": [
]
}
Example: po overrun (V3,
200 OK):
{
"id": 30,
"invoice_number": "CH-OVR-A-V3",
"status": "awaiting_review",
"issue_date": 1792472400,
"validation_date": null,
"uploaded_date": 1792577860,
"due_date": 1792577860,
"tax_amount": "95.0",
"tax_amount_overridden": false,
"tax_amount_override": null,
"shipping_amount": "0.0",
"shipping_amount_overridden": false,
"net_amount": "950.0",
"gross_amount": "1045.0",
"balance_amount": "1045.0",
"created_at": 1792577860,
"updated_at": 1792577860,
"standalone_invoice": false,
"can_accept": true,
"can_approve": false,
"can_reject": true,
"can_cancel": true,
"can_archive": true,
"can_dearchive": false,
"sage_exported": false,
"confidence_score": "0.0",
"digital_invoice": false,
"selected_purchase_order_ids": [
],
"payment_term_id": null,
"payment_terms_list": null,
"xero_export_status": null,
"xero_export_error_message": null,
"xero_last_export_at": null,
"xero_is_changed": false,
"duplicate_invoice_flag": false,
"payment_progress": "unpaid",
"gl_posting_date": null,
"submitted_with_unproven_supplier_email": false,
"currency": {
"id": 2000,
"iso_code": "USD",
"iso_numeric": "840",
"name": "United States Dollar",
"symbol": "$",
"created_at": "2026-10-21T04:44:44.793-05:00",
"updated_at": "2026-10-21T04:44:44.793-05:00"
},
"supplier": {
"id": 2,
"name": "Contoso Hardware",
"company_id": 20000,
"notes": null,
"phone_number": "+1 555 0300",
"address": "500 Tech Park, Austin, TX 78701",
"email": "sales@contoso.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Diego Ramos",
"tax_number": "74-7654321",
"payment_terms": "Net 45",
"created_at": "2026-10-21T04:48:43.563-05:00",
"updated_at": "2026-10-21T04:48:43.563-05:00",
"currency_id": 2000,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
},
"invoice_line_items": [
{
"id": 30,
"sequence_no": 1,
"description": "27in monitor",
"unit_price": "475.0",
"quantity": "2.0",
"vat": "10.0",
"net_amount": "950.0",
"base_net_amount": "950.0",
"deleted_at": null,
"billable_status": null,
"tax_rate_id": null,
"chart_of_account_id": null,
"qbo_customer_id": null,
"quickbooks_class_id": null,
"qbo_line_description": null,
"invoice_id": 30,
"purchase_order_id": 8,
"purchase_order_item_id": 15,
"product_id": null,
"markup_info": null,
"created_at": 1792577860,
"updated_at": 1792577860,
"tax_amount": "95.0",
"calculated_gross_amount": "1045.0",
"tax_exception": false,
"chart_of_account": {
"id": null,
"name": null
},
"qbo_customer": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
}
}
],
"purchase_orders": [
{
"id": 8,
"approval_key": "8-10/21/2026",
"creator_name": "Jane Doe",
"amount": "1200.0",
"status": "approved",
"supplier_name": "Contoso Hardware",
"supplier_id": 2,
"keywords": "8 Contoso Hardware Jane Doe 8-10/21/2026 approved 1306.50 Engineering Hardware FY26 USB-C dock 653.25 600.0 Engineering Hardware FY26 27in monitor 653.25 600.0 Not Set Not Set ",
"created_at": 1792576820000,
"currency_id": 2000,
"currency_symbol": "$",
"currency_iso_code": "USD",
"total_gross_amount": "1306.5",
"total_net_amount": "1200.0",
"base_gross_amount": 1306.5,
"tax_amount_overridden": false,
"tax_amount_override": null,
"effective_tax_amount": 106.5,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"submitted_on": 1792576820000,
"po_date": null,
"share_key": "756D0F9DC90ACCC55DA4AAF169FFFFC15BAA7E71",
"department_id": 2,
"department_name": "Engineering",
"budget_name": "Engineering Hardware FY26",
"compliance_status": null,
"delivered_on": null,
"delivery_status": "not_delivered",
"payment_status": "invoice_received",
"payment_progress": "unpaid",
"xero_export_status": null,
"synced_with_xero": false,
"xero_is_changed": false
}
],
"supplier_invoice_uploads": [
],
"histories": [
{
"id": 62,
"comment": "has created an invoice ",
"invoice_id": 30,
"creator_id": 40000,
"creator_name": "Jane Doe",
"created_at": 1792577860,
"updated_at": 1792577860,
"comment_formatted_time": "05:17",
"comment_formatted_date": "2026-10-21",
"formatted_comment": "<p>has created an invoice </p>"
}
],
"comments": [
],
"npayments": [
]
}
V3 V1
Record a supplier invoice, optionally linked to purchase order lines and with a PDF attached.
Creates an invoice for the company in app_company_id. The current user becomes
its owner.
Request format. Send JSON when no file is attached. To attach the invoice PDF,
send multipart/form-data with the same field names in bracket notation (for
example invoice[invoice_number] and
invoice[invoice_line_items_attributes][0][quantity]), and the file as
invoice[supplier_invoice_uploads_attributes][0][file]. The file must be an
actual file upload. A URL or string is not accepted and fails with a 500. Only
PDF is accepted, up to 30 MB.
Linking to POs. Give each line a purchase_order_id and
purchase_order_item_id (see
purchase_order_item_list). Lines without
them make a standalone invoice. A draft PO cannot be linked. Every linked PO gets
payment_status: invoice_received.
Status. The invoice is created as outstanding. It is created as
awaiting_review instead in two cases:
- The company setting
can_create_invoice_in_awaiting_reviewis on. - The PO overrun guard is enabled and the invoice exceeds a linked PO's total.
Amounts. net_amount, tax_amount and gross_amount are computed from the
lines (line tax = net_amount x vat / 100). The gross_amount you send is
replaced.
Dates. Send ISO YYYY-MM-DD. A date in any other format is dropped, and
issue_date, due_date and uploaded_date default to the current time.
Processing is synchronous: POST /invoices does not run OCR or AI extraction. Use POST /digital_invoices for that.
V3 only, e-invoicing. When the company has an active e-invoicing (PA) connection and the upload is a Factur-X PDF, V3 hands the file to the e-invoicing pipeline:
- The file can be in
invoice[supplier_invoice_uploads_attributes][0][file]or a top-levelfile. - A request with only the file (no invoice fields) returns the received e-invoice record, and the invoice is built later from the embedded XML.
- Optional parameters:
einvoicing_format(or theX-Einvoicing-Formatheader),pa_invoice_id(orX-Einvoicing-Pa-Invoice-Id) anddoc_type(defaultinvoice).
HTTP request
| Version | Request |
|---|---|
| V3 | POST /api/v3/invoices |
| V1 | POST /api/v1/invoices |
Permissions
The user's role must be allowed to upload invoices by the company settings
enable_admin_invoice_upload, enable_finance_invoice_upload,
enable_approver_invoice_upload or enable_member_invoice_upload (or the
upload_invoice role permission). Otherwise 401 Unauthorized access.
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
invoice[ |
string | No | Supplier's invoice reference. Required when the company setting invoice_reference_mandatory is on. Must be unique per supplier among non-cancelled invoices (Invoice reference already exists). Max 21 characters with an active QuickBooks connection. |
invoice[ |
integer | Yes | Supplier id. Optional only when the invoice is created as awaiting_review. |
invoice[ |
integer | Yes | Currency id (currency_id can't be blank otherwise). |
invoice[ |
date | No | Invoice date, YYYY-MM-DD. Defaults to now. |
invoice[ |
date | No | Due date, YYYY-MM-DD. Defaults to now. |
invoice[ |
date | No | Date received, YYYY-MM-DD. Stored but not returned by the API. |
invoice[ |
date | No | Recorded date, YYYY-MM-DD. Defaults to now. |
invoice[ |
date | No | Approval date. Normally set automatically when the invoice is approved. |
invoice[ |
number | No | Ignored in practice: recomputed from the line items when saved. |
invoice[ |
boolean | No | Mark the invoice as not PO-backed. |
invoice[ |
integer | No | V1 only (V3 ignores it). Payment term id. |
invoice[ |
boolean | No | Marks a scanned invoice. Changes the tax calculation to use the per-line tax amounts. |
invoice[ |
number | No | Extraction confidence for scanned invoices. |
invoice[ |
boolean | No | Sage export marker. |
invoice[ |
array[integer] | No | PO ids picked on the form (informational; the PO links come from the line items). |
invoice[ |
integer | No | Invoice-level custom field id. Required custom fields must be filled in. |
invoice[ |
string | No | Custom field value. |
invoice[ |
integer | No | Existing value id (update only). |
invoice[ |
string | No | Line description. |
invoice[ |
integer | No | Line order. Assigned automatically when missing. |
invoice[ |
number | No | Quantity. |
invoice[ |
number | No | Unit price. |
invoice[ |
number | No | Line net amount (normally quantity x unit_price). |
invoice[ |
number | No | Tax percentage of the line. This drives the invoice tax. |
invoice[ |
number | No | Net amount in the company base currency. |
invoice[ |
integer | No | Tax rate id (stored; the tax uses vat). |
invoice[ |
integer | No | PO this line bills. |
invoice[ |
integer | No | PO line this line bills. |
invoice[ |
integer | No | Chart of account id. |
invoice[ |
integer | No | QuickBooks customer id. |
invoice[ |
integer | No | QuickBooks class id. |
invoice[ |
string | No | Line description sent to QuickBooks. |
invoice[ |
string | No | QuickBooks billable status: NotBillable, Billable, HasBeenBilled. |
invoice[ |
array[object] | No | Line-level custom field values: custom_field_id, value, id. |
invoice[ |
integer | No | Existing line id (update only). |
invoice[ |
boolean | No | true with id deletes the line (update only). |
invoice[ |
file | No | The invoice PDF as a multipart file (max 30 MB). Non-PDF: The invoice you selected has to be a .pdf file. |
invoice[ |
integer | No | Existing upload id (update only). |
invoice[ |
boolean | No | true with id removes the file (update only). |
invoice[ |
integer | No | Ignored: the company always comes from app_company_id. |
Response
200 (not 201) with the created invoice in the detail format. Its histories
contain the "has created an invoice" entry.
Validation errors return 400 with an object keyed by attribute. Nested errors use
dotted keys, for example
{"error": {"supplier_invoice_uploads.file": ["..."]}, "status": 400}.
Side effects
- The
new_invoicewebhook fires. - Linked POs move to
payment_status: invoice_received. - Invoice approval flows are matched (when published).
- Duplicate invoice numbers are flagged.
V3 and V1 differences
- V3 ignores
invoice[payment_term_id]. This was verified: the same request leavespayment_term_idnull on V3. - V3 only: the e-invoicing (Factur-X) intake path described above.
- Otherwise identical requests and responses.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Upload disabled | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
| Validation error | 400 |
V3, V1 | {"error":{"supplier_id":["can't be blank"]},"status":400} |
| Duplicate ref | 400 |
V3, V1 | {"error":{"invoice_number":["Invoice reference already exists"]},"status":400} |
| Invalid file | 400 |
V3, V1 | {"error":{"supplier_invoice_uploads.file_content_type":["The invoice you selected has to be a .pdf file"],"supplier_invoice_uploads.file":["The invoice you selected has to be a .pdf file"]},"status":400} |
Update an invoice
curl -X PUT "https://app.procurementexpress.com/api/v3/invoices/2" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"invoice": {
"due_date": "2026-12-04",
"payment_term_id": 3,
"custom_field_values_attributes": [
{
"custom_field_id": 4,
"value": "AP-2026-10-B"
}
],
"invoice_line_items_attributes": [
{
"id": 3,
"quantity": 3,
"net_amount": 450
},
{
"id": 4,
"_destroy": true
}
]
}
}'
curl -X PUT "https://app.procurementexpress.com/api/v1/invoices/1" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"invoice": {
"due_date": "2026-12-04",
"payment_term_id": 3,
"custom_field_values_attributes": [
{
"custom_field_id": 4,
"value": "AP-2026-10-B"
}
],
"invoice_line_items_attributes": [
{
"id": 1,
"quantity": 3,
"net_amount": 450
},
{
"id": 2,
"_destroy": true
}
]
}
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v3/invoices/2",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
invoice: {
due_date: "2026-12-04",
payment_term_id: 3,
custom_field_values_attributes: [
{
custom_field_id: 4,
value: "AP-2026-10-B"
}
],
invoice_line_items_attributes: [
{
id: 3,
quantity: 3,
net_amount: 450
},
{
id: 4,
_destroy: true
}
]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v1/invoices/1",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
invoice: {
due_date: "2026-12-04",
payment_term_id: 3,
custom_field_values_attributes: [
{
custom_field_id: 4,
value: "AP-2026-10-B"
}
],
invoice_line_items_attributes: [
{
id: 1,
quantity: 3,
net_amount: 450
},
{
id: 2,
_destroy: true
}
]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
V3 response
200 OK:
{
"id": 2,
"invoice_number": "CH-2026-1002",
"status": "outstanding",
"issue_date": 1792472400,
"validation_date": null,
"uploaded_date": 1792576882,
"due_date": 1796364000,
"tax_amount": "45.0",
"tax_amount_overridden": false,
"tax_amount_override": null,
"shipping_amount": "0.0",
"shipping_amount_overridden": false,
"net_amount": "450.0",
"gross_amount": "495.0",
"balance_amount": "495.0",
"created_at": 1792576882,
"updated_at": 1792577065,
"standalone_invoice": false,
"can_accept": false,
"can_approve": true,
"can_reject": true,
"can_cancel": true,
"can_archive": true,
"can_dearchive": false,
"sage_exported": false,
"confidence_score": "0.0",
"digital_invoice": false,
"selected_purchase_order_ids": [
],
"payment_term_id": null,
"payment_terms_list": null,
"xero_export_status": null,
"xero_export_error_message": null,
"xero_last_export_at": null,
"xero_is_changed": false,
"duplicate_invoice_flag": false,
"payment_progress": "unpaid",
"gl_posting_date": null,
"submitted_with_unproven_supplier_email": false,
"currency": {
"id": 2000,
"iso_code": "USD",
"iso_numeric": "840",
"name": "United States Dollar",
"symbol": "$",
"created_at": "2026-10-21T04:44:44.793-05:00",
"updated_at": "2026-10-21T04:44:44.793-05:00"
},
"supplier": {
"id": 2,
"name": "Contoso Hardware",
"company_id": 20000,
"notes": null,
"phone_number": "+1 555 0300",
"address": "500 Tech Park, Austin, TX 78701",
"email": "sales@contoso.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Diego Ramos",
"tax_number": "74-7654321",
"payment_terms": "Net 45",
"created_at": "2026-10-21T04:48:43.563-05:00",
"updated_at": "2026-10-21T04:48:43.563-05:00",
"currency_id": 2000,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
},
"invoice_line_items": [
{
"id": 3,
"sequence_no": 1,
"description": "USB-C dock",
"unit_price": "150.0",
"quantity": "3.0",
"vat": "10.0",
"net_amount": "450.0",
"base_net_amount": "450.0",
"deleted_at": null,
"billable_status": null,
"tax_rate_id": 4,
"chart_of_account_id": null,
"qbo_customer_id": null,
"quickbooks_class_id": null,
"qbo_line_description": null,
"invoice_id": 2,
"purchase_order_id": 4,
"purchase_order_item_id": 6,
"product_id": null,
"markup_info": null,
"created_at": 1792576882,
"updated_at": 1792577065,
"tax_amount": "45.0",
"calculated_gross_amount": "495.0",
"tax_exception": false,
"chart_of_account": {
"id": null,
"name": null
},
"qbo_customer": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
}
}
],
"purchase_orders": [
{
"id": 4,
"approval_key": "4-10/21/2026",
"creator_name": "Jane Doe",
"amount": "1200.0",
"status": "approved",
"supplier_name": "Contoso Hardware",
"supplier_id": 2,
"keywords": "4 Contoso Hardware Jane Doe 4-10/21/2026 approved 1306.50 Engineering Hardware FY26 USB-C dock 653.25 600.0 Engineering Hardware FY26 27in monitor 653.25 600.0 Not Set Not Set ",
"created_at": 1792576811000,
"currency_id": 2000,
"currency_symbol": "$",
"currency_iso_code": "USD",
"total_gross_amount": "1306.5",
"total_net_amount": "1200.0",
"base_gross_amount": 1306.5,
"tax_amount_overridden": false,
"tax_amount_override": null,
"effective_tax_amount": 106.5,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"submitted_on": 1792576811000,
"po_date": null,
"share_key": "BC85C70BB47874D5FE56B85FB6FF6F65B1D13753",
"department_id": 2,
"department_name": "Engineering",
"budget_name": "Engineering Hardware FY26",
"compliance_status": null,
"delivered_on": null,
"delivery_status": "not_delivered",
"payment_status": "invoice_received",
"payment_progress": "unpaid",
"xero_export_status": null,
"synced_with_xero": false,
"xero_is_changed": false
}
],
"supplier_invoice_uploads": [
],
"histories": [
{
"id": 2,
"comment": "has created an invoice ",
"invoice_id": 2,
"creator_id": 40000,
"creator_name": "Jane Doe",
"created_at": 1792576882,
"updated_at": 1792576882,
"comment_formatted_time": "05:01",
"comment_formatted_date": "2026-10-21",
"formatted_comment": "<p>has created an invoice </p>"
},
{
"id": 6,
"comment": "Changes made to invoice CH-2026-1002: <br>Line item 1 Quantity from <b>2.0</b> to <b>3.0</b><br>Line item 1 Net Amount from <b>300.0</b> to <b>450.0</b><br>Line item 1 Tax Amount from <b>30.0</b> to <b>45.0</b><br>Line item 1 Gross Amount from <b>330.0</b> to <b>495.0</b>",
"invoice_id": 2,
"creator_id": 40000,
"creator_name": "Jane Doe",
"created_at": 1792577065,
"updated_at": 1792577065,
"comment_formatted_time": "05:04",
"comment_formatted_date": "2026-10-21",
"formatted_comment": "<p>Changes made to invoice <a href=\"/companies/20000/invoices/2\" rel=\"noopener noreferrer nofollow\">CH-2026-1002</a>: <br>Line item 1 Quantity from <b>2.0</b> to <b>3.0</b><br>Line item 1 Net Amount from <b>300.0</b> to <b>450.0</b><br>Line item 1 Tax Amount from <b>30.0</b> to <b>45.0</b><br>Line item 1 Gross Amount from <b>330.0</b> to <b>495.0</b></p>"
}
],
"comments": [
],
"npayments": [
]
}
V1 response
200 OK:
{
"id": 1,
"invoice_number": "CH-2026-1001",
"status": "outstanding",
"issue_date": 1792472400,
"validation_date": null,
"uploaded_date": 1792576873,
"due_date": 1796364000,
"tax_amount": "45.0",
"tax_amount_overridden": false,
"tax_amount_override": null,
"shipping_amount": "0.0",
"shipping_amount_overridden": false,
"net_amount": "450.0",
"gross_amount": "495.0",
"balance_amount": "495.0",
"created_at": 1792576873,
"updated_at": 1792577056,
"standalone_invoice": false,
"can_accept": false,
"can_approve": true,
"can_reject": true,
"can_cancel": true,
"can_archive": true,
"can_dearchive": false,
"sage_exported": false,
"confidence_score": "0.0",
"digital_invoice": false,
"selected_purchase_order_ids": [
],
"payment_term_id": 3,
"payment_terms_list": {
"id": 3,
"archived": false,
"company_id": 20000,
"created_at": "2026-10-21T04:44:45.145-05:00",
"day_of_month": 0,
"due_days": 30,
"name": "Net 30",
"term_type": "standard",
"updated_at": "2026-10-21T04:44:45.145-05:00"
},
"xero_export_status": null,
"xero_export_error_message": null,
"xero_last_export_at": null,
"xero_is_changed": false,
"duplicate_invoice_flag": false,
"payment_progress": "unpaid",
"gl_posting_date": null,
"submitted_with_unproven_supplier_email": false,
"currency": {
"id": 2000,
"iso_code": "USD",
"iso_numeric": "840",
"name": "United States Dollar",
"symbol": "$",
"created_at": "2026-10-21T04:44:44.793-05:00",
"updated_at": "2026-10-21T04:44:44.793-05:00"
},
"supplier": {
"id": 2,
"name": "Contoso Hardware",
"company_id": 20000,
"notes": null,
"phone_number": "+1 555 0300",
"address": "500 Tech Park, Austin, TX 78701",
"email": "sales@contoso.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Diego Ramos",
"tax_number": "74-7654321",
"payment_terms": "Net 45",
"created_at": "2026-10-21T04:48:43.563-05:00",
"updated_at": "2026-10-21T04:48:43.563-05:00",
"currency_id": 2000,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
},
"invoice_line_items": [
{
"id": 1,
"sequence_no": 1,
"description": "USB-C dock",
"unit_price": "150.0",
"quantity": "3.0",
"vat": "10.0",
"net_amount": "450.0",
"base_net_amount": "450.0",
"deleted_at": null,
"billable_status": null,
"tax_rate_id": 4,
"chart_of_account_id": null,
"qbo_customer_id": null,
"quickbooks_class_id": null,
"qbo_line_description": null,
"invoice_id": 1,
"purchase_order_id": 1,
"purchase_order_item_id": 1,
"product_id": null,
"markup_info": null,
"created_at": 1792576873,
"updated_at": 1792577056,
"tax_amount": "45.0",
"calculated_gross_amount": "495.0",
"tax_exception": false,
"chart_of_account": {
"id": null,
"name": null
},
"qbo_customer": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
}
}
],
"purchase_orders": [
{
"id": 1,
"approval_key": "1-10/21/2026",
"creator_name": "Jane Doe",
"amount": "1200.0",
"status": "approved",
"supplier_name": "Contoso Hardware",
"supplier_id": 2,
"keywords": "1 Contoso Hardware Jane Doe 1-10/21/2026 approved 1306.50 Engineering Hardware FY26 USB-C dock 653.25 600.0 Engineering Hardware FY26 27in monitor 653.25 600.0 Not Set Not Set ",
"created_at": 1792576723000,
"currency_id": 2000,
"currency_symbol": "$",
"currency_iso_code": "USD",
"total_gross_amount": "1306.5",
"total_net_amount": "1200.0",
"base_gross_amount": 1306.5,
"tax_amount_overridden": false,
"tax_amount_override": null,
"effective_tax_amount": 106.5,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"submitted_on": 1792576723000,
"po_date": null,
"share_key": "408EF0983EE977885C34631274E59C3B3AD1A461",
"department_id": 2,
"department_name": "Engineering",
"budget_name": "Engineering Hardware FY26",
"compliance_status": null,
"delivered_on": null,
"delivery_status": "not_delivered",
"payment_status": "invoice_received",
"payment_progress": "unpaid",
"xero_export_status": null,
"synced_with_xero": false,
"xero_is_changed": false
}
],
"supplier_invoice_uploads": [
],
"histories": [
{
"id": 1,
"comment": "has created an invoice ",
"invoice_id": 1,
"creator_id": 40000,
"creator_name": "Jane Doe",
"created_at": 1792576873,
"updated_at": 1792576873,
"comment_formatted_time": "05:01",
"comment_formatted_date": "2026-10-21",
"formatted_comment": "<p>has created an invoice </p>"
},
{
"id": 5,
"comment": "Changes made to invoice CH-2026-1001: <br>Line item 1 Quantity from <b>2.0</b> to <b>3.0</b><br>Line item 1 Net Amount from <b>300.0</b> to <b>450.0</b><br>Line item 1 Tax Amount from <b>30.0</b> to <b>45.0</b><br>Line item 1 Gross Amount from <b>330.0</b> to <b>495.0</b>",
"invoice_id": 1,
"creator_id": 40000,
"creator_name": "Jane Doe",
"created_at": 1792577056,
"updated_at": 1792577056,
"comment_formatted_time": "05:04",
"comment_formatted_date": "2026-10-21",
"formatted_comment": "<p>Changes made to invoice <a href=\"/companies/20000/invoices/1\" rel=\"noopener noreferrer nofollow\">CH-2026-1001</a>: <br>Line item 1 Quantity from <b>2.0</b> to <b>3.0</b><br>Line item 1 Net Amount from <b>300.0</b> to <b>450.0</b><br>Line item 1 Tax Amount from <b>30.0</b> to <b>45.0</b><br>Line item 1 Gross Amount from <b>330.0</b> to <b>495.0</b></p>"
}
],
"comments": [
],
"npayments": [
]
}
V3 V1
Change an invoice's header fields, line items, custom fields or attached files.
Takes the same invoice[...] parameters as create. Only the
fields you send change.
For nested records:
- Pass the
idof an existing line or upload to change it. - Leave out
idto add a new one. - Pass
idwith_destroy: trueto delete one.
Amounts are recalculated after the save. Changing invoice_number re-checks
uniqueness per supplier.
A history entry lists the line-level changes. The linked POs are marked
invoice_received. Invoice approval flows are re-matched, and integrations
(QuickBooks...) are updated.
HTTP request
| Version | Request |
|---|---|
| V3 | `PUT |
| V1 | `PUT |
Permissions
- The user needs access to the invoice: admin or finance, an approver, the owner, a mentioned user, or a department member when department sharing is on.
- The invoice must not be
archived,settledorcancelled. Otherwise401 Unauthorized access. - An invoice exported to Sage 200 returns 422
This invoice has been exported to Sage 200 and can no longer be edited....
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Invoice id. |
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
invoice[ |
object | No | Any parameter of create, with the same rules (V3 again ignores payment_term_id). |
invoice[ |
integer | No | Id of the line to change or delete. |
invoice[ |
boolean | No | true deletes the line given by id. |
invoice[ |
integer | No | Id of the file to remove (with _destroy: true). |
invoice[ |
integer | No | Id of the custom field value to change. |
Response
200 with the updated invoice in the detail format. Validation errors return
400 in the same format as create.
Side effects
- A history entry ("Changes made to invoice ...").
- Linked POs are marked
invoice_received. - QuickBooks / Sage sync of the invoice.
V3 and V1 differences
V3 ignores invoice[payment_term_id]: the same request sets payment_term_id to 3 on V1
and leaves it null on V3. PATCH and PUT behave the same in
both versions.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
| Duplicate ref | 400 |
V3, V1 | {"error":{"invoice_number":["Invoice reference already exists"]},"status":400} |
| Cancelled | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
| Forbidden | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
List purchase orders to invoice
curl "https://app.procurementexpress.com/api/v3/invoices/purchase_order_list?supplier_id=2&per_page=2&selected_ids=10" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/invoices/purchase_order_list?supplier_id=2&per_page=2&selected_ids=10" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/invoices/purchase_order_list",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
params: {
supplier_id: "2",
per_page: "2",
selected_ids: "10"
}
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/invoices/purchase_order_list",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
params: {
supplier_id: "2",
per_page: "2",
selected_ids: "10"
}
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"purchase_orders": [
{
"id": 10,
"approval_key": "10-10/21/2026",
"creator_name": "Jane Doe",
"amount": "1200.0",
"status": "approved",
"supplier_name": "Contoso Hardware",
"supplier_id": 2,
"keywords": "10 Contoso Hardware Jane Doe 10-10/21/2026 approved 1306.50 Engineering Hardware FY26 USB-C dock 653.25 600.0 Engineering Hardware FY26 27in monitor 653.25 600.0 Not Set Not Set ",
"created_at": 1792576822000,
"currency_id": 2000,
"currency_symbol": "$",
"currency_iso_code": "USD",
"total_gross_amount": "1306.5",
"total_net_amount": "1200.0",
"base_gross_amount": 1306.5,
"tax_amount_overridden": false,
"tax_amount_override": null,
"effective_tax_amount": 106.5,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"submitted_on": 1792576822000,
"po_date": null,
"share_key": "5731DC97332BF7369958FE78F1A077187CA1AD89",
"department_id": 2,
"department_name": "Engineering",
"budget_name": "Engineering Hardware FY26",
"compliance_status": null,
"delivered_on": null,
"delivery_status": "not_delivered",
"payment_status": "unpaid",
"payment_progress": "unpaid",
"xero_export_status": null,
"synced_with_xero": false,
"xero_is_changed": false,
"approver_requests": [
{
"id": 11,
"status": "approved",
"approver_name": "Priya Patel",
"approver_id": 50000,
"delegate_id": null
}
]
},
{
"id": 34,
"approval_key": "34-10/21/2026",
"creator_name": "Jane Doe",
"amount": "189.5",
"status": "approved",
"supplier_name": "Contoso Hardware",
"supplier_id": 2,
"keywords": "34 Contoso Hardware Jane Doe 34-10/21/2026 approved 206.32 DOCK-USB-C Engineering Hardware FY26 USB-C docking station 206.318125 189.5 PRJ-2026-014 CC-100 Engineering ",
"created_at": 1792577049000,
"currency_id": 2000,
"currency_symbol": "$",
"currency_iso_code": "USD",
"total_gross_amount": "206.32",
"total_net_amount": "189.5",
"base_gross_amount": 206.32,
"tax_amount_overridden": false,
"tax_amount_override": null,
"effective_tax_amount": 16.82,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"submitted_on": 1792577049000,
"po_date": null,
"share_key": "933B65C50ACBC55AD83BF85FD6CBB070CB01E89F",
"department_id": 2,
"department_name": "Engineering",
"budget_name": "Engineering Hardware FY26",
"compliance_status": null,
"delivered_on": null,
"delivery_status": "not_delivered",
"payment_status": "unpaid",
"payment_progress": "unpaid",
"xero_export_status": null,
"synced_with_xero": false,
"xero_is_changed": false,
"approver_requests": [
{
"id": 34,
"status": "approved",
"approver_name": "Priya Patel",
"approver_id": 50000,
"delegate_id": null
}
]
}
],
"meta": {
"current_page": 1,
"next_page": 2,
"prev_page": null,
"total_pages": 6,
"total_count": 12
}
}
V3 V1
Approved purchase orders of a supplier that still have work to invoice.
Use this endpoint to pick the POs an invoice bills. It returns POs of the company that match all of these conditions:
- They belong to the given
supplier_id. This parameter is effectively required: without it, only POs with no supplier are returned, which is usually none. - They use the given
currency_id(default: the company currency). - They are not archived.
- Their status is
approved, ordraft/pending/approvedwithinclude_unapproved=true. - They are not fully invoiced yet. Only invoices that are
ready_to_payorsettledcount toward that.
The results are limited to POs the user can open. POs listed in selected_ids are
always included and listed first, even when they do not match the filters.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/invoices/purchase_order_list |
| V1 | GET /api/v1/invoices/purchase_order_list |
Permissions
Any user of the company. The results are limited to POs the user can see (PO policy scope).
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
supplier_id |
integer | Yes | Supplier id. |
currency_id |
integer | No | PO currency. Default: company currency. |
selected_ids |
string | No | Comma-separated PO ids to always include (shown first), e.g. POs already on the invoice. |
include_unapproved |
boolean | No | true also returns draft and pending POs. Default: false. |
q |
string | No | PO search text (alias keyword). |
requester_ids |
string | No | Comma-separated creator ids. |
budget_ids |
string | No | Comma-separated budget ids. |
filter_dept_ids |
string | No | Comma-separated department ids. |
approver_ids |
string | No | Comma-separated approver ids. |
supplier_email_status |
string | No | Comma-separated supplier email statuses. |
sort |
string | No | Allow-listed PO sort column. Default: purchase_orders.submitted_on. |
direction |
string | No | asc or desc. Default: desc. |
page |
integer | No | Page number. Default: 1. |
per_page |
integer | No | Page size (any value). Default: 10. |
Response
200 with {"purchase_orders": [...], "meta": {...}}. Each PO is in the PO
summary format: id, approval_key, status, supplier_id, currency_id,
totals, payment_status, department_name, budget_name, approver_requests...
created_at and submitted_on are Unix milliseconds. meta holds the pagination
fields.
V3 and V1 differences
None - identical request and response.
List purchase order lines to invoice
curl "https://app.procurementexpress.com/api/v3/invoices/purchase_order_item_list?purchase_order_ids%5B%5D=1&purchase_order_ids%5B%5D=5" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/invoices/purchase_order_item_list?purchase_order_ids%5B%5D=1&purchase_order_ids%5B%5D=5" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/invoices/purchase_order_item_list",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
params: {
"purchase_order_ids[]": "5"
}
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/invoices/purchase_order_item_list",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
params: {
"purchase_order_ids[]": "5"
}
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1 (lists shortened to 2 items):
[
{
"id": 1,
"sequence_no": 1,
"description": "USB-C dock",
"unit_price": 150.0,
"quantity": 4.0,
"vat": "8.875",
"net_amount": "600.0",
"gross_amount": "653.25",
"budget_summary": "Engineering Hardware FY26 : ENG-HW : Capex",
"budget_name": "Engineering Hardware FY26",
"budget_cost_code": "ENG-HW",
"budget_cost_type": "Capex",
"iso_code": "$",
"tax_rate_id": 4,
"chart_of_account_id": null,
"qbo_customer_id": null,
"quickbooks_class_id": null,
"qbo_line_description": null,
"purchase_order_id": 1,
"approval_key": "1-10/21/2026",
"custom_field_values_json": [
{
"id": 1,
"value": null,
"custom_field_id": 3
}
]
},
{
"id": 2,
"sequence_no": 2,
"description": "27in monitor",
"unit_price": 300.0,
"quantity": 2.0,
"vat": "8.875",
"net_amount": "600.0",
"gross_amount": "653.25",
"budget_summary": "Engineering Hardware FY26 : ENG-HW : Capex",
"budget_name": "Engineering Hardware FY26",
"budget_cost_code": "ENG-HW",
"budget_cost_type": "Capex",
"iso_code": "$",
"tax_rate_id": 4,
"chart_of_account_id": null,
"qbo_customer_id": null,
"quickbooks_class_id": null,
"qbo_line_description": null,
"purchase_order_id": 1,
"approval_key": "1-10/21/2026",
"custom_field_values_json": [
{
"id": 2,
"value": null,
"custom_field_id": 3
}
]
}
]
V3 V1
The open lines of one or more purchase orders, with the quantity and amount still to invoice.
Returns one flat array with the lines of every requested PO, in PO-line order.
Each line shows what is still to be invoiced: invoice lines of ready_to_pay
and settled invoices are subtracted. When the remaining quantity is 0 or less,
the line comes back with quantity: 1 and the remaining net amount as
unit_price. Fully invoiced lines are left out.
You can copy the values straight into invoice_line_items_attributes. Pass the
line id as purchase_order_item_id and the PO's id as purchase_order_id.
Pass the ids as an array (purchase_order_ids[]=1&purchase_order_ids[]=5). A
comma-separated value is not split. POs the user cannot open are skipped.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/invoices/purchase_order_item_list |
| V1 | GET /api/v1/invoices/purchase_order_item_list |
Permissions
Any user of the company. POs outside the user's PO policy scope are left out.
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
purchase_order_ids[ |
array[integer] | Yes | PO ids. |
Response
200 with an array of line objects (an empty array when nothing matches). The
line fields are listed below.
The numbers are not all the same type: unit_price and quantity are JSON
numbers, while vat, net_amount and gross_amount are strings.
| Field | Type | Description |
|---|---|---|
[ |
integer | PO line id (use as purchase_order_item_id). |
[ |
integer | PO id. |
[ |
string | PO reference. |
[ |
integer / string | Line order and text. |
[ |
number | Quantity and unit price still to invoice. |
[ |
string | Effective tax percentage of the PO line. It is derived back from tax overrides when the PO line has one. |
[ |
string | Remaining net and gross. |
[ |
string | Budget of the PO line. |
[ |
string | PO currency: its symbol when the company shows currency symbols, otherwise its ISO code. |
[ |
integer / string | Accounting coding of the PO line. |
[ |
array[object] | Line custom field values: id, value, custom_field_id. |
V3 and V1 differences
None - identical request and response.
Comment on an invoice
curl -X POST "https://app.procurementexpress.com/api/v3/invoices/2/create_comment" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"invoice_comments": {
"comment": "Quantity corrected to 3 docks per the delivery note. See https://example.com/dn/4471"
}
}'
curl -X POST "https://app.procurementexpress.com/api/v1/invoices/1/create_comment" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"invoice_comments": {
"comment": "Quantity corrected to 3 docks per the delivery note. See https://example.com/dn/4471"
}
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/invoices/2/create_comment",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
invoice_comments: {
comment: "Quantity corrected to 3 docks per the delivery note. See https://example.com/dn/4471"
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/invoices/1/create_comment",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
invoice_comments: {
comment: "Quantity corrected to 3 docks per the delivery note. See https://example.com/dn/4471"
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 9,
"comment": "Quantity corrected to 3 docks per the delivery note. See https://example.com/dn/4471",
"invoice_id": 2,
"creator_id": 40000,
"creator_name": "Jane Doe",
"created_at": 1792577124,
"updated_at": 1792577124,
"comment_formatted_time": "05:05",
"comment_formatted_date": "2026-10-21",
"formatted_comment": "<p>Quantity corrected to 3 docks per the delivery note. See https://example.com/dn/4471</p>"
}
V3 V1
Add a user comment to an invoice.
Adds a comment to the invoice and writes it to the invoice's history. Links in the
text are turned into anchors in formatted_comment.
HTTP request
| Version | Request |
|---|---|
| V3 | POST /api/v3/invoices/:id/create_comment |
| V1 | POST /api/v1/invoices/:id/create_comment |
Permissions
- Company admins, finance users and approvers can comment on any invoice of the company.
- Team members can comment on invoices they own or are mentioned on, or on any
invoice when
enable_member_invoice_approvalis on.
The user's roles in the invoice's company are checked, not their default company.
Otherwise 401 Unauthorized access.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Invoice id. |
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
invoice_comments[ |
string | Yes | Comment text. It must be nested under invoice_comments: a missing wrapper returns 400 Required parameter are missing. A blank comment currently returns a 500. |
Response
200 with the comment: id, comment, invoice_id, creator_id,
creator_name, created_at / updated_at (Unix seconds),
comment_formatted_time, comment_formatted_date and formatted_comment (HTML).
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Missing param | 400 |
V3, V1 | {"error":"Required parameter are missing.","status":400} |
Accept an invoice awaiting review
curl -X PUT "https://app.procurementexpress.com/api/v3/invoices/15/accept" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl -X PUT "https://app.procurementexpress.com/api/v1/invoices/14/accept" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v3/invoices/15/accept",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v1/invoices/14/accept",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"success": true
}
Example: with overrun reason (V3,
200 OK):
{
"success": true
}
V3 V1
Move an awaiting_review invoice to outstanding so it can be approved.
Marks a reviewed invoice as correct: awaiting_review becomes outstanding. The
accepting user becomes the owner of invoices captured by the system. The suppliers
of the linked POs are notified that the invoice was received.
PO overrun guard. When invoice_po_overrun_enabled is on and the invoice
exceeds a linked PO's total:
- If the overrun is within
invoice_po_overrun_pctpercent, send a justification ininvoice[po_overrun_reason]. Without it the request returns422A reason is required when accepting an invoice that exceeds the PO amount. - If the overrun is above that limit, the request returns
422This invoice exceeds the PO overrun allowance. Edit the linked PO to increase its value before accepting.
An invoice flagged as a duplicate returns 422 This invoice has a duplicate
reference number. Please cancel or resolve the duplicate before approving.
HTTP request
| Version | Request |
|---|---|
| V3 | PUT /api/v3/invoices/:id/accept |
| V1 | PUT /api/v1/invoices/:id/accept |
Permissions
The invoice must be awaiting_review and not archived. If an invoice approval flow
is assigned, the user needs a pending approval on it. The user's role must be
allowed to review invoices by enable_admin_invoice_review,
enable_finance_invoice_review, enable_approver_invoice_review or
enable_member_invoice_review (or the upload_invoice role permission).
Otherwise 401 Unauthorized access.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Invoice id. |
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
invoice[ |
string | No | Justification, required when the PO overrun guard asks for a reason. Saved on the invoice the first time. |
Response
200 {"success": true}. Fetch the invoice to see its new status.
Side effects
- A history entry "has accepted an invoice" (legacy, non-flow path).
- Supplier notification for each linked PO.
- Invoice approval flows are matched.
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not awaiting review | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
| Overrun reason required | 422 |
V3, V1 | {"error":"A reason is required when accepting an invoice that exceeds the PO amount.","status":422} |
| Overrun exceeded | 422 |
V3, V1 | {"error":"This invoice exceeds the PO overrun allowance. Edit the linked PO to increase its value before accepting.","status":422} |
Approve an invoice
curl -X PUT "https://app.procurementexpress.com/api/v3/invoices/7/approve" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl -X PUT "https://app.procurementexpress.com/api/v1/invoices/6/approve" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v3/invoices/7/approve",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v1/invoices/6/approve",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"success": true
}
V3 V1
Approve an outstanding invoice (status becomes ready_to_pay).
Approves the invoice. Its status becomes ready_to_pay, and validation_date is
set to today. Once approved the invoice can be paid, and integrations that export
on approval pick it up.
An invoice that already has payments covering it in full settles immediately.
HTTP request
| Version | Request |
|---|---|
| V3 | PUT /api/v3/invoices/:id/approve |
| V1 | PUT /api/v1/invoices/:id/approve |
Permissions
All of the following must hold, otherwise 401 Unauthorized access:
- The invoice is
outstanding: notawaiting_review(accept it first),ready_to_pay,settledorcancelled. - It is not archived and not flagged as a duplicate.
- None of its linked POs is
draft,pending,rejectedorcancelled. - The user can see the invoice, or is a team member and
enable_member_invoice_approvalis on. - The user's role may approve invoices (
enable_admin_invoice_approval,enable_finance_invoice_approval,enable_approver_invoice_approval,enable_member_invoice_approval). - If an invoice approval flow is assigned, the user has a pending approval request on it.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Invoice id. |
Response
200 {"success": true}.
Side effects
- The
invoice_approvedwebhook fires. - The invoice-approved email is sent.
- A history entry "has approved an invoice".
- Accounting export (QuickBooks / Xero / Sage...) when the company exports on approval.
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Already approved | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
| Archived | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
| Po not approved | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
Reject an invoice
curl -X PUT "https://app.procurementexpress.com/api/v3/invoices/9/reject" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl -X PUT "https://app.procurementexpress.com/api/v1/invoices/8/reject" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v3/invoices/9/reject",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v1/invoices/8/reject",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"success": true
}
V3 V1
Reject an awaiting_review or outstanding invoice (status becomes cancelled).
Rejects the invoice, which sets its status to cancelled. There is no
separate rejected status, and the API takes no rejection reason. The request also
does the following:
- POs whose invoices are now all cancelled go back to
payment_status: unpaid. - Assigned approval flows are closed.
- The supplier is emailed when
send_invoice_rejected_email_to_supplieris on.
HTTP request
| Version | Request |
|---|---|
| V3 | PUT /api/v3/invoices/:id/reject |
| V1 | PUT /api/v1/invoices/:id/reject |
Permissions
- For an
awaiting_reviewinvoice, the rules of accept apply. - For an
outstandinginvoice, the rules of approve apply. - With an approval flow assigned, the user needs a pending approval request, and
the invoice must not be
ready_to_pay,settled,cancelledor archived.
The user's role must also be allowed to approve invoices
(enable_*_invoice_approval). Otherwise 401 Unauthorized access.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Invoice id. |
Response
200 {"success": true}.
Side effects
A history entry "has rejected an invoice". The rejection email goes to the supplier when the company setting is on.
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Already cancelled | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
Cancel an invoice
curl -X PUT "https://app.procurementexpress.com/api/v3/invoices/11/cancel" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl -X PUT "https://app.procurementexpress.com/api/v1/invoices/10/cancel" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v3/invoices/11/cancel",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v1/invoices/10/cancel",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"success": true
}
V3 V1
Cancel an invoice in any status except cancelled.
Sets the status to cancelled. This works from awaiting_review, outstanding,
ready_to_pay and settled, and also on archived invoices.
Cancelling also has these effects:
- Payments (npayments) recorded against the invoice are deleted.
- Amounts already booked as paid on the linked POs are reversed.
- POs whose invoices are now all cancelled return to
payment_status: unpaid. - A cancelled invoice no longer counts for duplicate detection.
HTTP request
| Version | Request |
|---|---|
| V3 | PUT /api/v3/invoices/:id/cancel |
| V1 | PUT /api/v1/invoices/:id/cancel |
Permissions
The user needs access to the invoice: admin or finance, an approver, the owner, a
mentioned user, or a department member under department sharing. The invoice must
not already be cancelled. Otherwise 401 Unauthorized access. A team member cannot
cancel other people's invoices.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Invoice id. |
Response
200 {"success": true}.
Side effects
A history entry "has cancelled an invoice". Linked payments are deleted, and the PO paid amounts and payment statuses are recalculated.
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Already cancelled | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
| Forbidden | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
Archive an invoice
curl -X PUT "https://app.procurementexpress.com/api/v3/invoices/13/archive" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl -X PUT "https://app.procurementexpress.com/api/v1/invoices/12/archive" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v3/invoices/13/archive",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v1/invoices/12/archive",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"success": true
}
V3 V1
Hide an invoice from the default lists.
Sets archived: true. The status does not change.
An archived invoice:
- Is left out of GET /invoices unless you pass
archived=true. - Cannot be edited, accepted, approved or paid. It can still be cancelled.
HTTP request
| Version | Request |
|---|---|
| V3 | PUT /api/v3/invoices/:id/archive |
| V1 | PUT /api/v1/invoices/:id/archive |
Permissions
The user needs access to the invoice: admin or finance, an approver, the owner, a
mentioned user, or a department member under department sharing. The invoice must
not already be archived. Otherwise 401 Unauthorized access.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Invoice id. |
Response
200 {"success": true}.
Side effects
A history entry "has archived an invoice".
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Already archived | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
| Forbidden | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
Unarchive an invoice
curl -X PUT "https://app.procurementexpress.com/api/v3/invoices/13/dearchive" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl -X PUT "https://app.procurementexpress.com/api/v1/invoices/12/dearchive" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v3/invoices/13/dearchive",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v1/invoices/12/dearchive",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"success": true
}
V3 V1
Restore an archived invoice.
Sets archived: false, so the invoice shows up in the default lists again and its
normal actions become available.
HTTP request
| Version | Request |
|---|---|
| V3 | PUT /api/v3/invoices/:id/dearchive |
| V1 | PUT /api/v1/invoices/:id/dearchive |
Permissions
The same access rule as archive. The invoice must be archived, otherwise
401 Unauthorized access.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Invoice id. |
Response
200 {"success": true}.
Side effects
A history entry "has de-archived an invoice".
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not archived | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
Rerun the approval flow of an invoice
# V1 only: this endpoint has no V3 equivalent.
curl -X POST "https://app.procurementexpress.com/api/v1/invoices/23/rerun_approval_flow" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl -X POST "https://app.procurementexpress.com/api/v1/invoices/23/rerun_approval_flow" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
# V1 only: this endpoint has no V3 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/invoices/23/rerun_approval_flow",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/invoices/23/rerun_approval_flow",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
V1 response
200 OK:
{
"status": "success",
"message": "Approval flow has been rerun successfully",
"invoice": {
"id": 23,
"status": "outstanding",
"invoice_number": "FAB-5521"
}
}
V3 — V1
Re-apply an edited invoice approval flow to an in-progress invoice (V1 only).
When an invoice approval flow is edited while invoices are going through it, those invoices are marked as having a changed flow. This endpoint restarts the approval of such an invoice:
- The current approval entities are reset.
- The published flows are matched again.
- The approvers are attached again.
- The invoice status is updated, and a system comment is added.
Previous approvals stay in the history.
The invoice is eligible only when all of these hold:
- It is not cancelled, archived or settled.
- The company has invoice approval flows enabled.
- Its approval entity is in progress, and the flow has changed since it was assigned.
HTTP request
| Version | Request |
|---|---|
| V1 | POST /api/v1/invoices/:id/rerun_approval_flow |
Permissions
Company admins only, in the invoice's company. If the user is not an admin, or the
invoice is not eligible, the response is 403
{"error": ["Not authorized to rerun approval flow"], "status": 403}. This is the
only invoice action that answers 403 instead of 401.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Invoice id. |
Response
200 with {"status": "success", "message": "Approval flow has been rerun successfully", "invoice": {"id", "status", "invoice_number"}}.
A failure inside the service returns 400 with the service errors. An unexpected
error returns 500
{"error": ["An unexpected error occurred while rerunning the approval flow"]}.
V3 and V1 differences
V1 only. The V3 route POST /api/v3/invoices/:id/rerun_approval_flow exists,
but the V3 controller has no such action, so it answers 404.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not eligible | 403 |
V1 | {"error":["Not authorized to rerun approval flow"],"status":403} |
| Not found | 404 |
V1 | {"error":"Invalid id passed","status":404} |
Scan an invoice document
curl -X POST "https://app.procurementexpress.com/api/v3/digital_invoices" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-F "upload_type=invoice" \
-F "file=@invoice-CH-2026-1003.pdf"
curl -X POST "https://app.procurementexpress.com/api/v1/digital_invoices" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-F "upload_type=invoice" \
-F "file=@invoice-CH-2026-1003.pdf"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/digital_invoices",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
},
payload: {
multipart: true,
upload_type: "invoice",
file: File.new("invoice-CH-2026-1003.pdf", "rb")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/digital_invoices",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
},
payload: {
multipart: true,
upload_type: "invoice",
file: File.new("invoice-CH-2026-1003.pdf", "rb")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
201 Created, same structure in V3 and V1:
{
"inbox_document_id": 2,
"status": "invalid",
"detected_as": null,
"confidence_score": 0.0,
"invalid_reason": "The AI invoice extraction service is temporarily unavailable. Both Google Gemini and OpenAI services could not process your request. Please try again later."
}
Example: request (V3,
201 Created):
{
"inbox_document_id": 4,
"status": "invalid",
"detected_as": null,
"confidence_score": 0.0,
"invalid_reason": "The AI invoice extraction service is temporarily unavailable. Both Google Gemini and OpenAI services could not process your request. Please try again later."
}
V3 V1
Upload an invoice (or purchase request) document for AI extraction.
Uploads a document for AI extraction (the Invoice Scan and Match feature). Send
multipart/form-data with file. Extraction runs synchronously within the
request: it typically takes a few seconds, and the result is in the response.
Scan & Match v2 (the current default for companies with the feature) works as follows:
- The file is stored and an AP inbox document is created.
- The AI extraction runs (Google Gemini, with OpenAI as fallback).
- The extracted data is written to the inbox document. Nothing is created automatically.
- The response is
201with the inbox document's id and outcome.statusis one of:needs_review: extracted, waiting for a reviewer to convert it into an invoice (or purchase order).ai_auto_created: created automatically, when the company enables auto-creation.invalid: not a usable invoice, or extraction failed.invalid_reasonexplains why.
- Review and convert the document in the AP inbox.
Legacy path (companies without Scan & Match v2): the extraction result is
turned into a record straight away. The response is 200 with an invoice
(detail format, digital_invoice: true, usually
awaiting_review), or with a draft purchase request (PO detail format) when
upload_type=request. When extraction fails, the response is 400 with the
extraction error.
HTTP request
| Version | Request |
|---|---|
| V3 | POST /api/v3/digital_invoices |
| V1 | POST /api/v1/digital_invoices |
Permissions
- For invoices (default
upload_type), the user's role must be allowed to upload invoices (enable_*_invoice_uploadsettings). Otherwise401. - For
upload_type=request, the user must be allowed to create purchase orders. - The company needs the Invoice Scan and Match feature. Without it the response
is
400Invoice Scan and Match feature is disabled for this company.
Body parameters (multipart/form-data)
| Parameter | Type | Required | Description |
|---|---|---|---|
file |
file | Yes | The document. For upload_type=invoice it must be a PDF: any other type returns 400 File content type The invoice you selected has to be a .pdf file .... With upload_type=request, images and office documents are accepted too. If it is missing, the response is 400 File is required. |
upload_type |
string | No | invoice, or request to scan a quote or receipt into a purchase request (case-insensitive). Default: invoice. |
Response
Scan & Match v2: 201 with
{"inbox_document_id", "status", "detected_as", "confidence_score", "invalid_reason"}.
detected_as is the AI's classification of the document. Legacy path: 200 with
the created invoice, or purchase request.
| Field | Type | Description |
|---|---|---|
inbox_document_id |
integer | Id of the AP inbox document created for the file. |
status |
string | needs_review, ai_auto_created or invalid after the scan (scanning while in progress, user_created / rejected later in the inbox). |
detected_as |
string | The AI's classification: invoice, purchase_order, quote, bill, receipt, handwritten_invoice, handwritten_receipt, statement, credit_note, delivery_note, job_report, catalogue, email_digest, invalid_photo, invalid_document; null when extraction failed. |
confidence_score |
number | Extraction confidence (0-100). |
invalid_reason |
string | Why the document is invalid, or null. |
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| No file | 400 |
V3, V1 | {"error":"File is required","status":400} |
| Not pdf | 400 |
V3, V1 | {"error":"File content type The invoice you selected has to be a .pdf file and File The invoice you selected has to be a .pdf file","status":400} |
| Disabled | 400 |
V3, V1 | {"error":"Invoice Scan and Match feature is disabled for this company","status":400} |
| Forbidden | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
Payments
There are two ways to record money paid to a supplier:
- Payments (
npayments): the payment record used by the AP module. A normal payment (payment_mode: normal) is allocated to one or more invoices. An advance payment (payment_mode: advance) is booked directly against purchase orders before any invoice exists. When a payment covers the rest of an approved (ready_to_pay) invoice, the invoice becomessettled. PO line payments are written for the linked purchase orders, which updates theirpayment_progressand, once fully paid, their status topaid. - PO payments ("mark as paid"): POST /purchase_orders/:purchase_order_id/payments records an amount per PO line. This is the API behind Mark as paid on a purchase order. It also creates the matching npayment automatically: a normal payment allocated to the PO's approved invoices when it has some, otherwise an advance payment.
Who can record payments
Only company admins and finance users may record payments (otherwise 401). The payments API is no longer behind a feature flag. It is refused (401) when the company syncs bill payments from QuickBooks (QuickBooks connection with bill-payment sync) or uses Dynamics sync, because those systems create the payments themselves. Anyone with a role in the company (including team members and approvers) can read a payment with GET /npayments/:id.
Enum values
| field | values |
|---|---|
ptype (payment type) |
bank_transfer (default), card, credit_card, check, cash, one_time_card, letter_of_credit, other, batch_payment (used by batch payments) |
payment_mode |
normal (default; allocated to invoices), advance (allocated to purchase orders) |
status |
active, cancelled. Read-only through the API |
API versions
V1 and V3 of POST /npayments have different contracts:
| V1 | V3 | |
|---|---|---|
supplier_id, currency_id |
sent by the client (required) | derived from the linked invoices / POs; not accepted |
| linked invoices / POs of different suppliers or currencies | accepted | 422 |
| unknown or other-company invoice / PO ids | 400 from the model validation | 422 Invoice(s) not found: ... |
| invoice overpayment | 400 when the allocation would exceed the invoice total | 422 when the allocation exceeds the invoice's remaining balance_amount |
nested id keys |
accepted | not accepted |
| success status | 200 | 201 |
| validation error status | 400 | 422 |
Both versions return the same payment object.
Endpoints in this section
| Endpoint | Method | Path | V3 | V1 |
|---|---|---|---|---|
| Get a payment | GET |
/npayments/:id |
✓ | ✓ |
| Record a payment | POST |
/npayments |
✓ | ✓ |
| Mark a purchase order as paid | POST |
/purchase_orders/:purchase_order_id/payments |
✓ | ✓ |
The payment (npayment) object
| Field | Type | Description |
|---|---|---|
id |
integer | Payment id. |
reference |
string | Your payment reference (bank reference, check number...). Optional. |
status |
string | active or cancelled. |
ptype |
string | Payment type, see the enum table. |
payment_mode |
string | normal (invoice payment) or advance (PO payment). |
date |
timestamp | Payment date, Unix seconds. |
amount |
string | Total amount (decimal string). |
created_at |
timestamp | Unix seconds. Currently returns the payment date, not the creation time. |
updated_at |
timestamp | Unix seconds. Currently returns the payment date, not the update time. |
archived |
boolean | True when the payment is archived. |
conversion_rate |
string | Exchange rate to the company base currency. |
currency |
object | id, name, iso_code, symbol. |
supplier |
object | id, name. |
user |
object | The user who recorded it: id, email, name. |
invoices |
array[object] | The invoices this payment is allocated to, in the invoice list format, with their status and balance after the payment. Empty for advance payments. |
npayment_comments |
array[object] | id, comment, creator_id, system_generated, created_at, updated_at (Unix seconds). |
Get a payment
curl "https://app.procurementexpress.com/api/v3/npayments/2" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/npayments/2" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/npayments/2",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/npayments/2",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 2,
"reference": "ACH-20261021-01",
"status": "active",
"ptype": "bank_transfer",
"date": 1792558800,
"amount": "165.0",
"created_at": 1792558800,
"updated_at": 1792558800,
"payment_mode": "normal",
"archived": false,
"conversion_rate": "1.0",
"currency": {
"id": 2000,
"name": "United States Dollar",
"iso_code": "USD",
"symbol": "$"
},
"supplier": {
"id": 2,
"name": "Contoso Hardware"
},
"user": {
"id": 40000,
"email": "jane.doe@example.com",
"name": "Jane Doe"
},
"invoices": [
{
"id": 16,
"invoice_number": "CH-PAY-V1",
"status": "settled",
"issue_date": 1792472400,
"validation_date": 1792558800,
"uploaded_date": 1792577159,
"due_date": 1795068000,
"tax_amount": "15.0",
"tax_amount_overridden": false,
"tax_amount_override": null,
"shipping_amount": "0.0",
"shipping_amount_overridden": false,
"net_amount": "150.0",
"gross_amount": "165.0",
"balance_amount": "0.0",
"supplier_id": 2,
"supplier_name": "Contoso Hardware",
"purchase_order_references": [
"9-10/21/2026"
],
"can_accept": false,
"can_approve": false,
"can_reject": false,
"can_cancel": true,
"can_archive": true,
"can_dearchive": false,
"standalone_invoice": false,
"confidence_score": "0.0",
"digital_invoice": false,
"payment_term_id": null,
"payment_terms_list": null,
"xero_export_status": null,
"xero_is_changed": false,
"duplicate_invoice_flag": false,
"payment_progress": "paid",
"submitted_with_unproven_supplier_email": false
}
],
"npayment_comments": [
{
"id": 2,
"comment": "Paid by ACH from operating account",
"creator_id": 40000,
"system_generated": false,
"created_at": 1792577521,
"updated_at": 1792577521
}
]
}
V3 V1
One payment with its currency, supplier, user, allocated invoices and comments.
Returns a payment of the company in app_company_id. The invoices list shows the
invoices the payment is allocated to, with their current status and balance.
Advance payments (PO payments) have an empty invoices list. The purchase orders
an advance payment is booked against are not included in the response.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/npayments/:id |
| V1 | GET /api/v1/npayments/:id |
Permissions
Company admins and finance users, plus anyone with a team member or approver role
in the company. Otherwise 401. An unknown id, or a payment of another company,
returns 404.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Payment id. |
Response
200 with the payment object.
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
Record a payment
curl -X POST "https://app.procurementexpress.com/api/v3/npayments" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"npayment": {
"reference": "ACH-20261021-02",
"ptype": "bank_transfer",
"payment_mode": "normal",
"date": "2026-10-21",
"amount": 165.0,
"npayment_invoices_attributes": [
{
"invoice_id": 17,
"gross_amount": 165.0
}
],
"npayment_comments_attributes": [
{
"comment": "Paid by ACH from operating account"
}
]
}
}'
curl -X POST "https://app.procurementexpress.com/api/v1/npayments" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"npayment": {
"reference": "ACH-20261021-01",
"ptype": "bank_transfer",
"payment_mode": "normal",
"date": "2026-10-21",
"amount": 165.0,
"supplier_id": 2,
"currency_id": 2000,
"npayment_invoices_attributes": [
{
"invoice_id": 16,
"gross_amount": 165.0
}
],
"npayment_comments_attributes": [
{
"comment": "Paid by ACH from operating account"
}
]
}
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/npayments",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
npayment: {
reference: "ACH-20261021-02",
ptype: "bank_transfer",
payment_mode: "normal",
date: "2026-10-21",
amount: 165.0,
npayment_invoices_attributes: [
{
invoice_id: 17,
gross_amount: 165.0
}
],
npayment_comments_attributes: [
{
comment: "Paid by ACH from operating account"
}
]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/npayments",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
npayment: {
reference: "ACH-20261021-01",
ptype: "bank_transfer",
payment_mode: "normal",
date: "2026-10-21",
amount: 165.0,
supplier_id: 2,
currency_id: 2000,
npayment_invoices_attributes: [
{
invoice_id: 16,
gross_amount: 165.0
}
],
npayment_comments_attributes: [
{
comment: "Paid by ACH from operating account"
}
]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
V3 response
201 Created:
{
"id": 3,
"reference": "ACH-20261021-02",
"status": "active",
"ptype": "bank_transfer",
"date": 1792558800,
"amount": "165.0",
"created_at": 1792558800,
"updated_at": 1792558800,
"payment_mode": "normal",
"archived": false,
"conversion_rate": "1.0",
"currency": {
"id": 2000,
"name": "United States Dollar",
"iso_code": "USD",
"symbol": "$"
},
"supplier": {
"id": 2,
"name": "Contoso Hardware"
},
"user": {
"id": 40000,
"email": "jane.doe@example.com",
"name": "Jane Doe"
},
"invoices": [
{
"id": 17,
"invoice_number": "CH-PAY-V3",
"status": "settled",
"issue_date": 1792472400,
"validation_date": 1792558800,
"uploaded_date": 1792577160,
"due_date": 1795068000,
"tax_amount": "15.0",
"tax_amount_overridden": false,
"tax_amount_override": null,
"shipping_amount": "0.0",
"shipping_amount_overridden": false,
"net_amount": "150.0",
"gross_amount": "165.0",
"balance_amount": "0.0",
"supplier_id": 2,
"supplier_name": "Contoso Hardware",
"purchase_order_references": [
"9-10/21/2026"
],
"can_accept": false,
"can_approve": false,
"can_reject": false,
"can_cancel": true,
"can_archive": true,
"can_dearchive": false,
"standalone_invoice": false,
"confidence_score": "0.0",
"digital_invoice": false,
"payment_term_id": null,
"payment_terms_list": null,
"xero_export_status": null,
"xero_is_changed": false,
"duplicate_invoice_flag": false,
"payment_progress": "paid",
"submitted_with_unproven_supplier_email": false
}
],
"npayment_comments": [
{
"id": 3,
"comment": "Paid by ACH from operating account",
"creator_id": 40000,
"system_generated": false,
"created_at": 1792577534,
"updated_at": 1792577534
}
]
}
V1 response
200 OK:
{
"id": 2,
"reference": "ACH-20261021-01",
"status": "active",
"ptype": "bank_transfer",
"date": 1792558800,
"amount": "165.0",
"created_at": 1792558800,
"updated_at": 1792558800,
"payment_mode": "normal",
"archived": false,
"conversion_rate": "1.0",
"currency": {
"id": 2000,
"name": "United States Dollar",
"iso_code": "USD",
"symbol": "$"
},
"supplier": {
"id": 2,
"name": "Contoso Hardware"
},
"user": {
"id": 40000,
"email": "jane.doe@example.com",
"name": "Jane Doe"
},
"invoices": [
{
"id": 16,
"invoice_number": "CH-PAY-V1",
"status": "settled",
"issue_date": 1792472400,
"validation_date": 1792558800,
"uploaded_date": 1792577159,
"due_date": 1795068000,
"tax_amount": "15.0",
"tax_amount_overridden": false,
"tax_amount_override": null,
"shipping_amount": "0.0",
"shipping_amount_overridden": false,
"net_amount": "150.0",
"gross_amount": "165.0",
"balance_amount": "0.0",
"supplier_id": 2,
"supplier_name": "Contoso Hardware",
"purchase_order_references": [
"9-10/21/2026"
],
"can_accept": false,
"can_approve": false,
"can_reject": false,
"can_cancel": true,
"can_archive": true,
"can_dearchive": false,
"standalone_invoice": false,
"confidence_score": "0.0",
"digital_invoice": false,
"payment_term_id": null,
"payment_terms_list": null,
"xero_export_status": null,
"xero_is_changed": false,
"duplicate_invoice_flag": false,
"payment_progress": "paid",
"submitted_with_unproven_supplier_email": false
}
],
"npayment_comments": [
{
"id": 2,
"comment": "Paid by ACH from operating account",
"creator_id": 40000,
"system_generated": false,
"created_at": 1792577521,
"updated_at": 1792577521
}
]
}
Example: partial (V3,
201 Created):
{
"id": 12,
"reference": "CHK-10443",
"status": "active",
"ptype": "check",
"date": 1792558800,
"amount": "100.0",
"created_at": 1792558800,
"updated_at": 1792558800,
"payment_mode": "normal",
"archived": false,
"conversion_rate": "1.0",
"currency": {
"id": 2000,
"name": "United States Dollar",
"iso_code": "USD",
"symbol": "$"
},
"supplier": {
"id": 2,
"name": "Contoso Hardware"
},
"user": {
"id": 40000,
"email": "jane.doe@example.com",
"name": "Jane Doe"
},
"invoices": [
{
"id": 7,
"invoice_number": "CH-APV-V3",
"status": "ready_to_pay",
"issue_date": 1792472400,
"validation_date": 1792558800,
"uploaded_date": 1792577150,
"due_date": 1795068000,
"tax_amount": "15.0",
"tax_amount_overridden": false,
"tax_amount_override": null,
"shipping_amount": "0.0",
"shipping_amount_overridden": false,
"net_amount": "150.0",
"gross_amount": "165.0",
"balance_amount": "65.0",
"supplier_id": 2,
"supplier_name": "Contoso Hardware",
"purchase_order_references": [
"5-10/21/2026"
],
"can_accept": false,
"can_approve": false,
"can_reject": false,
"can_cancel": true,
"can_archive": true,
"can_dearchive": false,
"standalone_invoice": false,
"confidence_score": "0.0",
"digital_invoice": false,
"payment_term_id": null,
"payment_terms_list": null,
"xero_export_status": null,
"xero_is_changed": false,
"duplicate_invoice_flag": false,
"payment_progress": "partially_paid",
"submitted_with_unproven_supplier_email": false
}
],
"npayment_comments": [
]
}
Example: advance (V3,
201 Created):
{
"id": 7,
"reference": "ADV-4-DEPOSIT",
"status": "active",
"ptype": "credit_card",
"date": 1792558800,
"amount": "200.0",
"created_at": 1792558800,
"updated_at": 1792558800,
"payment_mode": "advance",
"archived": false,
"conversion_rate": "1.0",
"currency": {
"id": 2000,
"name": "United States Dollar",
"iso_code": "USD",
"symbol": "$"
},
"supplier": {
"id": 2,
"name": "Contoso Hardware"
},
"user": {
"id": 40000,
"email": "jane.doe@example.com",
"name": "Jane Doe"
},
"invoices": [
],
"npayment_comments": [
{
"id": 5,
"comment": "30% deposit before shipment",
"creator_id": 40000,
"system_generated": false,
"created_at": 1792577609,
"updated_at": 1792577609
}
]
}
V3 V1
Record a payment against invoices (normal) or purchase orders (advance).
Creates a payment and books it.
Normal payment (payment_mode: normal, the default) is allocated to invoices.
List each invoice with the amount paid on it in npayment_invoices_attributes. A
partial amount is allowed. amount must equal the sum of the allocations, within
0.01. An allocation cannot exceed what is still owed on the invoice.
For each invoice, the request:
- Adds an invoice history entry ("... recorded a payment of USD 165.00 against this invoice").
- Recalculates
balance_amountandpayment_progress. - Writes the payment to the PO lines the invoice bills, which updates the POs'
payment_progressand moves a fully paid PO topaid.
A ready_to_pay invoice whose balance reaches 0 becomes settled. Pay approved
(ready_to_pay) invoices: an outstanding invoice is accepted too, but it only
shows payment_progress: paid and stays outstanding.
Advance payment (payment_mode: advance) is allocated to purchase orders,
before any invoice exists. List each PO with the amount in
npayment_link_orders_attributes. amount must equal the sum exactly. The POs
must be payable: approved, delivered or paid, or pending when the company
setting allow_po_paid_before_approved is on.
Dates. date is read in the company date_format (e.g. 10/21/2026). ISO
YYYY-MM-DD also works.
Fields set by the server. The user is always the token's user. Comments are
always stamped with the token's user and system_generated: false. status cannot
be set here.
HTTP request
| Version | Request |
|---|---|
| V3 | POST /api/v3/npayments |
| V1 | POST /api/v1/npayments |
Permissions
Company admins and finance users only. The request is refused when the company
syncs bill payments from QuickBooks or uses Dynamics sync. Otherwise
401 Unauthorized access. No feature flag is needed.
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
npayment[ |
number | Yes | Total paid. Must equal the sum of the invoice allocations (normal, within 0.01) or of the PO amounts (advance). |
npayment[ |
date | Yes | Payment date, in the company date format or YYYY-MM-DD. |
npayment[ |
string | No | Your payment reference. |
npayment[ |
string | No | Payment type: bank_transfer, card, credit_card, check, cash, one_time_card, letter_of_credit, other, batch_payment. Default: bank_transfer. |
npayment[ |
string | No | normal (pay invoices) or advance (pay POs). Default: normal. |
npayment[ |
integer | Yes | V1 only, required there. Supplier paid. V1 does not check that it matches the invoices' supplier. V3 derives it from the linked invoices or POs and ignores this parameter. |
npayment[ |
integer | No | V1 only. Payment currency. Defaults to the company currency. V3 derives it. |
npayment[ |
integer | No | Normal payments: an invoice of the company. At least one invoice is required for a normal payment. |
npayment[ |
number | No | The amount of this payment allocated to that invoice (not the invoice total). Each invoice may appear once. |
npayment[ |
boolean | No | Accepted for compatibility; use false or leave it out. |
npayment[ |
integer | No | V1 only; not meaningful on create. |
npayment[ |
integer | No | Advance payments: a payable PO of the company. At least one PO is required for an advance payment. |
npayment[ |
integer | No | Budget the amount is booked against (one of the PO's budgets). |
npayment[ |
number | No | Amount paid on that PO (and budget). |
npayment[ |
boolean | No | Accepted for compatibility; leave it out. |
npayment[ |
integer | No | V1 only; not meaningful on create. |
npayment[ |
string | No | A note on the payment. Blank comments are dropped. |
Response
V1 200, V3 201, with the payment object. Its invoices list shows the invoices
after the payment, for example status: settled, balance_amount: "0.0" and
payment_progress: paid.
Error responses:
- Validation errors return
{"error": [messages], "status": ...}, with status 400 in V1 and 422 in V3. - A nested
..._attributeskey that is not an array or object returns<key> must be an array of objects. - A missing
npaymentwrapper returns400 Required parameter are missing.
Side effects
- Invoice history entries; invoices settle when fully paid.
- PO payments are written for the linked POs. PO comments are added, and the POs'
payment_progress/statusare updated. - Integration sync of the payment (e.g. QuickBooks bill payment, Sage 200) when configured.
V3 and V1 differences
- V1 takes
supplier_id/currency_idfrom the client. V3 derives them from the linked invoices and POs, and returns 422 when they disagree (All linked invoices and purchase orders must belong to the same supplier/... same currency). - V3 checks the linked ids up front:
Invoice(s) not found: 999999/Purchase order(s) not found: ...(422). V1 returnsNpayment invoices invoice invoice does not exist(400). - Overpayment: V3 refuses an allocation above the invoice's remaining
balance_amountwithPayment of 200.0 exceeds the remaining balance (165.0) of invoice 6(422). V1 refuses an allocation that would take the invoice's allocated total above its gross withallocation of 200.0 for invoice 6 would take its allocated total to 200.0, outside the invoice total of 165.0(400). - Success: V1 200, V3 201. Validation errors: V1 400, V3 422.
- V3 does not accept nested
idkeys.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Forbidden | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
| Amount mismatch | 422 |
V3 | {"error":["Amount payment amount and invoice amounts are different"],"status":422} |
| Amount mismatch | 400 |
V1 | {"error":["Amount payment amount and invoice amounts are different"],"status":400} |
| Overpayment | 422 |
V3 | {"error":"Payment of 200.0 exceeds the remaining balance (165.0) of invoice 6","status":422} |
| Overpayment | 400 |
V1 | {"error":["allocation of 200.0 for invoice 6 would take its allocated total to 200.0, outside the invoice total of 165.0"],"status":400} |
| Invoice not found | 422 |
V3 | {"error":"Invoice(s) not found: 999999","status":422} |
| Invoice not found | 400 |
V1 | {"error":["Npayment invoices invoice invoice does not exist"],"status":400} |
| No items | 422 |
V3 | {"error":["Supplier can't be blank","Currency can't be blank","Items please add at least one invoice"],"status":422} |
| No items | 400 |
V1 | {"error":["Items please add at least one invoice"],"status":400} |
| Missing fields | 422 |
V3 | {"error":["Date can't be blank"],"status":422} |
| Missing fields | 400 |
V1 | {"error":["Date can't be blank","Supplier can't be blank"],"status":400} |
| Bad shape | 422 |
V3 | {"error":"npayment_invoices_attributes must be an array of objects","status":422} |
| Bad shape | 400 |
V1 | {"error":"npayment_invoices_attributes must be an array of objects","status":400} |
| Advance po not payable | 422 |
V3 | {"error":["Npayment link orders is invalid"],"status":422} |
| Advance po not payable | 400 |
V1 | {"error":["Npayment link orders is invalid"],"status":400} |
| Mixed suppliers | 422 |
V3 | {"error":"All linked invoices and purchase orders must belong to the same supplier","status":422} |
| Missing param | 400 |
V3, V1 | {"error":"Required parameter are missing.","status":400} |
Mark a purchase order as paid
curl -X POST "https://app.procurementexpress.com/api/v3/purchase_orders/7/payments" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"payment": {
"amount": 1306.5,
"note": "Paid in full by wire, ref WIRE-88214",
"purchase_order_item_payments_attributes": [
{
"purchase_order_item_id": 12,
"amount": 653.25
},
{
"purchase_order_item_id": 13,
"amount": 653.25
}
]
}
}'
curl -X POST "https://app.procurementexpress.com/api/v1/purchase_orders/6/payments" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"payment": {
"amount": 1306.5,
"note": "Paid in full by wire, ref WIRE-88213",
"purchase_order_item_payments_attributes": [
{
"purchase_order_item_id": 10,
"amount": 653.25
},
{
"purchase_order_item_id": 11,
"amount": 653.25
}
]
}
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/purchase_orders/7/payments",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
payment: {
amount: 1306.5,
note: "Paid in full by wire, ref WIRE-88214",
purchase_order_item_payments_attributes: [
{
purchase_order_item_id: 12,
amount: 653.25
},
{
purchase_order_item_id: 13,
amount: 653.25
}
]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/purchase_orders/6/payments",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
payment: {
amount: 1306.5,
note: "Paid in full by wire, ref WIRE-88213",
purchase_order_item_payments_attributes: [
{
purchase_order_item_id: 10,
amount: 653.25
},
{
purchase_order_item_id: 11,
amount: 653.25
}
]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"success": true
}
V3 V1
Record a payment on a purchase order, line by line ("mark as paid").
Records a payment on a purchase order. The amounts per PO line
(purchase_order_item_payments_attributes) are what count. The paid total is
the sum of the line amounts, and payment[amount] alone records nothing (the PO
comment then reads "USD 0.00"). Paying every line in full moves the PO to status
paid (payment_progress: paid). Otherwise payment_progress becomes
partially_paid.
The request also:
- Adds a PO comment: "Jane Doe marked a payment of USD 1,306.50 on this purchase order with payment terms Net 45", followed by the note. The PO creator is emailed if they opted in.
- Creates the matching payment (npayment). It is allocated to the PO's approved invoices if there are any, otherwise it is an advance payment on the PO's budgets.
- Settles approved invoices once the PO is fully paid.
Check can_mark_as_paid on the purchase order first. There is
no over-payment check: a further payment on an already paid PO is accepted.
purchase_order_id may be the PO id or its approval key.
HTTP request
| Version | Request |
|---|---|
| V3 | POST /api/v3/purchase_orders/:purchase_order_id/payments |
| V1 | POST /api/v1/purchase_orders/:purchase_order_id/payments |
Permissions
Company admins and finance users of the PO's company only. The PO must be
approved, delivered or paid, or pending when the company setting
allow_po_paid_before_approved is on. Otherwise 401 Unauthorized access. An
unknown PO returns 404.
Path parameters
| Parameter | Type | Description |
|---|---|---|
purchase_order_id |
integer | Purchase order id (or approval key). |
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
payment[ |
integer | Yes | A line of this PO. A line of another PO is rejected (purchase_order_item_payments is invalid). |
payment[ |
number | Yes | Amount paid on that line (gross). |
payment[ |
number | No | Total of the payment. Stored on the payment header, but the paid totals are computed from the line amounts. Send the sum of the lines. |
payment[ |
string | No | Note appended to the PO comment and saved on the created npayment. |
payment[ |
integer | No | Not meaningful on create (there is no update endpoint). |
Response
200 {"success": true}. Fetch the purchase order to see its new status,
payment_progress and payments.
Side effects
- A PO comment (category
payment_notes/payment_no_notes), with an email to the PO creator if they opted in. - An npayment is created.
- PO
payment_progress/statusare updated, and the PO's invoices settle when it is fully paid.
V3 and V1 differences
- Validation errors: V1 returns
422 {"error": {"purchase_order_item_payments": ["is invalid"]}, "status": 422}. V3 currently fails with a 500 (bug), so the V3 error capture is omitted. - V3 does not stamp the user on the line payments. V1 does.
- Success responses are identical.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Po not payable | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
| Forbidden | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
| Po not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
| Validation error | 422 |
V1 | {"error":{"purchase_order_item_payments":["is invalid"]},"status":422} |
Products
Products are your catalogue items (SKU, description, unit price) that users pick
when adding line items to a purchase order. A product can be linked to one or more
suppliers. A product linked to no supplier is available for every supplier:
GET /products?supplier_id=X returns the products linked to supplier X plus all
unlinked products.
Permissions. Any live member who can raise purchase orders may read products
(list, show, SKUs). Creating and updating products requires companyadmin,
finance or rfqcaseofficer, or the company setting Users can add products
(user_can_add_product) for other members. Otherwise the API answers 401 Unauthorized access.
Ownership checks. supplier_id, budget_id and tax_rate_id must belong to the
app_company_id company; an id from another company (or an unknown id) answers
404 Invalid id passed.
Archiving. Products cannot be archived or un-archived through the API (archived
is not a writable parameter). The archived query filter lists archived products.
Integrations. When the company is connected to QuickBooks Online with item-based
lines, create and update sync the product to QBO inline. A rejected update returns
400 with the QBO error. Sage-integrated companies get extra SKU and description
length and character rules (SKU required and unique, max 30 characters; description
max 60). Bulk-created products skip both syncs.
Endpoints in this section
| Endpoint | Method | Path | V3 | V1 |
|---|---|---|---|---|
| List products | GET |
/products |
✓ | ✓ |
| Get a product | GET |
/products/:id |
✓ | ✓ |
| List product SKUs | GET |
/products/skus |
✓ | ✓ |
| Create a product | POST |
/products |
✓ | ✓ |
| Bulk-create products for a supplier | POST |
/products/bulk_create |
✓ | ✓ |
| Update a product | PUT |
/products/:id |
✓ | ✓ |
The product object
| Field | Type | Description |
|---|---|---|
id |
integer | Product id. |
supplier_id |
integer | Write-only in practice: it echoes the supplier_id you sent on create/update, and is always null when a product is read (list, show, update without it). Use GET /products?supplier_id= to find a supplier's products. |
sku |
string | Stock keeping unit, may be null. |
description |
string | Product description (required, trimmed). |
unit_price |
string | Decimal unit price serialized as a string, e.g. "289.5". |
currency_id |
integer | Currency id, or null. Not writable through the API. |
archived |
boolean | true when archived. |
tax_rate_id |
integer | Default tax rate id, or null. |
budget_id |
integer | Default budget id, or null. |
created_at |
timestamp | ISO-8601 with offset. |
updated_at |
timestamp | ISO-8601 with offset. |
List products
curl "https://app.procurementexpress.com/api/v3/products?supplier_id=5" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/products?supplier_id=5" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/products",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
params: {
supplier_id: "5"
}
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/products",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
params: {
supplier_id: "5"
}
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1 (lists shortened to 2 items):
[
{
"id": 14,
"supplier_id": null,
"sku": "HDMI-2M",
"description": "HDMI 2.1 cable, 2 m",
"unit_price": "9.99",
"currency_id": null,
"archived": false,
"tax_rate_id": null,
"budget_id": null,
"created_at": "2026-10-21T05:05:15.724-05:00",
"updated_at": "2026-10-21T05:05:15.724-05:00"
},
{
"id": 10,
"supplier_id": null,
"sku": "CABLE-USBC-2M",
"description": "USB-C cable, 2 m, braided",
"unit_price": "14.5",
"currency_id": null,
"archived": false,
"tax_rate_id": 4,
"budget_id": 3,
"created_at": "2026-10-21T05:04:10.561-05:00",
"updated_at": "2026-10-21T05:05:01.248-05:00"
}
]
Example: paginated (V3,
200 OK) (lists shortened to 2 items):
[
{
"id": 14,
"supplier_id": null,
"sku": "HDMI-2M",
"description": "HDMI 2.1 cable, 2 m",
"unit_price": "9.99",
"currency_id": null,
"archived": false,
"tax_rate_id": null,
"budget_id": null,
"created_at": "2026-10-21T05:05:15.724-05:00",
"updated_at": "2026-10-21T05:05:15.724-05:00"
},
{
"id": 10,
"supplier_id": null,
"sku": "CABLE-USBC-2M",
"description": "USB-C cable, 2 m, braided",
"unit_price": "14.5",
"currency_id": null,
"archived": false,
"tax_rate_id": 4,
"budget_id": 3,
"created_at": "2026-10-21T05:04:10.561-05:00",
"updated_at": "2026-10-21T05:05:01.248-05:00"
}
]
Example: archived (V3,
200 OK):
[
{
"id": 13,
"supplier_id": null,
"sku": "FOOTREST-01",
"description": "Adjustable footrest",
"unit_price": "24.5",
"currency_id": null,
"archived": true,
"tax_rate_id": null,
"budget_id": null,
"created_at": "2026-10-21T05:05:15.448-05:00",
"updated_at": "2026-10-21T05:05:37.500-05:00"
}
]
Example: as teammember (V3,
200 OK) (lists shortened to 2 items):
[
{
"id": 14,
"supplier_id": null,
"sku": "HDMI-2M",
"description": "HDMI 2.1 cable, 2 m",
"unit_price": "9.99",
"currency_id": null,
"archived": false,
"tax_rate_id": null,
"budget_id": null,
"created_at": "2026-10-21T05:05:15.724-05:00",
"updated_at": "2026-10-21T05:05:15.724-05:00"
},
{
"id": 10,
"supplier_id": null,
"sku": "CABLE-USBC-2M",
"description": "USB-C cable, 2 m, braided",
"unit_price": "14.5",
"currency_id": null,
"archived": false,
"tax_rate_id": 4,
"budget_id": 3,
"created_at": "2026-10-21T05:04:10.561-05:00",
"updated_at": "2026-10-21T05:05:01.248-05:00"
}
]
V3 V1
Returns the company's active (or archived) products, optionally for one supplier.
Returns products of the app_company_id company sorted by description.
supplier_id: products linked to that supplier plus products linked to no supplier (those are available for every supplier). An unknown supplier id answers404.archived:truereturns only archived products. In V1false/omitted returns only active ones. In V3 the parameter is treated astruewhenever it is present, evenarchived=false, so in V3 omit it to get active products.- V1 paginates when
pageis given (per_page10/20/50/100, default 10) and then wraps the result as{products, meta}. V3 always returns every matching product as a bare array.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/products |
| V1 | GET /api/v1/products |
Permissions
Any live member of the company who can raise purchase orders (teammember,
approver, finance, companyadmin). All get the same product fields.
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
supplier_id |
integer | No | Products of this supplier plus unlinked products. 404 if not a supplier of the company. |
archived |
boolean | No | true lists archived products. V3: any value (even false) means archived. Default: false. |
page |
integer | No | V1 only: enables pagination and the {products, meta} envelope. Ignored by V3. |
per_page |
integer | No | V1 only, with page: 10, 20, 50 or 100 (others fall back to 10). Default: 10. |
Response
200. A JSON array of products (V1 with page: {"products": [...], "meta": {...}}).
Note that supplier_id is always null in listed products.
V3 and V1 differences
- Pagination: V1 supports
page/per_page(envelope{products, meta}). V3 ignores them and always returns the full bare array. archived: V1 casts the value (false-> active products). V3 usesparams[:archived].present?, soarchived=falsereturns archived products (verified:products_list_archived_falsereturned only the archived product in V3 and only active products in V1).
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Supplier not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
Get a product
curl "https://app.procurementexpress.com/api/v3/products/9" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/products/9" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/products/9",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/products/9",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 9,
"supplier_id": null,
"sku": "CHAIR-ERG-01",
"description": "Ergonomic mesh chair, black",
"unit_price": "275.0",
"currency_id": null,
"archived": false,
"tax_rate_id": 5,
"budget_id": 3,
"created_at": "2026-10-21T05:04:09.352-05:00",
"updated_at": "2026-10-21T05:05:01.027-05:00"
}
V3 V1
Returns one product of the company.
Returns the product if it belongs to the app_company_id company (archived products
included).
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/products/:id |
| V1 | GET /api/v1/products/:id |
Permissions
Same as List products: any live member who can raise purchase orders.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Product id. |
Response
200 with the product object (bare, no wrapper). supplier_id is always null.
404 {"error": "Invalid id passed"} for an id outside the company.
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
List product SKUs
curl "https://app.procurementexpress.com/api/v3/products/skus" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/products/skus" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/products/skus",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/products/skus",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
[
"MON-27",
"CHAIR-ERG-01",
"HDMI-2M",
"DESK-LAMP-LED",
"DESK-001",
"CABLE-USBC-2M",
"DOCK-USB-C",
"KB-WL-01"
]
Example: search (V3,
200 OK):
[
"CABLE-USBC-2M"
]
Example: by supplier (V3,
200 OK):
[
"CHAIR-ERG-01",
"DESK-LAMP-LED",
"CABLE-USBC-2M"
]
Example: archived (V3,
200 OK):
[
"FOOTREST-01"
]
V3 V1
Returns the SKU strings of the company's products, optionally searched.
Returns a bare array of SKU strings (products without a SKU are skipped), ordered by product description. Duplicate SKUs are not removed.
query: case-insensitive substring match on the SKU, limited to 10 results.supplier_id: products linked to that supplier plus unlinked products (404 if unknown).archived=true: SKUs of archived products. Combiningarchived=truewithquerycurrently always returns[](see findings). In V3 anyarchivedvalue means archived, as in List products.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/products/skus |
| V1 | GET /api/v1/products/skus |
Permissions
Same as List products.
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
query |
string | No | Substring of the SKU (case-insensitive). At most 10 SKUs are returned when set. |
supplier_id |
integer | No | Only SKUs of this supplier's products plus unlinked products. |
archived |
boolean | No | true returns SKUs of archived products. V3: any value means archived. Default: false. |
Response
200 with a JSON array of strings, e.g. ["CHAIR-ERG-01", "DESK-LAMP-LED"].
V3 and V1 differences
Same archived difference as List products (V1 casts archived, V3 treats any value as
true: archived=false returned ["FOOTREST-01"], the archived SKU, in V3 and all
active SKUs in V1). Otherwise identical.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Supplier not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
| Archived search | 200 |
V3, V1 | [] |
Create a product
curl -X POST "https://app.procurementexpress.com/api/v3/products" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"product": {
"sku": "CABLE-USBC-2M",
"description": "USB-C cable, 2 m",
"unit_price": 12.99,
"supplier_id": 5,
"budget_id": 1,
"tax_rate_id": 4
}
}'
curl -X POST "https://app.procurementexpress.com/api/v1/products" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"product": {
"sku": "CHAIR-ERG-01",
"description": "Ergonomic mesh chair",
"unit_price": 289.5,
"supplier_id": 4,
"budget_id": 3,
"tax_rate_id": 4
}
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/products",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
product: {
sku: "CABLE-USBC-2M",
description: "USB-C cable, 2 m",
unit_price: 12.99,
supplier_id: 5,
budget_id: 1,
tax_rate_id: 4
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/products",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
product: {
sku: "CHAIR-ERG-01",
description: "Ergonomic mesh chair",
unit_price: 289.5,
supplier_id: 4,
budget_id: 3,
tax_rate_id: 4
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 10,
"supplier_id": 5,
"sku": "CABLE-USBC-2M",
"description": "USB-C cable, 2 m",
"unit_price": "12.99",
"currency_id": null,
"archived": false,
"tax_rate_id": 4,
"budget_id": 1,
"created_at": "2026-10-21T05:04:10.561-05:00",
"updated_at": "2026-10-21T05:04:10.561-05:00"
}
V3 V1
Creates a product (V1 also links it to the given supplier).
Creates a product in the app_company_id company and returns it (200).
- V1 requires
product[supplier_id]and links the new product to that supplier. A missing or unknown supplier answers404 Invalid id passedbefore anything is saved. - V3 accepts
product[supplier_id](it must still be a supplier of the company) but does not link the product to it. The new product is linked to no supplier and is therefore offered for every supplier. Use Bulk-create products (which links) if you need the link in V3.
description is required (trimmed, Description can't be blank). budget_id and
tax_rate_id must belong to the company (404 otherwise). The response echoes the
supplier_id you sent; later reads return null.
HTTP request
| Version | Request |
|---|---|
| V3 | POST /api/v3/products |
| V1 | POST /api/v1/products |
Permissions
companyadmin, finance, rfqcaseofficer (or sysadmin), or any live member when the
company setting Users can add products (user_can_add_product) is on. Otherwise
401 Unauthorized access.
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
product[ |
string | Yes | Description (trimmed). Max 60 characters for Sage-integrated companies. |
product[ |
string | No | SKU. Required, unique and max 30 characters for Sage-integrated companies (Sage 50/200 also forbid some characters). |
product[ |
number | No | Unit price (decimal). |
product[ |
integer | Yes | V1: required; the product is linked to this supplier. V3: optional and only validated (must be a company supplier), the product is not linked. |
product[ |
integer | No | Default budget id of the company. |
product[ |
integer | No | Default tax rate id of the company. |
Response
200 with the product object. 400 {"error": [...]} on validation errors,
404 for an unknown/foreign supplier, budget or tax rate, 401 when not permitted.
Side effects
QuickBooks Online item sync (item-based companies) and Sage 200 export job when connected.
V3 and V1 differences
- V1 links the product to
supplier_id(asupplier_productsrow) and answers404whensupplier_idis missing (products_create_supplier_missing). - V3 never links the product.
supplier_idis optional and only checked for ownership. Verified: the V3-created product (supplier_id: 5sent) has no supplier link and is returned byGET /products?supplier_id=4. - Response shape and other validation identical.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Validation error | 400 |
V3, V1 | {"error":["Description can't be blank"],"status":400} |
| Supplier missing | 404 |
V1 | {"error":"Invalid id passed","status":404} |
| Supplier not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
| Foreign budget | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
| Unauthorized | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
Bulk-create products for a supplier
curl -X POST "https://app.procurementexpress.com/api/v3/products/bulk_create" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"supplier_id": 5,
"product": {
"product_item_attributes": [
{
"sku": "HDMI-2M",
"description": "HDMI 2.1 cable, 2 m",
"unit_price": 9.99
},
{
"sku": "KB-WL-01",
"description": "Wireless keyboard",
"unit_price": 49.0
}
]
}
}'
curl -X POST "https://app.procurementexpress.com/api/v1/products/bulk_create" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"supplier_id": 4,
"product": {
"product_item_attributes": [
{
"sku": "DESK-LAMP-LED",
"description": "LED desk lamp",
"unit_price": 39.0
},
{
"sku": "FOOTREST-01",
"description": "Adjustable footrest",
"unit_price": 24.5
}
]
}
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/products/bulk_create",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
supplier_id: 5,
product: {
product_item_attributes: [
{
sku: "HDMI-2M",
description: "HDMI 2.1 cable, 2 m",
unit_price: 9.99
},
{
sku: "KB-WL-01",
description: "Wireless keyboard",
unit_price: 49.0
}
]
}
}.to_json
)
puts response.code
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/products/bulk_create",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
supplier_id: 4,
product: {
product_item_attributes: [
{
sku: "DESK-LAMP-LED",
description: "LED desk lamp",
unit_price: 39.0
},
{
sku: "FOOTREST-01",
description: "Adjustable footrest",
unit_price: 24.5
}
]
}
}.to_json
)
puts response.code
Response
200 OK, same structure in V3 and V1:
true
V3 V1
Creates several products linked to one supplier in a single all-or-nothing request.
Creates every item in product[product_item_attributes] in the app_company_id
company and links each to supplier_id. The whole batch runs in one transaction:
if any item fails validation, nothing is saved and the first failing item's errors
are returned (400).
Bulk-created products are not synced to QuickBooks Online or exported to Sage.
The response is the literal JSON true and does not include the new ids. Use
GET /products?supplier_id= or GET /products/skus to find them.
HTTP request
| Version | Request |
|---|---|
| V3 | POST /api/v3/products/bulk_create |
| V1 | POST /api/v1/products/bulk_create |
Permissions
Same as Create a product.
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
supplier_id |
integer | Yes | Top-level (not inside product). Supplier of the company to link all products to. Unknown: 404 {"error": "Supplier not found", "status": "not_found"}. |
product[ |
array[object] | Yes | The products to create. Omitting this array while sending another product key currently causes a 500. |
product[ |
string | Yes | Description. |
product[ |
string | No | SKU. |
product[ |
number | No | Unit price. |
Response
200 with body true. Errors: 400 {"error": [...], "status": 400} (batch rolled
back), 404 {"error": "Supplier not found", "status": "not_found"}, 401, and 400 Required parameter are missing.
when there is no product object.
V3 and V1 differences
None - identical request and response (shared implementation).
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Validation error | 400 |
V3, V1 | {"error":["Description can't be blank"],"status":400} |
| Supplier not found | 404 |
V3, V1 | {"error":"Supplier not found","status":"not_found"} |
| Unauthorized | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
Update a product
curl -X PUT "https://app.procurementexpress.com/api/v3/products/10" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"product": {
"description": "USB-C cable, 2 m, braided",
"unit_price": 14.5,
"budget_id": 3
}
}'
curl -X PUT "https://app.procurementexpress.com/api/v1/products/9" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"product": {
"description": "Ergonomic mesh chair, black",
"unit_price": 275.0,
"tax_rate_id": 5
}
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v3/products/10",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
product: {
description: "USB-C cable, 2 m, braided",
unit_price: 14.5,
budget_id: 3
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v1/products/9",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
product: {
description: "Ergonomic mesh chair, black",
unit_price: 275.0,
tax_rate_id: 5
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 10,
"supplier_id": null,
"sku": "CABLE-USBC-2M",
"description": "USB-C cable, 2 m, braided",
"unit_price": "14.5",
"currency_id": null,
"archived": false,
"tax_rate_id": 4,
"budget_id": 3,
"created_at": "2026-10-21T05:04:10.561-05:00",
"updated_at": "2026-10-21T05:05:01.248-05:00"
}
V3 V1
Updates the given attributes of a product.
Updates only the attributes you send and returns the product. supplier_id is
accepted (and must be a supplier of the company) but does not change the
product's supplier links. archived cannot be changed through the API.
For QuickBooks Online item-based companies the change is validated against and
synced to QBO. A QBO rejection answers 400 with the QBO message.
HTTP request
| Version | Request |
|---|---|
| V3 | `PUT |
| V1 | `PUT |
Permissions
Same as Create a product: companyadmin, finance, rfqcaseofficer, or any live member
with Users can add products. Otherwise 401 Unauthorized access.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Product id. |
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
product[ |
string | No | Description (cannot be blank). |
product[ |
string | No | SKU. |
product[ |
number | No | Unit price. |
product[ |
integer | No | Company budget id. |
product[ |
integer | No | Company tax rate id. |
product[ |
integer | No | Validated for ownership only; no effect on supplier links (echoed in the response). |
Response
200 with the product object. 400 validation, 404 unknown product or foreign
budget/tax rate/supplier, 401 not permitted.
Side effects
QuickBooks Online item sync when connected and item-based.
V3 and V1 differences
None - identical request and response (shared implementation; PUT and PATCH accepted).
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Validation error | 400 |
V3, V1 | {"error":["Description can't be blank"],"status":400} |
| Not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
| Unauthorized | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
Purchase Orders
A purchase order (PO) is a request to buy goods or services from a supplier. It is raised by a
requester (its creator), coded to one or more budgets through its line items, routed to
approvers, and, once approved, gets a PO number and can be sent to the supplier, received,
invoiced and paid. Every PO belongs to the company named in the app_company_id header.
Until it is approved a PO is often called a purchase request; after approval it is a purchase order. Both are the same record and the same endpoints.
Receiving items, completing deliveries, forwarding to the supplier, PDFs, comments, uploads and payments are documented in their own sections. This section covers the PO itself, its approval decisions, cancelling, archiving and deleting.
Identifiers
| Field | Example | Notes |
|---|---|---|
id |
19 |
Numeric id. Use it in every :id path parameter. |
approval_key |
32-10/21/2026 |
The PO number. It is null until the PO is approved, unless the company reserves PO numbers for drafts. Its format comes from the company's PO number settings and can contain /. |
slug |
pex-32-10/21/2026-1 |
Set when the PO number is assigned. |
The :id path parameter accepts the numeric id, the PO number or the slug. When you pass a PO
number or slug, URL-encode it, for example GET /purchase_orders/32-10%2F21%2F2026.
Status lifecycle
status is the PO's lifecycle state. It is a plain enum, not a guarded state machine: the
transitions below are the ones the API performs.
status |
Meaning | How a PO gets there |
|---|---|---|
draft |
Saved but not submitted. Only its creator can see and edit it, plus company admins when the company opens drafts to admins. Budgets and approvers are not checked. | Create, update or bulk-save without commit: "Send". |
pending |
Submitted and waiting for approval. | commit: "Send" on create, update or bulk-save, when the PO needs at least one approval. |
approved |
Fully approved. It gets its PO number (approval_key). |
The last required approval (Approve), self-approval on submit, or Override and approve. |
rejected |
An approver rejected it. One rejection rejects the whole PO. | Reject. Editing and resubmitting does not leave rejected, so raise a new PO instead. |
cancelled |
Cancelled. All open approval requests are cancelled. | Cancel, from any status except paid. |
paid |
Payments cover the full gross amount. | Recording payments (see Payments). Marking it unpaid returns it to approved. |
delivered, deleted |
Legacy values. Current code never sets them. | None. Deliveries are tracked in delivery_status, and deleting a draft soft-deletes the record. |
draft --Send--> pending --all approvals--> approved --fully paid--> paid
| | \--any rejection--> rejected
| \--finance override--> approved
\--Send, creator can self-approve--> approved
draft / pending / approved / rejected --cancel--> cancelled (not paid)
draft --DELETE /purchase_orders/:id/delete--> (soft-deleted)
These status axes are independent of status:
delivery_status:not_delivered,partially_deliveredorcomplete_delivered. Changed by receiving items.payment_status:unpaid,invoice_receivedorready_to_pay. Tracks the invoice workflow.payment_progress:unpaid,partially_paidorpaid. Tracks how much has been paid.archived:trueorfalse. Archived POs are hidden from the default list. See Archive.statuses, in the detail response only: display labels such as["Pending", "Unpaid", "Undelivered"].
Submitting: `commit`
Create, update and bulk-save read a top-level commit value (a per-row commit in bulk-save).
commit: "Send" submits the PO for approval and runs every validation. Any other value,
including "Draft" or no commit at all, saves a draft. On create and update "Send" is
case-sensitive (update also accepts the label in the caller's locale). Bulk-save compares it
case-insensitively.
How approval routing works
When a PO is sent, the system works out who has to approve it. The first rule that applies wins:
- Approval flows. If the company has approval flows enabled and at least one published
purchase-order flow, the PO is matched against the flows' conditions (budget, department,
supplier, requester, amount, custom fields). Approvers come from the matching flows' steps,
and
approver_listis not used. See the Approval Flows section and Preview approval-flow approvers. - Budget approvers. Each budget can have approvers. If any line-item budget has approvers
and does not allow anyone to approve, the PO goes to that budget's approvers, chosen by
approval limit:
- Every user has a per-company approval limit. It is compared with the PO amount in the company's base currency, gross or net depending on the company's gross/net setting.
- When the company setting ignore approvers with a lower limit is on (it is on in the example company), the PO goes straight to the budget approver(s) with the lowest limit that covers the amount. Otherwise it climbs the chain from the lowest limit up to the first approver whose limit covers the amount.
- The requester is left out of their own approval chain, unless the company lets approvers approve their own requests or the requester is the only or last approver.
- Approvers on holiday are replaced by their delegate, or skipped if they have none.
- Manual approvers.
purchase_order[approver_list](an array of user ids) names the approvers explicitly. The sum of their approval limits must cover the PO amount, or the request fails with "The purchase order amount exceeds the approval limit of the approvers specified on the budget." - Self-approval. If no budget approver or manual approver is involved and the requester's
own approval limit covers the amount, the PO is approved on submit. (A requester with the
approver role must also be the qualifying approver with the highest limit in their
department, unless the company lets approvers approve their own requests. A team member
needs a non-zero limit and budgets that allow anyone to approve.) The PO is also approved
at once when budget routing makes the requester the only required approver. The response then has
status: "approved"andself_approved: true, and a "Self Approved" history comment is added. If the company has auto-approve self requests on, a requester who lists themselves inapprover_listhas their own request approved straight away.
Auto-route and manual-route companies. When every active budget of the company has at
least one approver, the company is auto-routing: POs are routed by budget approvers without
further input. When the company has at least one active budget without approvers, it is
manual-routing: on submit, and on every edit of a pending PO, you must pass approver_list,
unless the requester can self-approve. Otherwise the request fails with "Approvers Please
select at least one or more approvers". Each PO remembers the routing mode it was created
under.
Every approver gets a request (approver_requests[] on the PO). Budget routing activates
requests one at a time (current_pending_request). With manually listed approvers, everyone is
asked at once. The PO is approved when all active requests are approved, and rejected as soon
as one is rejected.
Approval decisions go through the approval token:
- Every request has an
accept_tokenand areject_token. They appear inapprover_requests[](list and detail responses) only when the caller is that request's approver, or the delegate covering for them. Other callers see the request without tokens. - To approve, send
POST /purchase_orders/:id/approvewith{"token": "<accept_token>"}in the body. To reject, sendPOST /purchase_orders/:id/rejectwith{"token": "<reject_token>"}. The token has to belong to this PO and to the caller. - The optional
app_sourceheader is recorded in the history comment, for example "Approved by Priya Patel (api)". It defaults toapi. - A finance user can approve without a token using Override and approve, when the company enables approval override.
Line items: `purchase_order_items_attributes`
Line items are nested attributes of the PO. Send them as an array under
purchase_order[purchase_order_items_attributes].
description,quantity,unit_price,budget_idand a tax rate (tax_rate_id) are required when the PO is sent.vat, the tax percentage, is taken fromtax_rate_idwhen you leave it out.- The server computes
net_amount = quantity x unit_priceandgross_amount = net_amount x (1 + vat / 100). Anynet_amountyou send is overwritten. The PO'stotal_net_amountandtotal_gross_amountare the sums.amountis the gross or net total, depending on the company setting. product_idlinks a catalogue product.item_numberis the SKU.- On update, pass
idto change an existing line and"_destroy": trueto remove one. Lines withoutidare added. Lines you leave out of the payload are kept. - Line-level custom fields go in
custom_field_values_attributeson the line. purchase_order_item_allocations_attributessplits a line across departments, budgets or GL codes, when the company enables detailed allocations.- Accounting fields (
chart_of_account_id,qbo_customer_id,quickbooks_class_id,qbo_line_description) apply when a QuickBooks Online connection exists. The chart of account is then required on submit.
Currencies and exchange rates
currency_idis required. You can sendpurchase_order[iso_code](for example"EUR") on create instead. The currency must be one of the company's supported currencies, otherwise the request fails with "Currency value you selected does not exist anymore".conversion_rateconverts the PO currency to the company base currency. It is set when the PO is created or its currency changes. The rate is the company's own exchange rate for the pair if one exists, otherwise the global rate table, otherwise a live rate lookup, falling back to1.0.base_gross_amount, andbase_net_amount/base_gross_amounton each line, are in the company base currency. Approval limits and budgets use these base amounts.gross_usd_amounton a line is the gross amount converted to USD with the global rate table only.
Custom field values
Company custom fields can live on the PO or on each line item. Set them with
custom_field_values_attributes: [{"custom_field_id": 1, "value": "PRJ-2026-014"}], on the PO
and on each line. Pass the value's id to change an existing value. After create, every
active PO-level field the request did not set gets a value of the field's default, or
"Not Set" (bulk-save skips this step). Required custom fields are enforced only on submit.
New purchase order template returns the active fields as empty
placeholders.
Who can do what
- List: company admins and finance users see every PO of the company. Others see POs they created, are mentioned on, or are an approver, delegate or approval-flow approver on. When the company shares PO access by department, they see their departments' POs instead. Drafts are visible only to their creator.
- View one PO: as above, except that approvers and finance users can open any PO of the company.
- Edit: finance users can edit any PO, and the creator can edit their own drafts.
- Approve or reject: the request's approver, or their delegate, with the token.
- Override and approve: finance users. Archive: finance users.
- Cancel: company admins, approvers and finance users, or the creator. Never for a paid PO.
- Delete: drafts only, by their creator or by a company admin with open draft access.
- When the company uses granular role permissions, the matching permission is also required:
create_po,edit_po,cancel_po_with_invoicesorcancel_po_without_invoices.
Side effects
Submitting creates approval requests, which email the approvers. Status changes email the
requester. Webhooks fire for the subscribed events: new_po (on create, and again when a
draft is sent), po_approved, po_cancelled, po_paid, po_delivered and po_update. No
webhook fires for a rejection. Approved, paid and cancelled POs sync to a connected
QuickBooks Online account. All of this runs in background jobs.
Errors
Errors come back as {"error": <message>, "status": <http status>}. error is a string for
authorization and lookup errors, and an array of messages for validation errors. Common cases:
400 for a validation error or missing purchase_order root, 401 "Unauthorized access"
when the caller's role does not allow the action, 403 for archive by a non-finance user,
and 404 "Invalid id passed" for an unknown id.
Endpoints in this section
| Endpoint | Method | Path | V3 | V1 |
|---|---|---|---|---|
| List purchase orders | GET |
/purchase_orders |
✓ | ✓ |
| Get a purchase order | GET |
/purchase_orders/:id |
✓ | ✓ |
| New purchase order template | GET |
/purchase_orders/new |
✓ | ✓ |
| Create a purchase order | POST |
/purchase_orders |
✓ | ✓ |
| Update a purchase order | PUT |
/purchase_orders/:id |
✓ | ✓ |
| Create purchase orders in bulk | POST |
/purchase_orders/bulk_save |
✓ | ✓ |
| Approve a purchase order | POST |
/purchase_orders/:id/approve |
✓ | ✓ |
| Reject a purchase order | POST |
/purchase_orders/:id/reject |
✓ | ✓ |
| Override and approve a purchase order | POST |
/purchase_orders/:id/override_and_approve |
✓ | ✓ |
| Cancel a purchase order | POST |
/purchase_orders/:id/cancel |
✓ | ✓ |
| Archive a purchase order | POST |
/purchase_orders/:id/archive |
✓ | ✓ |
| Delete a draft purchase order | DELETE |
/purchase_orders/:id/delete |
✓ | ✓ |
| Count my pending approvals | GET |
/purchase_orders/pending_request_count |
✓ | ✓ |
| Preview budget approvers | GET |
/purchase_orders/auto_approvers_list |
✓ | ✓ |
| Preview approval-flow approvers | POST |
/purchase_orders/approver_list |
✓ | ✓ |
The purchase order object
| Field | Type | Description |
|---|---|---|
id |
integer | Purchase order id. |
company_id |
integer | Company the PO belongs to. |
approval_key |
string | PO number. null until the PO is approved (or reserved). Can contain /; URL-encode it when used as :id. |
slug |
string | Friendly id, set together with the PO number; also accepted as :id. |
department_id |
integer | Department the PO is raised for (nullable). |
department_name |
string | Name of that department. |
supplier_id |
integer | Supplier id. A supplier_name that matches no active supplier creates a new supplier automatically. |
supplier_name |
string | Supplier name as stored on the PO. |
external_vendor_id |
string | The supplier's external vendor id (detail only). |
status |
string | Lifecycle status: draft, pending, approved, rejected, cancelled, paid (legacy delivered, deleted are not set by current code). See the lifecycle table. |
statuses |
array[string] | Display labels combining status, payment and delivery state, e.g. ["Pending", "Unpaid", "Undelivered"] or ["Approved", "Paid"]. |
submitted_on |
timestamp | When the PO was submitted (set again when a draft is sent). Unix seconds in the detail response, unix milliseconds in the list. |
po_date |
timestamp | PO date in unix milliseconds, or null. Not settable through the API. |
created_at |
timestamp | Unix seconds (detail); unix milliseconds in the list. |
updated_at |
timestamp | Unix seconds (detail only). |
currency_id |
integer | PO currency. |
currency_iso_code |
string | ISO code of the PO currency, e.g. USD. |
conversion_rate |
number | Rate from the PO currency to the company base currency (1.0 for the base currency). |
creator_id |
integer | Requester. Fixed once the PO exists; set it at creation with on_behalf_of. |
creator_name |
string | Requester's name. |
creator_email |
string | Requester's email (detail only). |
creator_network_id |
string | Requester's external user id in this company (detail only). |
amount |
string | Decimal string: the PO total used by the company, gross or net per the company's gross/net setting, in the PO currency. |
total_net_amount |
string | Decimal string: sum of line net amounts (PO currency). |
total_gross_amount |
string | Decimal string: net plus tax plus shipping (PO currency). |
base_gross_amount |
number | Gross total in the company base currency. |
tax_amount_overridden |
boolean | Whether a header-level tax amount override is applied. |
tax_amount_override |
string | Header tax override in the PO currency, or null. |
base_tax_amount_override |
string | Header tax override in the base currency, or null (detail only). |
effective_tax_amount |
number | Tax actually applied (override or sum of line taxes). |
shipping_amount_overridden |
boolean | Whether a header-level shipping amount is used. |
shipping_amount_override |
string | Shipping override in the PO currency, or null. |
base_shipping_amount_override |
string | Shipping override in the base currency, or null (detail only). |
effective_shipping_amount |
string | Shipping actually applied, decimal string. |
notes |
string | Free-text notes (detail only). |
delivery_status |
string | not_delivered, partially_delivered or complete_delivered. |
delivered_on |
timestamp | Delivery date: unix seconds, or 0 when not delivered (detail). The list returns the raw date or null. |
completely_delivered |
boolean | true when delivery_status is complete_delivered. |
payment_status |
string | Invoice workflow: unpaid, invoice_received or ready_to_pay. |
payment_progress |
string | Payment progress: unpaid, partially_paid or paid. |
custom_fields |
object | Legacy free-form key/value hash (purchase_order[custom_fields]); use custom_field_values instead. |
share_key |
string | Key of the public supplier share link. |
archived |
boolean | Whether the PO is archived. |
keywords |
string | Text the full-text search matches against (PO number, supplier, requester, status, amounts, line SKUs and descriptions, budget names, custom field values). |
self_approved |
boolean | true when the requester's own approval approved the PO. |
aff_link |
string | Affiliate link stored on the PO (see Get AFF link). |
xero_id |
string | Xero id of the exported PO, or null. |
synced_with_xero |
boolean | Whether the PO was exported to Xero. |
xero_export_status |
string | xero_not_exported, xero_export_success, xero_export_failed or null. |
xero_export_error_message |
string | Last Xero export error (detail only). |
xero_last_export_at |
timestamp | Last Xero export, unix seconds, or null (detail only). |
xero_is_changed |
boolean | Changed since the last Xero export. |
can_edit |
boolean | Permission hint for the caller: may edit (web rule; the API edit rule is stricter, see Update). |
can_cancel |
boolean | Caller may cancel (not already cancelled, and the cancel rule passes). |
can_archive |
boolean | Caller may archive (finance user in this company, PO not archived). |
can_mark_as_paid |
boolean | Caller may record full payment (approved, not paid). |
can_override |
boolean | Caller is a finance user and the PO is pending (does not reflect the company's approval-override setting). |
can_receive_item |
boolean | Caller may receive items. |
can_cancel_receiving_items |
boolean | Caller may undo a complete delivery. |
can_complete_delivery |
boolean | Caller may mark a partially delivered PO as complete. |
can_copy |
boolean | Caller may copy the PO (web feature). |
can_justify |
boolean | Caller may justify policy-compliance findings. |
has_global_policies |
boolean | Company has active global compliance policies. |
latest_compliance_check |
object | Most recent compliance check, or null. |
compliance_checks |
array[object] | Up to five most recent compliance checks. |
budgets |
array[object] | Distinct budgets used by the line items (id, name, amount, cost_code, cost_type, currency_id, base_amount, remaining_amount, approver_ids, department_ids, start_date/end_date in unix seconds, ...). |
tax_rates |
array[object] | One entry per single tax rate used: id, name, value (percent), tax_amount (PO currency). |
purchase_order_items |
array[object] | Line items ordered by sequence_no: id, sequence_no, description, item_number, product_id, budget_id, budget_summary, quantity, unit_price, net_amount, vat, gross_amount, tax_rate (object), base_net_amount, base_gross_amount, gross_usd_amount, received_quantity, status (mirrors the PO status), custom_field_values, chart_of_account/qbo_customer/quickbooks_class ({id, name}), qbo_line_description. Amounts are numbers. |
custom_field_values |
array[object] | PO-level values: id, custom_field_id, value, purchase_order_id, purchase_order_item_id, budget_id, date_format. For date fields value is a unix timestamp and value_str holds the raw string. |
approver_requests |
array[object] | Approval requests in creation order: id, status (pending, approved, rejected, cancelled, draft, holiday, holiday_pending, holiday_approved, holiday_rejected), approver_id, approver_name, delegate_id, and accept_token/reject_token only for the caller's own request. For POs routed in auto-routing mode, pending requests that are not yet active are hidden (in the detail response only when the approval-flow feature is off). |
approvers_with_flow |
array[object] | When approval flows are enabled: one entry per attached flow with status (in_progress, completed, rejected, cancelled), name, approval_flow_id, next_request_id, last_approval_date, up_to_date, requests[] (id, status, approver_id, delegate_id, ISO created_at/updated_at, approver_name, was_approval_override, on_holiday). Otherwise []. |
purchase_order_comments |
array[object] | History and comments, oldest first: id, comment, formatted_comment, creator_id, creator_name, category (e.g. approval_partial, approval), system_generated, status, created_at (unix seconds), uploads. |
payments |
array[object] | Payments recorded against the PO. |
purchase_order_item_payments |
array[object] | Per-line payment allocations. |
uploads |
array[object] | Attached files. |
invoices |
array[object] | Invoices linked to the PO's lines. |
supplier |
object | Full supplier record (id, name, email, address, contact_person, phone_number, tax_number, payment_terms, currency_id, ...), or null. |
List purchase orders
curl "https://app.procurementexpress.com/api/v3/purchase_orders?budget_id=10" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/purchase_orders?budget_id=10" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/purchase_orders",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
params: {
budget_id: "10"
}
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/purchase_orders",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
params: {
budget_id: "10"
}
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"purchase_orders": [
{
"id": 28,
"approval_key": "28-10/21/2026",
"creator_name": "Jane Doe",
"amount": "649.0",
"status": "approved",
"supplier_name": "Northwind Office Supplies",
"supplier_id": 1,
"keywords": "28 Northwind Office Supplies Jane Doe 28-10/21/2026 approved 706.60 DESK-001 Engineering Projects Standing desk, 60 x 30 in 706.59875 649.0 PRJ-2026-022 CC-100 Engineering ",
"created_at": 1792576940000,
"currency_id": 2000,
"currency_symbol": "$",
"currency_iso_code": "USD",
"total_gross_amount": "706.6",
"total_net_amount": "649.0",
"base_gross_amount": 706.6,
"tax_amount_overridden": false,
"tax_amount_override": null,
"effective_tax_amount": 57.6,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"submitted_on": 1792576940000,
"po_date": null,
"share_key": "1CDE72EF424384C523BB93DAB3FFEBB1371FCCCA",
"department_id": 2,
"department_name": "Engineering",
"budget_name": "Engineering Projects",
"compliance_status": null,
"delivered_on": null,
"delivery_status": "not_delivered",
"payment_status": "unpaid",
"payment_progress": "unpaid",
"xero_export_status": null,
"synced_with_xero": false,
"xero_is_changed": false,
"approver_requests": [
{
"id": 28,
"status": "approved",
"approver_name": "Jane Doe",
"approver_id": 40000,
"delegate_id": null,
"accept_token": "<accept-token>",
"reject_token": "<reject-token>"
}
]
},
{
"id": 27,
"approval_key": "27-10/21/2026",
"creator_name": "Jane Doe",
"amount": "649.0",
"status": "approved",
"supplier_name": "Northwind Office Supplies",
"supplier_id": 1,
"keywords": "27 Northwind Office Supplies Jane Doe 27-10/21/2026 approved 706.60 DESK-001 Engineering Projects Standing desk, 60 x 30 in 706.59875 649.0 PRJ-2026-022 CC-100 Engineering ",
"created_at": 1792576933000,
"currency_id": 2000,
"currency_symbol": "$",
"currency_iso_code": "USD",
"total_gross_amount": "706.6",
"total_net_amount": "649.0",
"base_gross_amount": 706.6,
"tax_amount_overridden": false,
"tax_amount_override": null,
"effective_tax_amount": 57.6,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"submitted_on": 1792576933000,
"po_date": null,
"share_key": "593C0379D2F2F566272877B3C056A901324680D4",
"department_id": 2,
"department_name": "Engineering",
"budget_name": "Engineering Projects",
"compliance_status": null,
"delivered_on": null,
"delivery_status": "not_delivered",
"payment_status": "unpaid",
"payment_progress": "unpaid",
"xero_export_status": null,
"synced_with_xero": false,
"xero_is_changed": false,
"approver_requests": [
{
"id": 27,
"status": "approved",
"approver_name": "Jane Doe",
"approver_id": 40000,
"delegate_id": null,
"accept_token": "<accept-token>",
"reject_token": "<reject-token>"
}
]
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 2
}
}
Example: awaiting my approval (V3,
200 OK) (lists shortened to 2 items):
{
"purchase_orders": [
{
"id": 24,
"approval_key": null,
"creator_name": "Jane Doe",
"amount": "3600.0",
"status": "pending",
"supplier_name": "Contoso Hardware",
"supplier_id": 2,
"keywords": "24 Contoso Hardware Jane Doe pending 3600.00 Engineering Hardware FY26 27-inch 4K monitor 3600.0 3600.0 ",
"created_at": 1792576919000,
"currency_id": 2000,
"currency_symbol": "$",
"currency_iso_code": "USD",
"total_gross_amount": "3600.0",
"total_net_amount": "3600.0",
"base_gross_amount": 3600.0,
"tax_amount_overridden": false,
"tax_amount_override": null,
"effective_tax_amount": 0.0,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"submitted_on": 1792576919000,
"po_date": null,
"share_key": "6BE6C523181DCE3B04D9FEBCC12EEC81B9263961",
"department_id": 2,
"department_name": "Engineering",
"budget_name": "Engineering Hardware FY26",
"compliance_status": "failed",
"delivered_on": null,
"delivery_status": "not_delivered",
"payment_status": "unpaid",
"payment_progress": "unpaid",
"xero_export_status": null,
"synced_with_xero": false,
"xero_is_changed": false,
"approver_requests": [
{
"id": 24,
"status": "pending",
"approver_name": "Priya Patel",
"approver_id": 50000,
"delegate_id": null,
"accept_token": "<accept-token>",
"reject_token": "<reject-token>"
}
]
},
{
"id": 20,
"approval_key": null,
"creator_name": "Jane Doe",
"amount": "2077.96",
"status": "pending",
"supplier_name": "Contoso Hardware",
"supplier_id": 2,
"keywords": "20 Contoso Hardware Jane Doe pending 2262.38 MON-27 Engineering Hardware FY26 27-inch 4K monitor 1437.10645 1319.96 DOCK-USB-C Engineering Hardware FY26 USB-C docking station 825.2725 758.0 PRJ-2026-014 CC-100 Engineering AT-0441 ",
"created_at": 1792576856000,
"currency_id": 2000,
"currency_symbol": "$",
"currency_iso_code": "USD",
"total_gross_amount": "2262.38",
"total_net_amount": "2077.96",
"base_gross_amount": 2262.38,
"tax_amount_overridden": false,
"tax_amount_override": null,
"effective_tax_amount": 184.42,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"submitted_on": 1792576856000,
"po_date": null,
"share_key": "AA36A0929C95210ADAD1FA685F5508BC2106E2D4",
"department_id": 2,
"department_name": "Engineering",
"budget_name": "Engineering Hardware FY26",
"compliance_status": null,
"delivered_on": null,
"delivery_status": "not_delivered",
"payment_status": "unpaid",
"payment_progress": "unpaid",
"xero_export_status": null,
"synced_with_xero": false,
"xero_is_changed": false,
"approver_requests": [
{
"id": 22,
"status": "pending",
"approver_name": "Priya Patel",
"approver_id": 50000,
"delegate_id": null,
"accept_token": "<accept-token>",
"reject_token": "<reject-token>"
}
]
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 5
}
}
Example: requests tab (V3,
200 OK) (lists shortened to 2 items):
{
"purchase_orders": [
{
"id": 24,
"approval_key": null,
"creator_name": "Jane Doe",
"amount": "3600.0",
"status": "pending",
"supplier_name": "Contoso Hardware",
"supplier_id": 2,
"keywords": "24 Contoso Hardware Jane Doe pending 3600.00 Engineering Hardware FY26 27-inch 4K monitor 3600.0 3600.0 ",
"created_at": 1792576919000,
"currency_id": 2000,
"currency_symbol": "$",
"currency_iso_code": "USD",
"total_gross_amount": "3600.0",
"total_net_amount": "3600.0",
"base_gross_amount": 3600.0,
"tax_amount_overridden": false,
"tax_amount_override": null,
"effective_tax_amount": 0.0,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"submitted_on": 1792576919000,
"po_date": null,
"share_key": "6BE6C523181DCE3B04D9FEBCC12EEC81B9263961",
"department_id": 2,
"department_name": "Engineering",
"budget_name": "Engineering Hardware FY26",
"compliance_status": "failed",
"delivered_on": null,
"delivery_status": "not_delivered",
"payment_status": "unpaid",
"payment_progress": "unpaid",
"xero_export_status": null,
"synced_with_xero": false,
"xero_is_changed": false,
"approver_requests": [
{
"id": 24,
"status": "pending",
"approver_name": "Priya Patel",
"approver_id": 50000,
"delegate_id": null
}
]
},
{
"id": 20,
"approval_key": null,
"creator_name": "Jane Doe",
"amount": "2077.96",
"status": "pending",
"supplier_name": "Contoso Hardware",
"supplier_id": 2,
"keywords": "20 Contoso Hardware Jane Doe pending 2262.38 MON-27 Engineering Hardware FY26 27-inch 4K monitor 1437.10645 1319.96 DOCK-USB-C Engineering Hardware FY26 USB-C docking station 825.2725 758.0 PRJ-2026-014 CC-100 Engineering AT-0441 ",
"created_at": 1792576856000,
"currency_id": 2000,
"currency_symbol": "$",
"currency_iso_code": "USD",
"total_gross_amount": "2262.38",
"total_net_amount": "2077.96",
"base_gross_amount": 2262.38,
"tax_amount_overridden": false,
"tax_amount_override": null,
"effective_tax_amount": 184.42,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"submitted_on": 1792576856000,
"po_date": null,
"share_key": "AA36A0929C95210ADAD1FA685F5508BC2106E2D4",
"department_id": 2,
"department_name": "Engineering",
"budget_name": "Engineering Hardware FY26",
"compliance_status": null,
"delivered_on": null,
"delivery_status": "not_delivered",
"payment_status": "unpaid",
"payment_progress": "unpaid",
"xero_export_status": null,
"synced_with_xero": false,
"xero_is_changed": false,
"approver_requests": [
{
"id": 22,
"status": "pending",
"approver_name": "Priya Patel",
"approver_id": 50000,
"delegate_id": null
}
]
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 5
}
}
Example: status without tab (V3,
200 OK) (lists shortened to 2 items):
{
"purchase_orders": [
{
"id": 24,
"approval_key": null,
"creator_name": "Jane Doe",
"amount": "3600.0",
"status": "pending",
"supplier_name": "Contoso Hardware",
"supplier_id": 2,
"keywords": "24 Contoso Hardware Jane Doe pending 3600.00 Engineering Hardware FY26 27-inch 4K monitor 3600.0 3600.0 ",
"created_at": 1792576919000,
"currency_id": 2000,
"currency_symbol": "$",
"currency_iso_code": "USD",
"total_gross_amount": "3600.0",
"total_net_amount": "3600.0",
"base_gross_amount": 3600.0,
"tax_amount_overridden": false,
"tax_amount_override": null,
"effective_tax_amount": 0.0,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"submitted_on": 1792576919000,
"po_date": null,
"share_key": "6BE6C523181DCE3B04D9FEBCC12EEC81B9263961",
"department_id": 2,
"department_name": "Engineering",
"budget_name": "Engineering Hardware FY26",
"compliance_status": "failed",
"delivered_on": null,
"delivery_status": "not_delivered",
"payment_status": "unpaid",
"payment_progress": "unpaid",
"xero_export_status": null,
"synced_with_xero": false,
"xero_is_changed": false,
"approver_requests": [
{
"id": 24,
"status": "pending",
"approver_name": "Priya Patel",
"approver_id": 50000,
"delegate_id": null
}
]
},
{
"id": 20,
"approval_key": null,
"creator_name": "Jane Doe",
"amount": "2077.96",
"status": "pending",
"supplier_name": "Contoso Hardware",
"supplier_id": 2,
"keywords": "20 Contoso Hardware Jane Doe pending 2262.38 MON-27 Engineering Hardware FY26 27-inch 4K monitor 1437.10645 1319.96 DOCK-USB-C Engineering Hardware FY26 USB-C docking station 825.2725 758.0 PRJ-2026-014 CC-100 Engineering AT-0441 ",
"created_at": 1792576856000,
"currency_id": 2000,
"currency_symbol": "$",
"currency_iso_code": "USD",
"total_gross_amount": "2262.38",
"total_net_amount": "2077.96",
"base_gross_amount": 2262.38,
"tax_amount_overridden": false,
"tax_amount_override": null,
"effective_tax_amount": 184.42,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"submitted_on": 1792576856000,
"po_date": null,
"share_key": "AA36A0929C95210ADAD1FA685F5508BC2106E2D4",
"department_id": 2,
"department_name": "Engineering",
"budget_name": "Engineering Hardware FY26",
"compliance_status": null,
"delivered_on": null,
"delivery_status": "not_delivered",
"payment_status": "unpaid",
"payment_progress": "unpaid",
"xero_export_status": null,
"synced_with_xero": false,
"xero_is_changed": false,
"approver_requests": [
{
"id": 22,
"status": "pending",
"approver_name": "Priya Patel",
"approver_id": 50000,
"delegate_id": null
}
]
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 5
}
}
Example: archived (V3,
200 OK):
{
"purchase_orders": [
{
"id": 44,
"approval_key": "44-10/21/2026",
"creator_name": "Jane Doe",
"amount": "649.0",
"status": "approved",
"supplier_name": "Contoso Hardware",
"supplier_id": 2,
"keywords": "44 Contoso Hardware Jane Doe 44-10/21/2026 approved 706.60 DOCK-USB-C Engineering Projects USB-C docking station 706.59875 649.0 PRJ-2026-014 CC-100 Engineering ",
"created_at": 1792577221000,
"currency_id": 2000,
"currency_symbol": "$",
"currency_iso_code": "USD",
"total_gross_amount": "706.6",
"total_net_amount": "649.0",
"base_gross_amount": 706.6,
"tax_amount_overridden": false,
"tax_amount_override": null,
"effective_tax_amount": 57.6,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"submitted_on": 1792577221000,
"po_date": null,
"share_key": "D9EE00738CD926ACF7BF7EE9D06D78A9A765387C",
"department_id": 2,
"department_name": "Engineering",
"budget_name": "Engineering Projects",
"compliance_status": null,
"delivered_on": null,
"delivery_status": "not_delivered",
"payment_status": "unpaid",
"payment_progress": "unpaid",
"xero_export_status": null,
"synced_with_xero": false,
"xero_is_changed": false,
"approver_requests": [
{
"id": 46,
"status": "approved",
"approver_name": "Jane Doe",
"approver_id": 40000,
"delegate_id": null,
"accept_token": "<accept-token>",
"reject_token": "<reject-token>"
}
]
},
{
"id": 43,
"approval_key": "43-10/21/2026",
"creator_name": "Jane Doe",
"amount": "649.0",
"status": "approved",
"supplier_name": "Contoso Hardware",
"supplier_id": 2,
"keywords": "43 Contoso Hardware Jane Doe 43-10/21/2026 approved 706.60 DOCK-USB-C Engineering Projects USB-C docking station 706.59875 649.0 PRJ-2026-014 CC-100 Engineering ",
"created_at": 1792577217000,
"currency_id": 2000,
"currency_symbol": "$",
"currency_iso_code": "USD",
"total_gross_amount": "706.6",
"total_net_amount": "649.0",
"base_gross_amount": 706.6,
"tax_amount_overridden": false,
"tax_amount_override": null,
"effective_tax_amount": 57.6,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"submitted_on": 1792577217000,
"po_date": null,
"share_key": "C7049A5A32473AB34294166270822FC3095F8070",
"department_id": 2,
"department_name": "Engineering",
"budget_name": "Engineering Projects",
"compliance_status": null,
"delivered_on": null,
"delivery_status": "not_delivered",
"payment_status": "unpaid",
"payment_progress": "unpaid",
"xero_export_status": null,
"synced_with_xero": false,
"xero_is_changed": false,
"approver_requests": [
{
"id": 45,
"status": "approved",
"approver_name": "Jane Doe",
"approver_id": 40000,
"delegate_id": null,
"accept_token": "<accept-token>",
"reject_token": "<reject-token>"
}
]
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 2
}
}
Example: sorted (V3,
200 OK) (lists shortened to 2 items):
{
"purchase_orders": [
{
"id": 27,
"approval_key": "27-10/21/2026",
"creator_name": "Jane Doe",
"amount": "649.0",
"status": "approved",
"supplier_name": "Northwind Office Supplies",
"supplier_id": 1,
"keywords": "27 Northwind Office Supplies Jane Doe 27-10/21/2026 approved 706.60 DESK-001 Engineering Projects Standing desk, 60 x 30 in 706.59875 649.0 PRJ-2026-022 CC-100 Engineering ",
"created_at": 1792576933000,
"currency_id": 2000,
"currency_symbol": "$",
"currency_iso_code": "USD",
"total_gross_amount": "706.6",
"total_net_amount": "649.0",
"base_gross_amount": 706.6,
"tax_amount_overridden": false,
"tax_amount_override": null,
"effective_tax_amount": 57.6,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"submitted_on": 1792576933000,
"po_date": null,
"share_key": "593C0379D2F2F566272877B3C056A901324680D4",
"department_id": 2,
"department_name": "Engineering",
"budget_name": "Engineering Projects",
"compliance_status": null,
"delivered_on": null,
"delivery_status": "not_delivered",
"payment_status": "unpaid",
"payment_progress": "unpaid",
"xero_export_status": null,
"synced_with_xero": false,
"xero_is_changed": false,
"approver_requests": [
{
"id": 27,
"status": "approved",
"approver_name": "Jane Doe",
"approver_id": 40000,
"delegate_id": null,
"accept_token": "<accept-token>",
"reject_token": "<reject-token>"
}
]
},
{
"id": 28,
"approval_key": "28-10/21/2026",
"creator_name": "Jane Doe",
"amount": "649.0",
"status": "approved",
"supplier_name": "Northwind Office Supplies",
"supplier_id": 1,
"keywords": "28 Northwind Office Supplies Jane Doe 28-10/21/2026 approved 706.60 DESK-001 Engineering Projects Standing desk, 60 x 30 in 706.59875 649.0 PRJ-2026-022 CC-100 Engineering ",
"created_at": 1792576940000,
"currency_id": 2000,
"currency_symbol": "$",
"currency_iso_code": "USD",
"total_gross_amount": "706.6",
"total_net_amount": "649.0",
"base_gross_amount": 706.6,
"tax_amount_overridden": false,
"tax_amount_override": null,
"effective_tax_amount": 57.6,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"submitted_on": 1792576940000,
"po_date": null,
"share_key": "1CDE72EF424384C523BB93DAB3FFEBB1371FCCCA",
"department_id": 2,
"department_name": "Engineering",
"budget_name": "Engineering Projects",
"compliance_status": null,
"delivered_on": null,
"delivery_status": "not_delivered",
"payment_status": "unpaid",
"payment_progress": "unpaid",
"xero_export_status": null,
"synced_with_xero": false,
"xero_is_changed": false,
"approver_requests": [
{
"id": 28,
"status": "approved",
"approver_name": "Jane Doe",
"approver_id": 40000,
"delegate_id": null,
"accept_token": "<accept-token>",
"reject_token": "<reject-token>"
}
]
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 7
}
}
Example: custom field filter (V3,
200 OK):
{
"purchase_orders": [
{
"id": 63,
"approval_key": "63-10/21/2026",
"creator_name": "Jane Doe",
"amount": "598.0",
"status": "approved",
"supplier_name": "Contoso Hardware",
"supplier_id": 2,
"keywords": "63 Contoso Hardware Jane Doe 63-10/21/2026 approved 645.84 MON-27 Engineering Projects 27-inch 4K monitor 598.0 598.0 PRJ-2026-040 CC-100 Engineering ",
"created_at": 1792577557000,
"currency_id": 1000,
"currency_symbol": "€",
"currency_iso_code": "EUR",
"total_gross_amount": "598.0",
"total_net_amount": "598.0",
"base_gross_amount": 645.84,
"tax_amount_overridden": false,
"tax_amount_override": null,
"effective_tax_amount": 0.0,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"submitted_on": 1792577557000,
"po_date": null,
"share_key": "1D15115CF8A089D125261E6C485E80CE4F3CE014",
"department_id": 2,
"department_name": "Engineering",
"budget_name": "Engineering Projects",
"compliance_status": null,
"delivered_on": null,
"delivery_status": "not_delivered",
"payment_status": "unpaid",
"payment_progress": "unpaid",
"xero_export_status": null,
"synced_with_xero": false,
"xero_is_changed": false,
"approver_requests": [
{
"id": 58,
"status": "approved",
"approver_name": "Jane Doe",
"approver_id": 40000,
"delegate_id": null,
"accept_token": "<accept-token>",
"reject_token": "<reject-token>"
}
]
},
{
"id": 62,
"approval_key": "62-10/21/2026",
"creator_name": "Jane Doe",
"amount": "598.0",
"status": "approved",
"supplier_name": "Contoso Hardware",
"supplier_id": 2,
"keywords": "62 Contoso Hardware Jane Doe 62-10/21/2026 approved 645.84 MON-27 Engineering Projects 27-inch 4K monitor 598.0 598.0 PRJ-2026-040 CC-100 Engineering ",
"created_at": 1792577556000,
"currency_id": 1000,
"currency_symbol": "€",
"currency_iso_code": "EUR",
"total_gross_amount": "598.0",
"total_net_amount": "598.0",
"base_gross_amount": 645.84,
"tax_amount_overridden": false,
"tax_amount_override": null,
"effective_tax_amount": 0.0,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"submitted_on": 1792577556000,
"po_date": null,
"share_key": "3DFF7AB9E5A83958EF040A90DBF628F114D3C00D",
"department_id": 2,
"department_name": "Engineering",
"budget_name": "Engineering Projects",
"compliance_status": null,
"delivered_on": null,
"delivery_status": "not_delivered",
"payment_status": "unpaid",
"payment_progress": "unpaid",
"xero_export_status": null,
"synced_with_xero": false,
"xero_is_changed": false,
"approver_requests": [
{
"id": 57,
"status": "approved",
"approver_name": "Jane Doe",
"approver_id": 40000,
"delegate_id": null,
"accept_token": "<accept-token>",
"reject_token": "<reject-token>"
}
]
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 2
}
}
V3 V1
Returns a page of purchase orders the caller can see, with filters, search and sorting.
Returns 20 purchase orders per page (fixed; per_page is ignored), newest first by default
(purchase_orders.submitted_on desc). Archived POs are excluded unless you pass archived.
Drafts are included only for their creator (and for company admins when the company opens
drafts to admins). Visibility follows the rules in the section intro.
Default listing differs between versions. V3 returns every visible PO in every status. V1 applies the web app's "tab" filter to the index: without a tab parameter V1 returns only approved, paid and cancelled POs that have already been approved once (the "orders" tab), so pending, draft and rejected POs are missing. Use these V1 tab parameters to choose a tab (V3 ignores them):
| V1 tab parameter | Returns |
|---|---|
| (none) | approved, paid, and cancelled-after-approval POs |
requests=true |
draft, pending, rejected and cancelled POs that were never approved |
all=true or all_pos=true |
every non-archived PO |
deliveries=true |
non-draft POs (any delivery status) |
invoices=true |
POs with payment_status invoice_received |
payments=true |
approved/paid POs awaiting payment (payment_status=unpaid narrows to approved, unpaid) |
POs waiting for my approval: requests=true&bell=true (both versions) returns only pending
POs where the caller (or the delegate covering for them) holds the currently active pending
request. Those rows include the caller's accept_token/reject_token.
Search: search=<text> (or search[query]=<text>) first looks for PO numbers containing
the text; if none match, it runs a prefix full-text search over keywords. Words, and the parts
of hyphenated words, are OR-ed (PRJ-2026-022 also matches PRJ-2026-014), so search is broad.
Sorting: sort must be <table>.<column> for a whitelisted table (purchase_orders,
budgets, departments, custom_field_values, companies, user_approval_limits, rfqs,
approval_flows) or users.first_name,users.last_name; anything else, including a bare column
such as total_gross_amount, is silently replaced by purchase_orders.submitted_on.
direction is asc or desc (default desc).
Pagination: V3 reads only orders_page; V1 reads page or orders_page. Use orders_page
for code that targets both. The meta object carries the page numbers and totals.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/purchase_orders |
| V1 | GET /api/v1/purchase_orders |
Permissions
Any active member of the company in app_company_id. Company admins and finance users see all
company POs; other users see the POs they are involved in (creator, mentioned, approver,
delegate or approval-flow approver), or their departments' POs when the company shares PO
access by department. V3 additionally refuses an account whose only role is RFQ case officer
(401 Unauthorized access); V1 does not check this.
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
orders_page |
integer | No | Page number (both versions). Default: 1. |
page |
integer | No | Page number, V1 only (V3 ignores it). Default: 1. |
search |
string | No | Free-text search (also accepted as search[query]). PO number match first, then OR-ed prefix full-text search. |
sort |
string | No | <table>.<column> from the whitelist, e.g. purchase_orders.total_gross_amount. Invalid values fall back to the default. Default: purchase_orders.submitted_on. |
direction |
string | No | asc or desc. Default: desc. |
status |
string | No | Exact PO status: draft, pending, approved, rejected, cancelled, paid. On V1 combine it with a tab parameter (e.g. requests=true&status=pending), because the default V1 tab already limits statuses. |
archived |
boolean | No | When present with any value (even false), only archived POs are returned and the V1 tab filter is skipped. |
requests |
boolean | No | true: V1 requests tab (see table). With bell=true: POs waiting for the caller's approval (both versions). Alone it has no filtering effect on V3. |
bell |
boolean | No | With requests=true: only pending POs whose active request belongs to the caller or their delegate. |
all |
boolean | No | V1 tab: every non-archived PO. Also all_pos. Ignored by V3. |
deliveries |
boolean | No | V1 tab: non-draft POs. Ignored by V3. |
invoices |
boolean | No | V1 tab: POs with an invoice received. Ignored by V3. |
payments |
boolean | No | V1 tab: payment queue. Ignored by V3. |
porder |
boolean | No | Only changes eager loading, except together with status=approved&bulk_request=true, where approved and paid POs are returned (approved only when hide_paid_pos_filter=true). |
supplier_id |
array[integer] | No | Only these suppliers (supplier_id=2 or supplier_id[]=1&supplier_id[]=2). Unknown ids are dropped. |
supplier_exclude_id |
array[integer] | No | Every supplier except these (POs without a supplier stay visible). Ignored when supplier_id is given. |
requester_id |
integer | No | Only POs created by this user. |
budget_id |
integer | No | Only POs with a line on this budget. Note: meta.total_count counts one row per matching line, so it can exceed the number of POs. |
filter_dept_id |
integer | No | Only POs of this department. |
approver_id |
integer | No | Only POs that have a request for this approver (with requests=true, only where that request is the active one). |
delivery_status |
string | No | not_delivered (or undelivered; approved/paid POs only), partially_delivered or complete_delivered (both exclude rejected and cancelled POs). |
payment_status |
string | No | unpaid (excludes paid POs), invoice_received or ready_to_pay. |
invoice_payments |
string | No | A payment_status value; matches that payment status or status=paid. |
pending_invoice |
boolean | No | true: approved POs with no linked invoice. |
compliance_status |
string | No | Compliance check status, e.g. passed or running, or not_run for POs never checked. |
custom_field_filter |
object | No | Custom field values to match, keyed custom_field_<id>, e.g. custom_field_filter[custom_field_1]=PRJ-2026-040. All given fields must match. |
date_filter |
string | No | Submission-date range: today, last 7days, last 30days, last 60days, last 90days, last 180days, last 1year, current_month, last_month, current_year, last_year. |
from |
string | No | Start of a custom submission-date range in the company date format (e.g. 10/01/2026 for %m/%d/%Y). Needs to. Unparseable dates are ignored. |
to |
string | No | End of the custom range (company date format). |
report_date_type |
string | No | po_date applies date_filter/from/to to the PO date instead of the submission date. |
po_date_filter |
string | No | PO-date range using the same named values as date_filter. |
status_v2 |
string | No | Event filter on the PO history: approval, paid, invoice_received, complete_delivered, send_supplier, or another history category; combined with date_filter/from/to it filters by when the event happened. |
supplier_email_status_filter |
array[integer] | No | Supplier email status codes (0 not sent, 10 sent, 20 opened, 30 acknowledged, 40 failed); excludes cancelled POs. |
approval_flow_versions |
string | No | latest, outdated (flow changed since attached) or no_approval_flow. |
synced_with_qbo_filter |
string | No | yes, no or all (QuickBooks Online sync). |
dynamics_statuses_filter |
string | No | dynamics_not_synced, dynamics_synced, dynamics_failed or all. |
updated_after |
timestamp | No | ISO-8601 timestamp; POs whose header, any line item or any custom field value changed at or after it. Useful for incremental sync. |
Response
200 with {"purchase_orders": [...], "meta": {...}} (same envelope in V1 and V3). Each element
is the compact list representation of a purchase order; the example filters on one budget
(budget_id=10) and returns its two approved POs.
Differences from the detail object: created_at, submitted_on and po_date are unix
milliseconds; delivered_on is the raw date or null; the list adds currency_symbol,
budget_name (budget of the lines, or a summary when several) and compliance_status; it does
not include line items, comments, custom field values, can_* flags, notes, updated_at or
the supplier object.
| Field | Type | Description |
|---|---|---|
purchase_orders |
array[object] | Compact purchase orders: id, approval_key, creator_name, amount, status, supplier_name, supplier_id, keywords, created_at, currency_id, currency_symbol, currency_iso_code, total_gross_amount, total_net_amount, base_gross_amount, tax/shipping override fields, submitted_on, po_date, share_key, department_id, department_name, budget_name, compliance_status, delivered_on, delivery_status, payment_status, payment_progress, xero_export_status, synced_with_xero, xero_is_changed, approver_requests. |
purchase_orders[ |
string | Symbol of the PO currency (list only). |
purchase_orders[ |
string | Budget name(s) used by the lines (list only). |
purchase_orders[ |
string | Compliance check status or null (list only). |
meta.current_page |
integer | Current page. |
meta.next_page |
integer | Next page or null. |
meta.prev_page |
integer | Previous page or null. |
meta.total_pages |
integer | Number of pages (20 per page). |
meta.total_count |
integer | Number of matching rows. |
V3 and V1 differences
Same envelope and element shape. Behaviour differs:
* Default filter: V1 applies the web "tab" filter (default: approved/paid/cancelled-after-approval only;
requests, all, all_pos, deliveries, invoices, payments select other tabs). V3 applies no tab
filter and returns all statuses; those tab parameters are no-ops on V3 (see the status=pending&search=MON-27
example: empty on V1, the pending POs on V3; requests=true alone returns the requests tab on V1 and the
unfiltered list on V3).
* Pagination: V1 accepts page or orders_page; V3 only orders_page (page=2 returns page 1 on V3).
* Authorization: V3 refuses RFQ-case-officer-only accounts with 401; V1 does not.
* requests=true&bell=true behaves the same in both.
Get a purchase order
curl "https://app.procurementexpress.com/api/v3/purchase_orders/19" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/purchase_orders/19" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/purchase_orders/19",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/purchase_orders/19",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 19,
"company_id": 20000,
"approval_key": null,
"department_id": 2,
"department_name": "Engineering",
"supplier_id": 2,
"supplier_name": "Contoso Hardware",
"external_vendor_id": null,
"status": "pending",
"submitted_on": 1792576848,
"po_date": null,
"currency_id": 2000,
"currency_iso_code": "USD",
"creator_id": 40000,
"creator_name": "Jane Doe",
"creator_email": "jane.doe@example.com",
"creator_network_id": null,
"amount": "2077.96",
"created_at": 1792576848,
"updated_at": 1792576848,
"notes": "Monitors and docks for the Q4 engineering hires",
"total_net_amount": "2077.96",
"total_gross_amount": "2262.38",
"base_gross_amount": 2262.38,
"tax_amount_overridden": false,
"tax_amount_override": null,
"base_tax_amount_override": null,
"effective_tax_amount": 184.42,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"base_shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"delivery_status": "not_delivered",
"payment_status": "unpaid",
"payment_progress": "unpaid",
"delivered_on": 0,
"custom_fields": {
},
"share_key": "37365FF04971E1EBBAFF5151950E9F27FAFF8F35",
"archived": false,
"conversion_rate": 1.0,
"keywords": "19 Contoso Hardware Jane Doe pending 2262.38 MON-27 Engineering Hardware FY26 27-inch 4K monitor 1437.10645 1319.96 DOCK-USB-C Engineering Hardware FY26 USB-C docking station 825.2725 758.0 PRJ-2026-014 CC-100 Engineering AT-0441 ",
"self_approved": false,
"xero_id": null,
"synced_with_xero": false,
"xero_export_status": null,
"xero_export_error_message": null,
"xero_last_export_at": null,
"xero_is_changed": false,
"can_cancel": true,
"can_archive": true,
"can_mark_as_paid": false,
"can_override": true,
"can_receive_item": true,
"can_cancel_receiving_items": false,
"can_edit": true,
"completely_delivered": false,
"aff_link": null,
"statuses": [
"Pending",
"Unpaid",
"Undelivered"
],
"budgets": [
{
"id": 1,
"company_id": 20000,
"name": "Engineering Hardware FY26",
"amount": 50000.0,
"cost_code": "ENG-HW",
"cost_type": "Capex",
"archived": false,
"currency_id": 2000,
"base_amount": 50000.0,
"base_rate": "1.0",
"allow_anyone_to_approve_a_po": false,
"start_date": 1767225600,
"end_date": 1798675200,
"summary": "Engineering Hardware FY26 : ENG-HW : Capex",
"remaining_amount": 23182.28,
"creator_id": 40000,
"qbo_class": null,
"department_ids": [
2
],
"approver_ids": [
40000,
50000
],
"created_at": "2026-10-21T04:48:43.335-05:00",
"updated_at": "2026-10-21T05:07:31.670-05:00",
"approved_this_month": 11963.5
}
],
"tax_rates": [
{
"id": 4,
"name": "Sales Tax",
"value": 8.875,
"tax_amount": 184.42
}
],
"can_complete_delivery": false,
"can_copy": true,
"can_justify": true,
"approvers_with_flow": [
],
"latest_compliance_check": null,
"slug": null,
"has_global_policies": true,
"purchase_order_items": [
{
"id": 36,
"description": "27-inch 4K monitor",
"purchase_order_id": 19,
"budget_id": 1,
"budget_summary": "Engineering Hardware FY26 : ENG-HW : Capex",
"gross_amount": 1437.10645,
"vat": 8.875,
"tax_rate": {
"id": 4,
"archived": false,
"company_id": 20000,
"created_at": "2026-10-21T04:48:43.422-05:00",
"name": "Sales Tax",
"tax_type": "single",
"updated_at": "2026-10-21T04:48:43.422-05:00",
"value": 8.875
},
"net_amount": 1319.96,
"status": "pending",
"quantity": 4.0,
"unit_price": 329.99,
"item_number": "MON-27",
"base_net_amount": 1319.96,
"base_gross_amount": 1437.11,
"gross_usd_amount": 1437.10645,
"product_id": 2,
"received_quantity": 0.0,
"custom_field_values": [
{
"id": 74,
"purchase_order_id": null,
"value": "AT-0441",
"custom_field_id": 3,
"purchase_order_item_id": 36,
"date_format": null,
"budget_id": null
}
],
"sequence_no": 1,
"chart_of_account": {
"id": null,
"name": null
},
"qbo_customer": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
},
"qbo_line_description": null
},
{
"id": 37,
"description": "USB-C docking station",
"purchase_order_id": 19,
"budget_id": 1,
"budget_summary": "Engineering Hardware FY26 : ENG-HW : Capex",
"gross_amount": 825.2725,
"vat": 8.875,
"tax_rate": {
"id": 4,
"archived": false,
"company_id": 20000,
"created_at": "2026-10-21T04:48:43.422-05:00",
"name": "Sales Tax",
"tax_type": "single",
"updated_at": "2026-10-21T04:48:43.422-05:00",
"value": 8.875
},
"net_amount": 758.0,
"status": "pending",
"quantity": 4.0,
"unit_price": 189.5,
"item_number": "DOCK-USB-C",
"base_net_amount": 758.0,
"base_gross_amount": 825.27,
"gross_usd_amount": 825.2725,
"product_id": 3,
"received_quantity": 0.0,
"custom_field_values": [
{
"id": 75,
"purchase_order_id": null,
"value": null,
"custom_field_id": 3,
"purchase_order_item_id": 37,
"date_format": null,
"budget_id": null
}
],
"sequence_no": 2,
"chart_of_account": {
"id": null,
"name": null
},
"qbo_customer": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
},
"qbo_line_description": null
}
],
"purchase_order_comments": [
],
"custom_field_values": [
{
"id": 72,
"purchase_order_id": 19,
"value": "PRJ-2026-014",
"custom_field_id": 1,
"purchase_order_item_id": null,
"date_format": null,
"budget_id": null
},
{
"id": 73,
"purchase_order_id": 19,
"value": "CC-100 Engineering",
"custom_field_id": 2,
"purchase_order_item_id": null,
"date_format": null,
"budget_id": null
}
],
"payments": [
],
"purchase_order_item_payments": [
],
"uploads": [
],
"invoices": [
],
"compliance_checks": [
],
"approver_requests": [
{
"id": 21,
"status": "pending",
"approver_name": "Priya Patel",
"approver_id": 50000,
"delegate_id": null
}
],
"supplier": {
"id": 2,
"name": "Contoso Hardware",
"company_id": 20000,
"notes": null,
"phone_number": "+1 555 0300",
"address": "500 Tech Park, Austin, TX 78701",
"email": "sales@contoso.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Diego Ramos",
"tax_number": "74-7654321",
"payment_terms": "Net 45",
"created_at": "2026-10-21T04:48:43.563-05:00",
"updated_at": "2026-10-21T04:48:43.563-05:00",
"currency_id": 2000,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
}
}
Example: as approver (V3,
200 OK):
{
"id": 19,
"company_id": 20000,
"approval_key": null,
"department_id": 2,
"department_name": "Engineering",
"supplier_id": 2,
"supplier_name": "Contoso Hardware",
"external_vendor_id": null,
"status": "pending",
"submitted_on": 1792576848,
"po_date": null,
"currency_id": 2000,
"currency_iso_code": "USD",
"creator_id": 40000,
"creator_name": "Jane Doe",
"creator_email": "jane.doe@example.com",
"creator_network_id": null,
"amount": "2077.96",
"created_at": 1792576848,
"updated_at": 1792576848,
"notes": "Monitors and docks for the Q4 engineering hires",
"total_net_amount": "2077.96",
"total_gross_amount": "2262.38",
"base_gross_amount": 2262.38,
"tax_amount_overridden": false,
"tax_amount_override": null,
"base_tax_amount_override": null,
"effective_tax_amount": 184.42,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"base_shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"delivery_status": "not_delivered",
"payment_status": "unpaid",
"payment_progress": "unpaid",
"delivered_on": 0,
"custom_fields": {
},
"share_key": "37365FF04971E1EBBAFF5151950E9F27FAFF8F35",
"archived": false,
"conversion_rate": 1.0,
"keywords": "19 Contoso Hardware Jane Doe pending 2262.38 MON-27 Engineering Hardware FY26 27-inch 4K monitor 1437.10645 1319.96 DOCK-USB-C Engineering Hardware FY26 USB-C docking station 825.2725 758.0 PRJ-2026-014 CC-100 Engineering AT-0441 ",
"self_approved": false,
"xero_id": null,
"synced_with_xero": false,
"xero_export_status": null,
"xero_export_error_message": null,
"xero_last_export_at": null,
"xero_is_changed": false,
"can_cancel": true,
"can_archive": false,
"can_mark_as_paid": false,
"can_override": false,
"can_receive_item": true,
"can_cancel_receiving_items": false,
"can_edit": true,
"completely_delivered": false,
"aff_link": null,
"statuses": [
"Pending",
"Unpaid",
"Undelivered"
],
"budgets": [
{
"id": 1,
"company_id": 20000,
"name": "Engineering Hardware FY26",
"amount": 50000.0,
"cost_code": "ENG-HW",
"cost_type": "Capex",
"archived": false,
"currency_id": 2000,
"base_amount": 50000.0,
"base_rate": "1.0",
"allow_anyone_to_approve_a_po": false,
"start_date": 1767225600,
"end_date": 1798675200,
"summary": "Engineering Hardware FY26 : ENG-HW : Capex",
"remaining_amount": 23182.28,
"creator_id": 40000,
"qbo_class": null,
"department_ids": [
2
],
"approver_ids": [
40000,
50000
],
"created_at": "2026-10-21T04:48:43.335-05:00",
"updated_at": "2026-10-21T05:07:31.670-05:00",
"approved_this_month": 11963.5
}
],
"tax_rates": [
{
"id": 4,
"name": "Sales Tax",
"value": 8.875,
"tax_amount": 184.42
}
],
"can_complete_delivery": false,
"can_copy": true,
"can_justify": false,
"approvers_with_flow": [
],
"latest_compliance_check": null,
"slug": null,
"has_global_policies": true,
"purchase_order_items": [
{
"id": 36,
"description": "27-inch 4K monitor",
"purchase_order_id": 19,
"budget_id": 1,
"budget_summary": "Engineering Hardware FY26 : ENG-HW : Capex",
"gross_amount": 1437.10645,
"vat": 8.875,
"tax_rate": {
"id": 4,
"archived": false,
"company_id": 20000,
"created_at": "2026-10-21T04:48:43.422-05:00",
"name": "Sales Tax",
"tax_type": "single",
"updated_at": "2026-10-21T04:48:43.422-05:00",
"value": 8.875
},
"net_amount": 1319.96,
"status": "pending",
"quantity": 4.0,
"unit_price": 329.99,
"item_number": "MON-27",
"base_net_amount": 1319.96,
"base_gross_amount": 1437.11,
"gross_usd_amount": 1437.10645,
"product_id": 2,
"received_quantity": 0.0,
"custom_field_values": [
{
"id": 74,
"purchase_order_id": null,
"value": "AT-0441",
"custom_field_id": 3,
"purchase_order_item_id": 36,
"date_format": null,
"budget_id": null
}
],
"sequence_no": 1,
"chart_of_account": {
"id": null,
"name": null
},
"qbo_customer": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
},
"qbo_line_description": null
},
{
"id": 37,
"description": "USB-C docking station",
"purchase_order_id": 19,
"budget_id": 1,
"budget_summary": "Engineering Hardware FY26 : ENG-HW : Capex",
"gross_amount": 825.2725,
"vat": 8.875,
"tax_rate": {
"id": 4,
"archived": false,
"company_id": 20000,
"created_at": "2026-10-21T04:48:43.422-05:00",
"name": "Sales Tax",
"tax_type": "single",
"updated_at": "2026-10-21T04:48:43.422-05:00",
"value": 8.875
},
"net_amount": 758.0,
"status": "pending",
"quantity": 4.0,
"unit_price": 189.5,
"item_number": "DOCK-USB-C",
"base_net_amount": 758.0,
"base_gross_amount": 825.27,
"gross_usd_amount": 825.2725,
"product_id": 3,
"received_quantity": 0.0,
"custom_field_values": [
{
"id": 75,
"purchase_order_id": null,
"value": null,
"custom_field_id": 3,
"purchase_order_item_id": 37,
"date_format": null,
"budget_id": null
}
],
"sequence_no": 2,
"chart_of_account": {
"id": null,
"name": null
},
"qbo_customer": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
},
"qbo_line_description": null
}
],
"purchase_order_comments": [
],
"custom_field_values": [
{
"id": 72,
"purchase_order_id": 19,
"value": "PRJ-2026-014",
"custom_field_id": 1,
"purchase_order_item_id": null,
"date_format": null,
"budget_id": null
},
{
"id": 73,
"purchase_order_id": 19,
"value": "CC-100 Engineering",
"custom_field_id": 2,
"purchase_order_item_id": null,
"date_format": null,
"budget_id": null
}
],
"payments": [
],
"purchase_order_item_payments": [
],
"uploads": [
],
"invoices": [
],
"compliance_checks": [
],
"approver_requests": [
{
"id": 21,
"status": "pending",
"approver_name": "Priya Patel",
"approver_id": 50000,
"delegate_id": null,
"accept_token": "<accept-token>",
"reject_token": "<reject-token>"
}
],
"supplier": {
"id": 2,
"name": "Contoso Hardware",
"company_id": 20000,
"notes": null,
"phone_number": "+1 555 0300",
"address": "500 Tech Park, Austin, TX 78701",
"email": "sales@contoso.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Diego Ramos",
"tax_number": "74-7654321",
"payment_terms": "Net 45",
"created_at": "2026-10-21T04:48:43.563-05:00",
"updated_at": "2026-10-21T04:48:43.563-05:00",
"currency_id": 2000,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
}
}
Example: by po number (V3,
200 OK):
{
"id": 32,
"company_id": 20000,
"approval_key": "32-10/21/2026",
"department_id": 2,
"department_name": "Engineering",
"supplier_id": 2,
"supplier_name": "Contoso Hardware",
"external_vendor_id": null,
"status": "approved",
"submitted_on": 1792577041,
"po_date": null,
"currency_id": 2000,
"currency_iso_code": "USD",
"creator_id": 40000,
"creator_name": "Jane Doe",
"creator_email": "jane.doe@example.com",
"creator_network_id": null,
"amount": "189.5",
"created_at": 1792577041,
"updated_at": 1792577098,
"notes": "Docking station, approve-v1 test",
"total_net_amount": "189.5",
"total_gross_amount": "206.32",
"base_gross_amount": 206.32,
"tax_amount_overridden": false,
"tax_amount_override": null,
"base_tax_amount_override": null,
"effective_tax_amount": 16.82,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"base_shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"delivery_status": "not_delivered",
"payment_status": "unpaid",
"payment_progress": "unpaid",
"delivered_on": 0,
"custom_fields": {
},
"share_key": "78B439A41A7228AC854C17D71AFC310101515482",
"archived": false,
"conversion_rate": 1.0,
"keywords": "32 Contoso Hardware Jane Doe 32-10/21/2026 approved 206.32 DOCK-USB-C Engineering Hardware FY26 USB-C docking station 206.318125 189.5 PRJ-2026-014 CC-100 Engineering ",
"self_approved": false,
"xero_id": null,
"synced_with_xero": false,
"xero_export_status": null,
"xero_export_error_message": null,
"xero_last_export_at": null,
"xero_is_changed": false,
"can_cancel": true,
"can_archive": true,
"can_mark_as_paid": true,
"can_override": false,
"can_receive_item": true,
"can_cancel_receiving_items": false,
"can_edit": true,
"completely_delivered": false,
"aff_link": null,
"statuses": [
"Approved",
"Unpaid",
"Undelivered"
],
"budgets": [
{
"id": 1,
"company_id": 20000,
"name": "Engineering Hardware FY26",
"amount": 50000.0,
"cost_code": "ENG-HW",
"cost_type": "Capex",
"archived": false,
"currency_id": 2000,
"base_amount": 50000.0,
"base_rate": "1.0",
"allow_anyone_to_approve_a_po": false,
"start_date": 1767225600,
"end_date": 1798675200,
"summary": "Engineering Hardware FY26 : ENG-HW : Capex",
"remaining_amount": 23182.28,
"creator_id": 40000,
"qbo_class": null,
"department_ids": [
2
],
"approver_ids": [
40000,
50000
],
"created_at": "2026-10-21T04:48:43.335-05:00",
"updated_at": "2026-10-21T05:07:31.670-05:00",
"approved_this_month": 11963.5
}
],
"tax_rates": [
{
"id": 4,
"name": "Sales Tax",
"value": 8.875,
"tax_amount": 16.82
}
],
"can_complete_delivery": false,
"can_copy": true,
"can_justify": true,
"approvers_with_flow": [
],
"latest_compliance_check": null,
"slug": "pex-32-10/21/2026-1",
"has_global_policies": true,
"purchase_order_items": [
{
"id": 52,
"description": "USB-C docking station",
"purchase_order_id": 32,
"budget_id": 1,
"budget_summary": "Engineering Hardware FY26 : ENG-HW : Capex",
"gross_amount": 206.318125,
"vat": 8.875,
"tax_rate": {
"id": 4,
"archived": false,
"company_id": 20000,
"created_at": "2026-10-21T04:48:43.422-05:00",
"name": "Sales Tax",
"tax_type": "single",
"updated_at": "2026-10-21T04:48:43.422-05:00",
"value": 8.875
},
"net_amount": 189.5,
"status": "approved",
"quantity": 1.0,
"unit_price": 189.5,
"item_number": "DOCK-USB-C",
"base_net_amount": 189.5,
"base_gross_amount": 206.32,
"gross_usd_amount": 206.318125,
"product_id": 3,
"received_quantity": 0.0,
"custom_field_values": [
{
"id": 116,
"purchase_order_id": null,
"value": null,
"custom_field_id": 3,
"purchase_order_item_id": 52,
"date_format": null,
"budget_id": null
}
],
"sequence_no": 1,
"chart_of_account": {
"id": null,
"name": null
},
"qbo_customer": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
},
"qbo_line_description": null
}
],
"purchase_order_comments": [
{
"id": 69,
"comment": "Purchase order fully approved",
"creator_id": 1,
"creator_name": "PX Automate",
"purchase_order_id": 32,
"status": "inactive",
"created_at": 1792577098,
"uploads": [
],
"comment_formatted_time": "05:04",
"comment_formatted_date": "2026-10-21",
"comment_display_name": "PX Automate",
"formatted_comment": "<p>Purchase order fully approved</p>",
"system_generated": true,
"category": "approval"
},
{
"id": 70,
"comment": "Purchase Order Approved by Priya Patel (api) for a total gross amount of USD 206.32.",
"creator_id": 50000,
"creator_name": "Priya Patel",
"purchase_order_id": 32,
"status": "active",
"created_at": 1792577098,
"uploads": [
],
"comment_formatted_time": "05:04",
"comment_formatted_date": "2026-10-21",
"comment_display_name": "Priya Patel",
"formatted_comment": "<p>Purchase Order Approved by Priya Patel (api) for a total gross amount of USD 206.32.</p>",
"system_generated": true,
"category": "approval_partial"
}
],
"custom_field_values": [
{
"id": 114,
"purchase_order_id": 32,
"value": "PRJ-2026-014",
"custom_field_id": 1,
"purchase_order_item_id": null,
"date_format": null,
"budget_id": null
},
{
"id": 115,
"purchase_order_id": 32,
"value": "CC-100 Engineering",
"custom_field_id": 2,
"purchase_order_item_id": null,
"date_format": null,
"budget_id": null
}
],
"payments": [
],
"purchase_order_item_payments": [
],
"uploads": [
],
"invoices": [
],
"compliance_checks": [
],
"approver_requests": [
{
"id": 32,
"status": "approved",
"approver_name": "Priya Patel",
"approver_id": 50000,
"delegate_id": null
}
],
"supplier": {
"id": 2,
"name": "Contoso Hardware",
"company_id": 20000,
"notes": null,
"phone_number": "+1 555 0300",
"address": "500 Tech Park, Austin, TX 78701",
"email": "sales@contoso.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Diego Ramos",
"tax_number": "74-7654321",
"payment_terms": "Net 45",
"created_at": "2026-10-21T04:48:43.563-05:00",
"updated_at": "2026-10-21T04:48:43.563-05:00",
"currency_id": 2000,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
}
}
V3 V1
Returns one purchase order with line items, approvals, history and permission hints.
Returns the full purchase order object (see the field reference above). :id may be the numeric
id, the PO number (approval_key) or the slug; URL-encode PO numbers that contain /
(GET /purchase_orders/32-10%2F21%2F2026).
approver_requests[] lists the approval requests. The accept_token and reject_token needed
by Approve and Reject are included only
on the caller's own request (as approver or as the delegate covering for the approver) - compare
the primary example (requester Jane, no tokens) with the "as approver" example (Priya, tokens
present). The can_* booleans are evaluated for the caller.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/purchase_orders/:id |
| V1 | GET /api/v1/purchase_orders/:id |
Permissions
Sysadmin; for drafts, the creator, users mentioned on it, or company admins when the company
opens drafts to admins; for submitted POs, company admins, approvers and finance users of the
company; otherwise the creator and mentioned users (or the department's members when the company
shares PO access by department). Anyone else gets 401 Unauthorized access.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
string | Numeric id, PO number (URL-encoded) or slug. |
Response
200 with the purchase order object (no root key). Unknown id: 404 {"error": "Invalid id passed"}.
A PO the caller may not view: 401 {"error": "Unauthorized access"}.
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
| Unauthorized | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
New purchase order template
curl "https://app.procurementexpress.com/api/v3/purchase_orders/new" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/purchase_orders/new" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/purchase_orders/new",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/purchase_orders/new",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"creator_id": 40000,
"amount": 0,
"status": "draft",
"supplier_name": null,
"notes": null,
"company_id": 20000,
"delivered_on": null,
"currency_id": 2000,
"department_id": null,
"supplier_id": null,
"self_approved": false,
"delivery_status": "not_delivered",
"custom_field_values_attributes": [
{
"value": null,
"custom_field_id": 1,
"budget_id": null
},
{
"value": null,
"custom_field_id": 2,
"budget_id": null
}
],
"purchase_order_items_attributes": [
{
"description": null,
"budget_id": null,
"gross_amount": null,
"vat": "0.0",
"net_amount": "0.0",
"status": "pending",
"quantity": "1.0",
"unit_price": null,
"item_number": null,
"base_net_amount": null,
"base_gross_amount": null,
"gross_usd_amount": null,
"product_id": null,
"received_quantity": null,
"custom_field_values_attributes": [
{
"value": null,
"custom_field_id": 3,
"budget_id": null
}
]
}
]
}
V3 V1
Returns an unsaved purchase order pre-filled with the caller's defaults, to build a create request.
Builds (but does not save) a draft purchase order for the caller: creator, company, currency (the
caller's preferred currency, else the company currency), one empty line item with the company's
default tax rate (vat), and a placeholder custom_field_values_attributes entry for every active
PO-level custom field plus one per active line-item custom field on the line. The key names match
the create payload (purchase_order_items_attributes, custom_field_values_attributes), so a client
can fill the template in and post it to Create.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/purchase_orders/new |
| V1 | GET /api/v1/purchase_orders/new |
Permissions
Any active member of the company.
Response
200 with creator_id, amount, status (draft), supplier_name, notes, company_id,
delivered_on, currency_id, department_id, supplier_id, self_approved, delivery_status,
custom_field_values_attributes[] (value, custom_field_id, budget_id) and
purchase_order_items_attributes[] (description, budget_id, amounts, vat, quantity 1.0, ...,
each with its own custom_field_values_attributes). Decimal values are strings. Nothing is persisted.
V3 and V1 differences
None - identical request and response.
Create a purchase order
curl -X POST "https://app.procurementexpress.com/api/v3/purchase_orders" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"commit": "Send",
"purchase_order": {
"supplier_id": 2,
"department_id": 2,
"currency_id": 2000,
"notes": "Monitors and docks for the Q4 engineering hires",
"purchase_order_items_attributes": [
{
"product_id": 2,
"item_number": "MON-27",
"description": "27-inch 4K monitor",
"quantity": 4,
"unit_price": 329.99,
"budget_id": 1,
"tax_rate_id": 4,
"vat": 8.875,
"custom_field_values_attributes": [
{
"custom_field_id": 3,
"value": "AT-0441"
}
]
},
{
"product_id": 3,
"item_number": "DOCK-USB-C",
"description": "USB-C docking station",
"quantity": 4,
"unit_price": 189.5,
"budget_id": 1,
"tax_rate_id": 4,
"vat": 8.875
}
],
"custom_field_values_attributes": [
{
"custom_field_id": 1,
"value": "PRJ-2026-014"
},
{
"custom_field_id": 2,
"value": "CC-100 Engineering"
}
],
"approver_list": [
50000
]
}
}'
curl -X POST "https://app.procurementexpress.com/api/v1/purchase_orders" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"commit": "Send",
"purchase_order": {
"supplier_id": 2,
"department_id": 2,
"currency_id": 2000,
"notes": "Monitors and docks for the Q4 engineering hires",
"purchase_order_items_attributes": [
{
"product_id": 2,
"item_number": "MON-27",
"description": "27-inch 4K monitor",
"quantity": 4,
"unit_price": 329.99,
"budget_id": 1,
"tax_rate_id": 4,
"vat": 8.875,
"custom_field_values_attributes": [
{
"custom_field_id": 3,
"value": "AT-0441"
}
]
},
{
"product_id": 3,
"item_number": "DOCK-USB-C",
"description": "USB-C docking station",
"quantity": 4,
"unit_price": 189.5,
"budget_id": 1,
"tax_rate_id": 4,
"vat": 8.875
}
],
"custom_field_values_attributes": [
{
"custom_field_id": 1,
"value": "PRJ-2026-014"
},
{
"custom_field_id": 2,
"value": "CC-100 Engineering"
}
],
"approver_list": [
50000
]
}
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/purchase_orders",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
commit: "Send",
purchase_order: {
supplier_id: 2,
department_id: 2,
currency_id: 2000,
notes: "Monitors and docks for the Q4 engineering hires",
purchase_order_items_attributes: [
{
product_id: 2,
item_number: "MON-27",
description: "27-inch 4K monitor",
quantity: 4,
unit_price: 329.99,
budget_id: 1,
tax_rate_id: 4,
vat: 8.875,
custom_field_values_attributes: [
{
custom_field_id: 3,
value: "AT-0441"
}
]
},
{
product_id: 3,
item_number: "DOCK-USB-C",
description: "USB-C docking station",
quantity: 4,
unit_price: 189.5,
budget_id: 1,
tax_rate_id: 4,
vat: 8.875
}
],
custom_field_values_attributes: [
{
custom_field_id: 1,
value: "PRJ-2026-014"
},
{
custom_field_id: 2,
value: "CC-100 Engineering"
}
],
approver_list: [50000]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/purchase_orders",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
commit: "Send",
purchase_order: {
supplier_id: 2,
department_id: 2,
currency_id: 2000,
notes: "Monitors and docks for the Q4 engineering hires",
purchase_order_items_attributes: [
{
product_id: 2,
item_number: "MON-27",
description: "27-inch 4K monitor",
quantity: 4,
unit_price: 329.99,
budget_id: 1,
tax_rate_id: 4,
vat: 8.875,
custom_field_values_attributes: [
{
custom_field_id: 3,
value: "AT-0441"
}
]
},
{
product_id: 3,
item_number: "DOCK-USB-C",
description: "USB-C docking station",
quantity: 4,
unit_price: 189.5,
budget_id: 1,
tax_rate_id: 4,
vat: 8.875
}
],
custom_field_values_attributes: [
{
custom_field_id: 1,
value: "PRJ-2026-014"
},
{
custom_field_id: 2,
value: "CC-100 Engineering"
}
],
approver_list: [50000]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 20,
"company_id": 20000,
"approval_key": null,
"department_id": 2,
"department_name": "Engineering",
"supplier_id": 2,
"supplier_name": "Contoso Hardware",
"external_vendor_id": null,
"status": "pending",
"submitted_on": 1792576856,
"po_date": null,
"currency_id": 2000,
"currency_iso_code": "USD",
"creator_id": 40000,
"creator_name": "Jane Doe",
"creator_email": "jane.doe@example.com",
"creator_network_id": null,
"amount": "2077.96",
"created_at": 1792576856,
"updated_at": 1792576856,
"notes": "Monitors and docks for the Q4 engineering hires",
"total_net_amount": "2077.96",
"total_gross_amount": "2262.38",
"base_gross_amount": 2262.38,
"tax_amount_overridden": false,
"tax_amount_override": null,
"base_tax_amount_override": null,
"effective_tax_amount": 184.42,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"base_shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"delivery_status": "not_delivered",
"payment_status": "unpaid",
"payment_progress": "unpaid",
"delivered_on": 0,
"custom_fields": {
},
"share_key": "AA36A0929C95210ADAD1FA685F5508BC2106E2D4",
"archived": false,
"conversion_rate": 1.0,
"keywords": "20 Contoso Hardware Jane Doe pending 2262.38 MON-27 Engineering Hardware FY26 27-inch 4K monitor 1437.10645 1319.96 DOCK-USB-C Engineering Hardware FY26 USB-C docking station 825.2725 758.0 PRJ-2026-014 CC-100 Engineering AT-0441 ",
"self_approved": false,
"xero_id": null,
"synced_with_xero": false,
"xero_export_status": null,
"xero_export_error_message": null,
"xero_last_export_at": null,
"xero_is_changed": false,
"can_cancel": true,
"can_archive": true,
"can_mark_as_paid": false,
"can_override": true,
"can_receive_item": true,
"can_cancel_receiving_items": false,
"can_edit": true,
"completely_delivered": false,
"aff_link": null,
"statuses": [
"Pending",
"Unpaid",
"Undelivered"
],
"budgets": [
{
"id": 1,
"company_id": 20000,
"name": "Engineering Hardware FY26",
"amount": 50000.0,
"cost_code": "ENG-HW",
"cost_type": "Capex",
"archived": false,
"currency_id": 2000,
"base_amount": 50000.0,
"base_rate": "1.0",
"allow_anyone_to_approve_a_po": false,
"start_date": 1767225600,
"end_date": 1798675200,
"summary": "Engineering Hardware FY26 : ENG-HW : Capex",
"remaining_amount": 31695.78,
"creator_id": 40000,
"qbo_class": null,
"department_ids": [
2
],
"approver_ids": [
40000,
50000
],
"created_at": "2026-10-21T04:48:43.335-05:00",
"updated_at": "2026-10-21T05:00:56.319-05:00",
"approved_this_month": 9600.0
}
],
"tax_rates": [
{
"id": 4,
"name": "Sales Tax",
"value": 8.875,
"tax_amount": 184.42
}
],
"can_complete_delivery": false,
"can_copy": true,
"can_justify": true,
"approvers_with_flow": [
],
"latest_compliance_check": null,
"slug": null,
"has_global_policies": true,
"purchase_order_items": [
{
"id": 38,
"description": "27-inch 4K monitor",
"purchase_order_id": 20,
"budget_id": 1,
"budget_summary": "Engineering Hardware FY26 : ENG-HW : Capex",
"gross_amount": 1437.10645,
"vat": 8.875,
"tax_rate": {
"id": 4,
"archived": false,
"company_id": 20000,
"created_at": "2026-10-21T04:48:43.422-05:00",
"name": "Sales Tax",
"tax_type": "single",
"updated_at": "2026-10-21T04:48:43.422-05:00",
"value": 8.875
},
"net_amount": 1319.96,
"status": "pending",
"quantity": 4.0,
"unit_price": 329.99,
"item_number": "MON-27",
"base_net_amount": 1319.96,
"base_gross_amount": 1437.11,
"gross_usd_amount": 1437.10645,
"product_id": 2,
"received_quantity": 0.0,
"custom_field_values": [
{
"id": 78,
"purchase_order_id": null,
"value": "AT-0441",
"custom_field_id": 3,
"purchase_order_item_id": 38,
"date_format": null,
"budget_id": null
}
],
"sequence_no": 1,
"chart_of_account": {
"id": null,
"name": null
},
"qbo_customer": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
},
"qbo_line_description": null
},
{
"id": 39,
"description": "USB-C docking station",
"purchase_order_id": 20,
"budget_id": 1,
"budget_summary": "Engineering Hardware FY26 : ENG-HW : Capex",
"gross_amount": 825.2725,
"vat": 8.875,
"tax_rate": {
"id": 4,
"archived": false,
"company_id": 20000,
"created_at": "2026-10-21T04:48:43.422-05:00",
"name": "Sales Tax",
"tax_type": "single",
"updated_at": "2026-10-21T04:48:43.422-05:00",
"value": 8.875
},
"net_amount": 758.0,
"status": "pending",
"quantity": 4.0,
"unit_price": 189.5,
"item_number": "DOCK-USB-C",
"base_net_amount": 758.0,
"base_gross_amount": 825.27,
"gross_usd_amount": 825.2725,
"product_id": 3,
"received_quantity": 0.0,
"custom_field_values": [
{
"id": 79,
"purchase_order_id": null,
"value": null,
"custom_field_id": 3,
"purchase_order_item_id": 39,
"date_format": null,
"budget_id": null
}
],
"sequence_no": 2,
"chart_of_account": {
"id": null,
"name": null
},
"qbo_customer": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
},
"qbo_line_description": null
}
],
"purchase_order_comments": [
],
"custom_field_values": [
{
"id": 76,
"purchase_order_id": 20,
"value": "PRJ-2026-014",
"custom_field_id": 1,
"purchase_order_item_id": null,
"date_format": null,
"budget_id": null
},
{
"id": 77,
"purchase_order_id": 20,
"value": "CC-100 Engineering",
"custom_field_id": 2,
"purchase_order_item_id": null,
"date_format": null,
"budget_id": null
}
],
"payments": [
],
"purchase_order_item_payments": [
],
"uploads": [
],
"invoices": [
],
"compliance_checks": [
],
"approver_requests": [
{
"id": 22,
"status": "pending",
"approver_name": "Priya Patel",
"approver_id": 50000,
"delegate_id": null
}
],
"supplier": {
"id": 2,
"name": "Contoso Hardware",
"company_id": 20000,
"notes": null,
"phone_number": "+1 555 0300",
"address": "500 Tech Park, Austin, TX 78701",
"email": "sales@contoso.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Diego Ramos",
"tax_number": "74-7654321",
"payment_terms": "Net 45",
"created_at": "2026-10-21T04:48:43.563-05:00",
"updated_at": "2026-10-21T04:48:43.563-05:00",
"currency_id": 2000,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
}
}
Example: draft (V3,
200 OK):
{
"id": 65,
"company_id": 20000,
"approval_key": null,
"department_id": 3,
"department_name": "Marketing",
"supplier_id": 1,
"supplier_name": "Northwind Office Supplies",
"external_vendor_id": null,
"status": "draft",
"submitted_on": 1792577576,
"po_date": null,
"currency_id": 2000,
"currency_iso_code": "USD",
"creator_id": 40000,
"creator_name": "Jane Doe",
"creator_email": "jane.doe@example.com",
"creator_network_id": null,
"amount": "245.0",
"created_at": 1792577576,
"updated_at": 1792577576,
"notes": "Printer paper restock, still collecting quotes",
"total_net_amount": "245.0",
"total_gross_amount": "266.74",
"base_gross_amount": 266.74,
"tax_amount_overridden": false,
"tax_amount_override": null,
"base_tax_amount_override": null,
"effective_tax_amount": 21.74,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"base_shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"delivery_status": "not_delivered",
"payment_status": "unpaid",
"payment_progress": "unpaid",
"delivered_on": 0,
"custom_fields": {
},
"share_key": "479957BB86FA6E396F60C6CD4D72CC51D7EB4D22",
"archived": false,
"conversion_rate": 1.0,
"keywords": "65 Northwind Office Supplies Jane Doe draft 266.74 Marketing Q4 Campaigns A4 copier paper, box of 5 reams 266.74375 245.0 ",
"self_approved": false,
"xero_id": null,
"synced_with_xero": false,
"xero_export_status": null,
"xero_export_error_message": null,
"xero_last_export_at": null,
"xero_is_changed": false,
"can_cancel": true,
"can_archive": true,
"can_mark_as_paid": false,
"can_override": false,
"can_receive_item": false,
"can_cancel_receiving_items": false,
"can_edit": true,
"completely_delivered": false,
"aff_link": null,
"statuses": [
"Draft"
],
"budgets": [
{
"id": 2,
"company_id": 20000,
"name": "Marketing Q4 Campaigns",
"amount": 20000.0,
"cost_code": "MKT-Q4",
"cost_type": "Opex",
"archived": false,
"currency_id": 2000,
"base_amount": 20000.0,
"base_rate": "1.0",
"allow_anyone_to_approve_a_po": false,
"start_date": 1767225600,
"end_date": 1798675200,
"summary": "Marketing Q4 Campaigns : MKT-Q4 : Opex",
"remaining_amount": 19160.0,
"creator_id": 40000,
"qbo_class": null,
"department_ids": [
3
],
"approver_ids": [
40000,
50000
],
"created_at": "2026-10-21T04:48:43.390-05:00",
"updated_at": "2026-10-21T05:08:28.148-05:00",
"approved_this_month": 0.0
}
],
"tax_rates": [
{
"id": 4,
"name": "Sales Tax",
"value": 8.875,
"tax_amount": 21.74
}
],
"can_complete_delivery": false,
"can_copy": true,
"can_justify": true,
"approvers_with_flow": [
],
"latest_compliance_check": null,
"slug": null,
"has_global_policies": true,
"purchase_order_items": [
{
"id": 87,
"description": "A4 copier paper, box of 5 reams",
"purchase_order_id": 65,
"budget_id": 2,
"budget_summary": "Marketing Q4 Campaigns : MKT-Q4 : Opex",
"gross_amount": 266.74375,
"vat": 8.875,
"tax_rate": {
"id": 4,
"archived": false,
"company_id": 20000,
"created_at": "2026-10-21T04:48:43.422-05:00",
"name": "Sales Tax",
"tax_type": "single",
"updated_at": "2026-10-21T04:48:43.422-05:00",
"value": 8.875
},
"net_amount": 245.0,
"status": "draft",
"quantity": 10.0,
"unit_price": 24.5,
"item_number": null,
"base_net_amount": 245.0,
"base_gross_amount": 266.74,
"gross_usd_amount": 266.74375,
"product_id": 24,
"received_quantity": 0.0,
"custom_field_values": [
{
"id": 221,
"purchase_order_id": null,
"value": null,
"custom_field_id": 3,
"purchase_order_item_id": 87,
"date_format": null,
"budget_id": null
}
],
"sequence_no": 1,
"chart_of_account": {
"id": null,
"name": null
},
"qbo_customer": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
},
"qbo_line_description": null
}
],
"purchase_order_comments": [
],
"custom_field_values": [
{
"id": 222,
"purchase_order_id": 65,
"value": "Not Set",
"custom_field_id": 1,
"purchase_order_item_id": null,
"date_format": null,
"budget_id": null
},
{
"id": 223,
"purchase_order_id": 65,
"value": "Not Set",
"custom_field_id": 2,
"purchase_order_item_id": null,
"date_format": null,
"budget_id": null
}
],
"payments": [
],
"purchase_order_item_payments": [
],
"uploads": [
],
"invoices": [
],
"compliance_checks": [
],
"approver_requests": [
],
"supplier": {
"id": 1,
"name": "Northwind Office Supplies",
"company_id": 20000,
"notes": "Preferred stationery vendor",
"phone_number": "+1 555 0200",
"address": "1 Commerce Way, Chicago, IL 60601",
"email": "orders@northwind.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Laura Callahan",
"tax_number": "36-1234567",
"payment_terms": "Net 30",
"created_at": "2026-10-21T04:48:43.487-05:00",
"updated_at": "2026-10-21T04:48:43.487-05:00",
"currency_id": 2000,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
}
}
Example: self approved (V3,
200 OK):
{
"id": 28,
"company_id": 20000,
"approval_key": "28-10/21/2026",
"department_id": 2,
"department_name": "Engineering",
"supplier_id": 1,
"supplier_name": "Northwind Office Supplies",
"external_vendor_id": null,
"status": "approved",
"submitted_on": 1792576940,
"po_date": null,
"currency_id": 2000,
"currency_iso_code": "USD",
"creator_id": 40000,
"creator_name": "Jane Doe",
"creator_email": "jane.doe@example.com",
"creator_network_id": null,
"amount": "649.0",
"created_at": 1792576940,
"updated_at": 1792576940,
"notes": "Standing desk for the lab bench",
"total_net_amount": "649.0",
"total_gross_amount": "706.6",
"base_gross_amount": 706.6,
"tax_amount_overridden": false,
"tax_amount_override": null,
"base_tax_amount_override": null,
"effective_tax_amount": 57.6,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"base_shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"delivery_status": "not_delivered",
"payment_status": "unpaid",
"payment_progress": "unpaid",
"delivered_on": 0,
"custom_fields": {
},
"share_key": "1CDE72EF424384C523BB93DAB3FFEBB1371FCCCA",
"archived": false,
"conversion_rate": 1.0,
"keywords": "28 Northwind Office Supplies Jane Doe 28-10/21/2026 approved 706.60 DESK-001 Engineering Projects Standing desk, 60 x 30 in 706.59875 649.0 PRJ-2026-022 CC-100 Engineering ",
"self_approved": true,
"xero_id": null,
"synced_with_xero": false,
"xero_export_status": null,
"xero_export_error_message": null,
"xero_last_export_at": null,
"xero_is_changed": false,
"can_cancel": true,
"can_archive": true,
"can_mark_as_paid": true,
"can_override": false,
"can_receive_item": true,
"can_cancel_receiving_items": false,
"can_edit": true,
"completely_delivered": false,
"aff_link": null,
"statuses": [
"Approved",
"Unpaid",
"Undelivered"
],
"budgets": [
{
"id": 10,
"company_id": 20000,
"name": "Engineering Projects",
"amount": 100000.0,
"cost_code": "ENG-PRJ",
"cost_type": "Opex",
"archived": false,
"currency_id": 2000,
"base_amount": 100000.0,
"base_rate": "1.0",
"allow_anyone_to_approve_a_po": false,
"start_date": 1767225600,
"end_date": 1798675200,
"summary": "Engineering Projects : ENG-PRJ : Opex",
"remaining_amount": 98702.0,
"creator_id": 40000,
"qbo_class": null,
"department_ids": [
2
],
"approver_ids": [
],
"created_at": "2026-10-21T05:01:50.744-05:00",
"updated_at": "2026-10-21T05:02:20.841-05:00",
"approved_this_month": 1298.0
}
],
"tax_rates": [
{
"id": 4,
"name": "Sales Tax",
"value": 8.875,
"tax_amount": 57.6
}
],
"can_complete_delivery": false,
"can_copy": true,
"can_justify": true,
"approvers_with_flow": [
],
"latest_compliance_check": null,
"slug": "pex-28-10/21/2026-1",
"has_global_policies": true,
"purchase_order_items": [
{
"id": 47,
"description": "Standing desk, 60 x 30 in",
"purchase_order_id": 28,
"budget_id": 10,
"budget_summary": "Engineering Projects : ENG-PRJ : Opex",
"gross_amount": 706.59875,
"vat": 8.875,
"tax_rate": {
"id": 4,
"archived": false,
"company_id": 20000,
"created_at": "2026-10-21T04:48:43.422-05:00",
"name": "Sales Tax",
"tax_type": "single",
"updated_at": "2026-10-21T04:48:43.422-05:00",
"value": 8.875
},
"net_amount": 649.0,
"status": "approved",
"quantity": 1.0,
"unit_price": 649.0,
"item_number": "DESK-001",
"base_net_amount": 649.0,
"base_gross_amount": 706.6,
"gross_usd_amount": 706.59875,
"product_id": 1,
"received_quantity": 0.0,
"custom_field_values": [
{
"id": 103,
"purchase_order_id": null,
"value": null,
"custom_field_id": 3,
"purchase_order_item_id": 47,
"date_format": null,
"budget_id": null
}
],
"sequence_no": 1,
"chart_of_account": {
"id": null,
"name": null
},
"qbo_customer": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
},
"qbo_line_description": null
}
],
"purchase_order_comments": [
{
"id": 38,
"comment": "Purchase Order Self Approved by Jane Doe for a total gross amount of USD 706.6.",
"creator_id": 40000,
"creator_name": "Jane Doe",
"purchase_order_id": 28,
"status": "active",
"created_at": 1792576940,
"uploads": [
],
"comment_formatted_time": "05:02",
"comment_formatted_date": "2026-10-21",
"comment_display_name": "Jane Doe",
"formatted_comment": "<p>Purchase Order Self Approved by Jane Doe for a total gross amount of USD 706.6.</p>",
"system_generated": true,
"category": "approval_partial"
}
],
"custom_field_values": [
{
"id": 101,
"purchase_order_id": 28,
"value": "PRJ-2026-022",
"custom_field_id": 1,
"purchase_order_item_id": null,
"date_format": null,
"budget_id": null
},
{
"id": 102,
"purchase_order_id": 28,
"value": "CC-100 Engineering",
"custom_field_id": 2,
"purchase_order_item_id": null,
"date_format": null,
"budget_id": null
}
],
"payments": [
],
"purchase_order_item_payments": [
],
"uploads": [
],
"invoices": [
],
"compliance_checks": [
],
"approver_requests": [
{
"id": 28,
"status": "approved",
"approver_name": "Jane Doe",
"approver_id": 40000,
"delegate_id": null,
"accept_token": "<accept-token>",
"reject_token": "<reject-token>"
}
],
"supplier": {
"id": 1,
"name": "Northwind Office Supplies",
"company_id": 20000,
"notes": "Preferred stationery vendor",
"phone_number": "+1 555 0200",
"address": "1 Commerce Way, Chicago, IL 60601",
"email": "orders@northwind.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Laura Callahan",
"tax_number": "36-1234567",
"payment_terms": "Net 30",
"created_at": "2026-10-21T04:48:43.487-05:00",
"updated_at": "2026-10-21T04:48:43.487-05:00",
"currency_id": 2000,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
}
}
Example: foreign currency (V3,
200 OK):
{
"id": 63,
"company_id": 20000,
"approval_key": "63-10/21/2026",
"department_id": 2,
"department_name": "Engineering",
"supplier_id": 2,
"supplier_name": "Contoso Hardware",
"external_vendor_id": null,
"status": "approved",
"submitted_on": 1792577557,
"po_date": null,
"currency_id": 1000,
"currency_iso_code": "EUR",
"creator_id": 40000,
"creator_name": "Jane Doe",
"creator_email": "jane.doe@example.com",
"creator_network_id": null,
"amount": "598.0",
"created_at": 1792577557,
"updated_at": 1792577557,
"notes": "Monitors for the Berlin office, invoiced in euros",
"total_net_amount": "598.0",
"total_gross_amount": "598.0",
"base_gross_amount": 645.84,
"tax_amount_overridden": false,
"tax_amount_override": null,
"base_tax_amount_override": null,
"effective_tax_amount": 0.0,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"base_shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"delivery_status": "not_delivered",
"payment_status": "unpaid",
"payment_progress": "unpaid",
"delivered_on": 0,
"custom_fields": {
},
"share_key": "1D15115CF8A089D125261E6C485E80CE4F3CE014",
"archived": false,
"conversion_rate": 1.08,
"keywords": "63 Contoso Hardware Jane Doe 63-10/21/2026 approved 645.84 MON-27 Engineering Projects 27-inch 4K monitor 598.0 598.0 PRJ-2026-040 CC-100 Engineering ",
"self_approved": true,
"xero_id": null,
"synced_with_xero": false,
"xero_export_status": null,
"xero_export_error_message": null,
"xero_last_export_at": null,
"xero_is_changed": false,
"can_cancel": true,
"can_archive": true,
"can_mark_as_paid": true,
"can_override": false,
"can_receive_item": true,
"can_cancel_receiving_items": false,
"can_edit": true,
"completely_delivered": false,
"aff_link": null,
"statuses": [
"Approved",
"Unpaid",
"Undelivered"
],
"budgets": [
{
"id": 10,
"company_id": 20000,
"name": "Engineering Projects",
"amount": 100000.0,
"cost_code": "ENG-PRJ",
"cost_type": "Opex",
"archived": false,
"currency_id": 2000,
"base_amount": 100000.0,
"base_rate": "1.0",
"allow_anyone_to_approve_a_po": false,
"start_date": 1767225600,
"end_date": 1798675200,
"summary": "Engineering Projects : ENG-PRJ : Opex",
"remaining_amount": 96112.32,
"creator_id": 40000,
"qbo_class": null,
"department_ids": [
2
],
"approver_ids": [
],
"created_at": "2026-10-21T05:01:50.744-05:00",
"updated_at": "2026-10-21T05:12:37.933-05:00",
"approved_this_month": 3887.68
}
],
"tax_rates": [
{
"id": 5,
"name": "Zero Rated",
"value": 0.0,
"tax_amount": 0.0
}
],
"can_complete_delivery": false,
"can_copy": true,
"can_justify": true,
"approvers_with_flow": [
],
"latest_compliance_check": null,
"slug": "pex-63-10/21/2026-1",
"has_global_policies": true,
"purchase_order_items": [
{
"id": 85,
"description": "27-inch 4K monitor",
"purchase_order_id": 63,
"budget_id": 10,
"budget_summary": "Engineering Projects : ENG-PRJ : Opex",
"gross_amount": 598.0,
"vat": 0.0,
"tax_rate": {
"id": 5,
"archived": false,
"company_id": 20000,
"created_at": "2026-10-21T04:48:43.426-05:00",
"name": "Zero Rated",
"tax_type": "single",
"updated_at": "2026-10-21T04:48:43.426-05:00",
"value": 0.0
},
"net_amount": 598.0,
"status": "approved",
"quantity": 2.0,
"unit_price": 299.0,
"item_number": "MON-27",
"base_net_amount": 645.84,
"base_gross_amount": 645.84,
"gross_usd_amount": 598.0,
"product_id": 2,
"received_quantity": 0.0,
"custom_field_values": [
{
"id": 217,
"purchase_order_id": null,
"value": null,
"custom_field_id": 3,
"purchase_order_item_id": 85,
"date_format": null,
"budget_id": null
}
],
"sequence_no": 1,
"chart_of_account": {
"id": null,
"name": null
},
"qbo_customer": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
},
"qbo_line_description": null
}
],
"purchase_order_comments": [
{
"id": 166,
"comment": "Purchase Order Self Approved by Jane Doe for a total gross amount of EUR 598.0.",
"creator_id": 40000,
"creator_name": "Jane Doe",
"purchase_order_id": 63,
"status": "active",
"created_at": 1792577558,
"uploads": [
],
"comment_formatted_time": "05:12",
"comment_formatted_date": "2026-10-21",
"comment_display_name": "Jane Doe",
"formatted_comment": "<p>Purchase Order Self Approved by Jane Doe for a total gross amount of EUR 598.0.</p>",
"system_generated": true,
"category": "approval_partial"
}
],
"custom_field_values": [
{
"id": 215,
"purchase_order_id": 63,
"value": "PRJ-2026-040",
"custom_field_id": 1,
"purchase_order_item_id": null,
"date_format": null,
"budget_id": null
},
{
"id": 216,
"purchase_order_id": 63,
"value": "CC-100 Engineering",
"custom_field_id": 2,
"purchase_order_item_id": null,
"date_format": null,
"budget_id": null
}
],
"payments": [
],
"purchase_order_item_payments": [
],
"uploads": [
],
"invoices": [
],
"compliance_checks": [
],
"approver_requests": [
{
"id": 58,
"status": "approved",
"approver_name": "Jane Doe",
"approver_id": 40000,
"delegate_id": null,
"accept_token": "<accept-token>",
"reject_token": "<reject-token>"
}
],
"supplier": {
"id": 2,
"name": "Contoso Hardware",
"company_id": 20000,
"notes": null,
"phone_number": "+1 555 0300",
"address": "500 Tech Park, Austin, TX 78701",
"email": "sales@contoso.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Diego Ramos",
"tax_number": "74-7654321",
"payment_terms": "Net 45",
"created_at": "2026-10-21T04:48:43.563-05:00",
"updated_at": "2026-10-21T04:48:43.563-05:00",
"currency_id": 2000,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
}
}
Example: on behalf of (V3,
200 OK):
{
"id": 56,
"company_id": 20000,
"approval_key": null,
"department_id": 2,
"department_name": "Engineering",
"supplier_id": 1,
"supplier_name": "Northwind Office Supplies",
"external_vendor_id": null,
"status": "draft",
"submitted_on": 1792577475,
"po_date": null,
"currency_id": 2000,
"currency_iso_code": "USD",
"creator_id": 41000,
"creator_name": "Sam Smith",
"creator_email": "sam.smith@example.com",
"creator_network_id": null,
"amount": "649.0",
"created_at": 1792577475,
"updated_at": 1792577475,
"notes": "Desk for Sam's home office, raised by Jane on his behalf",
"total_net_amount": "649.0",
"total_gross_amount": "706.6",
"base_gross_amount": 706.6,
"tax_amount_overridden": false,
"tax_amount_override": null,
"base_tax_amount_override": null,
"effective_tax_amount": 57.6,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"base_shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"delivery_status": "not_delivered",
"payment_status": "unpaid",
"payment_progress": "unpaid",
"delivered_on": 0,
"custom_fields": {
},
"share_key": "BB90E701847595D905BD8161199A6F69249250A8",
"archived": false,
"conversion_rate": 1.0,
"keywords": "56 Northwind Office Supplies Sam Smith draft 706.60 DESK-001 Engineering Hardware FY26 Standing desk, 60 x 30 in 706.59875 649.0 ",
"self_approved": false,
"xero_id": null,
"synced_with_xero": false,
"xero_export_status": null,
"xero_export_error_message": null,
"xero_last_export_at": null,
"xero_is_changed": false,
"can_cancel": true,
"can_archive": true,
"can_mark_as_paid": false,
"can_override": false,
"can_receive_item": false,
"can_cancel_receiving_items": false,
"can_edit": true,
"completely_delivered": false,
"aff_link": null,
"statuses": [
"Draft"
],
"budgets": [
{
"id": 1,
"company_id": 20000,
"name": "Engineering Hardware FY26",
"amount": 50000.0,
"cost_code": "ENG-HW",
"cost_type": "Capex",
"archived": false,
"currency_id": 2000,
"base_amount": 50000.0,
"base_rate": "1.0",
"allow_anyone_to_approve_a_po": false,
"start_date": 1767225600,
"end_date": 1798675200,
"summary": "Engineering Hardware FY26 : ENG-HW : Capex",
"remaining_amount": 17586.81,
"creator_id": 40000,
"qbo_class": null,
"department_ids": [
2
],
"approver_ids": [
40000,
50000
],
"created_at": "2026-10-21T04:48:43.335-05:00",
"updated_at": "2026-10-21T05:10:33.664-05:00",
"approved_this_month": 14863.5
}
],
"tax_rates": [
{
"id": 4,
"name": "Sales Tax",
"value": 8.875,
"tax_amount": 57.6
}
],
"can_complete_delivery": false,
"can_copy": true,
"can_justify": true,
"approvers_with_flow": [
],
"latest_compliance_check": null,
"slug": null,
"has_global_policies": true,
"purchase_order_items": [
{
"id": 80,
"description": "Standing desk, 60 x 30 in",
"purchase_order_id": 56,
"budget_id": 1,
"budget_summary": "Engineering Hardware FY26 : ENG-HW : Capex",
"gross_amount": 706.59875,
"vat": 8.875,
"tax_rate": {
"id": 4,
"archived": false,
"company_id": 20000,
"created_at": "2026-10-21T04:48:43.422-05:00",
"name": "Sales Tax",
"tax_type": "single",
"updated_at": "2026-10-21T04:48:43.422-05:00",
"value": 8.875
},
"net_amount": 649.0,
"status": "draft",
"quantity": 1.0,
"unit_price": 649.0,
"item_number": "DESK-001",
"base_net_amount": 649.0,
"base_gross_amount": 706.6,
"gross_usd_amount": 706.59875,
"product_id": 1,
"received_quantity": 0.0,
"custom_field_values": [
{
"id": 198,
"purchase_order_id": null,
"value": null,
"custom_field_id": 3,
"purchase_order_item_id": 80,
"date_format": null,
"budget_id": null
}
],
"sequence_no": 1,
"chart_of_account": {
"id": null,
"name": null
},
"qbo_customer": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
},
"qbo_line_description": null
}
],
"purchase_order_comments": [
],
"custom_field_values": [
{
"id": 199,
"purchase_order_id": 56,
"value": "Not Set",
"custom_field_id": 1,
"purchase_order_item_id": null,
"date_format": null,
"budget_id": null
},
{
"id": 200,
"purchase_order_id": 56,
"value": "Not Set",
"custom_field_id": 2,
"purchase_order_item_id": null,
"date_format": null,
"budget_id": null
}
],
"payments": [
],
"purchase_order_item_payments": [
],
"uploads": [
],
"invoices": [
],
"compliance_checks": [
],
"approver_requests": [
],
"supplier": {
"id": 1,
"name": "Northwind Office Supplies",
"company_id": 20000,
"notes": "Preferred stationery vendor",
"phone_number": "+1 555 0200",
"address": "1 Commerce Way, Chicago, IL 60601",
"email": "orders@northwind.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Laura Callahan",
"tax_number": "36-1234567",
"payment_terms": "Net 30",
"created_at": "2026-10-21T04:48:43.487-05:00",
"updated_at": "2026-10-21T04:48:43.487-05:00",
"currency_id": 2000,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
}
}
V3 V1
Creates a purchase order as a draft or submits it for approval in one call.
Creates a purchase order for the caller (or, for company admins, on behalf of another employee).
- Without
commit: "Send"the PO is saved as a draft: no budget, approver or required-field checks, and no approval requests. - With
commit: "Send"it is validated in full and routed (see How approval routing works): the response ispendingwithapprover_requests[], orapprovedwhen the requester can self-approve (self_approved: true, "Self Approved" history comment, PO number assigned).
Validation on submit: at least one line item; every line needs description, quantity,
unit_price, budget_id and a tax rate; required custom fields must have values; the currency must
be supported by the company; the department must belong to the company; approvers must be resolvable
(in manual-routing companies pass approver_list) and their combined approval limit must cover the
amount; budgets may not be overrun unless the company allows budget overruns.
Supplier: pass supplier_id, or supplier_name. A supplier_name that matches an active supplier
(case-insensitively) links it; otherwise a new supplier with that name is created. When the
company uses a fixed supplier list, supplier_id is required.
After saving, missing PO-level custom field values are created with the field's default value or
Not Set, approval flows are attached when enabled, and budget totals are recalculated.
HTTP request
| Version | Request |
|---|---|
| V3 | POST /api/v3/purchase_orders |
| V1 | POST /api/v1/purchase_orders |
Permissions
Any active member of the company (when granular role permissions are enabled, the create_po
permission is required, otherwise 401). on_behalf_of is limited to company admins: anyone else
gets 400 {"error": ["You are not authorized to create purchase order on behalf of other users."]},
and an id that is not an active employee gives 400 {"error": ["Invalid user_id provided for on_behalf_of"]}.
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
commit |
string | No | Send to submit for approval (case-sensitive); anything else, or omitted, saves a draft. |
purchase_order |
object | Yes | Root object. Missing: 400 {"error": "Required parameter are missing."}. |
purchase_order[ |
integer | No | Supplier id. Required when the company uses a fixed supplier list. One of supplier_id/supplier_name is required. |
purchase_order[ |
string | No | Supplier name (squished, max 255). Matched case-insensitively to an active supplier; when there is no match a new supplier is created. |
purchase_order[ |
string | No | Overrides supplier_name and creates a supplier with this name when no supplier_id is set. |
purchase_order[ |
integer | No | Department of the company (Invalid department otherwise). |
purchase_order[ |
integer | Yes | PO currency; must be a company-supported currency. Required unless iso_code is given (Currency can't be blank). |
purchase_order[ |
string | No | Create only: currency ISO code used when currency_id is blank, e.g. EUR. An unknown code currently causes a 500. |
purchase_order[ |
string | No | Free-text notes. |
purchase_order[ |
string | No | Submission timestamp (any parseable date/time); defaults to now. Reset to now when a draft is later sent. |
purchase_order[ |
integer | No | Create only, company admins only: user id of an active employee to record as the requester (creator_id). No history comment is added. |
purchase_order[ |
array[integer] | No | User ids of the approvers to ask (manual routing). Required on submit in manual-routing companies unless the requester can self-approve. Duplicates are removed. The approvers' combined approval limit must cover the amount. Ignored when approval flows route the PO. |
purchase_order[ |
string | No | Affiliate link to store on the PO. |
purchase_order[ |
object | No | Legacy free-form key/value hash stored in custom_fields. Prefer custom_field_values_attributes. |
purchase_order[ |
integer | No | PO-level custom field id. |
purchase_order[ |
string | No | Value (for select fields one of the options; dates in the company date format). |
purchase_order[ |
integer | No | Existing value id (update). |
purchase_order[ |
string | Yes | Line description (required on submit). A description matching a product of the supplier links that product automatically. |
purchase_order[ |
number | Yes | Quantity (required on submit). |
purchase_order[ |
number | Yes | Unit price in the PO currency (required on submit). |
purchase_order[ |
integer | Yes | Budget the line is charged to (required on submit: Please select a budget). |
purchase_order[ |
integer | Yes | Tax rate (required on submit: Tax rate can't be blank), unless the advanced tax model is used. |
purchase_order[ |
number | No | Tax percentage (< 100). Derived from tax_rate_id when omitted. |
purchase_order[ |
number | No | Ignored: recomputed as quantity x unit_price. |
purchase_order[ |
string | No | SKU / item number. |
purchase_order[ |
integer | No | Catalogue product. |
purchase_order[ |
integer | No | Display order; assigned automatically when omitted. |
purchase_order[ |
integer | No | Line-level department. |
purchase_order[ |
integer | No | GL account; required on submit when QuickBooks Online is connected (account-based). |
purchase_order[ |
integer | No | QuickBooks customer (billable to). |
purchase_order[ |
integer | No | QuickBooks class. |
purchase_order[ |
string | No | Description sent to QuickBooks. |
purchase_order[ |
string | No | Xero line id. |
purchase_order[ |
string | No | QuickBooks line id. |
purchase_order[ |
string | No | Zapier line id. |
purchase_order[ |
boolean | No | Archive flag of the line. |
purchase_order[ |
integer | No | Line-level custom field id. |
purchase_order[ |
string | No | Line-level custom field value. |
purchase_order[ |
integer | No | Existing line value id (update). |
purchase_order[ |
integer | No | Allocation department (detailed allocations). |
purchase_order[ |
integer | No | Allocation budget. |
purchase_order[ |
string | No | Allocation GL code. |
purchase_order[ |
number | No | Share of the line in percent. |
purchase_order[ |
number | No | Share of the line as an amount. |
purchase_order[ |
string | No | Name of the allocation template used. |
purchase_order[ |
integer | No | Existing allocation id (update); _destroy: true removes it. |
purchase_order[ |
string | No | External system of a line id mapping (e.g. quickbooks). |
purchase_order[ |
string | No | Id of the line in that system. |
purchase_order[ |
string | No | Tenant/realm in that system. |
purchase_order[ |
string | No | External system of a PO id mapping. |
purchase_order[ |
string | No | Id of the PO in that system. |
purchase_order[ |
string | No | Tenant/realm in that system. |
purchase_order[ |
string | No | Comment added to the PO history, authored by the caller (blank comments are skipped). Author, company and system flags cannot be set. |
purchase_order[ |
file | No | File attached to that comment (multipart requests only). |
purchase_order[ |
file | No | File to attach to the PO (multipart only; for JSON clients use the Uploads endpoints). |
purchase_order[ |
integer | No | Existing upload; with _destroy: true removes it if the caller may remove attachments. Existing files cannot be replaced. |
purchase_order[ |
integer | No | Low-level approval request rows (id, approver_id, _destroy); prefer approver_list. |
purchase_order[ |
array[object] | No | Legacy allocation groups: id, allocation_target_id, group_id, maybe_value, _destroy; values in a group must add up to the PO total. |
purchase_order[ |
integer | No | Not accepted (ignored). The requester is the caller, or on_behalf_of. |
purchase_order[ |
date | No | Not accepted (ignored). |
Response
200 with the full purchase order object (same as Get a purchase order).
In the primary example Jane submits a PO for 4 monitors and 4 docks on the Engineering budget with
approver_list: [50000]; it is pending with one request for Priya Patel, approval_key is still
null. Validation failures return 400 {"error": ["<message>", ...], "status": 400} with every
message (each prefixed with the attribute name, e.g. Approvers ..., Purchase order items budget ...).
Side effects
new_po webhook; when submitted, approval request emails to the approvers (or a status email on
self-approval) and observer notifications; history comments for self-approval; budget totals
recalculated; a new supplier record when supplier_name does not match one.
V3 and V1 differences
Request and response are identical. One behavioural difference: the app_source request header is
stored as the PO source. V3 falls back to api for an unknown value; V1 assigns it unchecked, so
an unknown value (e.g. app_source: acme_erp, or web) makes V1 answer 500. Allowed values:
website, mobile_ios, mobile_android, mobile_other, mobile_android_offline,
mobile_ios_offline, push_cart, zapier, api, sage50, sage200, po_generator,
xero_marketplace, sage200_api.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Missing approvers | 400 |
V3, V1 | {"error":["Approvers Please select at least one or more approvers"],"status":400} |
| Approval limit | 400 |
V3, V1 | {"error":["Approvers The purchase order amount exceeds the approval limit of the approvers specified on the budget."],"status":400} |
| Unsupported currency | 400 |
V3, V1 | {"error":["Currency Currency value you selected does not exist anymore. Please change it if necessary."],"status":400} |
| On behalf of not admin | 400 |
V3, V1 | {"error":["You are not authorized to create purchase order on behalf of other users."],"status":400} |
| On behalf of invalid user | 400 |
V3, V1 | {"error":["Invalid user_id provided for on_behalf_of"],"status":400} |
| Missing root | 400 |
V3, V1 | {"error":"Required parameter are missing.","status":400} |
Update a purchase order
curl -X PUT "https://app.procurementexpress.com/api/v3/purchase_orders/54" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"commit": "Send",
"purchase_order": {
"notes": "Docking stations and a monitor for the design team, submitted for approval",
"approver_list": [
50000
],
"purchase_order_items_attributes": [
{
"id": 77,
"quantity": 3,
"custom_field_values_attributes": [
{
"id": 188,
"custom_field_id": 3,
"value": "AT-0502"
}
]
},
{
"product_id": 2,
"item_number": "MON-27",
"description": "27-inch 4K monitor",
"quantity": 1,
"unit_price": 329.99,
"budget_id": 1,
"tax_rate_id": 4,
"vat": 8.875
}
],
"custom_field_values_attributes": [
{
"id": 186,
"custom_field_id": 1,
"value": "PRJ-2026-031"
}
]
}
}'
curl -X PUT "https://app.procurementexpress.com/api/v1/purchase_orders/52" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"commit": "Send",
"purchase_order": {
"notes": "Docking stations and a monitor for the design team, submitted for approval",
"approver_list": [
50000
],
"purchase_order_items_attributes": [
{
"id": 73,
"quantity": 3,
"custom_field_values_attributes": [
{
"id": 179,
"custom_field_id": 3,
"value": "AT-0502"
}
]
},
{
"product_id": 2,
"item_number": "MON-27",
"description": "27-inch 4K monitor",
"quantity": 1,
"unit_price": 329.99,
"budget_id": 1,
"tax_rate_id": 4,
"vat": 8.875
}
],
"custom_field_values_attributes": [
{
"id": 177,
"custom_field_id": 1,
"value": "PRJ-2026-031"
}
]
}
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v3/purchase_orders/54",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
commit: "Send",
purchase_order: {
notes: "Docking stations and a monitor for the design team, submitted for approval",
approver_list: [50000],
purchase_order_items_attributes: [
{
id: 77,
quantity: 3,
custom_field_values_attributes: [
{
id: 188,
custom_field_id: 3,
value: "AT-0502"
}
]
},
{
product_id: 2,
item_number: "MON-27",
description: "27-inch 4K monitor",
quantity: 1,
unit_price: 329.99,
budget_id: 1,
tax_rate_id: 4,
vat: 8.875
}
],
custom_field_values_attributes: [
{
id: 186,
custom_field_id: 1,
value: "PRJ-2026-031"
}
]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v1/purchase_orders/52",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
commit: "Send",
purchase_order: {
notes: "Docking stations and a monitor for the design team, submitted for approval",
approver_list: [50000],
purchase_order_items_attributes: [
{
id: 73,
quantity: 3,
custom_field_values_attributes: [
{
id: 179,
custom_field_id: 3,
value: "AT-0502"
}
]
},
{
product_id: 2,
item_number: "MON-27",
description: "27-inch 4K monitor",
quantity: 1,
unit_price: 329.99,
budget_id: 1,
tax_rate_id: 4,
vat: 8.875
}
],
custom_field_values_attributes: [
{
id: 177,
custom_field_id: 1,
value: "PRJ-2026-031"
}
]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 54,
"company_id": 20000,
"approval_key": null,
"department_id": 2,
"department_name": "Engineering",
"supplier_id": 2,
"supplier_name": "Contoso Hardware",
"external_vendor_id": null,
"status": "pending",
"submitted_on": 1792577433,
"po_date": null,
"currency_id": 2000,
"currency_iso_code": "USD",
"creator_id": 40000,
"creator_name": "Jane Doe",
"creator_email": "jane.doe@example.com",
"creator_network_id": null,
"amount": "898.49",
"created_at": 1792577425,
"updated_at": 1792577433,
"notes": "Docking stations and a monitor for the design team, submitted for approval",
"total_net_amount": "898.49",
"total_gross_amount": "978.23",
"base_gross_amount": 978.23,
"tax_amount_overridden": false,
"tax_amount_override": null,
"base_tax_amount_override": null,
"effective_tax_amount": 79.74,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"base_shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"delivery_status": "not_delivered",
"payment_status": "unpaid",
"payment_progress": "unpaid",
"delivered_on": 0,
"custom_fields": {
},
"share_key": "C59632C683A16AC71BE4AE2EF6B779A07CF27471",
"archived": false,
"conversion_rate": 1.0,
"keywords": "54 Contoso Hardware Jane Doe pending 978.23 DOCK-USB-C Engineering Hardware FY26 USB-C docking station 618.954375 568.5 MON-27 Engineering Hardware FY26 27-inch 4K monitor 359.276613 329.99 PRJ-2026-031 CC-100 Engineering AT-0502 ",
"self_approved": false,
"xero_id": null,
"synced_with_xero": false,
"xero_export_status": null,
"xero_export_error_message": null,
"xero_last_export_at": null,
"xero_is_changed": false,
"can_cancel": true,
"can_archive": true,
"can_mark_as_paid": false,
"can_override": true,
"can_receive_item": true,
"can_cancel_receiving_items": false,
"can_edit": true,
"completely_delivered": false,
"aff_link": null,
"statuses": [
"Pending",
"Unpaid",
"Undelivered"
],
"budgets": [
{
"id": 1,
"company_id": 20000,
"name": "Engineering Hardware FY26",
"amount": 50000.0,
"cost_code": "ENG-HW",
"cost_type": "Capex",
"archived": false,
"currency_id": 2000,
"base_amount": 50000.0,
"base_rate": "1.0",
"allow_anyone_to_approve_a_po": false,
"start_date": 1767225600,
"end_date": 1798675200,
"summary": "Engineering Hardware FY26 : ENG-HW : Capex",
"remaining_amount": 17586.81,
"creator_id": 40000,
"qbo_class": null,
"department_ids": [
2
],
"approver_ids": [
40000,
50000
],
"created_at": "2026-10-21T04:48:43.335-05:00",
"updated_at": "2026-10-21T05:10:33.664-05:00",
"approved_this_month": 14863.5
}
],
"tax_rates": [
{
"id": 4,
"name": "Sales Tax",
"value": 8.875,
"tax_amount": 79.74
}
],
"can_complete_delivery": false,
"can_copy": true,
"can_justify": true,
"approvers_with_flow": [
],
"latest_compliance_check": null,
"slug": null,
"has_global_policies": true,
"purchase_order_items": [
{
"id": 77,
"description": "USB-C docking station",
"purchase_order_id": 54,
"budget_id": 1,
"budget_summary": "Engineering Hardware FY26 : ENG-HW : Capex",
"gross_amount": 618.954375,
"vat": 8.875,
"tax_rate": {
"id": 4,
"archived": false,
"company_id": 20000,
"created_at": "2026-10-21T04:48:43.422-05:00",
"name": "Sales Tax",
"tax_type": "single",
"updated_at": "2026-10-21T04:48:43.422-05:00",
"value": 8.875
},
"net_amount": 568.5,
"status": "pending",
"quantity": 3.0,
"unit_price": 189.5,
"item_number": "DOCK-USB-C",
"base_net_amount": 568.5,
"base_gross_amount": 618.95,
"gross_usd_amount": 618.954375,
"product_id": 3,
"received_quantity": 0.0,
"custom_field_values": [
{
"id": 188,
"purchase_order_id": null,
"value": "AT-0502",
"custom_field_id": 3,
"purchase_order_item_id": 77,
"date_format": null,
"budget_id": null
}
],
"sequence_no": 1,
"chart_of_account": {
"id": null,
"name": null
},
"qbo_customer": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
},
"qbo_line_description": null
},
{
"id": 78,
"description": "27-inch 4K monitor",
"purchase_order_id": 54,
"budget_id": 1,
"budget_summary": "Engineering Hardware FY26 : ENG-HW : Capex",
"gross_amount": 359.276613,
"vat": 8.875,
"tax_rate": {
"id": 4,
"archived": false,
"company_id": 20000,
"created_at": "2026-10-21T04:48:43.422-05:00",
"name": "Sales Tax",
"tax_type": "single",
"updated_at": "2026-10-21T04:48:43.422-05:00",
"value": 8.875
},
"net_amount": 329.99,
"status": "pending",
"quantity": 1.0,
"unit_price": 329.99,
"item_number": "MON-27",
"base_net_amount": 329.99,
"base_gross_amount": 359.28,
"gross_usd_amount": 359.276613,
"product_id": 2,
"received_quantity": 0.0,
"custom_field_values": [
{
"id": 189,
"purchase_order_id": null,
"value": null,
"custom_field_id": 3,
"purchase_order_item_id": 78,
"date_format": null,
"budget_id": null
}
],
"sequence_no": 2,
"chart_of_account": {
"id": null,
"name": null
},
"qbo_customer": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
},
"qbo_line_description": null
}
],
"purchase_order_comments": [
],
"custom_field_values": [
{
"id": 186,
"purchase_order_id": 54,
"value": "PRJ-2026-031",
"custom_field_id": 1,
"purchase_order_item_id": null,
"date_format": null,
"budget_id": null
},
{
"id": 187,
"purchase_order_id": 54,
"value": "CC-100 Engineering",
"custom_field_id": 2,
"purchase_order_item_id": null,
"date_format": null,
"budget_id": null
}
],
"payments": [
],
"purchase_order_item_payments": [
],
"uploads": [
],
"invoices": [
],
"compliance_checks": [
],
"approver_requests": [
{
"id": 55,
"status": "pending",
"approver_name": "Priya Patel",
"approver_id": 50000,
"delegate_id": null
}
],
"supplier": {
"id": 2,
"name": "Contoso Hardware",
"company_id": 20000,
"notes": null,
"phone_number": "+1 555 0300",
"address": "500 Tech Park, Austin, TX 78701",
"email": "sales@contoso.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Diego Ramos",
"tax_number": "74-7654321",
"payment_terms": "Net 45",
"created_at": "2026-10-21T04:48:43.563-05:00",
"updated_at": "2026-10-21T04:48:43.563-05:00",
"currency_id": 2000,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
}
}
V3 V1
Edits a purchase order and optionally submits a draft for approval.
Accepts the same purchase_order[...] parameters as Create (except the
create-only iso_code and on_behalf_of). Only the attributes you send change. For line items and
custom field values, pass id to change an existing row, omit id to add one, and send
"_destroy": true to delete a line; rows you do not mention are kept.
commit: "Send" (or the localized "Send" label) on a draft submits it: status becomes
pending (or approved when self-approval applies) and the PO is routed exactly as on create. The
example submits a draft, raising a line from 1 to 3 units, adding a monitor line, changing a PO and a
line custom field, and naming Priya as approver.
Editing a non-draft PO keeps its status. For POs that are not approved or paid, approvers are
re-evaluated; in manual-routing companies you must resend approver_list on every edit of a pending
PO. When the gross total of a submitted PO changes, an "Edited from X to Y" history comment is added.
A rejected PO stays rejected even when updated with commit: "Send".
HTTP request
| Version | Request |
|---|---|
| V3 | `PUT |
| V1 | `PUT |
Permissions
Finance users may edit any PO of the company (any status); the creator may edit only their own
drafts; anyone else gets 401 Unauthorized access (with granular role permissions, edit_po is
also required). Approvers cannot edit through the API.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
string | Numeric id, PO number (URL-encoded) or slug. |
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
commit |
string | No | Send submits a draft for approval. Ignored for non-drafts other than re-running approver assignment. |
purchase_order[ |
object | Yes | Any create parameter except iso_code and on_behalf_of (see Create for the full list). |
purchase_order[ |
integer | No | Existing line to change. |
purchase_order[ |
boolean | No | true deletes that line. |
purchase_order[ |
integer | No | Existing custom field value to change. |
purchase_order[ |
array[integer] | No | Approvers to (re)assign; required in manual-routing companies when sending or editing a pending PO. |
Response
200 with the full purchase order object. Validation errors: 400 {"error": [...]} (the example
adds a line without a budget to a pending PO without resending approver_list, producing both
messages). Unknown id: 404.
Side effects
po_update webhook (or the status webhook when the status changes); approval request emails when
a draft is sent or approvers change; "Edited from ... to ..." comment on total changes; removed
uploads are recorded in the history.
V3 and V1 differences
None - identical request and response. PATCH is routed to the same action as PUT in both versions.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Validation error | 400 |
V3, V1 | {"error":["Approvers Please select at least one or more approvers","Purchase order items budget Please select a budget"],"status":400} |
| Unauthorized | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
Create purchase orders in bulk
curl -X POST "https://app.procurementexpress.com/api/v3/purchase_orders/bulk_save" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"purchase_order": {
"data": [
{
"_id": "erp-req-1001",
"commit": "Send",
"supplier_id": 2,
"department_id": 2,
"currency_id": 2000,
"notes": "Docking stations for the support desk",
"approver_list": [
50000
],
"purchase_order_items_attributes": [
{
"product_id": 3,
"item_number": "DOCK-USB-C",
"description": "USB-C docking station",
"quantity": 2,
"unit_price": 189.5,
"budget_id": 1,
"tax_rate_id": 4
}
],
"custom_field_values_attributes": [
{
"custom_field_id": 1,
"value": "PRJ-2026-051"
}
]
},
{
"_id": "erp-req-1002",
"commit": "Draft",
"supplier_id": 1,
"department_id": 3,
"currency_id": 2000,
"notes": "Whiteboard markers, waiting on a quote",
"purchase_order_items_attributes": [
{
"description": "Whiteboard markers, pack of 12",
"quantity": 4,
"unit_price": 14.25,
"budget_id": 2,
"tax_rate_id": 4
}
]
},
{
"_id": "erp-req-1003",
"commit": "Send",
"supplier_id": 1,
"department_id": 3,
"currency_id": 2000,
"notes": "Row with no line items",
"approver_list": [
50000
]
}
]
}
}'
curl -X POST "https://app.procurementexpress.com/api/v1/purchase_orders/bulk_save" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"purchase_order": {
"data": [
{
"_id": "erp-req-1001",
"commit": "Send",
"supplier_id": 2,
"department_id": 2,
"currency_id": 2000,
"notes": "Docking stations for the support desk",
"approver_list": [
50000
],
"purchase_order_items_attributes": [
{
"product_id": 3,
"item_number": "DOCK-USB-C",
"description": "USB-C docking station",
"quantity": 2,
"unit_price": 189.5,
"budget_id": 1,
"tax_rate_id": 4
}
],
"custom_field_values_attributes": [
{
"custom_field_id": 1,
"value": "PRJ-2026-051"
}
]
},
{
"_id": "erp-req-1002",
"commit": "Draft",
"supplier_id": 1,
"department_id": 3,
"currency_id": 2000,
"notes": "Whiteboard markers, waiting on a quote",
"purchase_order_items_attributes": [
{
"description": "Whiteboard markers, pack of 12",
"quantity": 4,
"unit_price": 14.25,
"budget_id": 2,
"tax_rate_id": 4
}
]
},
{
"_id": "erp-req-1003",
"commit": "Send",
"supplier_id": 1,
"department_id": 3,
"currency_id": 2000,
"notes": "Row with no line items",
"approver_list": [
50000
]
}
]
}
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/purchase_orders/bulk_save",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
purchase_order: {
data: [
{
_id: "erp-req-1001",
commit: "Send",
supplier_id: 2,
department_id: 2,
currency_id: 2000,
notes: "Docking stations for the support desk",
approver_list: [50000],
purchase_order_items_attributes: [
{
product_id: 3,
item_number: "DOCK-USB-C",
description: "USB-C docking station",
quantity: 2,
unit_price: 189.5,
budget_id: 1,
tax_rate_id: 4
}
],
custom_field_values_attributes: [
{
custom_field_id: 1,
value: "PRJ-2026-051"
}
]
},
{
_id: "erp-req-1002",
commit: "Draft",
supplier_id: 1,
department_id: 3,
currency_id: 2000,
notes: "Whiteboard markers, waiting on a quote",
purchase_order_items_attributes: [
{
description: "Whiteboard markers, pack of 12",
quantity: 4,
unit_price: 14.25,
budget_id: 2,
tax_rate_id: 4
}
]
},
{
_id: "erp-req-1003",
commit: "Send",
supplier_id: 1,
department_id: 3,
currency_id: 2000,
notes: "Row with no line items",
approver_list: [50000]
}
]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/purchase_orders/bulk_save",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
purchase_order: {
data: [
{
_id: "erp-req-1001",
commit: "Send",
supplier_id: 2,
department_id: 2,
currency_id: 2000,
notes: "Docking stations for the support desk",
approver_list: [50000],
purchase_order_items_attributes: [
{
product_id: 3,
item_number: "DOCK-USB-C",
description: "USB-C docking station",
quantity: 2,
unit_price: 189.5,
budget_id: 1,
tax_rate_id: 4
}
],
custom_field_values_attributes: [
{
custom_field_id: 1,
value: "PRJ-2026-051"
}
]
},
{
_id: "erp-req-1002",
commit: "Draft",
supplier_id: 1,
department_id: 3,
currency_id: 2000,
notes: "Whiteboard markers, waiting on a quote",
purchase_order_items_attributes: [
{
description: "Whiteboard markers, pack of 12",
quantity: 4,
unit_price: 14.25,
budget_id: 2,
tax_rate_id: 4
}
]
},
{
_id: "erp-req-1003",
commit: "Send",
supplier_id: 1,
department_id: 3,
currency_id: 2000,
notes: "Row with no line items",
approver_list: [50000]
}
]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"done": [
{
"_id": "erp-req-1001",
"id": 69
},
{
"_id": "erp-req-1002",
"id": 70
}
],
"failed": [
{
"_id": "erp-req-1003",
"errors": {
"items": [
": please add at least one item"
]
}
}
]
}
V3 V1
Creates several purchase orders in one request and reports per-row success or failure.
Each element of purchase_order[data][] is a create payload (the same attributes as
purchase_order[...] in Create, without the purchase_order wrapper)
plus two bulk-only keys: _id, a client reference echoed back, and commit (send, any case,
submits that row; anything else saves a draft). Rows are processed independently and in order; a
failing row does not stop the others and nothing is rolled back.
Differences from single create: the PO source is the app_source header when it is a valid source,
else api; required custom fields are not enforced on sent rows; missing custom field values are not
back-filled; on_behalf_of is accepted but has no effect; archived suppliers, products, custom
fields, budgets, tax rates and departments are rejected (mobile sync checks).
HTTP request
| Version | Request |
|---|---|
| V3 | POST /api/v3/purchase_orders/bulk_save |
| V1 | POST /api/v1/purchase_orders/bulk_save |
Permissions
Any active member of the company. No role or permission check is applied (not even granular create_po).
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
purchase_order[ |
array[object] | Yes | Rows to create. |
purchase_order[ |
string | No | Client reference echoed in done[]._id / failed[]._id. |
purchase_order[ |
string | No | send (case-insensitive) submits the row; otherwise draft. |
purchase_order[ |
object | No | Any create attribute: supplier_id, supplier_name, new_supplier_name, department_id, currency_id, notes, submitted_on, approver_list, purchase_order_items_attributes, custom_field_values_attributes, third_party_id_mappings_attributes, purchase_order_comments_attributes, uploads_attributes, requests_attributes, allocations_attributes. |
Response
200 with {"done": [{"_id": ..., "id": <new PO id>}], "failed": [{"_id": ..., "errors": {...}}]}.
The status is 200 even when every row fails. errors is an object keyed by attribute with arrays
of messages (e.g. {"items": [": please add at least one item"]}), not a list of full messages. In
the example row 1 is submitted to Priya, row 2 saved as a draft, row 3 fails.
| Field | Type | Description |
|---|---|---|
done |
array[object] | Created rows: _id (your reference) and id (new PO id). |
failed |
array[object] | Rejected rows: _id and errors (attribute => messages). |
Side effects
Same as create for each saved row (webhooks, approval emails, self-approval comment).
V3 and V1 differences
None - identical request and response.
Approve a purchase order
curl -X POST "https://app.procurementexpress.com/api/v3/purchase_orders/33/approve" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"token": "<token>"
}'
curl -X POST "https://app.procurementexpress.com/api/v1/purchase_orders/32/approve" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"token": "<token>"
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/purchase_orders/33/approve",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
token: "<token>"
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/purchase_orders/32/approve",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
token: "<token>"
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"status": true
}
Example: deprecated get (V1,
200 OK):
{
"status": true
}
V3 V1
Records the caller's approval of a pending purchase order using their accept token.
Approves the caller's approval request on this PO. Send the request's accept_token (from
approver_requests[], visible only to that approver or their delegate) as token in the body.
When this was the last outstanding approval the PO becomes approved and gets its PO number;
otherwise the next approver in the chain is activated and notified. An "Approved by
Checks, in order:
1. The token must be the accept token of a request on this PO whose approver or delegate is
the caller; otherwise 400 "Token you provided is not valid". When the request is delegated,
only the delegate may use it.
2. Request already approved: 400 "You had already approved this purchase order".
3. PO cancelled: 400 "Purchase order was already cancelled".
4. PO rejected: 400 "Purchase order was already rejected".
The app_source header names the channel recorded in the history (api by default). Allowed:
api, website, web, push_cart, zapier, sage50, sage200, mobile_ios,
mobile_android, mobile_other, mobile_android_offline, mobile_ios_offline. email and
unknown values are refused with 400 "Unauthorized access".
Send the token in the request body of a POST or PUT, never in the URL. V1 still accepts GET with
?token= as a deprecated path for an older mobile app: the response carries a
Deprecation: true header and the path is switched off when the server sets
API_V1_GET_DECISIONS=off (then 405 with Allow: POST, PUT). HEAD is always refused with 405.
V3 does not route GET at all (404 "Invalid URL").
HTTP request
| Version | Request |
|---|---|
| V3 | `POST |
| V1 | `POST |
Permissions
The approver of the request (or the delegate covering for them), identified by the token. No role check beyond that.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
string | Numeric id, PO number (URL-encoded) or slug. |
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
token |
string | Yes | The accept_token of the caller's request on this PO. |
Response
200 {"status": true}. Fetch the PO again to see the new status. Errors are
400 {"error": "<message>", "status": 400} (see the checks above).
Side effects
History comment; when the PO becomes approved: "Purchase order fully approved" event, PO number,
po_approved webhook, status email to the requester, QuickBooks sync; otherwise the next approver is
emailed.
V3 and V1 differences
Same request and response for POST and PUT. V1 also accepts GET (deprecated, Deprecation: true
response header, retired by API_V1_GET_DECISIONS=off); V3 answers GET with 404 Invalid URL.
HEAD on V1 returns 405 with Allow: POST, PUT.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Invalid token | 400 |
V3, V1 | {"error":"Token you provided is not valid","status":400} |
| Not your request | 400 |
V3, V1 | {"error":"Token you provided is not valid","status":400} |
| Already approved | 400 |
V3, V1 | {"error":"You had already approved this purchase order","status":400} |
| Rejected po | 400 |
V3, V1 | {"error":"Purchase order was already rejected","status":400} |
| Cancelled po | 400 |
V3, V1 | {"error":"Purchase order was already cancelled","status":400} |
| App source refused | 400 |
V3, V1 | {"error":"Unauthorized access","status":400} |
| Get not allowed | 404 |
V3 | {"error":"Invalid URL","status":404} |
Reject a purchase order
curl -X POST "https://app.procurementexpress.com/api/v3/purchase_orders/36/reject" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"token": "<token>"
}'
curl -X POST "https://app.procurementexpress.com/api/v1/purchase_orders/35/reject" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"token": "<token>"
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/purchase_orders/36/reject",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
token: "<token>"
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/purchase_orders/35/reject",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
token: "<token>"
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"status": true
}
V3 V1
Rejects a purchase order on behalf of the caller using their reject token.
Rejects the caller's approval request on this PO with the request's reject_token. One rejection
rejects the whole PO (status: "rejected"). A rejected PO cannot be resubmitted: raise a new one.
The API takes no rejection reason: only token is read, any other body field is ignored, and
the history comment is "Purchase order rejected (
Checks: token must be this PO's reject token for the caller (or their delegate) -
400 "Token you provided is not valid"; request already rejected - 400 "You had already rejected
this purchase order"; PO cancelled - 400 "Purchase order was already cancelled"; PO already
rejected by someone else - 400 "Purchase order was already rejected". The app_source header rules
and the GET/HEAD rules are the same as for Approve.
HTTP request
| Version | Request |
|---|---|
| V3 | `POST |
| V1 | `POST |
Permissions
The approver of the request (or their delegate), identified by the token.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
string | Numeric id, PO number (URL-encoded) or slug. |
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
token |
string | Yes | The reject_token of the caller's request on this PO. |
Response
200 {"status": true}. Errors: 400 {"error": "<message>", "status": 400}.
Side effects
Request and PO marked rejected, status email to the requester and a rejection email to approvers who had already approved. No webhook fires for rejections.
V3 and V1 differences
Same for POST and PUT. V1 also accepts deprecated GET (Deprecation: true header); V3 answers GET
with 404 Invalid URL.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Invalid token | 400 |
V3, V1 | {"error":"Token you provided is not valid","status":400} |
| Already rejected | 400 |
V3, V1 | {"error":"You had already rejected this purchase order","status":400} |
| Cancelled po | 400 |
V3, V1 | {"error":"Purchase order was already cancelled","status":400} |
Override and approve a purchase order
curl -X POST "https://app.procurementexpress.com/api/v3/purchase_orders/38/override_and_approve" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl -X POST "https://app.procurementexpress.com/api/v1/purchase_orders/37/override_and_approve" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/purchase_orders/38/override_and_approve",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/purchase_orders/37/override_and_approve",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"status": true
}
V3 V1
Lets a finance user approve a purchase order immediately, skipping the remaining approvers.
Approval override: the PO becomes approved at once without any approver token. The caller is added
as an approved request flagged approval_override, every other pending request is cancelled, attached
approval flows are marked completed, and the history reads "Purchase Order Approved using Approval
Override ...". No body is needed.
The only refusal besides permissions is a budget overrun: when the PO would overrun a budget (and the
company does not allow overruns) the override is refused with 400 "We could not approve this purchase
order. Please contactsupport@procurementexpress.com." and a history comment explains why.
Use it on pending POs only (the can_override flag in the PO detail is true only for pending POs).
The endpoint itself does not check the status (see findings).
HTTP request
| Version | Request |
|---|---|
| V3 | `POST |
| V1 | `POST |
Permissions
Finance users of the PO's company, and only when the company setting enable approval override is on.
Anyone else gets 401 Unauthorized access.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
string | Numeric id, PO number (URL-encoded) or slug. |
Response
200 {"status": true}.
Side effects
Approval email to the requester, po_approved webhook, PO number, QuickBooks sync.
V3 and V1 differences
Same for POST and PUT. V1 also accepts deprecated GET (Deprecation: true); V3 answers GET with 404.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Unauthorized | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
Cancel a purchase order
curl -X POST "https://app.procurementexpress.com/api/v3/purchase_orders/30/cancel" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl -X POST "https://app.procurementexpress.com/api/v1/purchase_orders/29/cancel" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/purchase_orders/30/cancel",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/purchase_orders/29/cancel",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"status": true
}
Example: already cancelled (V3,
200 OK):
{
"status": true
}
V3 V1
Cancels a purchase order and its open approval requests.
Sets status to cancelled (skipping budget and approver validation), cancels the approval requests
and in-progress approval flows, releases the PO's amount from its budgets, marks its line-item payment
allocations deleted, and adds a "
Allowed from any status except paid, and not when an invoice linked to the PO is already settled.
The call is not idempotent-safe: cancelling an already-cancelled PO answers 200 again and adds
another history comment. Check can_cancel in the PO detail first.
HTTP request
| Version | Request |
|---|---|
| V3 | POST /api/v3/purchase_orders/:id/cancel |
| V1 | POST /api/v1/purchase_orders/:id/cancel |
Permissions
Company admins (any of their active companies), approvers and finance users of the company, or the
PO's creator. With granular role permissions, cancel_po_with_invoices or
cancel_po_without_invoices is required depending on whether invoices are linked. Paid POs, POs with
settled invoices, and other callers get 401 Unauthorized access.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
string | Numeric id, PO number (URL-encoded) or slug. |
Response
200 {"status": true}. If the update fails validation the API answers
422 {"error": {<attribute>: [...]}, "status": "unprocessable_entity"} (status as a string).
Side effects
po_cancelled webhook, cancellation notifications (and supplier email when configured), QuickBooks sync.
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Unauthorized | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
| Paid | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
Archive a purchase order
curl -X POST "https://app.procurementexpress.com/api/v3/purchase_orders/44/archive" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl -X POST "https://app.procurementexpress.com/api/v1/purchase_orders/43/archive" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/purchase_orders/44/archive",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/purchase_orders/43/archive",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"archived": true
}
Example: unarchive (V3,
200 OK):
{
"archived": false
}
V3 V1
Toggles the archived flag of a purchase order.
Toggles archived: an active PO is archived, an archived PO is restored. The response tells you
the new value. Archived POs disappear from the default list (use archived=true to list them) but
keep their status; archiving also marks in-progress approval flows as cancelled. A history comment
("archived by" / "de-archived by") is added. Archiving does not push changes to QuickBooks.
HTTP request
| Version | Request |
|---|---|
| V3 | POST /api/v3/purchase_orders/:id/archive |
| V1 | POST /api/v1/purchase_orders/:id/archive |
Permissions
Finance users of the PO's company (and sysadmins). Anyone else gets 403
{"error": "Unauthorized access", "status": 403} - deliberately not 401, so mobile apps do not
treat it as a signed-out session.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
string | Numeric id, PO number (URL-encoded) or slug. |
Response
200 {"archived": true} after archiving, 200 {"archived": false} after restoring.
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Forbidden | 403 |
V3, V1 | {"error":"Unauthorized access","status":403} |
Delete a draft purchase order
curl -X DELETE "https://app.procurementexpress.com/api/v3/purchase_orders/46/delete" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl -X DELETE "https://app.procurementexpress.com/api/v1/purchase_orders/45/delete" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :delete,
url: "https://app.procurementexpress.com/api/v3/purchase_orders/46/delete",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :delete,
url: "https://app.procurementexpress.com/api/v1/purchase_orders/45/delete",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"status": true
}
V3 V1
Soft-deletes a draft purchase order.
Deletes a draft. The record is soft-deleted together with its line items, comments, uploads,
custom field values, allocations, payments and requests, and budget totals are recalculated. It no
longer appears anywhere and its id answers 404. Submitted POs cannot be deleted - cancel them instead.
Note the path: DELETE /purchase_orders/:id/delete (there is no DELETE /purchase_orders/:id).
HTTP request
| Version | Request |
|---|---|
| V3 | DELETE /api/v3/purchase_orders/:id/delete |
| V1 | DELETE /api/v1/purchase_orders/:id/delete |
Permissions
Only for drafts, by their creator, or by a company admin when the company opens drafts to admins.
Non-drafts and other callers get 401 Unauthorized access.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
string | Numeric id or slug of the draft. |
Response
200 {"status": true}. A deleted or unknown id: 404 {"error": "Invalid id passed"}.
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not draft | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
| Not creator | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
| Not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
Count my pending approvals
curl "https://app.procurementexpress.com/api/v3/purchase_orders/pending_request_count" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/purchase_orders/pending_request_count" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/purchase_orders/pending_request_count",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/purchase_orders/pending_request_count",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"total_pending_request": 16
}
V3 V1
Returns how many purchase orders are waiting for the caller's approval in this company.
Counts the caller's approval requests (as approver, or as the delegate covering for an approver)
that are pending, currently active, on non-archived pending POs of the company in
app_company_id. It normally equals meta.total_count of GET /purchase_orders?requests=true&bell=true.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/purchase_orders/pending_request_count |
| V1 | GET /api/v1/purchase_orders/pending_request_count |
Permissions
Any active member of the company.
Response
200 {"total_pending_request": <integer>}. When the caller has no pending approvals the value is
null, not 0 (see the second example, Sam Smith, a team member).
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| None | 200 |
V3, V1 | {"total_pending_request":null} |
Preview budget approvers
curl "https://app.procurementexpress.com/api/v3/purchase_orders/auto_approvers_list?budget_ids%5B%5D=1&gross_total=2262.38" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/purchase_orders/auto_approvers_list?budget_ids%5B%5D=1&gross_total=2262.38" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/purchase_orders/auto_approvers_list",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
params: {
"budget_ids[]": "1",
gross_total: "2262.38"
}
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/purchase_orders/auto_approvers_list",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
params: {
"budget_ids[]": "1",
gross_total: "2262.38"
}
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
[
{
"id": 50000,
"first_name": "Priya",
"last_name": "Patel",
"name": "Priya Patel",
"email": "priya.patel@example.com",
"approver_name": "Priya Patel"
}
]
Example: above limit (V3,
200 OK):
[
{
"id": 40000,
"first_name": "Jane",
"last_name": "Doe",
"name": "Jane Doe",
"email": "jane.doe@example.com",
"approver_name": "Jane Doe"
}
]
V3 V1
Returns the budget approvers a purchase order with the given budgets and amount would be routed to.
Runs the budget-approver routing (see How approval routing works, step 2) for a hypothetical PO
raised by the caller with the given budgets and amount, and returns the approvers in routing order.
It does not consider approval flows or approver_list; use
Preview approval-flow approvers for flows.
gross_total is compared with approval limits as is, so pass the amount in the company base currency
(gross or net per the company's gross/net setting). Unknown or other-company budget ids are ignored;
no budgets returns []. The requester is excluded from their own chain unless they are the only or
last approver (the example above the limit, as Sam, returns Jane because only her limit covers 75,000).
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/purchase_orders/auto_approvers_list |
| V1 | GET /api/v1/purchase_orders/auto_approvers_list |
Permissions
Any active team member, approver, finance user or company admin of the company; otherwise
401 Unauthorized access.
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
budget_ids[ |
array[integer] | No | Budgets of the PO's lines. |
gross_total |
number | No | PO amount in the company base currency. Default: 0. |
show_last_approver_on_edit |
boolean | No | true returns only the last approver of the chain. |
Response
200 with an array of approvers: id, first_name, last_name, name, email, approver_name
(display name, including "on behalf of" when a delegate stands in).
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| No budgets | 200 |
V3, V1 | [] |
Preview approval-flow approvers
curl -X POST "https://app.procurementexpress.com/api/v3/purchase_orders/approver_list" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"test_mode": true,
"approval_flow_id": 1,
"purchase_order": {
"supplier_id": 2,
"department_id": 5,
"currency_id": 2000,
"purchase_order_items_attributes": [
{
"product_id": 3,
"description": "USB-C docking station",
"quantity": 2,
"unit_price": 189.5,
"budget_id": 4,
"tax_rate_id": 4,
"vat": 8.875
}
]
}
}'
curl -X POST "https://app.procurementexpress.com/api/v1/purchase_orders/approver_list" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"test_mode": true,
"approval_flow_id": 1,
"purchase_order": {
"supplier_id": 2,
"department_id": 5,
"currency_id": 2000,
"purchase_order_items_attributes": [
{
"product_id": 3,
"description": "USB-C docking station",
"quantity": 2,
"unit_price": 189.5,
"budget_id": 4,
"tax_rate_id": 4,
"vat": 8.875
}
]
}
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/purchase_orders/approver_list",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
test_mode: true,
approval_flow_id: 1,
purchase_order: {
supplier_id: 2,
department_id: 5,
currency_id: 2000,
purchase_order_items_attributes: [
{
product_id: 3,
description: "USB-C docking station",
quantity: 2,
unit_price: 189.5,
budget_id: 4,
tax_rate_id: 4,
vat: 8.875
}
]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/purchase_orders/approver_list",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
test_mode: true,
approval_flow_id: 1,
purchase_order: {
supplier_id: 2,
department_id: 5,
currency_id: 2000,
purchase_order_items_attributes: [
{
product_id: 3,
description: "USB-C docking station",
quantity: 2,
unit_price: 189.5,
budget_id: 4,
tax_rate_id: 4,
vat: 8.875
}
]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
[
{
"approval_flow_name": "Lab PO routing v1 - updated",
"approval_flow_id": 1,
"approvers": [
{
"name": "Priya Patel",
"email": "priya.patel@example.com",
"id": 50000
}
]
}
]
Example: no matching flow (V3,
200 OK):
[
]
V3 V1
Returns, grouped by approval flow, the approvers the approval flows would assign to a purchase order.
Builds an unsaved PO from purchase_order[...] (same attributes as create), computes its totals and
base-currency amounts, and evaluates the company's published purchase-order approval flows against
it (department, budget, supplier, requester, amounts, custom fields). Returns the approvers of the
matching flows' applicable steps, grouped by flow, in step order. The caller (as requester) is left out
unless a matching flow allows self-approval. When no published flow matches, the answer is [] - which
is also the answer in companies without approval flows; use
Preview budget approvers there.
test_mode with approval_flow_id additionally evaluates that one unpublished flow, so you can try a
flow before publishing it (the primary example previews the unpublished "Lab PO routing" flow; without
test mode the same PO matches nothing).
HTTP request
| Version | Request |
|---|---|
| V3 | POST /api/v3/purchase_orders/approver_list |
| V1 | POST /api/v1/purchase_orders/approver_list |
Permissions
Any active team member, approver, finance user or company admin of the company; otherwise 401.
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
purchase_order |
object | Yes | PO attributes as in Create (supplier_id, department_id, currency_id, purchase_order_items_attributes, custom_field_values_attributes, ...). Missing: 400 Required parameter are missing. |
purchase_order[ |
integer | No | Id of the PO being edited, if any. |
purchase_order[ |
number | No | Gross total to use when the lines give zero. |
purchase_order[ |
number | No | Net total to use when the lines give zero. |
test_mode |
boolean | No | Top-level. Together with approval_flow_id, also evaluate that unpublished flow. |
approval_flow_id |
integer | No | Top-level. Flow to include in test mode. |
Response
200 with an array of {"approval_flow_name", "approval_flow_id", "approvers": [{"name", "email", "id"}]},
or [].
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Missing root | 400 |
V3, V1 | {"error":"Required parameter are missing.","status":400} |
Purchase Orders: Attachments
Files (delivery notes, quotes, photos, signed documents...) are attached to an existing purchase order or to an existing purchase order comment. There is no "upload first, reference the file later" step for these endpoints: you always name the parent record in the same multipart request.
| Goal | Call |
|---|---|
| Attach a file to a purchase order | POST /uploads/po with po_id |
| Post a comment with a file | POST /purchase_orders/:id/comments (text), then POST /uploads/poc with the returned comment id as poc_id |
| Check whether an upload already went through (retry after a timeout) | GET /uploads/status?upload_token=... |
| Attach files while creating/updating a PO | multipart purchase_order[uploads_attributes][][file] on the purchase order create/update endpoints (see Purchase Orders) |
Upload tokens. Every upload carries a client-generated uploads_attributes[upload_token]
(at least 7 characters). It is a de-duplication key, not a credential: it must be unique
among your own uploads, so re-sending the same token returns 400 with
"Upload token already synced" instead of storing the file twice. If a request times out,
call GET /uploads/status with the token to find out whether the file was stored. A UUID
works well.
Files. Maximum size 30 MB. Accepted content types (taken from the multipart part's
Content-Type): PDF, JPEG, PNG, GIF, WebP, HEIC/HEIF, TIFF, DWG/DXF, Word, Excel, PowerPoint,
OpenDocument text, RTF, CSV, plain text, XML, ZIP, Outlook .msg, .eml (message/rfc822),
PostScript and application/octet-stream. A part declared as application/pdf must really
contain PDF data ("File does not contain recognizable PDF data" otherwise). Files are stored
privately; the url returned is a pre-signed download link that expires after 5 minutes
and always downloads (Content-Disposition: attachment, Content-Type: application/octet-stream).
Fetch the purchase order again (or GET /uploads/status) for a fresh link.
Where uploads show up. Files attached to the PO are listed in
uploads[] of Get purchase order details; comment files are
in purchase_order_comments[].uploads[]. Both kinds of upload id can be passed in
uploads when sending the PO to a supplier.
Who may attach. The same rule as for comments: the PO creator, users mentioned on the
PO, company admins / approvers / finance users of the PO's company (for non-draft POs),
company admins on drafts when Open draft access is enabled, and department members when
Share PO access by department is enabled. Anyone else gets 401 Unauthorized access.
Endpoints in this section
| Endpoint | Method | Path | V3 | V1 |
|---|---|---|---|---|
| Upload a file to a purchase order | POST |
/uploads/po |
✓ | ✓ |
| Upload a file to a PO comment | POST |
/uploads/poc |
✓ | ✓ |
| Get upload status | GET |
/uploads/status |
✓ | ✓ |
The upload object
| Field | Type | Description |
|---|---|---|
id |
integer | Upload id. Pass it in uploads of the forward endpoint to email the file to a supplier. |
file_file_name |
string | Original file name. |
file_content_type |
string | Content type declared for the file part (for example application/pdf). |
url |
string | Pre-signed download URL, valid for 5 minutes, forced download as application/octet-stream. null when the caller may not read the file in the current company. |
upload_token |
string | The client-generated token sent with the upload. |
Upload a file to a purchase order
curl -X POST "https://app.procurementexpress.com/api/v3/uploads/po" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-F "po_id=12" \
-F "uploads_attributes[upload_token]=pofulfil-v3-dn-0001" \
-F "uploads_attributes[file]=@delivery-note.pdf"
curl -X POST "https://app.procurementexpress.com/api/v1/uploads/po" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-F "po_id=11" \
-F "uploads_attributes[upload_token]=pofulfil-v1-dn-0001" \
-F "uploads_attributes[file]=@delivery-note.pdf"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/uploads/po",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
},
payload: {
multipart: true,
po_id: "12",
"uploads_attributes[upload_token]": "pofulfil-v3-dn-0001",
"uploads_attributes[file]": File.new("delivery-note.pdf", "rb")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/uploads/po",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
},
payload: {
multipart: true,
po_id: "11",
"uploads_attributes[upload_token]": "pofulfil-v1-dn-0001",
"uploads_attributes[file]": File.new("delivery-note.pdf", "rb")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 5,
"file_file_name": "delivery-note.pdf",
"file_content_type": "application/pdf",
"url": "https://pex-files.s3.amazonaws.com/uploads/files/000/000/005/original/delivery-note.pdf?response-content-disposition=attachment%3B%20filename%3D%22delivery-note.pdf%22%3B%20filename%2A%3DUTF-8%27%27delivery-note.pdf&response-content-type=application%2Foctet-stream&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20261021%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20261021T101136Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=16ebd955e754145a341ba058a2390d34bb323a2107fae2eef0dead1a65252209",
"upload_token": "<upload-token>"
}
V3 V1
Attach a file to an existing purchase order (multipart).
Stores the file and attaches it to the purchase order po_id. The request must be
multipart/form-data with the file as a real binary part in uploads_attributes[file]
(do not send JSON or base64).
Checks run in this order: the upload token (400 "No token passed" when missing,
400 "Token you provided is not valid" when shorter than 7 characters), the purchase order
(404 when it does not exist in the company given by app_company_id), your permission to
collaborate on it (401), then the file itself (presence, type, size, PDF content, token
uniqueness) - file errors come back as 400 with an array of messages.
The file appears immediately in the PO's uploads[]. Nothing is e-mailed.
HTTP request
| Version | Request |
|---|---|
| V3 | POST /api/v3/uploads/po |
| V1 | POST /api/v1/uploads/po |
Permissions
PO creator, users mentioned on the PO, company admins / approvers / finance users of the
PO's company (non-draft POs), company admins on drafts with Open draft access, department
members with Share PO access by department. Others: 401 {"error": "Unauthorized access"}.
Body parameters (multipart/form-data)
| Parameter | Type | Required | Description |
|---|---|---|---|
po_id |
integer | Yes | Purchase order id (numeric). |
uploads_attributes[ |
string | Yes | Client-generated de-duplication token, at least 7 characters, unique among your uploads (a UUID is ideal). Reusing one returns 400 "Upload token already synced". |
uploads_attributes[ |
file | Yes | The file part. Max 30 MB; see the accepted types above. A part declared application/pdf must contain PDF data. |
Response
200 with the stored Upload object (not wrapped). url is a pre-signed link valid for
5 minutes.
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Token blank | 400 |
V3, V1 | {"error":"No token passed","status":400} |
| Token short | 400 |
V3, V1 | {"error":"Token you provided is not valid","status":400} |
| Duplicate token | 400 |
V3, V1 | {"error":["Upload token already synced"],"status":400} |
| Content mismatch | 400 |
V3, V1 | {"error":["File does not contain recognizable PDF data"],"status":400} |
| Missing file | 400 |
V3, V1 | {"error":["File can't be blank"],"status":400} |
| Unauthorized | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
| Not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
Upload a file to a PO comment
curl -X POST "https://app.procurementexpress.com/api/v3/uploads/poc" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-F "poc_id=54" \
-F "uploads_attributes[upload_token]=pofulfil-v3-photo-0001" \
-F "uploads_attributes[file]=@damaged-box.png"
curl -X POST "https://app.procurementexpress.com/api/v1/uploads/poc" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-F "poc_id=53" \
-F "uploads_attributes[upload_token]=pofulfil-v1-photo-0001" \
-F "uploads_attributes[file]=@damaged-box.png"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/uploads/poc",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
},
payload: {
multipart: true,
poc_id: "54",
"uploads_attributes[upload_token]": "pofulfil-v3-photo-0001",
"uploads_attributes[file]": File.new("damaged-box.png", "rb")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/uploads/poc",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
},
payload: {
multipart: true,
poc_id: "53",
"uploads_attributes[upload_token]": "pofulfil-v1-photo-0001",
"uploads_attributes[file]": File.new("damaged-box.png", "rb")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 9,
"file_file_name": "damaged-box.png",
"file_content_type": "image/png",
"url": "https://pex-files.s3.amazonaws.com/uploads/files/000/000/009/original/damaged-box.png?response-content-disposition=attachment%3B%20filename%3D%22damaged-box.png%22%3B%20filename%2A%3DUTF-8%27%27damaged-box.png&response-content-type=application%2Foctet-stream&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20261021%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20261021T101147Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=9052bc89264a4e7498bc961590d9686e0b2579b3ab10d7c26b3d1f6db540ca33",
"upload_token": "<upload-token>"
}
V3 V1
Attach a file to an existing purchase order comment (multipart).
Attaches a file to the purchase order comment poc_id (the id returned by
Create a comment). Use it to post "comment + attachment": create the
comment, then upload one or more files to it. The file is listed under that comment's
uploads[] in the PO details.
Same token checks, file validation and error order as
Upload a file to a purchase order. The comment must
belong to a purchase order of the app_company_id company (404 otherwise).
HTTP request
| Version | Request |
|---|---|
| V3 | POST /api/v3/uploads/poc |
| V1 | POST /api/v1/uploads/poc |
Permissions
Same rule as attaching to the comment's purchase order (creator, mentioned users, company
admins / approvers / finance users, ...). Others: 401 Unauthorized access.
Body parameters (multipart/form-data)
| Parameter | Type | Required | Description |
|---|---|---|---|
poc_id |
integer | Yes | Purchase order comment id. |
uploads_attributes[ |
string | Yes | Client-generated de-duplication token, at least 7 characters, unique among your uploads. |
uploads_attributes[ |
file | Yes | The file part (max 30 MB). |
Response
200 with the stored Upload object.
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Token blank | 400 |
V3, V1 | {"error":"No token passed","status":400} |
| Unauthorized | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
| Not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
Get upload status
curl "https://app.procurementexpress.com/api/v3/uploads/status?upload_token=pofulfil-v3-dn-0001" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/uploads/status?upload_token=pofulfil-v1-dn-0001" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/uploads/status",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
params: {
upload_token: "pofulfil-v3-dn-0001"
}
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/uploads/status",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
params: {
upload_token: "pofulfil-v1-dn-0001"
}
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 5,
"file_file_name": "delivery-note.pdf",
"file_content_type": "application/pdf",
"url": "https://pex-files.s3.amazonaws.com/uploads/files/000/000/005/original/delivery-note.pdf?response-content-disposition=attachment%3B%20filename%3D%22delivery-note.pdf%22%3B%20filename%2A%3DUTF-8%27%27delivery-note.pdf&response-content-type=application%2Foctet-stream&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20261021%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20261021T101155Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=dc8e900e40b58883bb97e83c336cd0221587a91f7cc9e310271aeba81325595a",
"upload_token": "<upload-token>"
}
V3 V1
Look up one of your uploads by its upload token.
Returns the upload that you created with the given upload_token, for example to find
out whether an upload whose response was lost actually went through. Only the calling
user's own uploads are searched (another user's token answers 400), and the token is
matched exactly (no length rule here).
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/uploads/status |
| V1 | GET /api/v1/uploads/status |
Permissions
Any authenticated user; only their own uploads are visible.
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
upload_token |
string | Yes | The token sent in uploads_attributes[upload_token]. |
Response
200 with the Upload object (with a fresh 5-minute url), or
400 {"error": "Token you provided is not valid"} when you have no upload with that token.
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 400 |
V3, V1 | {"error":"Token you provided is not valid","status":400} |
| Other user | 400 |
V3, V1 | {"error":"Token you provided is not valid","status":400} |
Purchase Orders: Comments
Comments are the conversation and audit trail of a purchase order. The PO details
response (Get purchase order details) lists all of them in
purchase_order_comments[] - user comments as well as system entries (approvals,
deliveries, edits, emails to the supplier...), which carry system_generated and a
category.
Comments created through the API are always plain-text user comments with category
mobile_api_comment. To attach a file to a comment, create the comment first and then
upload the file with Upload a file to a PO comment.
Mentions. Any e-mail address in the comment text that belongs to an active user of the company mentions that user: they are added to the PO's mentioned users (which also lets them open and comment on the PO) and receive a "you were tagged" notification.
Notifications. A new comment notifies (in-app and by e-mail, depending on each user's PO comment notification setting) the PO creator, the PO's approvers, finance users (of the PO's department when Share PO access by department is on) and the company owner.
Endpoints in this section
| Endpoint | Method | Path | V3 | V1 |
|---|---|---|---|---|
| Comment on a purchase order | POST |
/purchase_orders/:purchase_order_id/comments |
✓ | ✓ |
The purchase order comment object
| Field | Type | Description |
|---|---|---|
id |
integer | Comment id (use as poc_id to attach files). |
comment |
string | Comment text with HTML links stripped. |
formatted_comment |
string | HTML version of the comment (paragraphs, internal links auto-linked, mention markup removed). |
creator_id |
integer | User who wrote the comment. |
creator_name |
string | Name of the author. |
comment_display_name |
string | Name to display (supplier account name for comments posted by a supplier). |
purchase_order_id |
integer | Purchase order the comment belongs to. |
status |
string | active, deleted or inactive. |
created_at |
timestamp | Creation time, unix seconds. |
comment_formatted_date |
string | Creation date, YYYY-MM-DD. |
comment_formatted_time |
string | Creation time of day, HH:MM. |
uploads |
array[object] | Files attached to the comment (Upload objects). |
system_generated |
boolean | false for comments written by people, true for system history entries. |
category |
string | Kind of entry. API comments: mobile_api_comment. System entries include approval, approval_partial, partially_delivered, complete_delivered, send_supplier, send_supplier_fail, send_supplier_open, send_supplier_po_acknowledged, invoice_received, payment_notes, payment_no_notes, po_archive_action, item_budget_changed, qbo, qbo_sync_failed, three_way_match, compliance and Dynamics sync categories, and default. |
Comment on a purchase order
curl -X POST "https://app.procurementexpress.com/api/v3/purchase_orders/12/comments" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"comment": "Paper boxes arrived slightly damaged - marco.rossi@example.com can you hold the invoice until we confirm with Northwind? See https://example.com/claims/1234"
}'
curl -X POST "https://app.procurementexpress.com/api/v1/purchase_orders/11/comments" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"comment": "Paper boxes arrived slightly damaged - marco.rossi@example.com can you hold the invoice until we confirm with Northwind? See https://example.com/claims/1234"
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/purchase_orders/12/comments",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
comment: "Paper boxes arrived slightly damaged - marco.rossi@example.com can you hold the invoice until we confirm with Northwind? See https://example.com/claims/1234"
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/purchase_orders/11/comments",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
comment: "Paper boxes arrived slightly damaged - marco.rossi@example.com can you hold the invoice until we confirm with Northwind? See https://example.com/claims/1234"
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 54,
"comment": "Paper boxes arrived slightly damaged - marco.rossi@example.com can you hold the invoice until we confirm with Northwind? See https://example.com/claims/1234",
"creator_id": 40000,
"creator_name": "Jane Doe",
"purchase_order_id": 12,
"status": "active",
"created_at": 1792577000,
"uploads": [
],
"comment_formatted_time": "05:03",
"comment_formatted_date": "2026-10-21",
"comment_display_name": "Jane Doe",
"formatted_comment": "<p>Paper boxes arrived slightly damaged - marco.rossi@example.com can you hold the invoice until we confirm with Northwind? See https://example.com/claims/1234</p>",
"system_generated": false,
"category": "mobile_api_comment"
}
V3 V1
Add a comment to a purchase order.
Adds a user comment (category mobile_api_comment) to the purchase order. The text is
required - to attach a file, create the comment and then use
Upload a file to a PO comment with the returned id.
E-mail addresses of active company users in the text mention those users (they gain
access to the PO and are notified). Send the body as JSON ({"comment": "..."}) or as a
form field; the parameter is top-level, not nested.
HTTP request
| Version | Request |
|---|---|
| V3 | POST /api/v3/purchase_orders/:purchase_order_id/comments |
| V1 | POST /api/v1/purchase_orders/:purchase_order_id/comments |
Permissions
PO creator, users mentioned on the PO, company admins / approvers / finance users of the
PO's company (non-draft POs), company admins on drafts with Open draft access, department
members with Share PO access by department. Others: 401 Unauthorized access.
Path parameters
| Parameter | Type | Description |
|---|---|---|
purchase_order_id |
integer | Purchase order id (numeric). |
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
comment |
string | Yes | Comment text. Plain text; links are stripped from comment and auto-linked in formatted_comment. Blank -> 400. |
Response
200 with the created comment object (not wrapped). A blank comment returns
400 with the validation errors keyed by attribute:
{"error": {"comment": [": please add a comment or attach a file"]}, "status": 400}.
Side effects
In-app + e-mail notification to the PO creator, approvers, finance users and the company owner (per their notification settings); "tagged" notification and PO access for mentioned users.
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Blank | 400 |
V3, V1 | {"error":{"comment":[": please add a comment or attach a file"]},"status":400} |
| Unauthorized | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
| Not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
Purchase Orders: Receiving & Delivery
Receiving records what has physically arrived against each line of a purchase order.
Each receipt adds to the line's received_quantity, writes a delivery record and a PO
history comment, and recalculates the PO's delivery_status:
delivery_status |
Meaning |
|---|---|
not_delivered |
Nothing received yet (or every receipt was cancelled). |
partially_delivered |
Some quantity received, but not the full ordered quantity of every line. |
complete_delivered |
The received quantity equals the ordered quantity on every line. |
When can a PO be received? The PO must not be draft or cancelled. A pending
(not yet approved) PO can only be received when the company setting
Receive items before approval (receive_item_before_approval) is on. When the company uses
role permissions, the caller needs the Deliver PO permission. Check can_receive_item in
Get purchase order details before showing a receive action;
the details response also carries can_cancel_receiving_items, can_complete_delivery,
completely_delivered, delivery_status, delivered_on (unix seconds, 0 when never
received) and purchase_order_items[].received_quantity.
Dates. delivered_on must be written in the company's date format
(company_setting.date_format from Get company details, for
example %m/%d/%Y -> 10/21/2026). A date in any other format is silently ignored for the
PO's delivered_on.
Closing a delivery short. When a supplier will not deliver the rest, call
Complete delivery: it reduces each line's ordered quantity to what was
received and marks the PO complete_delivered (company setting
Enable complete delivery option).
Side effects. A change of delivery_status fires the po_delivered webhook event (for
partial and complete deliveries) and, when the PO becomes fully delivered, notifies finance
users and company admins who have the PO received notification enabled.
Endpoints in this section
| Endpoint | Method | Path | V3 | V1 |
|---|---|---|---|---|
| Receive items | POST |
/purchase_orders/:id/receiving_items |
✓ | ✓ |
| Cancel received items | POST |
/purchase_orders/:id/cancel_receiving_items |
✓ | ✓ |
| Complete delivery | POST |
/purchase_orders/:id/complete_delivery |
✓ | ✓ |
Receive items
curl -X POST "https://app.procurementexpress.com/api/v3/purchase_orders/12/receiving_items" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"purchase_order": {
"delivered_on": "10/20/2026",
"notes": "First pallet arrived; markers complete, paper short by 6 boxes",
"items": [
{
"id": 22,
"quantity": 4
},
{
"id": 23,
"quantity": 6
}
]
}
}'
curl -X POST "https://app.procurementexpress.com/api/v1/purchase_orders/11/receiving_items" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"purchase_order": {
"delivered_on": "10/20/2026",
"notes": "First pallet arrived; markers complete, paper short by 6 boxes",
"items": [
{
"id": 20,
"quantity": 4
},
{
"id": 21,
"quantity": 6
}
]
}
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/purchase_orders/12/receiving_items",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
purchase_order: {
delivered_on: "10/20/2026",
notes: "First pallet arrived; markers complete, paper short by 6 boxes",
items: [
{
id: 22,
quantity: 4
},
{
id: 23,
quantity: 6
}
]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/purchase_orders/11/receiving_items",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
purchase_order: {
delivered_on: "10/20/2026",
notes: "First pallet arrived; markers complete, paper short by 6 boxes",
items: [
{
id: 20,
quantity: 4
},
{
id: 21,
quantity: 6
}
]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"status": true
}
Example: full (V3,
200 OK):
{
"status": true
}
Example: negative (V3,
200 OK):
{
"status": true
}
Example: pending po (V3,
200 OK):
{
"status": true
}
V3 V1
Record received quantities for one or more purchase order lines.
Adds each quantity to the line's received_quantity (it is incremental: send what
arrived in this delivery, not the running total). Lines you do not list are unchanged. After
a successful receipt the PO's delivery_status is recalculated, delivered_on is set from
delivered_on, and a history comment such as
"Jane Doe received 10.0 quantity on this purchase order at 10/20/2026<br> Notes: ..." is
added (category partially_delivered or complete_delivered).
Validation (nothing is saved when any line fails - the whole request is rejected):
itemsmust be present and non-empty ->"Received Quantity can not be blank".- every
quantitymust be a number ->"Invalid Received Quantity". - not all quantities may be
0->"Receiving Quantity cannot be 0 for all items.". - for a positive ordered quantity the new quantity must lie between minus the already
received quantity and the remaining quantity -> otherwise
"Received Quantity must be less than total quantity". A negative quantity therefore reverses part of an earlier receipt (for example goods returned damaged). - line custom field validation errors are returned as their messages.
Line ids that do not belong to the purchase order are ignored. Concurrent receipts of the
same PO are serialised; if the PO stays locked for 5 seconds the request fails with
400 {"error": ["Please try again"]} and nothing is applied.
HTTP request
| Version | Request |
|---|---|
| V3 | POST /api/v3/purchase_orders/:id/receiving_items |
| V1 | POST /api/v1/purchase_orders/:id/receiving_items |
Permissions
The PO must not be draft or cancelled, and a pending PO only when the company setting
Receive items before approval is on; with role permissions enabled the caller needs the
Deliver PO permission. Otherwise 401 {"error": "Unauthorized access"}.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Purchase order id. |
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
purchase_order[ |
array[object] | Yes | Lines received in this delivery. Send as a JSON array (form-encoded bodies must use purchase_order[items][][id] / [][quantity]; indexed keys such as items[0][id] cause a 500). |
purchase_order[ |
integer | Yes | Purchase order item id (purchase_order_items[].id). |
purchase_order[ |
number | Yes | Quantity received now (decimals allowed; negative to reverse a previous receipt). |
purchase_order[ |
string | No | Delivery date in the company date format (company_setting.date_format, e.g. 10/21/2026 for %m/%d/%Y). Other formats are ignored for the PO's delivered_on. |
purchase_order[ |
string | No | Free text appended to the receipt history comment. |
Response
200 {"status": true} on success; read the new quantities and delivery_status from
Get purchase order details. Validation failures return
400 {"error": ["<message>"], "status": 400} (only the first problem is reported); a missing
purchase_order object returns 400 {"error": "Required parameter are missing."}.
Side effects
Delivery record per line, PO history comment, po_delivered webhook when delivery_status
changes, "PO received" notification to finance users/company admins when the PO becomes fully
delivered.
V3 and V1 differences
None - identical request and response (verified on separate POs for every success case).
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Over quantity | 400 |
V3, V1 | {"error":["Received Quantity must be less than total quantity"],"status":400} |
| Blank | 400 |
V3, V1 | {"error":["Received Quantity can not be blank"],"status":400} |
| Invalid quantity | 400 |
V3, V1 | {"error":["Invalid Received Quantity"],"status":400} |
| Zero | 400 |
V3, V1 | {"error":["Receiving Quantity cannot be 0 for all items."],"status":400} |
| Missing root | 400 |
V3, V1 | {"error":"Required parameter are missing.","status":400} |
| Draft | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
| Cancelled | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
| Not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
Cancel received items
curl -X POST "https://app.procurementexpress.com/api/v3/purchase_orders/14/cancel_receiving_items" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl -X POST "https://app.procurementexpress.com/api/v1/purchase_orders/13/cancel_receiving_items" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/purchase_orders/14/cancel_receiving_items",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/purchase_orders/13/cancel_receiving_items",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"status": true
}
V3 V1
Reverse every receipt on a purchase order and set it back to not delivered.
Resets received_quantity to 0 on every line, archives (or, with the newer delivery
model, reverses) the delivery records, clears delivered_on, sets delivery_status to
not_delivered, and adds a history comment listing what was removed
("Delivery items removed and purchase order set to Undelivered due to cancellation action by ...").
There is no body and no per-line option - to undo part of a receipt, send a negative
quantity to Receive items instead.
The web app offers this action only for fully delivered POs (can_cancel_receiving_items
in the PO details is true only when completely_delivered), but the API also accepts
partially delivered and undelivered POs and always answers {"status": true}.
HTTP request
| Version | Request |
|---|---|
| V3 | POST /api/v3/purchase_orders/:id/cancel_receiving_items |
| V1 | POST /api/v1/purchase_orders/:id/cancel_receiving_items |
Permissions
Same rule as Receive items: not for draft or cancelled POs, pending
only with Receive items before approval, Deliver PO permission when role permissions are
enabled. Otherwise 401 Unauthorized access.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Purchase order id. |
Response
200 {"status": true}.
Side effects
History comment on the PO; delivery_status change (no webhook is defined for
not_delivered).
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Draft | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
| Not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
Complete delivery
curl -X POST "https://app.procurementexpress.com/api/v3/purchase_orders/16/complete_delivery" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl -X POST "https://app.procurementexpress.com/api/v1/purchase_orders/15/complete_delivery" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/purchase_orders/16/complete_delivery",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/purchase_orders/15/complete_delivery",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"status": true
}
V3 V1
Close a partially delivered purchase order at the quantities received so far.
For a PO the supplier will not finish delivering: every line's ordered quantity is set to
its received_quantity (so line amounts and the PO totals are reduced - in the example the
net total went from 300.90 to 225.93), delivery_status becomes complete_delivered, and a
history comment such as
"Jane Doe closed delivery with total quantity of 13.0 and gross amount of $ 245.99 on 10/20/2026"
is added. This cannot be undone through the API.
HTTP request
| Version | Request |
|---|---|
| V3 | POST /api/v3/purchase_orders/:id/complete_delivery |
| V1 | POST /api/v1/purchase_orders/:id/complete_delivery |
Permissions
Requires the company setting Enable complete delivery option
(enable_complete_delivery_option; always available when role permissions are enabled, then
with the Deliver PO permission), a PO in status approved or paid, and
delivery_status = partially_delivered. Otherwise 401 Unauthorized access. Check
can_complete_delivery in the PO details.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Purchase order id. |
Response
200 {"status": true}.
Side effects
Line quantities and PO totals change; history comment; po_delivered webhook.
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not partial | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
Purchase Orders: Sending to Suppliers
Once a purchase order is approved you can e-mail it to the supplier, generate its PDF, and manage the reusable send to supplier e-mail templates.
Requirements for e-mailing a PO (Send a purchase order to a supplier
and Save or update a send-to-supplier template):
the company must have an active paid subscription (companies on the free trial get
401 Unauthorized access), the PO must be approved and have a supplier, and - when role
permissions are enabled - the caller needs the Send to supplier permission.
Templates. A template has a unique template_name, a label (used as the e-mail
subject) and a text (the e-mail body). Templates are listed with
List send-to-supplier templates and created/updated through
the forward endpoint's template mode. There is no endpoint to delete a template. Subject and
body may contain these placeholders, replaced when the e-mail is sent:
| Placeholder | Replaced with |
|---|---|
{Company.Name} |
Your company name |
{PurchaseOrder.Number} |
The PO number (approval_key) |
{Supplier.Name} |
The PO's supplier name |
{Supplier.Contact.Person} |
The supplier's contact person |
{Requester.Name} |
The PO creator's name |
To send with a template, read it from the list and pass its label as email_subject and
its text as note.
Endpoints in this section
| Endpoint | Method | Path | V3 | V1 |
|---|---|---|---|---|
| Send a purchase order to a supplier | POST |
/purchase_orders/:id/forward |
✓ | ✓ |
| Save or update a send-to-supplier template | POST |
/purchase_orders/:id/forward |
✓ | ✓ |
| List send-to-supplier templates | GET |
/send_to_supplier_templates |
✓ | ✓ |
| Generate a purchase order PDF | GET |
/purchase_orders/:id/generate_pdf |
✓ | ✓ |
Send a purchase order to a supplier
curl -X POST "https://app.procurementexpress.com/api/v3/purchase_orders/12/forward" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"emails": "orders@northwind.example.com",
"cc": "accounts.payable@example.com",
"email_subject": "Purchase order {PurchaseOrder.Number} from {Company.Name}",
"note": "Dear {Supplier.Name},\n\nPlease find purchase order {PurchaseOrder.Number} attached, together with our delivery note and a photo of the damaged boxes.\n\nThanks,\n{Requester.Name}",
"uploads": [
5,
9
]
}'
curl -X POST "https://app.procurementexpress.com/api/v1/purchase_orders/11/forward" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"emails": "orders@northwind.example.com",
"cc": "accounts.payable@example.com",
"email_subject": "Purchase order {PurchaseOrder.Number} from {Company.Name}",
"note": "Dear {Supplier.Name},\n\nPlease find purchase order {PurchaseOrder.Number} attached, together with our delivery note and a photo of the damaged boxes.\n\nThanks,\n{Requester.Name}",
"uploads": [
4,
8
]
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/purchase_orders/12/forward",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
emails: "orders@northwind.example.com",
cc: "accounts.payable@example.com",
email_subject: "Purchase order {PurchaseOrder.Number} from {Company.Name}",
note: "Dear {Supplier.Name},\n\nPlease find purchase order {PurchaseOrder.Number} attached, together with our delivery note and a photo of the damaged boxes.\n\nThanks,\n{Requester.Name}",
uploads: [5, 9]
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/purchase_orders/11/forward",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
emails: "orders@northwind.example.com",
cc: "accounts.payable@example.com",
email_subject: "Purchase order {PurchaseOrder.Number} from {Company.Name}",
note: "Dear {Supplier.Name},\n\nPlease find purchase order {PurchaseOrder.Number} attached, together with our delivery note and a photo of the damaged boxes.\n\nThanks,\n{Requester.Name}",
uploads: [4, 8]
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"sent": true
}
Example: minimal (V3,
200 OK):
{
"sent": true
}
V3 V1
E-mail an approved purchase order (and optional attachments) to the supplier.
Queues the "send to supplier" e-mail: your message with a link to the online purchase order
(the PO PDF is attached when the company setting Attach PDF to e-mails to suppliers is on)
plus the selected attachments goes to emails, with cc in copy, and a separate copy is sent
to the PO creator. Placeholders in email_subject and
note ({Supplier.Name}, {PurchaseOrder.Number}, ...) are replaced when the e-mail is
built. The request returns as soon as the e-mail is queued.
On success the PO's supplier e-mail status becomes sent and a history comment is added,
e.g. "Purchase order sent to orders@northwind.example.com and email copy sent to accounts.payable@example.com with files: delivery-note.pdf, damaged-box.png"
(category send_supplier). Selected comment attachments are also linked to the PO's
uploads.
Without save_template/update_template this endpoint sends; with one of them it manages a
template instead and sends nothing (see
Save or update a send-to-supplier template).
HTTP request
| Version | Request |
|---|---|
| V3 | POST /api/v3/purchase_orders/:id/forward |
| V1 | POST /api/v1/purchase_orders/:id/forward |
Permissions
Company with an active paid subscription (trial companies: 401), PO approved with a
supplier, Send to supplier permission when role permissions are enabled (401 Unauthorized
access otherwise). The caller must also be allowed to view the PO and every selected file -
otherwise the request is refused with 400 {"error": {"error": "unauthorized upload id given."}}
(also when no files were selected).
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Purchase order id. |
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
emails |
string | Yes | Recipient address(es) as ONE string, comma-separated (e.g. "orders@northwind.example.com, dispatch@northwind.example.com"). Usually the PO's supplier.email. An array is not accepted (500). Max 100 recipients. |
note |
string | Yes | E-mail body (plain text with placeholders). Blank -> 400 "Note can't be blank". |
email_subject |
string | No | E-mail subject (placeholders allowed). |
cc |
string | No | CC address(es), comma-separated string or array. Defaults to the PO creator's e-mail. |
uploads |
array[integer] | No | Ids of files to attach - uploads of this PO or of its comments (uploads[].id, purchase_order_comments[].uploads[].id). Max 100; any other id -> 400 unauthorized upload id given. |
Response
200 {"sent": true} once the e-mail is queued. Validation errors return 400 with an array
of messages ("Note can't be blank", "Email does not appear to be a valid e-mail address");
the policy refusal returns 401 Unauthorized access.
Side effects
Two queued e-mails (supplier copy and creator copy, delivered by background jobs), history
comment send_supplier, supplier e-mail status sent, attachment links marked as sent.
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Note blank | 400 |
V3, V1 | {"error":["Note can't be blank"],"status":400} |
| Invalid email | 400 |
V3, V1 | {"error":["Email does not appear to be a valid e-mail address"],"status":400} |
| Foreign upload | 400 |
V3, V1 | {"error":{"error":"unauthorized upload id given."},"status":400} |
| Not approved | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
| Trial company | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
Save or update a send-to-supplier template
curl -X POST "https://app.procurementexpress.com/api/v3/purchase_orders/12/forward" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"save_template": true,
"template_name": "Delivery follow-up",
"email_subject": "PO {PurchaseOrder.Number} - delivery schedule",
"note": "Dear {Supplier.Name},\n\nPlease confirm the delivery date for purchase order {PurchaseOrder.Number}.\n\nThanks,\n{Requester.Name}"
}'
curl -X POST "https://app.procurementexpress.com/api/v1/purchase_orders/11/forward" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"save_template": true,
"template_name": "Delivery follow-up",
"email_subject": "PO {PurchaseOrder.Number} - delivery schedule",
"note": "Dear {Supplier.Name},\n\nPlease confirm the delivery date for purchase order {PurchaseOrder.Number}.\n\nThanks,\n{Requester.Name}"
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/purchase_orders/12/forward",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
save_template: true,
template_name: "Delivery follow-up",
email_subject: "PO {PurchaseOrder.Number} - delivery schedule",
note: "Dear {Supplier.Name},\n\nPlease confirm the delivery date for purchase order {PurchaseOrder.Number}.\n\nThanks,\n{Requester.Name}"
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/purchase_orders/11/forward",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
save_template: true,
template_name: "Delivery follow-up",
email_subject: "PO {PurchaseOrder.Number} - delivery schedule",
note: "Dear {Supplier.Name},\n\nPlease confirm the delivery date for purchase order {PurchaseOrder.Number}.\n\nThanks,\n{Requester.Name}"
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 13,
"company_id": 20000,
"label": "PO {PurchaseOrder.Number} - delivery schedule",
"text": "Dear {Supplier.Name},\n\nPlease confirm the delivery date for purchase order {PurchaseOrder.Number}.\n\nThanks,\n{Requester.Name}",
"is_default": false,
"template_name": "Delivery follow-up"
}
Example: update (V3,
200 OK):
{
"id": 13,
"company_id": 20000,
"label": "PO {PurchaseOrder.Number} - please confirm delivery",
"text": "Dear {Supplier.Name},\n\nCould you confirm the delivery date for purchase order {PurchaseOrder.Number}.\n\nThanks,\n{Requester.Name}",
"is_default": true,
"template_name": "Delivery follow-up"
}
V3 V1
Create or update a company e-mail template through the forward endpoint (nothing is sent).
The forward endpoint has two template modes, selected by a flag. No e-mail is sent and
emails, cc and uploads are ignored:
save_template: truecreates a template withtemplate_name,label=email_subjectandtext=note.update_template: truefinds the template whosetemplate_namematches and replaces its subject and body (the name itself cannot be changed). Unknown name ->400 {"error": {"error": "no template found"}}.
Add is_default: true to make the template the company default for the web send-to-supplier
form (it cannot be unset through the API). Any approved PO with a supplier can be used in the
path; the template is company-wide, not tied to that PO.
HTTP request
| Version | Request |
|---|---|
| V3 | POST /api/v3/purchase_orders/:id/forward |
| V1 | POST /api/v1/purchase_orders/:id/forward |
Permissions
Same gate as sending: paid subscription, PO approved with a supplier, Send to supplier
permission when role permissions are enabled. Otherwise 401 Unauthorized access.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Id of any approved purchase order with a supplier. |
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
save_template |
boolean | No | true to create a template (any non-empty value counts). |
update_template |
boolean | No | true to update the template named template_name (ignored when save_template is present). |
template_name |
string | Yes | Unique template name within the company (400 "Template Name has already registered" on create when taken). |
email_subject |
string | Yes | Stored as the template label (the e-mail subject). Blank -> "Subject can't be blank". |
note |
string | Yes | Stored as the template text (the e-mail body). Blank -> "Message can't be blank". |
is_default |
boolean | No | true makes this the company's default template. |
Response
200 with the template object (id, company_id, label, text, is_default,
template_name). Validation errors: 400 with an array of messages.
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Save duplicate | 400 |
V3, V1 | {"error":["Template Name has already registered"],"status":400} |
| Save invalid | 400 |
V3, V1 | {"error":["Message can't be blank","Subject can't be blank"],"status":400} |
| Update not found | 400 |
V3, V1 | {"error":{"error":"no template found"},"status":400} |
List send-to-supplier templates
curl "https://app.procurementexpress.com/api/v3/send_to_supplier_templates" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/send_to_supplier_templates" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/send_to_supplier_templates",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/send_to_supplier_templates",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
[
{
"id": 1,
"company_id": 20000,
"label": "New order from {Company.Name} - PO {PurchaseOrder.Number}",
"text": "Dear {Supplier.Name}, \n\nPlease find our new purchase order attached. \n\nGet your invoice into the hands of the right people by attaching it directly into our purchase order system. \n\nClick on the link below to attach your invoice to the purchase order. \n\nThank you, \nTech Company",
"is_default": false,
"template_name": "Default Template"
},
{
"id": 2,
"company_id": 20000,
"label": "Standard purchase order email",
"text": "Hello {{supplier_contact}},\n\nPlease find purchase order {{po_number}} attached.\n\nThanks,\n{{company_name}}",
"is_default": false,
"template_name": "standard_po"
}
]
Example: with default (V3,
200 OK) (lists shortened to 2 items):
[
{
"id": 1,
"company_id": 20000,
"label": "New order from {Company.Name} - PO {PurchaseOrder.Number}",
"text": "Dear {Supplier.Name}, \n\nPlease find our new purchase order attached. \n\nGet your invoice into the hands of the right people by attaching it directly into our purchase order system. \n\nClick on the link below to attach your invoice to the purchase order. \n\nThank you, \nTech Company",
"is_default": false,
"template_name": "Default Template"
},
{
"id": 2,
"company_id": 20000,
"label": "Standard purchase order email",
"text": "Hello {{supplier_contact}},\n\nPlease find purchase order {{po_number}} attached.\n\nThanks,\n{{company_name}}",
"is_default": false,
"template_name": "standard_po"
}
]
V3 V1
List the company's e-mail templates for sending purchase orders to suppliers.
Returns every template of the company (no pagination, no filters, no guaranteed order). Every
company starts with a "Default Template". is_default marks the template selected as the
company default (none in a new company).
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/send_to_supplier_templates |
| V1 | GET /api/v1/send_to_supplier_templates |
Permissions
Any user of the company.
Response
200 with a JSON array of templates.
| Field | Type | Description |
|---|---|---|
[ |
integer | Template id. |
[ |
integer | Owning company. |
[ |
string | E-mail subject (pass as email_subject). |
[ |
string | E-mail body (pass as note). |
[ |
boolean | true for the company's default template. |
[ |
string | Unique template name (used by update_template). |
V3 and V1 differences
None - identical request and response.
Generate a purchase order PDF
curl "https://app.procurementexpress.com/api/v3/purchase_orders/12/generate_pdf" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/purchase_orders/11/generate_pdf" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/purchase_orders/12/generate_pdf",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/purchase_orders/11/generate_pdf",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"pdf_link": "public/PO-12-21102026050554-Acme Corporation"
}
V3 V1
Render the purchase order PDF and return a short-lived download link.
Renders the PO with the company's PDF template (any status, drafts included - draft PDFs are
named PO-Draft-...) and returns a JSON object with pdf_link. The response itself is JSON,
not the PDF.
In production pdf_link is a pre-signed link to <file name>.pdf (Content-Type:
application/pdf) that expires after 60 seconds - download it immediately. The file name
follows the company's PDF naming setting, for example PO-11-21102026050541-Acme Corporation.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/purchase_orders/:id/generate_pdf |
| V1 | GET /api/v1/purchase_orders/:id/generate_pdf |
Permissions
Any user of the company that owns the PO.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Purchase order id. |
Response
200 {"pdf_link": "<url>"}, a pre-signed link to the PDF that is valid for 60 seconds.
Download it straight away. (The example was recorded on a development server, which
returns a server-side file path instead of a URL.)
V3 and V1 differences
None in the response. V1 calls authorize @purchase_order, :generate_pdf? (same company), V3
performs no authorization call - with the PO already scoped to the company the outcome is the same.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
QuickBooks Online
When a company connects QuickBooks Online (QBO) (Settings -> Integrations in the web app), ProcurementExpress imports and keeps in sync:
- the chart of accounts (QBO Accounts) - used on PO / invoice line items and linkable to budgets,
- classes - optional line-item dimension,
- customers / projects - optional line-item dimension,
- plus vendors (suppliers), locations (departments), items (products), tax rates and payment terms, which surface in their own resources.
Approved POs, bills and payments are then pushed back to QBO.
The endpoints in the following sections are read-only mirrors of those QBO lists: there are no create/update/delete endpoints - change the data in QuickBooks and it syncs into ProcurementExpress. The only writable resource here is Item Budget Mappings, a ProcurementExpress-side setting for item-based companies.
What the list endpoints return
GET /chart_of_accounts, GET /qbo_classes and GET /qbo_customers only
return records that
- are not archived (QBO "inactive" records are archived; all of them are archived when QuickBooks is disconnected),
- are mapped to the currently connected QuickBooks company (realm) - records imported from a previously connected QBO company are hidden, and
- are visible to one of the caller's roles. Company admins choose, per account / class / customer, which roles may use it (QuickBooks settings -> access rights). By default newly imported records are visible to company admin, approver, finance and team member.
Without an active QuickBooks connection the lists are therefore normally
empty. The GET .../:id endpoints have none of these filters: they return
any record of the company by id, including archived ones.
Is QuickBooks connected?
Read company_setting.is_quickbooks_connected from
GET /companies/details (or GET /companies/:id). It is true
while the connection is active, false after a disconnect, and null when
the company has never connected QBO - treat anything but true as "not
connected". The same company_setting object carries the related settings:
| Setting | Effect |
|---|---|
link_budgets_to_qbo_accounts |
Budgets carry a chart of account (budget.chart_of_account_id); choosing a budget on a line picks its account and vice versa. |
link_budgets_to_qbo_class |
Choosing a budget on a line picks the budget's QuickBooks class. |
qbo_class_active / qbo_class_required |
Show the class field on line items / make quickbooks_class_id mandatory on PO line items while QBO is connected (error Quickbooks class Can't be blank). |
qbo_customer_active / qbo_customer_required |
Same for qbo_customer_id. |
qbo_class_alias / qbo_customer_alias |
Custom labels for the class / customer fields. |
show_qbo_chart_of_account_number |
Prefix account names with the account number in display_name-style labels. |
qbo_description_required |
Item-based companies must enter a QuickBooks line description. |
Note: chart of accounts records can also be created by the Microsoft
Dynamics 365 integration (main accounts). Those records have no
QuickBooks mapping and are therefore not returned by
GET /chart_of_accounts (they are returned by GET /chart_of_accounts/:id
and GET /companies/chart_of_accounts).
QuickBooks Online: Chart of Accounts
The QuickBooks Online accounts (expense, cost of goods sold, asset, ...)
that PO and invoice line items are coded to (chart_of_account_id) and that
a budget can be linked to (budget.chart_of_account_id). Read-only: the list
is imported from QuickBooks. See QuickBooks Online for
which accounts the list returns.
Endpoints in this section
| Endpoint | Method | Path | V3 | V1 |
|---|---|---|---|---|
| List chart of accounts | GET |
/chart_of_accounts |
✓ | ✓ |
| Get a chart of account | GET |
/chart_of_accounts/:id |
✓ | ✓ |
The chart of account object
| Field | Type | Description |
|---|---|---|
id |
integer | ProcurementExpress id of the account (use this as chart_of_account_id). |
name |
string | Account name (QBO Name). |
classification |
string | QBO classification, e.g. Expense, Asset, Liability, Revenue, Equity. |
account_type |
string | QBO account type, e.g. Expense, Cost of Goods Sold, Other Current Asset. |
currency_code |
string | ISO currency of the account. |
account_number |
string | QBO account number, or null. |
display_name |
string | <name> - <account_type>; the name is the fully qualified (parent:child) name when known and is prefixed with the account number when show_qbo_chart_of_account_number is on. |
archived |
boolean | true when inactive in QBO or QuickBooks was disconnected. |
company_id |
integer | Owning company. |
List chart of accounts
curl "https://app.procurementexpress.com/api/v3/chart_of_accounts" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/chart_of_accounts" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/chart_of_accounts",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/chart_of_accounts",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1 (lists shortened to 2 items):
{
"chart_of_accounts": [
{
"id": 1,
"name": "Office Expenses",
"classification": "Expense",
"account_type": "Expense",
"currency_code": "USD",
"account_number": "6000",
"display_name": "Office Expenses - Expense",
"archived": false,
"company_id": 20000
},
{
"id": 3,
"name": "Advertising & Marketing",
"classification": "Expense",
"account_type": "Expense",
"currency_code": "USD",
"account_number": "6200",
"display_name": "Advertising & Marketing - Expense",
"archived": false,
"company_id": 20000
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 3
}
}
Example: search (V3,
200 OK):
{
"chart_of_accounts": [
{
"id": 2,
"name": "Computer Equipment",
"classification": "Expense",
"account_type": "Expense",
"currency_code": "USD",
"account_number": "6100",
"display_name": "Computer Equipment - Expense",
"archived": false,
"company_id": 20000
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 1
}
}
Example: paginated (V3,
200 OK):
{
"chart_of_accounts": [
{
"id": 3,
"name": "Advertising & Marketing",
"classification": "Expense",
"account_type": "Expense",
"currency_code": "USD",
"account_number": "6200",
"display_name": "Advertising & Marketing - Expense",
"archived": false,
"company_id": 20000
}
],
"meta": {
"current_page": 2,
"next_page": 3,
"prev_page": 1,
"total_pages": 3,
"total_count": 3
}
}
Example: teammember (V3,
200 OK):
{
"chart_of_accounts": [
{
"id": 1,
"name": "Office Expenses",
"classification": "Expense",
"account_type": "Expense",
"currency_code": "USD",
"account_number": "6000",
"display_name": "Office Expenses - Expense",
"archived": false,
"company_id": 20000
},
{
"id": 2,
"name": "Computer Equipment",
"classification": "Expense",
"account_type": "Expense",
"currency_code": "USD",
"account_number": "6100",
"display_name": "Computer Equipment - Expense",
"archived": false,
"company_id": 20000
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 2
}
}
V3 V1
Returns the active QuickBooks accounts the caller may use, paginated.
Returns accounts that are active, mapped to the currently connected
QuickBooks company and visible to one of the caller's roles (see
QuickBooks Online). Without an active QuickBooks
connection the list is normally empty (total_count: 0).
search- case-insensitive substring of the account number or fully qualified name.page/per_page-per_pagedefaults to the company's results-per-page setting (20 by default); there is no upper limit.
No sort order is applied; sort client-side if you need one.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/chart_of_accounts |
| V1 | GET /api/v1/chart_of_accounts |
Permissions
Any member of the company; the result is filtered by the caller's roles in
the company (accounts restricted to other roles are left out - in the
chart_of_account_list_teammember example a team member does not see
"Advertising & Marketing", which is restricted to company admin and finance).
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
search |
string | No | Substring of account number or fully qualified name. |
page |
integer | No | Page number. Default: 1. |
per_page |
integer | No | Page size (no maximum). Default: company results per page (20). |
Response
200 {"chart_of_accounts": [Chart of account, ...], "meta": {...}}.
| Field | Type | Description |
|---|---|---|
meta.current_page |
integer | Current page. |
meta.next_page |
integer | Next page or null. |
meta.prev_page |
integer | Previous page or null. |
meta.total_pages |
integer | Number of pages. |
meta.total_count |
integer | Number of matching accounts. |
V3 and V1 differences
None - identical request and response.
Get a chart of account
curl "https://app.procurementexpress.com/api/v3/chart_of_accounts/2" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/chart_of_accounts/2" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/chart_of_accounts/2",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/chart_of_accounts/2",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 2,
"name": "Computer Equipment",
"classification": "Expense",
"account_type": "Expense",
"currency_code": "USD",
"account_number": "6100",
"display_name": "Computer Equipment - Expense",
"archived": false,
"company_id": 20000
}
Example: archived (V3,
200 OK):
{
"id": 4,
"name": "Old Travel",
"classification": "Expense",
"account_type": "Expense",
"currency_code": "USD",
"account_number": "6900",
"display_name": "Old Travel - Expense",
"archived": true,
"company_id": 20000
}
V3 V1
Returns one account of the company by id, archived included.
Returns any chart of account of the company by id - archived accounts, accounts of a previously connected QuickBooks company and accounts hidden from the caller's roles included (the list filters do not apply).
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/chart_of_accounts/:id |
| V1 | GET /api/v1/chart_of_accounts/:id |
Permissions
Any member of the company. Unknown id / other company -> 404.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Chart of account id. |
Response
200 with the Chart of account object (no envelope).
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
QuickBooks Online: Classes
QuickBooks Online classes, an optional tracking dimension on PO and
invoice line items (quickbooks_class_id). Whether the field is shown or
required is controlled by qbo_class_active / qbo_class_required. A budget
can carry a class (budget.quickbooks_class), which is applied to lines when
link_budgets_to_qbo_class is on. Read-only; see
QuickBooks Online for the list filters.
Endpoints in this section
| Endpoint | Method | Path | V3 | V1 |
|---|---|---|---|---|
| List QuickBooks classes | GET |
/qbo_classes |
✓ | ✓ |
| Get a QuickBooks class | GET |
/qbo_classes/:id |
✓ | ✓ |
The quickbooks class object
| Field | Type | Description |
|---|---|---|
id |
integer | ProcurementExpress id of the class (use as quickbooks_class_id). |
fully_qualified_name |
string | Class name including parents, e.g. East:Boston. |
archived |
boolean | true when inactive in QBO or QuickBooks was disconnected. |
company_id |
integer | Owning company. |
List QuickBooks classes
curl "https://app.procurementexpress.com/api/v3/qbo_classes" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/qbo_classes" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/qbo_classes",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/qbo_classes",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"quickbooks_classes": [
{
"id": 1,
"fully_qualified_name": "Northeast",
"archived": false,
"company_id": 20000
},
{
"id": 2,
"fully_qualified_name": "West Coast",
"archived": false,
"company_id": 20000
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 2
}
}
Example: search (V3,
200 OK):
{
"quickbooks_classes": [
{
"id": 2,
"fully_qualified_name": "West Coast",
"archived": false,
"company_id": 20000
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 1
}
}
V3 V1
Returns the active QuickBooks classes the caller may use, paginated.
Same filters as the chart of accounts list: active, mapped to the connected
QuickBooks company, visible to one of the caller's roles. search matches a
substring of fully_qualified_name (case-insensitive). per_page defaults
to the company's results-per-page setting, no maximum. No sort order.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/qbo_classes |
| V1 | GET /api/v1/qbo_classes |
Permissions
Any member of the company; filtered by the caller's roles.
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
search |
string | No | Substring of the fully qualified name. |
page |
integer | No | Page number. Default: 1. |
per_page |
integer | No | Page size (no maximum). Default: company results per page (20). |
Response
200 {"quickbooks_classes": [QuickBooks class, ...], "meta": {...}} -
note the root key is quickbooks_classes, not qbo_classes.
| Field | Type | Description |
|---|---|---|
meta.current_page |
integer | Current page. |
meta.next_page |
integer | Next page or null. |
meta.prev_page |
integer | Previous page or null. |
meta.total_pages |
integer | Number of pages. |
meta.total_count |
integer | Number of matching classes. |
V3 and V1 differences
None - identical request and response.
Get a QuickBooks class
curl "https://app.procurementexpress.com/api/v3/qbo_classes/1" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/qbo_classes/1" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/qbo_classes/1",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/qbo_classes/1",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 1,
"fully_qualified_name": "Northeast",
"archived": false,
"company_id": 20000
}
V3 V1
Returns one QuickBooks class of the company by id.
Returns any class of the company by id, archived or restricted ones included.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/qbo_classes/:id |
| V1 | GET /api/v1/qbo_classes/:id |
Permissions
Any member of the company. Unknown id -> 404.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | QuickBooks class id. |
Response
200 with the QuickBooks class object.
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
QuickBooks Online: Customers
QuickBooks Online customers and projects, used to mark PO / invoice line
items as billable to a customer (qbo_customer_id). Shown / required
according to qbo_customer_active / qbo_customer_required. Read-only; see
QuickBooks Online for the list filters.
Endpoints in this section
| Endpoint | Method | Path | V3 | V1 |
|---|---|---|---|---|
| List QuickBooks customers | GET |
/qbo_customers |
✓ | ✓ |
| Get a QuickBooks customer | GET |
/qbo_customers/:id |
✓ | ✓ |
The quickbooks customer object
| Field | Type | Description |
|---|---|---|
id |
integer | ProcurementExpress id of the customer (use as qbo_customer_id). |
fully_qualified_name |
string | Customer name including parent customer, e.g. Initech LLC:Website rebuild for a project. |
archived |
boolean | true when inactive in QBO or QuickBooks was disconnected. |
company_id |
integer | Owning company. |
List QuickBooks customers
curl "https://app.procurementexpress.com/api/v3/qbo_customers" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/qbo_customers" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/qbo_customers",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/qbo_customers",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"qbo_customers": [
{
"id": 1,
"fully_qualified_name": "Initech LLC",
"archived": false,
"company_id": 20000
},
{
"id": 2,
"fully_qualified_name": "Umbrella Corporation",
"archived": false,
"company_id": 20000
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 2
}
}
Example: search (V3,
200 OK):
{
"qbo_customers": [
{
"id": 2,
"fully_qualified_name": "Umbrella Corporation",
"archived": false,
"company_id": 20000
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 1
}
}
V3 V1
Returns the active QuickBooks customers / projects the caller may use, paginated.
Same filters as the chart of accounts list: active, mapped to the connected
QuickBooks company, visible to one of the caller's roles. search matches a
substring of fully_qualified_name (case-insensitive). per_page defaults
to the company's results-per-page setting (20 when unset), no maximum.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/qbo_customers |
| V1 | GET /api/v1/qbo_customers |
Permissions
Any member of the company; filtered by the caller's roles.
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
search |
string | No | Substring of the fully qualified name. |
page |
integer | No | Page number. Default: 1. |
per_page |
integer | No | Page size (no maximum). Default: company results per page (20). |
Response
200 {"qbo_customers": [QuickBooks customer, ...], "meta": {...}}.
| Field | Type | Description |
|---|---|---|
meta.current_page |
integer | Current page. |
meta.next_page |
integer | Next page or null. |
meta.prev_page |
integer | Previous page or null. |
meta.total_pages |
integer | Number of pages. |
meta.total_count |
integer | Number of matching customers. |
V3 and V1 differences
None - identical request and response (the V3 controller computes the default page size slightly differently in code, with the same result).
Get a QuickBooks customer
curl "https://app.procurementexpress.com/api/v3/qbo_customers/1" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/qbo_customers/1" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/qbo_customers/1",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/qbo_customers/1",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 1,
"fully_qualified_name": "Initech LLC",
"archived": false,
"company_id": 20000
}
V3 V1
Returns one QuickBooks customer of the company by id.
Returns any customer of the company by id, archived or restricted ones included.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/qbo_customers/:id |
| V1 | GET /api/v1/qbo_customers/:id |
Permissions
Any member of the company. Unknown id -> 404.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | QuickBooks customer id. |
Response
200 with the QuickBooks customer object.
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
QuickBooks Online: Item Budget Mappings
For companies whose QuickBooks expense line detail is item based (QuickBooks settings -> "Item details"), an item budget mapping says: when product P is ordered by department D, code the line to budget B. On an item-based PO with a header department, a line for a mapped product is auto-coded to the mapped budget (and the budget is locked on the line).
Rules:
- One mapping per product and department (
product_id+department_idis unique). - The budget must be linked to the mapped department, and product, department and budget must belong to the company.
- A mapping becomes stale when its budget is later unlinked from the
department (or deleted). Stale mappings are ignored when coding POs and are
reported in the
stalearray of bulk update.
Availability: V3 only (V1 answers 404 Invalid URL). Every endpoint
answers 422 ["Item-based mode is not enabled for this account"] unless
the company's expense line detail is item based; this check runs before the
permission check. Clients cannot read the item-based setting through the
API, so treat that 422 as "feature not in use".
Endpoints in this section
| Endpoint | Method | Path | V3 | V1 |
|---|---|---|---|---|
| List item budget mappings | GET |
/qbo_item_budget_mappings |
✓ | — |
| Get an item budget mapping | GET |
/qbo_item_budget_mappings/:id |
✓ | — |
| Create an item budget mapping | POST |
/qbo_item_budget_mappings |
✓ | — |
| Update an item budget mapping | PUT |
/qbo_item_budget_mappings/:id |
✓ | — |
| Delete an item budget mapping | DELETE |
/qbo_item_budget_mappings/:id |
✓ | — |
| Bulk create or update item budget mappings | PUT |
/qbo_item_budget_mappings/bulk_update |
✓ | — |
The item budget mapping object
| Field | Type | Description |
|---|---|---|
id |
integer | Mapping id. |
product_id |
integer | Product (QuickBooks item) id. |
department_id |
integer | Department id. |
budget_id |
integer | Budget the product is coded to for this department. |
company_id |
integer | Owning company. |
created_at |
timestamp | ISO-8601 with UTC offset. |
updated_at |
timestamp | ISO-8601 with UTC offset. |
product_name |
string | Product display name (QuickBooks fully qualified name when known, otherwise parent:description). |
department_name |
string | Department name. |
budget_name |
string | Budget name. |
List item budget mappings
curl "https://app.procurementexpress.com/api/v3/qbo_item_budget_mappings" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
# V3 only: this endpoint has no V1 equivalent.
curl "https://app.procurementexpress.com/api/v3/qbo_item_budget_mappings" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/qbo_item_budget_mappings",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
# V3 only: this endpoint has no V1 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/qbo_item_budget_mappings",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
V3 response
200 OK(lists shortened to 2 items):
{
"qbo_item_budget_mappings": [
{
"id": 1,
"product_id": 2,
"department_id": 2,
"budget_id": 1,
"company_id": 20000,
"created_at": "2026-10-21T05:08:21.338-05:00",
"updated_at": "2026-10-21T05:08:21.338-05:00",
"product_name": "27-inch monitor",
"department_name": "Engineering",
"budget_name": "Engineering Hardware FY26"
},
{
"id": 2,
"product_id": 1,
"department_id": 4,
"budget_id": 3,
"company_id": 20000,
"created_at": "2026-10-21T05:08:29.845-05:00",
"updated_at": "2026-10-21T05:08:29.845-05:00",
"product_name": "Standing desk",
"department_name": "Operations",
"budget_name": "Office Supplies"
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 3
}
}
Example: filtered (V3,
200 OK):
{
"qbo_item_budget_mappings": [
{
"id": 1,
"product_id": 2,
"department_id": 2,
"budget_id": 1,
"company_id": 20000,
"created_at": "2026-10-21T05:08:21.338-05:00",
"updated_at": "2026-10-21T05:08:21.338-05:00",
"product_name": "27-inch monitor",
"department_name": "Engineering",
"budget_name": "Engineering Hardware FY26"
}
],
"meta": {
"current_page": 1,
"next_page": 2,
"prev_page": null,
"total_pages": 2,
"total_count": 2
}
}
V3 V1 —
Returns the company's product/department -> budget mappings, paginated.
Lists mappings of the company, optionally filtered by product_id and/or
department_id. Paginated: per_page defaults to 50, max 100.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/qbo_item_budget_mappings |
Permissions
Any member of the company (no role check on reads). Requires item-based mode (422 otherwise).
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
product_id |
integer | No | Only mappings of this product. |
department_id |
integer | No | Only mappings of this department. |
page |
integer | No | Page number. Default: 1. |
per_page |
integer | No | Page size, max 100. Default: 50. |
Response
200 {"qbo_item_budget_mappings": [Item budget mapping, ...], "meta": {current_page, next_page, prev_page, total_pages, total_count}}.
V3 and V1 differences
V3 only; GET /api/v1/qbo_item_budget_mappings answers 404 Invalid URL.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not item based | 422 |
V3 | {"error":["Item-based mode is not enabled for this account"],"status":422} |
Get an item budget mapping
curl "https://app.procurementexpress.com/api/v3/qbo_item_budget_mappings/1" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
# V3 only: this endpoint has no V1 equivalent.
curl "https://app.procurementexpress.com/api/v3/qbo_item_budget_mappings/1" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/qbo_item_budget_mappings/1",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
# V3 only: this endpoint has no V1 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/qbo_item_budget_mappings/1",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
V3 response
200 OK:
{
"id": 1,
"product_id": 2,
"department_id": 2,
"budget_id": 1,
"company_id": 20000,
"created_at": "2026-10-21T05:08:21.338-05:00",
"updated_at": "2026-10-21T05:08:21.338-05:00",
"product_name": "27-inch monitor",
"department_name": "Engineering",
"budget_name": "Engineering Hardware FY26"
}
V3 V1 —
Returns one mapping by id.
Returns a mapping of the company by id.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/qbo_item_budget_mappings/:id |
Permissions
Any member of the company. Requires item-based mode (422). Unknown id -> 404.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Mapping id. |
Response
200 with the Item budget mapping object.
V3 and V1 differences
V3 only; V1 answers 404 Invalid URL.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 404 |
V3 | {"error":"Invalid id passed","status":404} |
| Not item based | 422 |
V3 | {"error":["Item-based mode is not enabled for this account"],"status":422} |
Create an item budget mapping
curl -X POST "https://app.procurementexpress.com/api/v3/qbo_item_budget_mappings" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"qbo_item_budget_mapping": {
"product_id": 2,
"department_id": 2,
"budget_id": 1
}
}'
# V3 only: this endpoint has no V1 equivalent.
curl -X POST "https://app.procurementexpress.com/api/v3/qbo_item_budget_mappings" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"qbo_item_budget_mapping": {
"product_id": 2,
"department_id": 2,
"budget_id": 1
}
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/qbo_item_budget_mappings",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
qbo_item_budget_mapping: {
product_id: 2,
department_id: 2,
budget_id: 1
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
# V3 only: this endpoint has no V1 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/qbo_item_budget_mappings",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
qbo_item_budget_mapping: {
product_id: 2,
department_id: 2,
budget_id: 1
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
V3 response
200 OK:
{
"id": 1,
"product_id": 2,
"department_id": 2,
"budget_id": 1,
"company_id": 20000,
"created_at": "2026-10-21T05:08:21.338-05:00",
"updated_at": "2026-10-21T05:08:21.338-05:00",
"product_name": "27-inch monitor",
"department_name": "Engineering",
"budget_name": "Engineering Hardware FY26"
}
V3 V1 —
Maps a product, for one department, to a budget.
Creates a mapping. The product/department pair must not be mapped yet (use update or bulk update to change an existing pair), the budget must be linked to the department, and all three records must belong to the company.
HTTP request
| Version | Request |
|---|---|
| V3 | POST /api/v3/qbo_item_budget_mappings |
Permissions
Company admin (or the company's creator); otherwise 401 Unauthorized
access. Requires item-based mode (422, checked first).
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
qbo_item_budget_mapping[ |
integer | Yes | Product of the company. |
qbo_item_budget_mapping[ |
integer | Yes | Department of the company. |
qbo_item_budget_mapping[ |
integer | Yes | Budget linked to that department. |
Response
200 with the created mapping. Validation errors -> 400 with an array:
Product already has a budget mapping for this department,
Budget must belong to the selected department,
Product must exist / Budget must exist / Budget can't be blank,
... must belong to the same company.
Side effects
New item-based POs whose header department matches will code lines for the product to the mapped budget.
V3 and V1 differences
V3 only; V1 answers 404 Invalid URL.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Duplicate | 400 |
V3 | {"error":["Product already has a budget mapping for this department"],"status":400} |
| Wrong department | 400 |
V3 | {"error":["Budget must belong to the selected department"],"status":400} |
| Validation error | 400 |
V3 | {"error":["Product must exist","Budget must exist","Budget can't be blank"],"status":400} |
| Forbidden | 401 |
V3 | {"error":"Unauthorized access","status":401} |
| Not item based | 422 |
V3 | {"error":["Item-based mode is not enabled for this account"],"status":422} |
Update an item budget mapping
curl -X PUT "https://app.procurementexpress.com/api/v3/qbo_item_budget_mappings/3" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"qbo_item_budget_mapping": {
"budget_id": 1
}
}'
# V3 only: this endpoint has no V1 equivalent.
curl -X PUT "https://app.procurementexpress.com/api/v3/qbo_item_budget_mappings/3" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"qbo_item_budget_mapping": {
"budget_id": 1
}
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v3/qbo_item_budget_mappings/3",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
qbo_item_budget_mapping: {
budget_id: 1
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
# V3 only: this endpoint has no V1 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v3/qbo_item_budget_mappings/3",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
qbo_item_budget_mapping: {
budget_id: 1
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
V3 response
200 OK:
{
"id": 3,
"product_id": 3,
"department_id": 2,
"budget_id": 1,
"company_id": 20000,
"created_at": "2026-10-21T05:08:29.872-05:00",
"updated_at": "2026-10-21T05:08:37.425-05:00",
"product_name": "USB-C dock",
"department_name": "Engineering",
"budget_name": "Engineering Hardware FY26"
}
V3 V1 —
Changes the product, department or budget of a mapping.
Partial update; the same validations as create apply to the resulting mapping.
HTTP request
| Version | Request |
|---|---|
| V3 | `PUT |
Permissions
Company admin (or company creator); otherwise 401. Requires item-based mode (422). Unknown id -> 404.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Mapping id. |
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
qbo_item_budget_mapping[ |
integer | No | Product of the company. |
qbo_item_budget_mapping[ |
integer | No | Department of the company. |
qbo_item_budget_mapping[ |
integer | No | Budget linked to the (new) department. |
Response
200 with the updated mapping; 400 with an array of messages on validation failure.
V3 and V1 differences
V3 only; V1 answers 404 Invalid URL. PUT and PATCH both accepted.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Wrong department | 400 |
V3 | {"error":["Budget must belong to the selected department"],"status":400} |
| Forbidden | 401 |
V3 | {"error":"Unauthorized access","status":401} |
| Not item based | 422 |
V3 | {"error":["Item-based mode is not enabled for this account"],"status":422} |
Delete an item budget mapping
curl -X DELETE "https://app.procurementexpress.com/api/v3/qbo_item_budget_mappings/4" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
# V3 only: this endpoint has no V1 equivalent.
curl -X DELETE "https://app.procurementexpress.com/api/v3/qbo_item_budget_mappings/4" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :delete,
url: "https://app.procurementexpress.com/api/v3/qbo_item_budget_mappings/4",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
# V3 only: this endpoint has no V1 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :delete,
url: "https://app.procurementexpress.com/api/v3/qbo_item_budget_mappings/4",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
V3 response
200 OK:
{
"deleted": true
}
V3 V1 —
Permanently deletes a mapping.
Deletes the mapping; the product falls back to its default budget / manual selection on new POs.
HTTP request
| Version | Request |
|---|---|
| V3 | DELETE /api/v3/qbo_item_budget_mappings/:id |
Permissions
Company admin (or company creator); otherwise 401. Requires item-based mode (422). Unknown id -> 404.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Mapping id. |
Response
200 {"deleted": true}.
V3 and V1 differences
V3 only; V1 answers 404 Invalid URL.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 404 |
V3 | {"error":"Invalid id passed","status":404} |
| Forbidden | 401 |
V3 | {"error":"Unauthorized access","status":401} |
| Not item based | 422 |
V3 | {"error":["Item-based mode is not enabled for this account"],"status":422} |
Bulk create or update item budget mappings
curl -X PUT "https://app.procurementexpress.com/api/v3/qbo_item_budget_mappings/bulk_update" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"mappings": [
{
"product_id": 1,
"department_id": 4,
"budget_id": 3
},
{
"product_id": 3,
"department_id": 2,
"budget_id": 5
},
{
"product_id": 2,
"department_id": 2,
"budget_id": 1
}
]
}'
# V3 only: this endpoint has no V1 equivalent.
curl -X PUT "https://app.procurementexpress.com/api/v3/qbo_item_budget_mappings/bulk_update" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"mappings": [
{
"product_id": 1,
"department_id": 4,
"budget_id": 3
},
{
"product_id": 3,
"department_id": 2,
"budget_id": 5
},
{
"product_id": 2,
"department_id": 2,
"budget_id": 1
}
]
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v3/qbo_item_budget_mappings/bulk_update",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
mappings: [
{
product_id: 1,
department_id: 4,
budget_id: 3
},
{
product_id: 3,
department_id: 2,
budget_id: 5
},
{
product_id: 2,
department_id: 2,
budget_id: 1
}
]
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
# V3 only: this endpoint has no V1 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v3/qbo_item_budget_mappings/bulk_update",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
mappings: [
{
product_id: 1,
department_id: 4,
budget_id: 3
},
{
product_id: 3,
department_id: 2,
budget_id: 5
},
{
product_id: 2,
department_id: 2,
budget_id: 1
}
]
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
V3 response
200 OK:
{
"created": [
{
"id": 2,
"product_id": 1,
"department_id": 4,
"budget_id": 3,
"company_id": 20000,
"created_at": "2026-10-21T05:08:29.845-05:00",
"updated_at": "2026-10-21T05:08:29.845-05:00",
"product_name": "Standing desk",
"department_name": "Operations",
"budget_name": "Office Supplies"
},
{
"id": 3,
"product_id": 3,
"department_id": 2,
"budget_id": 5,
"company_id": 20000,
"created_at": "2026-10-21T05:08:29.872-05:00",
"updated_at": "2026-10-21T05:08:29.872-05:00",
"product_name": "USB-C dock",
"department_name": "Engineering",
"budget_name": "Facilities FY27"
}
],
"updated": [
{
"id": 1,
"product_id": 2,
"department_id": 2,
"budget_id": 1,
"company_id": 20000,
"created_at": "2026-10-21T05:08:21.338-05:00",
"updated_at": "2026-10-21T05:08:21.338-05:00",
"product_name": "27-inch monitor",
"department_name": "Engineering",
"budget_name": "Engineering Hardware FY26"
}
],
"stale": [
]
}
Example: stale (V3,
200 OK):
{
"created": [
],
"updated": [
{
"id": 3,
"product_id": 3,
"department_id": 2,
"budget_id": 5,
"company_id": 20000,
"created_at": "2026-10-21T05:08:29.872-05:00",
"updated_at": "2026-10-21T05:08:46.740-05:00",
"product_name": "USB-C dock",
"department_name": "Engineering",
"budget_name": "Facilities FY27"
}
],
"stale": [
3
]
}
V3 V1 —
Upserts many mappings at once, keyed by product and department.
For each entry of mappings, finds the mapping for (product_id,
department_id) or creates it, and sets its budget_id.
- An entry identical to an existing mapping is not re-validated; it is
returned in
updated, and listed instalewhen its budget no longer belongs to the department, so you can flag it. - Entries that fail validation are collected; the response is then 400 with all their messages (flattened, without saying which entry failed).
- Partial writes: the batch is rolled back only when more than 10 entries fail. With 1-10 failures the valid entries are saved even though the response is 400 - re-read the list after an error.
mappingsmust be a non-empty array (an empty or missing array -> 400Required parameter are missing.).
HTTP request
| Version | Request |
|---|---|
| V3 | PUT /api/v3/qbo_item_budget_mappings/bulk_update |
Permissions
Company admin (or company creator); otherwise 401. Requires item-based mode (422).
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
mappings[ |
array[object] | Yes | Non-empty list of entries. |
mappings[ |
integer | Yes | Product of the company. |
mappings[ |
integer | Yes | Department of the company. |
mappings[ |
integer | Yes | Budget linked to that department. |
Response
200 {"created": [mapping, ...], "updated": [mapping, ...], "stale": [mapping_id, ...]}.
In the primary example two new pairs were created and one unchanged pair was
reported in updated. In ..._stale the budget had since been unlinked from
the department, so the unchanged mapping is reported in stale.
| Field | Type | Description |
|---|---|---|
created |
array[object] | Mappings created by this request. |
updated |
array[object] | Existing mappings that were changed or submitted unchanged. |
stale |
array[integer] | Ids of unchanged mappings whose budget no longer belongs to the mapped department. |
V3 and V1 differences
V3 only; PUT /api/v1/qbo_item_budget_mappings/bulk_update answers 404
Invalid URL.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Errors | 400 |
V3 | {"error":["Budget must belong to the selected department"],"status":400} |
| Missing param | 400 |
V3 | {"error":"Required parameter are missing.","status":400} |
| Forbidden | 401 |
V3 | {"error":"Unauthorized access","status":401} |
| Not item based | 422 |
V3 | {"error":["Item-based mode is not enabled for this account"],"status":422} |
SAM.gov Supplier Audit
The SAM.gov Supplier Audit checks a supplier against the U.S. federal System for Award Management (SAM.gov) Exclusions list (FAR 9.4: debarred or suspended entities must not be awarded contracts). ProcurementExpress calls the SAM.gov Exclusions API for you, stores every result as a check record, and serves a fresh result from that store for 24 hours.
Prerequisites. These must all hold, otherwise POST /sam_gov/check returns
200 {"status": "unchecked", "supplier_id": ...} and makes no SAM.gov call:
- the Policies module is enabled for the company (feature flag
enable_policies, enabled by ProcurementExpress); - the company has opted in to SAM.gov (Company settings > Supplier tab, setting
sam_gov_enabled); - a SAM.gov API key is configured: the company's own key (stored encrypted in the company settings), or else the deployment-wide key. Get a key from SAM.gov.
GET /sam_gov/status/:supplier_id only needs (1). It returns the latest stored
check even if the company later opted out.
What a check does. It searches SAM.gov by supplier name (always), by uei
when the supplier has one, and by cage_code when it has one. It then removes
duplicate hits and stores a check. A screenshot of the SAM.gov result page is
captured in the background, so verification_screenshot_url is null until it
exists.
status |
Meaning |
|---|---|
unchecked |
No check exists or none was run (prerequisites not met). Only appears in the small {status, supplier_id} object. |
clear |
No active exclusion found. |
flagged |
At least one active exclusion found. Review exclusions. |
error |
SAM.gov could not be queried. search_params.error holds the message and search_params.error_code one of rate_limited, invalid_api_key, bad_request, unavailable. |
Caching rules (POST /sam_gov/check):
- the latest non-error check younger than 24 hours is returned as-is (
fresh: true); - an
errorcheck younger than 5 minutes is returned as-is, so a failing key is not retried on every call; force: trueskips both and always calls SAM.gov.
The supplier's uei and cage_code are set with the
Suppliers create/update endpoints.
Endpoints in this section
| Endpoint | Method | Path | V3 | V1 |
|---|---|---|---|---|
| Check a supplier against SAM.gov | POST |
/sam_gov/check |
✓ | ✓ |
| Get a supplier's latest SAM.gov check | GET |
/sam_gov/status/:supplier_id |
✓ | ✓ |
The sam.gov check object
| Field | Type | Description |
|---|---|---|
id |
integer | Check record id. |
supplier_id |
integer | Checked supplier. |
supplier_name |
string | Supplier name at the time of the check. |
uei |
string | Supplier UEI at the time of the check, or null. |
status |
string | clear, flagged or error. |
total_records |
integer | Number of (de-duplicated) exclusion records found. |
has_active_exclusions |
boolean | Whether any exclusion is currently active. |
exclusions |
array[object] | Exclusion records: entity_name, uei, cage_code, exclusion_type, exclusion_program, excluding_agency, activation_date, termination_date (null when indefinite), record_status, active (boolean). Key order is not significant. |
search_params |
object | For clear/flagged: methods (any of name, uei, cage_code), supplier_name, uei, cage_code. For error: error (message) and error_code. |
checked_at |
timestamp | ISO-8601 with offset (second precision), e.g. 2026-10-21T03:09:15-05:00. |
fresh |
boolean | true when checked_at is less than 24 hours ago (also for error checks). |
verification_screenshot_url |
string | Link to download the PNG screenshot of the SAM.gov result page. It expires after five minutes and only works for the calling user and company. null while the screenshot is pending, or for error checks. |
sam_gov_search_url |
string | Link to the same exclusion search on sam.gov (keyword = UEI, else supplier name). |
Check a supplier against SAM.gov
curl -X POST "https://app.procurementexpress.com/api/v3/sam_gov/check" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"supplier_id": 4,
"force": false
}'
curl -X POST "https://app.procurementexpress.com/api/v1/sam_gov/check" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"supplier_id": 4,
"force": false
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/sam_gov/check",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
supplier_id: 4,
force: false
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/sam_gov/check",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
supplier_id: 4,
force: false
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 1,
"supplier_id": 4,
"supplier_name": "Fabrikam Furniture",
"uei": "FABRK1234567",
"status": "clear",
"total_records": 0,
"has_active_exclusions": false,
"exclusions": [
],
"search_params": {
"uei": "FABRK1234567",
"methods": [
"name",
"uei",
"cage_code"
],
"cage_code": "1ABC2",
"supplier_name": "Fabrikam Furniture"
},
"checked_at": "2026-10-21T03:09:15-05:00",
"fresh": true,
"verification_screenshot_url": "https://app.procurementexpress.com/sam-gov-evidence/download?token=eyJfcmFpbHMiOnsibWVzc2FnZSI6ImV5SjJaWEp6YVc5dUlqb3hMQ0poZFdScFpXNWpaU0k2SW1Gd2FTSXNJblZ6WlhKZmFXUWlPalF3TURBd0xDSmpiMjF3WVc1NVgybGtJam95TURBd01Dd2ljM1Z3Y0d4cFpYSmZhV1FpT2pRc0ltTm9aV05yWDJsa0lqb3hMQ0ptYVd4bFgzWmxjbk5wYjI0aU9pSXlZMk5oWXpreFpHVmpNMlprWlRnd1lXUTROREZtTVRNeFlUYzFNREpsTjJJNE9UZzBOMkk1Wm1Oa1l6WTBZelJoT0RkbVkySmhaV00wWXpFNFpqTXhJaXdpYVhOemRXVmtYMkYwSWpveE56a3lOVGMzTXpVM0xDSmxlSEJwY21WelgyRjBJam94TnpreU5UYzNOalUzZlE9PSIsImV4cCI6IjIwMjYtMTAtMjFUMTA6MTQ6MTcuODg5WiIsInB1ciI6InNhbS1nb3YtZXZpZGVuY2Uuc2NyZWVuc2hvdC1kb3dubG9hZC52MSJ9fQ%3D%3D--e01620cd8326c5e5782cd7cc2a00e0aedd57a35516dde8031a5a6ab5668864fc",
"sam_gov_search_url": "https://sam.gov/search/?page=1&pageSize=25&sfm%5BsimpleSearch%5D%5BkeywordRadio%5D=ALL&sfm%5BsimpleSearch%5D%5BkeywordTags%5D%5B0%5D%5Bkey%5D=FABRK1234567&sfm%5BsimpleSearch%5D%5BkeywordTags%5D%5B0%5D%5Bvalue%5D=FABRK1234567&sfm%5Bstatus%5D%5Bis_active%5D=true&sfm%5Bstatus%5D%5Bis_inactive%5D=false&sort=-modifiedDate"
}
Example: flagged (V3,
200 OK):
{
"id": 2,
"supplier_id": 5,
"supplier_name": "Tailspin Electronics",
"uei": "TAILS7654321",
"status": "flagged",
"total_records": 1,
"has_active_exclusions": true,
"exclusions": [
{
"uei": "TAILS7654321",
"active": true,
"cage_code": "9XYZ8",
"entity_name": "TAILSPIN ELECTRONICS LLC",
"record_status": "Active",
"exclusion_type": "Ineligible (Proceedings Pending)",
"activation_date": "2026-03-02",
"excluding_agency": "GENERAL SERVICES ADMINISTRATION",
"termination_date": null,
"exclusion_program": "Reciprocal"
}
],
"search_params": {
"uei": "TAILS7654321",
"methods": [
"name",
"uei",
"cage_code"
],
"cage_code": "9XYZ8",
"supplier_name": "Tailspin Electronics"
},
"checked_at": "2026-10-21T04:39:15-05:00",
"fresh": true,
"verification_screenshot_url": null,
"sam_gov_search_url": "https://sam.gov/search/?page=1&pageSize=25&sfm%5BsimpleSearch%5D%5BkeywordRadio%5D=ALL&sfm%5BsimpleSearch%5D%5BkeywordTags%5D%5B0%5D%5Bkey%5D=TAILS7654321&sfm%5BsimpleSearch%5D%5BkeywordTags%5D%5B0%5D%5Bvalue%5D=TAILS7654321&sfm%5Bstatus%5D%5Bis_active%5D=true&sfm%5Bstatus%5D%5Bis_inactive%5D=false&sort=-modifiedDate"
}
Example: error backoff (V3,
200 OK):
{
"id": 3,
"supplier_id": 6,
"supplier_name": "Litware Stationery",
"uei": null,
"status": "error",
"total_records": 0,
"has_active_exclusions": false,
"exclusions": [
],
"search_params": {
"error": "SAM.gov API timeout after 1 retries: execution expired",
"error_code": "unavailable"
},
"checked_at": "2026-10-21T05:08:15-05:00",
"fresh": true,
"verification_screenshot_url": null,
"sam_gov_search_url": "https://sam.gov/search/?page=1&pageSize=25&sfm%5BsimpleSearch%5D%5BkeywordRadio%5D=ALL&sfm%5BsimpleSearch%5D%5BkeywordTags%5D%5B0%5D%5Bkey%5D=Litware+Stationery+%28sup-prod%29&sfm%5BsimpleSearch%5D%5BkeywordTags%5D%5B0%5D%5Bvalue%5D=Litware+Stationery+%28sup-prod%29&sfm%5Bstatus%5D%5Bis_active%5D=true&sfm%5Bstatus%5D%5Bis_inactive%5D=false&sort=-modifiedDate"
}
Example: not opted in (V3,
200 OK):
{
"status": "unchecked",
"supplier_id": 4
}
V3 V1
Returns a fresh SAM.gov exclusions check for the supplier, calling SAM.gov when no recent result exists.
Runs (or re-uses) a SAM.gov Exclusions check for a supplier of the company, following the caching rules in the section intro:
- If prerequisites are not met (Policies module, SAM.gov opt-in, API key), it returns
200 {"status": "unchecked", "supplier_id": <id>}and stores nothing. - Without
force: the latestclear/flaggedcheck younger than 24 h is returned, else anerrorcheck younger than 5 minutes is returned. - Otherwise SAM.gov is queried synchronously (name, then UEI and CAGE code when set; 8 s timeout, one retry on timeout), a new check is stored and returned, and a screenshot capture is queued.
SAM.gov failures that the client recognises (rate limit, rejected key, bad request,
HTTP 5xx, timeout) are stored as an error check and returned with HTTP 200.
Check status, not the HTTP code. Connection-level failures (connection refused,
DNS or TLS errors) are currently not caught and answer 500.
HTTP request
| Version | Request |
|---|---|
| V3 | POST /api/v3/sam_gov/check |
| V1 | POST /api/v1/sam_gov/check |
Permissions
The caller must be allowed to view the supplier's full record: companyadmin,
finance, rfqcaseofficer, or teammember/approver when Allow members to see
suppliers is on. Others: 401 Unauthorized access.
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
supplier_id |
integer | Yes | Supplier of the company. Unknown or missing: 404 (when the Policies module is on). |
force |
boolean | No | true ignores stored results and always queries SAM.gov. Default: false. |
Response
200 with a SAM.gov check object (status clear, flagged or error), or 200
with {"status": "unchecked", "supplier_id": ...} when no check could run.
Side effects
Creates a sam_gov_checks record per real SAM.gov call (including failed calls) and
queues a background job that captures the SAM.gov screenshot.
V3 and V1 differences
None - identical request and response (the V3 controller is a copy of V1).
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
| Unauthorized | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
| Upstream unreachable | 500 |
V3, V1 | HTML error page (no JSON body) |
Get a supplier's latest SAM.gov check
curl "https://app.procurementexpress.com/api/v3/sam_gov/status/4" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/sam_gov/status/4" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/sam_gov/status/4",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/sam_gov/status/4",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 1,
"supplier_id": 4,
"supplier_name": "Fabrikam Furniture",
"uei": "FABRK1234567",
"status": "clear",
"total_records": 0,
"has_active_exclusions": false,
"exclusions": [
],
"search_params": {
"uei": "FABRK1234567",
"methods": [
"name",
"uei",
"cage_code"
],
"cage_code": "1ABC2",
"supplier_name": "Fabrikam Furniture"
},
"checked_at": "2026-10-21T03:09:15-05:00",
"fresh": true,
"verification_screenshot_url": "https://app.procurementexpress.com/sam-gov-evidence/download?token=eyJfcmFpbHMiOnsibWVzc2FnZSI6ImV5SjJaWEp6YVc5dUlqb3hMQ0poZFdScFpXNWpaU0k2SW1Gd2FTSXNJblZ6WlhKZmFXUWlPalF3TURBd0xDSmpiMjF3WVc1NVgybGtJam95TURBd01Dd2ljM1Z3Y0d4cFpYSmZhV1FpT2pRc0ltTm9aV05yWDJsa0lqb3hMQ0ptYVd4bFgzWmxjbk5wYjI0aU9pSXlZMk5oWXpreFpHVmpNMlprWlRnd1lXUTROREZtTVRNeFlUYzFNREpsTjJJNE9UZzBOMkk1Wm1Oa1l6WTBZelJoT0RkbVkySmhaV00wWXpFNFpqTXhJaXdpYVhOemRXVmtYMkYwSWpveE56a3lOVGMzTXpVM0xDSmxlSEJwY21WelgyRjBJam94TnpreU5UYzNOalUzZlE9PSIsImV4cCI6IjIwMjYtMTAtMjFUMTA6MTQ6MTcuMzQ1WiIsInB1ciI6InNhbS1nb3YtZXZpZGVuY2Uuc2NyZWVuc2hvdC1kb3dubG9hZC52MSJ9fQ%3D%3D--00481ad98f7f647aaa6e0c43716d88da938e4e984b9de8706970321bc1891fe4",
"sam_gov_search_url": "https://sam.gov/search/?page=1&pageSize=25&sfm%5BsimpleSearch%5D%5BkeywordRadio%5D=ALL&sfm%5BsimpleSearch%5D%5BkeywordTags%5D%5B0%5D%5Bkey%5D=FABRK1234567&sfm%5BsimpleSearch%5D%5BkeywordTags%5D%5B0%5D%5Bvalue%5D=FABRK1234567&sfm%5Bstatus%5D%5Bis_active%5D=true&sfm%5Bstatus%5D%5Bis_inactive%5D=false&sort=-modifiedDate"
}
Example: flagged (V3,
200 OK):
{
"id": 2,
"supplier_id": 5,
"supplier_name": "Tailspin Electronics",
"uei": "TAILS7654321",
"status": "flagged",
"total_records": 1,
"has_active_exclusions": true,
"exclusions": [
{
"uei": "TAILS7654321",
"active": true,
"cage_code": "9XYZ8",
"entity_name": "TAILSPIN ELECTRONICS LLC",
"record_status": "Active",
"exclusion_type": "Ineligible (Proceedings Pending)",
"activation_date": "2026-03-02",
"excluding_agency": "GENERAL SERVICES ADMINISTRATION",
"termination_date": null,
"exclusion_program": "Reciprocal"
}
],
"search_params": {
"uei": "TAILS7654321",
"methods": [
"name",
"uei",
"cage_code"
],
"cage_code": "9XYZ8",
"supplier_name": "Tailspin Electronics"
},
"checked_at": "2026-10-21T04:39:15-05:00",
"fresh": true,
"verification_screenshot_url": null,
"sam_gov_search_url": "https://sam.gov/search/?page=1&pageSize=25&sfm%5BsimpleSearch%5D%5BkeywordRadio%5D=ALL&sfm%5BsimpleSearch%5D%5BkeywordTags%5D%5B0%5D%5Bkey%5D=TAILS7654321&sfm%5BsimpleSearch%5D%5BkeywordTags%5D%5B0%5D%5Bvalue%5D=TAILS7654321&sfm%5Bstatus%5D%5Bis_active%5D=true&sfm%5Bstatus%5D%5Bis_inactive%5D=false&sort=-modifiedDate"
}
Example: unchecked (V3,
200 OK):
{
"status": "unchecked",
"supplier_id": 8
}
V3 V1
Returns the most recent stored SAM.gov check for a supplier without calling SAM.gov.
Returns the most recent stored check (any status, including error) for the supplier
in this company, or {"status": "unchecked", "supplier_id": <id>} when there is none.
Never calls SAM.gov. Only the Policies module needs to be on: it answers even if the
company is not (or no longer) opted in to SAM.gov.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/sam_gov/status/:supplier_id |
| V1 | GET /api/v1/sam_gov/status/:supplier_id |
Permissions
Same as Check a supplier against SAM.gov (supplier view permission). Others:
401 Unauthorized access.
Path parameters
| Parameter | Type | Description |
|---|---|---|
supplier_id |
integer | Supplier id. |
Response
200 with a SAM.gov check object or the small unchecked object. 404 for an
unknown supplier.
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
| Unauthorized | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
Suppliers
A supplier (vendor) is a company you buy from. Purchase orders, invoices and
products reference suppliers by id. Suppliers belong to one company (the
app_company_id header company) and are never deleted through the API - set
archived: true instead.
Who sees what. Every live member of the company can pick a supplier (the PO form needs it), but only some roles see the full record:
| Caller | List / top / show return |
|---|---|
companyadmin, finance, rfqcaseofficer (or sysadmin) |
full supplier object |
teammember / approver when the company setting Allow members to see suppliers (allow_members_to_see_suppliers) is on |
full supplier object |
any other live member (e.g. a plain teammember) |
picker object only: id, name, currency_id, department_ids, archived |
Company settings that change supplier behaviour
Setting (company_setting) |
Effect on the API |
|---|---|
user_can_add_supplier (default true) |
lets a plain teammember create suppliers. When off, a team member's POST /suppliers answers 401 Unauthorized access. Admin, finance, rfqcaseofficer and supplier_admin can always create. |
add_supplier_approval + supplier_approver |
supplier approval workflow. When on, a create by anyone other than the configured supplier_approver user does not create a supplier: it files a supplier approval request (see Suppliers: Approvals) and returns a message instead of a supplier. |
Supplier approval (multi-approver, enabled per company by support) + supplier_approver_ids |
PUT /suppliers/:id by a caller without the supplier_admin role is refused with 403 / code: supplier_approval_required; such edits must go through the web app's approval flow. |
mandatory_supplier_fields |
any of email, phone_number, contact_person, tax_number, address, notes, payment_terms (checked against payment_term_id), external_vendor_id, uei, cage_code (and, with batch payments enabled, bank fields) become required on create and update: 400 with e.g. Email can't be blank. |
supplier_tax_number_required |
tax_number becomes required. |
fixed_supplier_list |
does not affect this API. It only controls whether purchase orders must pick an existing supplier (supplier_id) or may use a free-text supplier name. |
Departments. department_ids restricts a supplier to departments. A supplier with
no departments is available to every department. GET /suppliers?department_id=X
returns suppliers linked to department X plus suppliers with no department. The
list is not filtered by the caller's own departments.
Archiving. Archive or restore with PUT /suppliers/:id and supplier[archived].
GET /suppliers and GET /suppliers/top return only active suppliers unless you
pass archived=true, which returns only archived ones. Names must be unique
(case-insensitively) among suppliers with the same archived state, so an archived
and an active supplier may share a name.
Not available through the API: supplier custom field values, tags, uploads, addresses
(beyond the free-text address), ratings, and deleting or merging suppliers.
Integrations. Saving a supplier may queue syncs to connected accounting systems (Great Plains, Sage 50/200, Sage Intacct). Changing name, email, phone, contact, tax number or currency on a Xero-synced supplier marks it as not yet synced.
Endpoints in this section
| Endpoint | Method | Path | V3 | V1 |
|---|---|---|---|---|
| List suppliers | GET |
/suppliers |
✓ | ✓ |
| List the caller's most used suppliers | GET |
/suppliers/top |
✓ | ✓ |
| Get a supplier | GET |
/suppliers/:id |
✓ | ✓ |
| Create a supplier | POST |
/suppliers |
✓ | ✓ |
| Update a supplier | PUT |
/suppliers/:id |
✓ | ✓ |
The supplier object
| Field | Type | Description |
|---|---|---|
id |
integer | Supplier id. |
name |
string | Name. Required, 1-255 characters, whitespace squished. Unique (case-insensitive) per company among suppliers with the same archived value. |
company_id |
integer | Owning company. |
notes |
string | Free-text notes. |
phone_number |
string | Phone number (max 255). |
address |
string | Free-text address (lines separated by \r\n). |
email |
string | Main email address the PO is sent to. |
payment_details |
string | Free-text payment details. |
archived |
boolean | true when archived. |
contact_person |
string | Contact name (max 255). |
tax_number |
string | Tax / VAT number (max 255). |
payment_terms |
string | Free-text payment terms label (for example Net 30). |
created_at |
timestamp | ISO-8601 with the company time-zone offset, e.g. 2026-10-21T05:00:12.451-05:00. |
updated_at |
timestamp | ISO-8601 with offset. |
currency_id |
integer | Default currency for POs to this supplier (see the currencies endpoint), or null. |
department_ids |
array[integer] | Departments the supplier is restricted to. Empty means all departments. |
external_vendor_id |
string | Your ERP vendor number. Unique per company among suppliers with the same archived state, max 64 characters. |
uei |
string | SAM.gov Unique Entity Identifier (used by the SAM.gov audit). |
cage_code |
string | CAGE code (used by the SAM.gov audit). |
third_party_id_mappings |
array[object] | Integration id mappings, returned as the raw records: id, service (one of xero, zapier, quickbooks, sage50, sage200, sage200_api, mindee, llm, greatplains, sage_intacct, lightyear), third_party_id, realm_id, synced, migrated, meta, invoice_id, purchase_order_id, third_party_id_mappable_type, third_party_id_mappable_id, created_at, updated_at. |
List suppliers
curl "https://app.procurementexpress.com/api/v3/suppliers?search=sup-prod" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/suppliers?search=sup-prod" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/suppliers",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
params: {
search: "sup-prod"
}
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/suppliers",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
params: {
search: "sup-prod"
}
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
V3 response
200 OK(lists shortened to 2 items):
{
"suppliers": [
{
"id": 4,
"name": "Fabrikam Furniture",
"company_id": 20000,
"notes": "Office furniture and fit-out",
"phone_number": "+1 555 0501",
"address": "12 Industrial Ave, Grand Rapids, MI 49503",
"email": "orders@fabrikam.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Ana Trujillo-Reyes",
"tax_number": "38-2223334",
"payment_terms": "Net 60",
"created_at": "2026-10-21T05:00:12.451-05:00",
"updated_at": "2026-10-21T05:00:45.684-05:00",
"currency_id": 2000,
"department_ids": [
2
],
"external_vendor_id": "VEND-1001",
"uei": "FABRK1234567",
"cage_code": "1ABC2",
"third_party_id_mappings": [
]
},
{
"id": 6,
"name": "Litware Stationery",
"company_id": 20000,
"notes": null,
"phone_number": null,
"address": null,
"email": null,
"payment_details": null,
"archived": false,
"contact_person": null,
"tax_number": null,
"payment_terms": null,
"created_at": "2026-10-21T05:00:20.013-05:00",
"updated_at": "2026-10-21T05:00:46.216-05:00",
"currency_id": null,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 3
}
}
V1 response
200 OK(lists shortened to 2 items):
[
{
"id": 4,
"name": "Fabrikam Furniture",
"company_id": 20000,
"notes": "Office furniture and fit-out",
"phone_number": "+1 555 0501",
"address": "12 Industrial Ave, Grand Rapids, MI 49503",
"email": "orders@fabrikam.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Ana Trujillo-Reyes",
"tax_number": "38-2223334",
"payment_terms": "Net 60",
"created_at": "2026-10-21T05:00:12.451-05:00",
"updated_at": "2026-10-21T05:00:45.684-05:00",
"currency_id": 2000,
"department_ids": [
2
],
"external_vendor_id": "VEND-1001",
"uei": "FABRK1234567",
"cage_code": "1ABC2",
"third_party_id_mappings": [
]
},
{
"id": 6,
"name": "Litware Stationery",
"company_id": 20000,
"notes": null,
"phone_number": null,
"address": null,
"email": null,
"payment_details": null,
"archived": false,
"contact_person": null,
"tax_number": null,
"payment_terms": null,
"created_at": "2026-10-21T05:00:20.013-05:00",
"updated_at": "2026-10-21T05:00:46.216-05:00",
"currency_id": null,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
}
]
Example: paginated (V3,
200 OK) (lists shortened to 2 items):
{
"suppliers": [
{
"id": 4,
"name": "Fabrikam Furniture",
"company_id": 20000,
"notes": "Office furniture and fit-out",
"phone_number": "+1 555 0501",
"address": "12 Industrial Ave, Grand Rapids, MI 49503",
"email": "orders@fabrikam.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Ana Trujillo-Reyes",
"tax_number": "38-2223334",
"payment_terms": "Net 60",
"created_at": "2026-10-21T05:00:12.451-05:00",
"updated_at": "2026-10-21T05:00:45.684-05:00",
"currency_id": 2000,
"department_ids": [
2
],
"external_vendor_id": "VEND-1001",
"uei": "FABRK1234567",
"cage_code": "1ABC2",
"third_party_id_mappings": [
]
},
{
"id": 6,
"name": "Litware Stationery",
"company_id": 20000,
"notes": null,
"phone_number": null,
"address": null,
"email": null,
"payment_details": null,
"archived": false,
"contact_person": null,
"tax_number": null,
"payment_terms": null,
"created_at": "2026-10-21T05:00:20.013-05:00",
"updated_at": "2026-10-21T05:00:46.216-05:00",
"currency_id": null,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 3
}
}
Example: archived (V3,
200 OK):
{
"suppliers": [
{
"id": 7,
"name": "Wide World Importers",
"company_id": 20000,
"notes": null,
"phone_number": null,
"address": null,
"email": "hello@wideworld.example.com",
"payment_details": null,
"archived": true,
"contact_person": null,
"tax_number": null,
"payment_terms": null,
"created_at": "2026-10-21T05:00:37.246-05:00",
"updated_at": "2026-10-21T05:01:00.568-05:00",
"currency_id": null,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
},
{
"id": 8,
"name": "Wingtip Supplies",
"company_id": 20000,
"notes": null,
"phone_number": null,
"address": null,
"email": "hello@wingtip.example.com",
"payment_details": null,
"archived": true,
"contact_person": null,
"tax_number": null,
"payment_terms": null,
"created_at": "2026-10-21T05:00:37.434-05:00",
"updated_at": "2026-10-21T05:01:00.764-05:00",
"currency_id": null,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 2
}
}
Example: by department (V3,
200 OK) (lists shortened to 2 items):
{
"suppliers": [
{
"id": 2,
"name": "Contoso Hardware",
"company_id": 20000,
"notes": null,
"phone_number": "+1 555 0300",
"address": "500 Tech Park, Austin, TX 78701",
"email": "sales@contoso.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Diego Ramos",
"tax_number": "74-7654321",
"payment_terms": "Net 45",
"created_at": "2026-10-21T04:48:43.563-05:00",
"updated_at": "2026-10-21T04:48:43.563-05:00",
"currency_id": 2000,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
},
{
"id": 3,
"name": "Globex Logistics",
"company_id": 20000,
"notes": null,
"phone_number": "+1 555 0400",
"address": "77 Harbor Blvd, Oakland, CA 94607",
"email": "billing@globex.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Hank Scorpio",
"tax_number": "94-1112223",
"payment_terms": "Due on receipt",
"created_at": "2026-10-21T04:48:43.570-05:00",
"updated_at": "2026-10-21T04:48:43.570-05:00",
"currency_id": 2000,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 5
}
}
Example: as teammember (V3,
200 OK) (lists shortened to 2 items):
{
"suppliers": [
{
"id": 2,
"name": "Contoso Hardware",
"currency_id": 2000,
"department_ids": [
],
"archived": false
},
{
"id": 4,
"name": "Fabrikam Furniture",
"currency_id": 2000,
"department_ids": [
2,
4
],
"archived": false
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 8
}
}
V3 V1
Returns the company's active (or archived) suppliers, sorted by name.
Returns suppliers of the app_company_id company sorted case-insensitively by name.
- Archived: only active suppliers by default;
archived=truereturns only archived ones. - Search:
searchis a case-insensitive substring match on name, email or phone number. - Department:
department_idreturns suppliers linked to that department plus suppliers with no department. An unknown department id answers404. - Pagination differs by version (see Version differences). V1 returns a bare
array of every matching supplier unless
pageis given; then it returns{suppliers: [...], meta: {...}}withper_pagerecords per page. V3 always paginates, 20 per page.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/suppliers |
| V1 | GET /api/v1/suppliers |
Permissions
Any live member of the company. companyadmin, finance and rfqcaseofficer (and
teammember/approver when Allow members to see suppliers is on) get the full
supplier object. Every other member gets the picker object (id, name,
currency_id, department_ids, archived). See suppliers_list_as_teammember.
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
search |
string | No | Case-insensitive substring match on name, email or phone number. |
archived |
boolean | No | true returns only archived suppliers; otherwise only active ones. Default: false. |
department_id |
integer | No | Only suppliers linked to this department, plus suppliers with no department. Must be a department of the company (else 404). |
page |
integer | No | V1: enables pagination and the {suppliers, meta} envelope. V3: page number (default 1). |
per_page |
integer | No | V1 only, with page: one of 10, 20, 50, 100. Any other value falls back to 10. Ignored by V3 (always 20). Default: 10. |
show_mappings |
boolean | No | Accepted for backwards compatibility; has no effect (third_party_id_mappings is always included). |
Response
200. V1 without page: a JSON array of suppliers. V1 with page, and always in
V3: {"suppliers": [...], "meta": {current_page, next_page, prev_page, total_pages, total_count}}.
| Field | Type | Description |
|---|---|---|
meta.current_page |
integer | Current page. |
meta.next_page |
integer | Next page number or null. |
meta.prev_page |
integer | Previous page number or null. |
meta.total_pages |
integer | Number of pages. |
meta.total_count |
integer | Number of matching suppliers. |
V3 and V1 differences
- V1 returns a bare array (all records, no limit) unless
pageis passed. Withpage, it returns the{suppliers, meta}envelope withper_page(10/20/50/100, default 10) records per page. - V3 always returns the
{suppliers, meta}envelope with a fixed 20 records per page and ignoresper_page. - Filters, ordering, fields and errors are identical.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Department not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
List the caller's most used suppliers
curl "https://app.procurementexpress.com/api/v3/suppliers/top?top=3" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/suppliers/top?top=3" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/suppliers/top",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
params: {
top: "3"
}
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/suppliers/top",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
params: {
top: "3"
}
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
[
{
"id": 2,
"name": "Contoso Hardware",
"company_id": 20000,
"notes": null,
"phone_number": "+1 555 0300",
"address": "500 Tech Park, Austin, TX 78701",
"email": "sales@contoso.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Diego Ramos",
"tax_number": "74-7654321",
"payment_terms": "Net 45",
"created_at": "2026-10-21T04:48:43.563-05:00",
"updated_at": "2026-10-21T04:48:43.563-05:00",
"currency_id": 2000,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
},
{
"id": 1,
"name": "Northwind Office Supplies",
"company_id": 20000,
"notes": "Preferred stationery vendor",
"phone_number": "+1 555 0200",
"address": "1 Commerce Way, Chicago, IL 60601",
"email": "orders@northwind.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Laura Callahan",
"tax_number": "36-1234567",
"payment_terms": "Net 30",
"created_at": "2026-10-21T04:48:43.487-05:00",
"updated_at": "2026-10-21T04:48:43.487-05:00",
"currency_id": 2000,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
}
]
Example: fallback (V3,
200 OK):
[
{
"id": 2,
"name": "Contoso Hardware",
"currency_id": 2000,
"department_ids": [
],
"archived": false
},
{
"id": 4,
"name": "Fabrikam Furniture",
"currency_id": 2000,
"department_ids": [
2
],
"archived": false
}
]
Example: archived (V3,
200 OK):
[
{
"id": 7,
"name": "Wide World Importers",
"company_id": 20000,
"notes": null,
"phone_number": null,
"address": null,
"email": "hello@wideworld.example.com",
"payment_details": null,
"archived": true,
"contact_person": null,
"tax_number": null,
"payment_terms": null,
"created_at": "2026-10-21T05:00:37.246-05:00",
"updated_at": "2026-10-21T05:01:00.568-05:00",
"currency_id": null,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
},
{
"id": 8,
"name": "Wingtip Supplies",
"company_id": 20000,
"notes": null,
"phone_number": null,
"address": null,
"email": "hello@wingtip.example.com",
"payment_details": null,
"archived": true,
"contact_person": null,
"tax_number": null,
"payment_terms": null,
"created_at": "2026-10-21T05:00:37.434-05:00",
"updated_at": "2026-10-21T05:01:00.764-05:00",
"currency_id": null,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
}
]
V3 V1
Returns up to top suppliers the calling user has raised the most purchase orders with.
Ranks the company's suppliers by the number of purchase orders the calling user
created with each one, and returns at most top of them, most used first. The
list is not padded: a user with POs to two suppliers gets two suppliers even with
top=5.
If the user has no such purchase orders, it falls back to the first top
suppliers in name order, using the same search, archived and department_id
filters as List suppliers (see suppliers_top_fallback).
department_id also applies to the ranked list. search applies only to the fallback.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/suppliers/top |
| V1 | GET /api/v1/suppliers/top |
Permissions
Same as List suppliers: any live member; non-admin members get the picker object.
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
top |
integer | No | Maximum number of suppliers. Default: 5. |
archived |
boolean | No | true ranks/returns archived suppliers only. Default: false. |
department_id |
integer | No | Restrict to suppliers of this department plus suppliers with no department (404 if unknown). |
search |
string | No | Only used by the fallback (user has no POs): substring match on name, email or phone. |
Response
200 with a bare JSON array of suppliers (never paginated, no meta).
V3 and V1 differences
None - identical request and response (V3 casts top to an integer before use; V1
passes it through; both behave the same for numeric values).
Get a supplier
curl "https://app.procurementexpress.com/api/v3/suppliers/4" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/suppliers/4" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/suppliers/4",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/suppliers/4",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 4,
"name": "Fabrikam Furniture",
"company_id": 20000,
"notes": "Office furniture and fit-out",
"phone_number": "+1 555 0501",
"address": "12 Industrial Ave, Grand Rapids, MI 49503",
"email": "orders@fabrikam.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Ana Trujillo-Reyes",
"tax_number": "38-2223334",
"payment_terms": "Net 60",
"created_at": "2026-10-21T05:00:12.451-05:00",
"updated_at": "2026-10-21T05:00:45.684-05:00",
"currency_id": 2000,
"department_ids": [
2
],
"external_vendor_id": "VEND-1001",
"uei": "FABRK1234567",
"cage_code": "1ABC2",
"third_party_id_mappings": [
]
}
Example: as teammember (V3,
200 OK):
{
"id": 4,
"name": "Fabrikam Furniture",
"currency_id": 2000,
"department_ids": [
2
],
"archived": false
}
V3 V1
Returns one supplier of the company.
Returns the supplier with the given id if it belongs to the app_company_id company
(archived suppliers included).
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/suppliers/:id |
| V1 | GET /api/v1/suppliers/:id |
Permissions
Any live member of the company may read it. The full object is returned to
companyadmin, finance, rfqcaseofficer (and to teammember/approver when
Allow members to see suppliers is on). Everyone else gets the picker object
(suppliers_show_as_teammember). Department membership is not checked.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Supplier id. |
Response
200 with the supplier object. 404 {"error": "Invalid id passed"} when the id is
not a supplier of the company.
V3 and V1 differences
None - identical request and response. (Authentication failures differ as for every
endpoint: V1 answers 401 {"error": "Authentication token is invalid"}; V3 answers
401 with an empty body and a WWW-Authenticate header.)
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
| No company | 401 |
V3, V1 | {"error":"App company id is nil","status":401} |
| Bad token | 401 |
V3, V1 | (empty body) |
Create a supplier
curl -X POST "https://app.procurementexpress.com/api/v3/suppliers" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"supplier": {
"name": "Tailspin Electronics",
"email": "sales@tailspin.example.com",
"cc_emails": "ar@tailspin.example.com",
"address": "88 Circuit Blvd, San Jose, CA 95134",
"contact_person": "Mei Chen",
"phone_number": "+1 555 0600",
"tax_number": "77-4445556",
"currency_id": 2000,
"payment_term_id": 3,
"payment_terms": "Net 30",
"notes": "Peripherals and cabling",
"external_vendor_id": "VEND-1002",
"department_ids": [
2
],
"uei": "TAILS7654321",
"cage_code": "9XYZ8"
}
}'
curl -X POST "https://app.procurementexpress.com/api/v1/suppliers" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"supplier": {
"name": "Fabrikam Furniture",
"email": "orders@fabrikam.example.com",
"cc_emails": "ap@fabrikam.example.com, sales@fabrikam.example.com",
"address": "12 Industrial Ave, Grand Rapids, MI 49503",
"contact_person": "Ana Trujillo",
"phone_number": "+1 555 0500",
"tax_number": "38-2223334",
"currency_id": 2000,
"payment_term_id": 3,
"payment_terms": "Net 30",
"notes": "Office furniture and fit-out",
"external_vendor_id": "VEND-1001",
"department_ids": [
2,
4
],
"uei": "FABRK1234567",
"cage_code": "1ABC2"
}
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/suppliers",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
supplier: {
name: "Tailspin Electronics",
email: "sales@tailspin.example.com",
cc_emails: "ar@tailspin.example.com",
address: "88 Circuit Blvd, San Jose, CA 95134",
contact_person: "Mei Chen",
phone_number: "+1 555 0600",
tax_number: "77-4445556",
currency_id: 2000,
payment_term_id: 3,
payment_terms: "Net 30",
notes: "Peripherals and cabling",
external_vendor_id: "VEND-1002",
department_ids: [2],
uei: "TAILS7654321",
cage_code: "9XYZ8"
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/suppliers",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
supplier: {
name: "Fabrikam Furniture",
email: "orders@fabrikam.example.com",
cc_emails: "ap@fabrikam.example.com, sales@fabrikam.example.com",
address: "12 Industrial Ave, Grand Rapids, MI 49503",
contact_person: "Ana Trujillo",
phone_number: "+1 555 0500",
tax_number: "38-2223334",
currency_id: 2000,
payment_term_id: 3,
payment_terms: "Net 30",
notes: "Office furniture and fit-out",
external_vendor_id: "VEND-1001",
department_ids: [2, 4],
uei: "FABRK1234567",
cage_code: "1ABC2"
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 5,
"name": "Tailspin Electronics",
"company_id": 20000,
"notes": "Peripherals and cabling",
"phone_number": "+1 555 0600",
"address": "88 Circuit Blvd, San Jose, CA 95134",
"email": "sales@tailspin.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Mei Chen",
"tax_number": "77-4445556",
"payment_terms": "Net 30",
"created_at": "2026-10-21T05:00:12.726-05:00",
"updated_at": "2026-10-21T05:00:12.726-05:00",
"currency_id": 2000,
"department_ids": [
2
],
"external_vendor_id": "VEND-1002",
"uei": "TAILS7654321",
"cage_code": "9XYZ8",
"third_party_id_mappings": [
]
}
Example: pending approval (V3,
200 OK):
{
"message": "Your request to create a new supplier was sent to jane.doe@example.com, check your email for updates."
}
Example: as teammember (V3,
200 OK):
{
"id": 8,
"name": "Wingtip Supplies",
"company_id": 20000,
"notes": null,
"phone_number": null,
"address": null,
"email": "hello@wingtip.example.com",
"payment_details": null,
"archived": false,
"contact_person": null,
"tax_number": null,
"payment_terms": null,
"created_at": "2026-10-21T05:00:37.434-05:00",
"updated_at": "2026-10-21T05:00:37.434-05:00",
"currency_id": null,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
}
V3 V1
Creates a supplier, or files a supplier approval request when the approval workflow is on.
Creates a supplier in the app_company_id company and returns it (200).
Supplier approval workflow. When the company setting add_supplier_approval is
on and the caller is not the user configured as supplier_approver, no supplier
is created. A supplier approval request is filed and the approver(s) are emailed.
The response is then 200 with only a message (and, in V1, pending_approval: true)
and no supplier id (suppliers_create_pending_approval). The request shows up in
GET /supplier_approvals.
Validation (400, error is an array of messages):
namerequired, 1-255 characters, unique per company (case-insensitive, among suppliers with the same archived state):Name must be unique.external_vendor_idunique per company (External vendor has already registered), max 64 characters.cc_emailscomma-separated valid emails, max 255 characters in total.- Fields listed in the company's Mandatory supplier fields setting, and
tax_numberwhen Supplier tax number required is on. - In approval mode the name must also not match an existing active supplier
(
Name Supplier name must be unique.) or another non-rejected request (Name has already been taken, from code).
Request format. Send the attributes under a supplier object. With a JSON body,
top-level keys that are supplier attribute names are also wrapped into supplier
automatically, but always send the supplier wrapper. A body without any supplier
attributes answers 400 Required parameter are missing.
Source. The record's source is taken from the optional APP_SOURCE header
(default api). An unrecognised value currently causes a 500.
Side effects. Queues exports to connected accounting systems (Great Plains, Sage 50/200, Sage Intacct). Paper-trail audit entry. In approval mode, approval emails are sent.
HTTP request
| Version | Request |
|---|---|
| V3 | POST /api/v3/suppliers |
| V1 | POST /api/v1/suppliers |
Permissions
companyadmin, finance, rfqcaseofficer, supplier_admin (and sysadmin) may always
create. A teammember may create only while the company setting Users can add
suppliers (user_can_add_supplier, default on) is on. Otherwise: 401 {"error": "Unauthorized access"}
(suppliers_create_not_allowed). Note that a team member who creates a supplier
receives the full supplier object in the response even if they can only see picker
fields when listing.
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
supplier[ |
string | Yes | Name, 1-255 characters, unique per company (case-insensitive). |
supplier[ |
string | No | Main email (the PO is sent here). Must be a valid address. Required when listed in mandatory supplier fields. |
supplier[ |
string | No | Comma-separated CC addresses, max 255 characters. Not returned in the response. |
supplier[ |
string | No | Free-text address. |
supplier[ |
string | No | Notes. |
supplier[ |
string | No | Free-text payment details. |
supplier[ |
string | No | Phone number, max 255. |
supplier[ |
string | No | Contact name, max 255. |
supplier[ |
string | No | Tax/VAT number, max 255. Required when the company requires tax numbers. |
supplier[ |
integer | No | Default currency id. Must be an existing currency (an unknown id currently causes a 500). |
supplier[ |
string | No | Free-text payment terms label, returned as payment_terms. |
supplier[ |
integer | No | Id of one of the company's payment terms. Not returned in the response. This is the field checked when payment_terms is a mandatory field. |
supplier[ |
boolean | No | Create directly as archived. Default: false. |
supplier[ |
string | No | ERP vendor number, unique per company, max 64. |
supplier[ |
string | No | SAM.gov Unique Entity Identifier (12 characters). |
supplier[ |
string | No | CAGE code (5 characters). |
supplier[ |
array[integer] | No | Departments to restrict the supplier to. Empty or omitted = available to all departments. An unknown id answers 404. |
supplier[ |
string | No | Integration service: xero, zapier, quickbooks, sage50, sage200, sage200_api, mindee, llm, greatplains, sage_intacct or lightyear (else 400 ... is not a valid service). |
supplier[ |
string | No | The supplier's id in that service. |
supplier[ |
string | No | Realm / tenant id in that service (e.g. QuickBooks realm). |
supplier[ |
string | No | Shortcut: adds a xero mapping with this id. |
supplier[ |
string | No | Shortcut: adds a zapier mapping with this id. |
supplier[ |
string | No | Shortcut: adds a quickbooks mapping with this id. |
supplier[ |
string | No | Bank details for batch payments (sensitive; never echoed back). Also accepted: beneficiary_id, bank_account_number, bank_country, bank_address_line1, bank_address_line2, bank_city, national_clearing_code, branch_name, sort_code, swift_code, payment_reference, beneficiary_classification, and id (to update an existing entry). |
supplier[ |
array[integer] | No | Payment providers configured in the company. At least one is required as soon as any bank field is sent (Supplier payment details payment provider ids required at least one provider). Each provider decides which bank fields are then required. |
Response
200 (not 201) with the created supplier object. In approval mode 200 with
{"message": "Your request to create a new supplier was sent to <approver email>, check your email for updates."}
(V1 adds "pending_approval": true). Validation errors: 400 with
{"error": [messages...], "status": 400} (V1 adds pending_approval).
Side effects
Accounting-system export jobs (Great Plains, Sage 50/200, Sage Intacct) when connected; audit-log (paper trail) entry; in approval mode, approval-request emails to the approver(s).
V3 and V1 differences
- Approval-mode success: V1 returns
{message, pending_approval: true}, V3 returns{message}only. - Validation errors: both
400. V1 addspending_approval(falsefor a normal create,truewhen the failed record was an approval request). V3 has no such key. - Permitted parameters, validation and permissions are identical.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Validation error | 400 |
V3, V1 | {"error":["Name must be unique.","External vendor has already registered","Cc emails does not appear to be a valid e-mail address"],"status":400} |
| Name missing | 400 |
V3, V1 | {"error":["Name can't be blank","Name is too short (minimum is 1 character)"],"status":400} |
| Mandatory field missing | 400 |
V3, V1 | {"error":["Email can't be blank"],"status":400} |
| Params missing | 400 |
V3, V1 | {"error":"Required parameter are missing.","status":400} |
| Not allowed | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
| Pending approval error | 400 |
V3, V1 | {"error":["Name Supplier name must be unique."],"status":400} |
Update a supplier
curl -X PUT "https://app.procurementexpress.com/api/v3/suppliers/5" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"supplier": {
"contact_person": "Mei Chen",
"phone_number": "+1 555 0601",
"payment_term_id": 4,
"payment_terms": "Net 60",
"department_ids": [
2,
3
]
}
}'
curl -X PUT "https://app.procurementexpress.com/api/v1/suppliers/4" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"supplier": {
"contact_person": "Ana Trujillo-Reyes",
"phone_number": "+1 555 0501",
"payment_term_id": 4,
"payment_terms": "Net 60",
"department_ids": [
2
]
}
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v3/suppliers/5",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
supplier: {
contact_person: "Mei Chen",
phone_number: "+1 555 0601",
payment_term_id: 4,
payment_terms: "Net 60",
department_ids: [2, 3]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v1/suppliers/4",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
supplier: {
contact_person: "Ana Trujillo-Reyes",
phone_number: "+1 555 0501",
payment_term_id: 4,
payment_terms: "Net 60",
department_ids: [2]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 5,
"name": "Tailspin Electronics",
"company_id": 20000,
"notes": "Peripherals and cabling",
"phone_number": "+1 555 0601",
"address": "88 Circuit Blvd, San Jose, CA 95134",
"email": "sales@tailspin.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Mei Chen",
"tax_number": "77-4445556",
"payment_terms": "Net 60",
"created_at": "2026-10-21T05:00:12.726-05:00",
"updated_at": "2026-10-21T05:00:45.947-05:00",
"currency_id": 2000,
"department_ids": [
2,
3
],
"external_vendor_id": "VEND-1002",
"uei": "TAILS7654321",
"cage_code": "9XYZ8",
"third_party_id_mappings": [
]
}
Example: archive (V3,
200 OK):
{
"id": 8,
"name": "Wingtip Supplies",
"company_id": 20000,
"notes": null,
"phone_number": null,
"address": null,
"email": "hello@wingtip.example.com",
"payment_details": null,
"archived": true,
"contact_person": null,
"tax_number": null,
"payment_terms": null,
"created_at": "2026-10-21T05:00:37.434-05:00",
"updated_at": "2026-10-21T05:01:00.764-05:00",
"currency_id": null,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
}
Example: mappings (V3,
200 OK):
{
"id": 5,
"name": "Tailspin Electronics",
"company_id": 20000,
"notes": "Peripherals and cabling",
"phone_number": "+1 555 0601",
"address": "88 Circuit Blvd, San Jose, CA 95134",
"email": "sales@tailspin.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Mei Chen",
"tax_number": "77-4445556",
"payment_terms": "Net 60",
"created_at": "2026-10-21T05:00:12.726-05:00",
"updated_at": "2026-10-21T05:00:45.947-05:00",
"currency_id": 2000,
"department_ids": [
2,
3
],
"external_vendor_id": "VEND-1002",
"uei": "TAILS7654321",
"cage_code": "9XYZ8",
"third_party_id_mappings": [
{
"id": 13,
"created_at": "2026-10-21T05:07:08.512-05:00",
"invoice_id": null,
"meta": {
},
"migrated": false,
"purchase_order_id": null,
"realm_id": null,
"service": "zapier",
"synced": false,
"third_party_id": "ZAP-TAILSPIN-01",
"third_party_id_mappable_id": 5,
"third_party_id_mappable_type": "Supplier",
"updated_at": "2026-10-21T05:07:08.512-05:00"
}
]
}
V3 V1
Updates the given attributes of a supplier; also used to archive and restore.
Updates only the attributes you send and returns the full supplier. Archive with
{"supplier": {"archived": true}}, restore with false (restoring fails with
Name must be unique. if an active supplier already has the same name).
supplier[department_ids][] replaces the whole department list (send [] to make the
supplier available to all departments). third_party_id_mappings_attributes entries
are added; existing mappings cannot be changed or removed through the API.
Supplier approval. When the company has the multi-approver supplier approval
feature with at least one selected supplier approver, and the caller does not hold
the supplier_admin role, the update is refused: 403 with
code: "supplier_approval_required". Such edits (for example bank details) must be
made in the web app, where they go through approval.
Validation is the same as on create, including mandatory supplier fields.
HTTP request
| Version | Request |
|---|---|
| V3 | `PUT |
| V1 | `PUT |
Permissions
companyadmin, finance, rfqcaseofficer, supplier_admin (or sysadmin) of the
supplier's company. A teammember or approver cannot update suppliers, even with
Users can add suppliers on: 401 Unauthorized access.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Supplier id. |
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
supplier[ |
object | Yes | Any of the attributes accepted by Create a supplier (same names, types and rules): name, email, cc_emails, address, notes, payment_details, phone_number, archived, tax_number, contact_person, currency_id, payment_terms, payment_term_id, external_vendor_id, uei, cage_code, xero_id, zapier_id, quickbooks_id, department_ids[], third_party_id_mappings_attributes[], supplier_payment_details_attributes[] (pass id to update an existing bank-detail entry). |
supplier[ |
boolean | No | true archives, false restores. |
supplier[ |
array[integer] | No | Replaces the department list. |
Response
200 with the full supplier object. 400 {"error": [...]} on validation errors,
404 for an unknown id, 401 when the role may not edit suppliers, 403
{"error": "Supplier changes need approval; please edit on the web.", "code": "supplier_approval_required"}
under supplier approval.
Side effects
Accounting-system update jobs (Great Plains, Sage Intacct export / inactivate on
archive / reactivate on restore), Xero sync flag reset when synced fields change,
department cache refresh when departments or archived change, audit-log entry.
V3 and V1 differences
None - identical request and response (PUT and PATCH accepted in both).
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Validation error | 400 |
V3, V1 | {"error":["Name must be unique."],"status":400} |
| Not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
| Unauthorized | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
| Approval required | 403 |
V3, V1 | {"error":"Supplier changes need approval; please edit on the web.","code":"supplier_approval_required","status":403} |
Suppliers: Approvals
When the supplier approval workflow is on, suppliers requested by users (other than the configured approver) are stored as supplier approval requests until an approver accepts or rejects them in the web app. The V1 API can list these requests; approving or rejecting them is only possible in the web app.
Prerequisites (both are required):
- The company setting Supplier approval (
company_setting.add_supplier_approval, Company settings > Supplier tab) must be on. Otherwise the endpoint answers400Please enable supplier approval setting from your company setting, Supplier tab. - API access to supplier approvals must be enabled for the company by the
ProcurementExpress support/success team (feature flag
enable_supplier_approval_api_access). Otherwise the endpoint answers400Please contact support/success team to enable supplier approval.
Requests are created with POST /suppliers (see Create a supplier):
while the setting is on, a create by anyone other than the user configured as
supplier_approver files a request instead of creating a supplier.
status |
Meaning |
|---|---|
pending |
waiting for an approver |
approved |
accepted; a supplier was created from it |
rejected |
rejected by an approver |
Endpoints in this section
| Endpoint | Method | Path | V3 | V1 |
|---|---|---|---|---|
| List supplier approval requests | GET |
/supplier_approvals |
— | ✓ |
The supplier approval request object
| Field | Type | Description |
|---|---|---|
id |
integer | Approval request id (not a supplier id). |
name |
string | Requested supplier name. |
notes |
string | Notes. |
phone_number |
string | Phone number. |
address |
string | Address. |
email |
string | Email. |
status |
string | pending, approved or rejected. |
requester |
object | User who requested it: id, email, name, roles (that user's current roles). {} if unknown. |
approver |
object | User who approved/rejected it (same shape), or {} while pending. |
created_at |
timestamp | Unix seconds (note: suppliers use ISO-8601). |
updated_at |
timestamp | Unix seconds. |
uei |
string | SAM.gov UEI. |
cage_code |
string | CAGE code. |
List supplier approval requests
# V1 only: this endpoint has no V3 equivalent.
curl "https://app.procurementexpress.com/api/v1/supplier_approvals" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/supplier_approvals" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
# V1 only: this endpoint has no V3 equivalent.
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/supplier_approvals",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/supplier_approvals",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
V1 response
200 OK:
{
"supplier_approvals": [
{
"id": 1,
"name": "Proseware Labs",
"notes": null,
"phone_number": "+1 555 0700",
"address": "5 Research Park, Durham, NC 27709",
"email": "accounts@proseware.example.com",
"status": "pending",
"requester": {
"id": 41000,
"email": "sam.smith@example.com",
"name": "Sam Smith",
"roles": [
"teammember"
]
},
"approver": {
},
"created_at": 1792576974,
"updated_at": 1792576974,
"uei": "PROSE1234567",
"cage_code": null
},
{
"id": 2,
"name": "Adventure Works Cycles",
"notes": null,
"phone_number": "+1 555 0800",
"address": "1 Trail Road, Bend, OR 97701",
"email": "b2b@adventure-works.example.com",
"status": "pending",
"requester": {
"id": 41000,
"email": "sam.smith@example.com",
"name": "Sam Smith",
"roles": [
"teammember"
]
},
"approver": {
},
"created_at": 1792576974,
"updated_at": 1792576974,
"uei": null,
"cage_code": null
}
],
"meta": {
"current_page": 1,
"next_page": null,
"prev_page": null,
"total_pages": 1,
"total_count": 2
}
}
V3 — V1
Returns the company's supplier approval requests (all statuses), 10 per page.
Lists every supplier approval request of the company (pending, approved and rejected),
oldest updated_at first, paginated with a fixed 10 records per page.
Requires the Supplier approval company setting and API access enabled by support
(see the section intro). Otherwise the endpoint answers 400 with the messages
shown in the errors table.
The search parameter is currently broken and answers 500 for any non-empty
value (see supplier_approvals_list_search). Do not send it.
HTTP request
| Version | Request |
|---|---|
| V1 | GET /api/v1/supplier_approvals |
Permissions
companyadmin, finance, supplier_admin or supplier_approver in the company
(or sysadmin). Other roles: 401 Unauthorized access. The permission check runs before the
setting/flag checks.
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
page |
integer | No | Page number (10 per page, not configurable). Default: 1. |
search |
string | No | Intended to filter by name/email/phone. Currently answers 500 (bug), do not use. |
Response
200 with {"supplier_approvals": [...], "meta": {current_page, next_page, prev_page, total_pages, total_count}}.
Timestamps are Unix seconds.
| Field | Type | Description |
|---|---|---|
meta.total_count |
integer | Number of requests (all statuses). |
V3 and V1 differences
V1 only. The route does not exist in V3: GET /api/v3/supplier_approvals answers
404 {"error": "Invalid URL"} (supplier_approvals_list_v3_absent).
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Setting disabled | 400 |
V1 | {"error":"Please enable supplier approval setting from your company setting, Supplier tab.","status":400} |
| Flag disabled | 400 |
V1 | {"error":"Please contact support/success team to enable supplier approval.","status":400} |
| Unauthorized | 401 |
V1 | {"error":"Unauthorized access","status":401} |
| Search | 500 |
V1 | HTML error page (no JSON body) |
Tax Rates
Tax rates are the company's sales-tax / VAT percentages that PO and invoice
line items reference (tax_rate_id). value is a percentage (e.g.
8.875 means 8.875 %).
There are two types (tax_type):
tax_type |
Meaning |
|---|---|
single |
A plain rate. The only type that can be created through the API. |
combined |
A group of single rates (e.g. Canadian GST + QST = 5 + 9.975 = 14.975). value is always the sum of its component rates and tax_rate_items lists the components. Combined rates are created in the web app (Settings -> Company settings -> Tax); via the API you can only rename or archive them - a value sent for a combined rate is ignored and recalculated. |
Rates are never deleted; set archived: true to retire one. The company's
default tax rate cannot be archived.
Accounting integrations: while the company has an active QuickBooks
Online connection, or Sage 50 / Sage 200 is connected, tax rates are synced
from the accounting system and POST /tax_rates is refused with 401.
Renaming / changing the value of a rate that is synced to Xero drops its
Xero link, so it must be re-mapped before the next Xero export.
Endpoints in this section
| Endpoint | Method | Path | V3 | V1 |
|---|---|---|---|---|
| List tax rates | GET |
/tax_rates |
✓ | ✓ |
| Get a tax rate | GET |
/tax_rates/:id |
✓ | ✓ |
| Create a tax rate | POST |
/tax_rates |
✓ | ✓ |
| Update a tax rate | PUT |
/tax_rates/:id |
✓ | ✓ |
The tax rate object
| Field | Type | Description |
|---|---|---|
id |
integer | Tax rate id. |
name |
string | Display name. |
value |
number | Percentage, at most 100. For combined rates the sum of the component rates. |
archived |
boolean | true when archived (hidden from new POs). |
company_id |
integer | Owning company. |
tax_type |
string | single or combined. |
tax_rate_items |
array[object] | null for single rates. For combined rates, one entry per component: {id, combined_tax_rate: {id, name, value}}. |
created_at |
timestamp | ISO-8601 with UTC offset. |
updated_at |
timestamp | ISO-8601 with UTC offset. |
List tax rates
curl "https://app.procurementexpress.com/api/v3/tax_rates" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/tax_rates" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/tax_rates",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/tax_rates",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
[
{
"id": 4,
"name": "Sales Tax",
"value": 8.875,
"archived": false,
"company_id": 20000,
"tax_type": "single",
"tax_rate_items": null,
"created_at": "2026-10-21T04:48:43.422-05:00",
"updated_at": "2026-10-21T04:48:43.422-05:00"
},
{
"id": 5,
"name": "Zero Rated",
"value": 0.0,
"archived": false,
"company_id": 20000,
"tax_type": "single",
"tax_rate_items": null,
"created_at": "2026-10-21T04:48:43.426-05:00",
"updated_at": "2026-10-21T04:48:43.426-05:00"
}
]
Example: archived (V3,
200 OK) (lists shortened to 2 items):
[
{
"id": 2,
"name": "None",
"value": 0.0,
"archived": true,
"company_id": 20000,
"tax_type": "single",
"tax_rate_items": null,
"created_at": "2026-10-21T04:44:45.267-05:00",
"updated_at": "2026-10-21T04:44:45.267-05:00"
},
{
"id": 3,
"name": "Tax exempt",
"value": 0.0,
"archived": true,
"company_id": 20000,
"tax_type": "single",
"tax_rate_items": null,
"created_at": "2026-10-21T04:44:45.268-05:00",
"updated_at": "2026-10-21T04:44:45.268-05:00"
}
]
Example: archived false (V3,
200 OK) (lists shortened to 2 items):
[
{
"id": 2,
"name": "None",
"value": 0.0,
"archived": true,
"company_id": 20000,
"tax_type": "single",
"tax_rate_items": null,
"created_at": "2026-10-21T04:44:45.267-05:00",
"updated_at": "2026-10-21T04:44:45.267-05:00"
},
{
"id": 3,
"name": "Tax exempt",
"value": 0.0,
"archived": true,
"company_id": 20000,
"tax_type": "single",
"tax_rate_items": null,
"created_at": "2026-10-21T04:44:45.268-05:00",
"updated_at": "2026-10-21T04:44:45.268-05:00"
}
]
V3 V1
Returns all active (or archived) tax rates of the company.
Returns every tax rate of the company, ordered by name, not paginated.
By default only active rates are returned; archived switches to archived
rates - but the two versions parse it differently:
- V1 casts it as a boolean:
true/1-> archived,false/0or absent -> active. - V3 checks only whether the parameter is present: any value,
including
archived=false, returns the archived rates. Omit the parameter to get active rates.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/tax_rates |
| V1 | GET /api/v1/tax_rates |
Permissions
Any live member of the company (all roles).
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
archived |
boolean | No | Archived instead of active rates. V3: any non-empty value (even false) means archived - omit it for active rates. Default: false. |
Response
200 with a bare JSON array of Tax rate objects (no pagination, no
envelope, in both versions). tax_rate_items is null for single rates.
V3 and V1 differences
Same envelope and fields. archived=false differs: V1 returns active rates,
V3 returns archived rates (see tax_rate_list_archived_false).
Get a tax rate
curl "https://app.procurementexpress.com/api/v3/tax_rates/4" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/tax_rates/4" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/tax_rates/4",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/tax_rates/4",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 4,
"name": "Sales Tax",
"value": 8.875,
"archived": false,
"company_id": 20000,
"tax_type": "single",
"tax_rate_items": null,
"created_at": "2026-10-21T04:48:43.422-05:00",
"updated_at": "2026-10-21T04:48:43.422-05:00"
}
Example: combined (V3,
200 OK):
{
"id": 31,
"name": "GST + QST",
"value": 14.975,
"archived": false,
"company_id": 20000,
"tax_type": "combined",
"tax_rate_items": [
{
"id": 1,
"combined_tax_rate": {
"id": 29,
"name": "GST",
"value": 5.0
}
},
{
"id": 2,
"combined_tax_rate": {
"id": 30,
"name": "QST",
"value": 9.975
}
}
],
"created_at": "2026-10-21T05:05:56.418-05:00",
"updated_at": "2026-10-21T05:05:56.418-05:00"
}
V3 V1
Returns one tax rate, including archived ones and combined-rate components.
Returns a tax rate of the company by id (archived included). For a combined
rate, tax_rate_items lists the component rates.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/tax_rates/:id |
| V1 | GET /api/v1/tax_rates/:id |
Permissions
Any member of the company. Unknown id or another company's rate -> 404.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Tax rate id. |
Response
200 with the Tax rate object.
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
Create a tax rate
curl -X POST "https://app.procurementexpress.com/api/v3/tax_rates" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"tax_rate": {
"name": "City Tax",
"value": 2.5
}
}'
curl -X POST "https://app.procurementexpress.com/api/v1/tax_rates" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"tax_rate": {
"name": "State Tax",
"value": 6.25
}
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/tax_rates",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
tax_rate: {
name: "City Tax",
value: 2.5
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/tax_rates",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
tax_rate: {
name: "State Tax",
value: 6.25
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 27,
"name": "City Tax",
"value": 2.5,
"archived": false,
"company_id": 20000,
"tax_type": "single",
"tax_rate_items": null,
"created_at": "2026-10-21T05:04:59.768-05:00",
"updated_at": "2026-10-21T05:04:59.768-05:00"
}
V3 V1
Creates a single tax rate.
Creates a single tax rate in the header company. Combined rates cannot be
created through the API (tax_type and component ids are not accepted).
Creation is refused while the company has an active QuickBooks Online
connection or Sage 50 / Sage 200 is connected, because rates are then
synced from the accounting system.
HTTP request
| Version | Request |
|---|---|
| V3 | POST /api/v3/tax_rates |
| V1 | POST /api/v1/tax_rates |
Permissions
Company admin or finance role, and no active QuickBooks Online / Sage 50 /
Sage 200 connection. Otherwise 401 Unauthorized access.
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
tax_rate[ |
string | Yes | Display name. |
tax_rate[ |
number | Yes | Percentage, at most 100 (Value Not greater than 100). |
tax_rate[ |
boolean | No | Create the rate already archived. Default: false. |
tax_rate[ |
integer | No | Ignored - the company always comes from the app_company_id header. |
Response
200 (not 201) with the created Tax rate object. Validation failures ->
400 {"error": ["Name can't be blank", ...], "status": 400}.
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Validation error | 400 |
V3, V1 | {"error":["Name can't be blank","Value Not greater than 100"],"status":400} |
| Missing wrapper | 400 |
V3, V1 | {"error":"Required parameter are missing.","status":400} |
| Forbidden | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
Update a tax rate
curl -X PUT "https://app.procurementexpress.com/api/v3/tax_rates/27" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"tax_rate": {
"name": "City Tax 2.75%",
"value": 2.75
}
}'
curl -X PUT "https://app.procurementexpress.com/api/v1/tax_rates/26" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"tax_rate": {
"name": "State Tax 6.5%",
"value": 6.5
}
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v3/tax_rates/27",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
tax_rate: {
name: "City Tax 2.75%",
value: 2.75
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v1/tax_rates/26",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
tax_rate: {
name: "State Tax 6.5%",
value: 6.5
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 27,
"name": "City Tax 2.75%",
"value": 2.75,
"archived": false,
"company_id": 20000,
"tax_type": "single",
"tax_rate_items": null,
"created_at": "2026-10-21T05:04:59.768-05:00",
"updated_at": "2026-10-21T05:05:26.074-05:00"
}
Example: archive (V3,
200 OK):
{
"id": 27,
"name": "City Tax 2.75%",
"value": 2.75,
"archived": true,
"company_id": 20000,
"tax_type": "single",
"tax_rate_items": null,
"created_at": "2026-10-21T05:04:59.768-05:00",
"updated_at": "2026-10-21T05:05:35.251-05:00"
}
V3 V1
Renames, re-values or (un)archives a tax rate.
Partial update of name, value and archived. Archiving is done here
(archived: true); the company's default tax rate cannot be archived
(400 The default tax rate cannot be archived as at least one tax rate must
be available to create a new purchase order.). For a combined rate a sent
value is ignored: the value is always recomputed from the components.
Changing the name or value of a rate synced to Xero removes its Xero mapping.
Allowed even while an accounting system is connected.
HTTP request
| Version | Request |
|---|---|
| V3 | `PUT |
| V1 | `PUT |
Permissions
Company admin or finance role; otherwise 401 Unauthorized access. Unknown
id -> 404.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Tax rate id. |
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
tax_rate[ |
string | No | Display name (cannot be blank). |
tax_rate[ |
number | No | Percentage, at most 100. Ignored for combined rates. |
tax_rate[ |
boolean | No | true archives, false restores. Not allowed for the company default rate. |
tax_rate[ |
integer | No | Ignored. |
Response
200 with the updated Tax rate object.
V3 and V1 differences
None - identical request and response; PUT and PATCH both accepted.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Validation error | 400 |
V3, V1 | {"error":["Value Not greater than 100"],"status":400} |
| Not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
| Forbidden | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
Users
The current user is the person the credential acts as: the owner of the
OAuth application for a V3 client credentials token, or the signed-in user for a
V1 authentication_token. These endpoints read and update that user's own
profile. They do not need the app_company_id header (the 2FA check then uses
the user's default company, employer_id). To manage other users of a company
use the company / employee endpoints.
A user can belong to several companies, with different roles in each. Roles are
companyadmin, approver, finance and teammember; they and the approval limit
are per company, so read them from the companies[] entry for the company you
act in, not from the top-level fields.
Changing the e-mail address is not immediate: the request is accepted, a
confirmation link is sent to the new address, the response still shows the
current address and carries the header X-Email-Change-Pending: true. Only one
pending change per minute is accepted.
Changing the password requires current_password; a wrong or missing current
password answers 422 Current password is incorrect. and counts towards the
account lockout. Passwords need at least 9 characters with a lowercase letter, a
digit and an uppercase or special character. Changing the password does not
replace the V1 token or revoke OAuth tokens.
Phone numbers are stored in E.164 form (+1 555 0101 becomes +15550101).
Endpoints in this section
| Endpoint | Method | Path | V3 | V1 |
|---|---|---|---|---|
| Get the current user | GET |
/currentuser |
✓ | ✓ |
| Update the current user | PUT |
/currentuser |
✓ | ✓ |
| Update the current user (by id) | PUT |
/users/:id |
✓ | ✓ |
The user object
| Field | Type | Description |
|---|---|---|
id |
integer | User id. |
email |
string | Login e-mail address (lower-case). |
name |
string | First and last name. |
approver_name |
string | Name shown as approver; equals name on these endpoints. |
phone_number |
string | Phone number in E.164 form, or null. |
setup_incomplete |
boolean | true while the user has no company yet (for example right after register). |
employer_id |
integer | The user's default company. Used for the 2FA check when a request has no app_company_id. Null when the user has no company. |
approval_limit |
number | Not meaningful on these endpoints: always 1000000.0. Use companies[].approval_limit. |
companies |
array[object] | Every company the user is a member of, including archived memberships. With slim_companies (V1 only) every company except employer_id is reduced to id, name, is_removed. |
companies[ |
integer | Company id (use as app_company_id). |
companies[ |
string | Company name. |
companies[ |
string | The user's external id in that company (set by an admin in the web app, max 64 characters, unique per company), or null. |
companies[ |
boolean | true when the user's membership is archived; requests with this app_company_id answer 401. |
companies[ |
boolean | true when the company is not paid up; requests with this app_company_id answer 402. |
companies[ |
boolean | true when the company is inactive (removed). |
companies[ |
number | The user's approval limit in that company (1000000.0 when none is set). |
companies[ |
boolean | Company is on the trial plan. |
companies[ |
boolean | Company trial has ended without a subscription. |
companies[ |
integer | Days until the current subscription period ends (negative when past). |
companies[ |
array[string] | The user's roles in that company: companyadmin, approver, finance, teammember. |
companies[ |
boolean | The company has at least one active budget without approvers. |
roles |
array[string] | Always null on these endpoints; use companies[].roles. |
external_user_id |
string | Always null at the top level; see companies[].external_user_id. |
authentication_token |
string | The V1 API token. Only returned by login, verify_otp, register and google_login. |
Get the current user
curl "https://app.procurementexpress.com/api/v3/currentuser" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/currentuser" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/currentuser",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/currentuser",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 40000,
"email": "jane.doe@example.com",
"name": "Jane Doe",
"approver_name": "Jane Doe",
"phone_number": "+1 555 0140",
"setup_incomplete": false,
"employer_id": 20000,
"approval_limit": 1000000.0,
"companies": [
{
"id": 20000,
"name": "Acme Corporation",
"external_user_id": null,
"membership_archived": false,
"is_locked": false,
"is_removed": false,
"approval_limit": 1000000.0,
"in_trial": true,
"trial_expired": false,
"remaining_trial_days": 365,
"roles": [
"companyadmin",
"approver",
"finance",
"teammember"
],
"has_unassigned_budgets": true
}
],
"roles": null,
"external_user_id": null
}
Example: teammember (V3,
200 OK):
{
"id": 41000,
"email": "sam.smith@example.com",
"name": "Sam Smith",
"approver_name": "Sam Smith",
"phone_number": "+1 555 0141",
"setup_incomplete": false,
"employer_id": 20000,
"approval_limit": 1000000.0,
"companies": [
{
"id": 20000,
"name": "Acme Corporation",
"external_user_id": null,
"membership_archived": false,
"is_locked": false,
"is_removed": false,
"approval_limit": 0.0,
"in_trial": true,
"trial_expired": false,
"remaining_trial_days": 365,
"roles": [
"teammember"
],
"has_unassigned_budgets": true
}
],
"roles": null,
"external_user_id": null
}
Example: slim companies (V3,
200 OK) (lists shortened to 2 items):
{
"id": 5,
"email": "blake.blocked@example.com",
"name": "Blake Blocked",
"approver_name": "Blake Blocked",
"phone_number": "+15550199",
"setup_incomplete": false,
"employer_id": 4,
"approval_limit": 1000000.0,
"companies": [
{
"id": 4,
"name": "Blake Home",
"external_user_id": null,
"membership_archived": false,
"is_locked": false,
"is_removed": false,
"approval_limit": 1000000.0,
"in_trial": true,
"trial_expired": false,
"remaining_trial_days": 365,
"roles": [
"companyadmin",
"approver",
"finance",
"teammember"
],
"has_unassigned_budgets": false
},
{
"id": 5,
"name": "Initrode Inc (blocked)",
"external_user_id": null,
"membership_archived": false,
"is_locked": true,
"is_removed": false,
"approval_limit": 1000000.0,
"in_trial": true,
"trial_expired": false,
"remaining_trial_days": 365,
"roles": [
"companyadmin",
"approver",
"finance",
"teammember"
],
"has_unassigned_budgets": false
}
],
"roles": null,
"external_user_id": null
}
V3 V1
Returns the user the credential acts as, with every company membership and the roles in each.
For a V3 client credentials token this is the owner of the OAuth application.
app_company_id is optional (when sent it only selects which company's 2FA rule
applies). The response never contains the authentication_token.
To check whether the company you act in is the user's default company, compare
employer_id with your app_company_id.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/currentuser |
| V1 | GET /api/v1/currentuser |
Permissions
Any valid credential. 2FA: refused with 401 otp_required when the user's default
company (or the app_company_id company) requires 2FA the credential has not passed.
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
slim_companies |
string | No | V1 only: any non-empty value reduces every company except employer_id to {id, name, is_removed}. Ignored by V3. |
Response
200 with the User object (see the section's field reference).
V3 and V1 differences
Same body. V3 ignores slim_companies (always full company entries - see the
users_current_slim_companies pair).
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Missing token | 401 |
V3, V1 | (empty body) |
Update the current user
curl -X PUT "https://app.procurementexpress.com/api/v3/currentuser" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"first_name": "Lee",
"last_name": "Login",
"phone_number": "+1 555 0102"
}'
curl -X PUT "https://app.procurementexpress.com/api/v1/currentuser" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"first_name": "Leland",
"last_name": "Login",
"phone_number": "+1 555 0101"
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v3/currentuser",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"Content-Type" => "application/json"
},
payload: {
first_name: "Lee",
last_name: "Login",
phone_number: "+1 555 0102"
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v1/currentuser",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"Content-Type" => "application/json"
},
payload: {
first_name: "Leland",
last_name: "Login",
phone_number: "+1 555 0101"
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 4,
"email": "lee.login@example.com",
"name": "Lee Login",
"approver_name": "Lee Login",
"phone_number": "+15550102",
"setup_incomplete": false,
"employer_id": 3,
"approval_limit": 1000000.0,
"companies": [
{
"id": 3,
"name": "Lee Workshop",
"external_user_id": null,
"membership_archived": false,
"is_locked": false,
"is_removed": false,
"approval_limit": 1000000.0,
"in_trial": true,
"trial_expired": false,
"remaining_trial_days": 365,
"roles": [
"companyadmin",
"approver",
"finance",
"teammember"
],
"has_unassigned_budgets": false
}
],
"roles": null,
"external_user_id": null
}
Example: password (V3,
200 OK):
{
"id": 8,
"email": "jamie.join@joinme-auth-users.example",
"name": "Jamie Join",
"approver_name": "Jamie Join",
"phone_number": "+15550199",
"setup_incomplete": true,
"employer_id": null,
"approval_limit": 1000000.0,
"companies": [
],
"roles": null,
"external_user_id": null
}
Example: email (V3,
200 OK):
{
"id": 8,
"email": "jamie.join@joinme-auth-users.example",
"name": "Jamie Join",
"approver_name": "Jamie Join",
"phone_number": "+15550199",
"setup_incomplete": true,
"employer_id": null,
"approval_limit": 1000000.0,
"companies": [
],
"roles": null,
"external_user_id": null
}
V3 V1
Change the current user's name, phone number, password or e-mail address.
Updates only the fields you send. Rules:
- Password: send
password,password_confirmationandcurrent_password. Without a correctcurrent_password:422 Current password is incorrect.(counts towards lockout). Accounts that never set a password need no current password. - E-mail: not changed directly. A confirmation link is e-mailed to the new
address; the response shows the current address and has the header
X-Email-Change-Pending: true. Sending the current address is a no-op. An invalid address or a second request within a minute answers422 This email change is invalid or has expired. Please start a new request. nameis split on spaces into first and last name (only the first two words are kept); preferfirst_name/last_name.- Validation errors answer 400 with the first message, e.g.
Last name can't be blank.
HTTP request
| Version | Request |
|---|---|
| V3 | `PUT |
| V1 | `PUT |
Permissions
Any valid credential for the user. Because this changes the account, the
credential must satisfy the 2FA requirement of every company that requires
it (401 otp_required otherwise), even when app_company_id names a company
without 2FA.
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
first_name |
string | No | Max 80 characters; HTML is stripped. |
last_name |
string | No | Max 80 characters; cannot be blank. |
name |
string | No | Full name, split into first and last name. |
phone_number |
string | No | Max 30 characters; stored in E.164 form. |
email |
string | No | New e-mail address; starts the confirmation flow (see description). |
password |
string | No | New password (min 9 characters, lowercase letter, digit, uppercase or special character). |
password_confirmation |
string | No | Must equal password. |
current_password |
string | No | Required with password / password_confirmation. |
country |
string | No | Country code. |
terms_of_service |
string | No | Accepted but not meaningful after sign-up. |
ip |
string | No | Accepted (stored). |
user_policy_time |
timestamp | No | Accepted (policy acceptance time). |
user_policy_url[ |
array[string] | No | Accepted (accepted policy URLs). |
Response
200 with the updated User object.
Side effects
E-mail change sends a confirmation e-mail to the new address.
V3 and V1 differences
None - identical request and response (both accept PUT and POST).
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Invalid | 400 |
V3, V1 | {"error":"Last name can't be blank","status":400} |
| Password unproven | 422 |
V3, V1 | {"error":"Current password is incorrect.","status":422} |
| Email invalid | 422 |
V1 | {"error":"This email change is invalid or has expired. Please start a new request.","status":"unprocessable_entity"} |
| Otp required | 401 |
V3 | {"error":"Two-factor authentication is required for this company. Sign in again with your one-time code.","otp_required":true,"status":401} |
Update the current user (by id)
curl -X PATCH "https://app.procurementexpress.com/api/v3/users/4" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"phone_number": "+1 555 0104"
}'
curl -X PUT "https://app.procurementexpress.com/api/v1/users/4" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"phone_number": "+1 555 0103"
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :patch,
url: "https://app.procurementexpress.com/api/v3/users/4",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"Content-Type" => "application/json"
},
payload: {
phone_number: "+1 555 0104"
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v1/users/4",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"Content-Type" => "application/json"
},
payload: {
phone_number: "+1 555 0103"
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 4,
"email": "lee.login@example.com",
"name": "Lee Login",
"approver_name": "Lee Login",
"phone_number": "+15550104",
"setup_incomplete": false,
"employer_id": 3,
"approval_limit": 1000000.0,
"companies": [
{
"id": 3,
"name": "Lee Workshop",
"external_user_id": null,
"membership_archived": false,
"is_locked": false,
"is_removed": false,
"approval_limit": 1000000.0,
"in_trial": true,
"trial_expired": false,
"remaining_trial_days": 365,
"roles": [
"companyadmin",
"approver",
"finance",
"teammember"
],
"has_unassigned_budgets": false
}
],
"roles": null,
"external_user_id": null
}
Example: other id ignored (V3,
200 OK):
{
"id": 4,
"email": "lee.login@example.com",
"name": "Lee Login",
"approver_name": "Lee Login",
"phone_number": "+15550104",
"setup_incomplete": false,
"employer_id": 3,
"approval_limit": 1000000.0,
"companies": [
{
"id": 3,
"name": "Lee Workshop",
"external_user_id": null,
"membership_archived": false,
"is_locked": false,
"is_removed": false,
"approval_limit": 1000000.0,
"in_trial": true,
"trial_expired": false,
"remaining_trial_days": 365,
"roles": [
"companyadmin",
"approver",
"finance",
"teammember"
],
"has_unassigned_budgets": false
}
],
"roles": null,
"external_user_id": null
}
V3 V1
Alias of PUT /currentuser; the id in the path is ignored.
Runs exactly the same action as PUT /currentuser (same parameters, rules and
response). The :id path segment is not used: the request always updates the
user the credential acts as, even if another user's id is given. Prefer
PUT /currentuser.
HTTP request
| Version | Request |
|---|---|
| V3 | `PUT |
| V1 | `PUT |
Permissions
Same as PUT /currentuser.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Ignored; pass the current user's id. |
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
first_name |
string | No | See PUT /currentuser for every accepted field. |
last_name |
string | No | See PUT /currentuser. |
phone_number |
string | No | See PUT /currentuser. |
Response
200 with the updated User object (always the caller).
V3 and V1 differences
None - identical request and response (PUT and PATCH in both).
Webhooks
Webhooks push a JSON POST to an HTTPS endpoint you own whenever something happens to a
purchase order, an invoice or a report in your company, so an integration does not have to
poll. A webhook subscription has a destination url, a list of event_type keys (all from
one event family), and optional delivery options (json_wrapper, send_as_text, custom
headers / extra root keys, authentication).
Subscriptions are managed by company admins with the endpoints below or in the web app (Settings → Integrations → Webhooks). Some options are web-app only: the authentication type (Basic / OAuth2), the OAuth2 client credentials, the Test button, and hard deletion.
Event types
Every event is fired by a model callback in ProcurementExpress. The table states exactly when
(verified by driving records through the API and inspecting the deliveries).
A subscription may only list events of one family (event_type mixing families → 400
Event type is invalid); create one subscription per family.
| Family | event key |
Fires when | object |
|---|---|---|---|
| Purchase order | new_po |
A PO record is created – in any status, including draft – and again when a draft is submitted (draft → pending). A PO created directly with commit: "Send" fires it once; a draft that is later submitted fires it twice (first payload may show status: "draft"). |
PurchaseOrder |
| Purchase order | po_approved |
status changes to approved (final approval). In practice it is followed ~0.1 s later by a po_update (the approval key is stamped in a second save). |
PurchaseOrder |
| Purchase order | po_delivered |
delivery_status changes to partially_delivered or complete_delivered – i.e. on every receipt that changes the delivery status, not only on full delivery. Reverting a receipt back to not_delivered fires nothing. |
PurchaseOrder |
| Purchase order | po_paid |
status changes to paid (payments recorded against the PO reach its gross total). |
PurchaseOrder |
| Purchase order | po_cancelled |
status changes to cancelled. |
PurchaseOrder |
| Purchase order | po_update |
Any other saved change to the PO that changes neither status nor delivery_status: edits (notes, lines, approvers), approval-key stamping after approval, payment_status → invoice_received when an invoice is linked, etc. |
PurchaseOrder |
| Invoice | new_invoice |
An invoice is created (any initial status: awaiting_review, outstanding, …). |
Invoice |
| Invoice | invoice_approved |
An invoice is approved to ready_to_pay (API PUT /invoices/:id/approve, web approve, bulk approve, final step of an invoice approval flow, or re-approval of a settled invoice that again has a balance). |
Invoice |
| Report | report_completed |
A report export finishes (report_generated becomes true). |
the report class, e.g. PurchaseOrderReport |
| E-invoicing | invoice_received_pa, invoice_validated_pa, invoice_rejected_syntax, invoice_delivered_pa, invoice_accepted_pa, invoice_rejected_business, invoice_payment_status, invoice_received_pex, invoice_accepted_pex, invoice_approved_pex, e_reporting_submitted, e_reporting_ack_received |
Lifecycle events of the French e-invoicing module (companies with a Plateforme Agréée connection). These use a different envelope, headers and retry policy – see E-invoicing events below. | – |
Changes that never fire a webhook: PO rejection (status rejected – verified: no
event at all, not even po_update), adding comments or attachments to a PO (verified for
uploads), invoice accept / reject / cancel / settle, and any change made with column-level
writes by internal jobs. There is also an in-memory duplicate guard: only the first qualifying
save of a given record object in one request fires, so a second transition made later in the
same request (for example an automatic approval right after creation) can be swallowed. Treat
webhooks as "something changed, here is the current state", not as a complete transition log.
A special event TestWebhook is sent when an admin clicks Test in the web app: it uses
the company's latest PO (or latest invoice / latest completed report for those families),
the normal envelope, and records the outcome in the subscription's tested and
response_code fields – those two fields are only updated by this test.
Delivery pipeline and guarantees
- The model callback enqueues a background job only if at least one non-archived subscription in the company lists the event.
- That job waits 4 seconds, then, for each matching subscription (newest subscription
first), serializes the record as it is at that moment and enqueues one delivery job per
subscription with that frozen JSON. The payload therefore shows the state ~4 s (plus queue
latency) after the change, not the state at the instant of the event – two events fired
close together (e.g.
po_approved+po_update) usually carry the same data. - The delivery job re-checks that the subscription is still not archived, the company is
still active and the record still belongs to the company, resolves the destination, runs
the OAuth2 token exchange if configured, and sends one
POST.
| Property | Behaviour |
|---|---|
| Synchronous? | No – always asynchronous (a background job). The API call that changed the record never waits for, or reports on, webhook delivery. |
| Success | Any 2xx response. |
| Timeouts | Connect 5 s, read 10 s, write 10 s, and a hard 25 s budget for the whole attempt (DNS + OAuth token request + POST + response). |
| Redirects | Not followed – a 3xx is a failed delivery. |
| Response limits | Response body ≤ 64 KiB, response headers ≤ 16 KiB (larger = failure). Your response body is ignored. |
| Retries | None. A non-2xx answer, timeout, TLS/DNS error or rejected destination is a final, logged failure; the event is not re-sent. (Only internal errors raised before a delivery is attempted use the job system's 5 automatic retries.) |
| Ordering | Not guaranteed. Each event and each subscription is a separate job; jobs run concurrently. |
| Duplicates / idempotency | Possible (see new_po for drafts, po_approved + po_update). Generic webhooks carry no event id or sequence number. De-duplicate on event + payload.id + payload.updated_at (unix seconds), and treat the payload as a hint: fetch the record with GET /purchase_orders/:id / GET /invoices/:id when you need authoritative state. Respond 2xx quickly and process asynchronously – a slow endpoint is a lost event. |
| Delivery history | Not available through the API. |
| Disabled subscriptions | archived: true stops delivery immediately, including jobs already queued. Subscriptions of a deactivated company never deliver. |
Request format
Every delivery is an HTTPS POST with Content-Type: application/json:
POST /pex/webhooks HTTP/1.1
Host: erp.example.com
Content-Type: application/json
Accept: */*
User-Agent: Ruby
Accept-Encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3
X-Source-System: pex-production <- one line per "header" attribute you configured
Authorization: Basic … <- only for Basic / OAuth2 subscriptions (see below)
{
"event": "po_approved",
"object": "PurchaseOrder",
"tenant": "acme-corp",
"payload": { "id": 51, "status": "approved", "...": "..." }
}
event– the event key (orTestWebhook);object–PurchaseOrder,Invoiceor the report class name.- Root attributes (
attrib_type: root) are added as extra top-level string keys (tenantabove). A root key namedeventorobjectoverwrites those fields – avoid it. json_wrapperrenames thepayloadkey (e.g."data"). Blank →payload. Do not useevent/object.send_as_text: truesends the object as a JSON-encoded string instead of an object ("text": "{\"id\":50,…}"), meant for chat tools such as Slack incoming webhooks; the request is stillapplication/json.- Header attributes (
attrib_type: header) are sent as HTTP headers. Header names must be valid tokens and must not beHost,Content-Length,Transfer-Encoding,Connection,Upgrade,Expect,TE,Trailer,Via,Keep-AliveorProxy-*: such a subscription saves fine but every delivery fails.
There is no signature. Generic webhooks carry no HMAC/signature header, no timestamp and
no event id, so a receiver cannot cryptographically prove a request came from
ProcurementExpress. Protect your endpoint with: (1) Basic or OAuth2 authentication, or a
long random secret in a custom header (e.g. X-Webhook-Secret); (2) a hard-to-guess URL path;
(3) network allow-listing, if your network requires it (ProcurementExpress does not publish
egress addresses in this reference – ask support whether fixed addresses are available); and (4) re-fetching the record through the API before acting on high-value
events. (The e-invoicing family is different – it is HMAC-signed, see below.)
Request headers for each authentication style, as recorded:
{
"oauth_token_request": {
"method": "POST",
"url": "https://login.acme.test/oauth2/token",
"headers": {
"Host": "login.acme.test",
"Accept-Encoding": "gzip;q=1.0,deflate;q=0.6,identity;q=0.3",
"Accept": "*/*",
"User-Agent": "Ruby",
"Content-Type": "application/x-www-form-urlencoded",
"Content-Length": "91"
},
"body": "client_id=pex-webhooks&client_secret=<client-secret>&grant_type=client_credentials"
},
"oauth_bearer_delivery": {
"method": "POST",
"url": "https://erp.acme.test/pex/oauth",
"headers": {
"Host": "erp.acme.test",
"Accept-Encoding": "gzip;q=1.0,deflate;q=0.6,identity;q=0.3",
"Accept": "*/*",
"User-Agent": "Ruby",
"Content-Type": "application/json",
"Authorization": "Bearer <token-from-your-token-url>",
"Content-Length": "5846"
},
"body": "{\"event\":\"po_approved\",\"object\":\"PurchaseOrder\",...}"
},
"basic_auth_delivery": {
"method": "POST",
"url": "https://erp.acme.test/pex/basic",
"headers": {
"Host": "erp.acme.test",
"Accept-Encoding": "gzip;q=1.0,deflate;q=0.6,identity;q=0.3",
"Accept": "*/*",
"User-Agent": "Ruby",
"Content-Type": "application/json",
"Authorization": "Basic cGV4LXN5bmM6czNjcjN0LWJhc2ljLXBhc3M=",
"Content-Length": "5846"
},
"body": "{\"event\":\"po_approved\",\"object\":\"PurchaseOrder\",...}"
},
"no_auth_delivery": {
"method": "POST",
"url": "https://erp.acme.test/pex/po-events",
"headers": {
"Host": "erp.acme.test",
"Accept-Encoding": "gzip;q=1.0,deflate;q=0.6,identity;q=0.3",
"Accept": "*/*",
"User-Agent": "Ruby",
"Content-Type": "application/json",
"Content-Length": "5846"
},
"body": "{\"event\":\"po_approved\",\"object\":\"PurchaseOrder\",...}"
},
"custom_headers_delivery": {
"method": "POST",
"url": "https://erp.acme.test/pex/webhooks",
"headers": {
"Host": "erp.acme.test",
"Accept-Encoding": "gzip;q=1.0,deflate;q=0.6,identity;q=0.3",
"Accept": "*/*",
"User-Agent": "Ruby",
"Content-Type": "application/json",
"X-Source-System": "pex-production",
"X-Api-Key": "demo-key-not-real",
"Content-Length": "5864"
},
"body": "{\"event\":\"po_approved\",\"object\":\"PurchaseOrder\",...}"
}
}
Authentication of deliveries
| Auth type | How to configure | What is sent |
|---|---|---|
| None (default) | – | No Authorization header. Use a secret header attribute if you need a shared secret. |
| Basic | Web app only: choose Basic and enter username + password. | Authorization: Basic base64(username:password) on every delivery. |
| OAuth2 client credentials | Web app only: client id, client secret and token URL (HTTPS). | Before every delivery: POST <token_url> with form body grant_type=client_credentials&client_id=…&client_secret=… (credentials in the body, not HTTP Basic; no scope). The token endpoint must answer 2xx with JSON containing access_token (≤ 64 KiB). The payload is then sent with Authorization: Bearer <access_token>. Tokens are not cached/reused between deliveries. If the token request fails, the payload is not sent. |
| Static bearer / API key | Add a header attribute, e.g. Authorization: Bearer … or X-Api-Key: …. |
The header as configured (for Basic/OAuth2 subscriptions their own Authorization header wins). |
Destination rules
Checked when a subscription is saved and again before every delivery:
https://only, on port 443 (or 8443); nouser:password@, no#fragment, ≤ 2000 characters. Plainhttp://→400Url must use https://.- The host must not be
localhost,*.local,*.internal,*.localdomain; literal private, loopback, link-local, CGNAT, multicast or documentation IP addresses are refused (Url points at an address that is not allowed). - At delivery time the host name is resolved and every A/AAAA answer must be a public address (at most 8 answers); the connection is pinned to the approved address, TLS certificates are verified, and proxies are not used. A host that resolves to a private address is skipped (failed delivery) even if it was accepted when saved.
Attachments (PO uploads, PO comment uploads, invoice files)
Attachment URLs inside webhook payloads are not the 5-minute S3 links the API returns.
Each uploads[].url (PO), purchase_order_comments[].uploads[].url and invoice
attachments[] entry is a ProcurementExpress link:
https://app.procurementexpress.com/webhook-attachments/download?token=<signed token>
- The token is issued when the delivery is sent and is valid for 48 hours from then. No cookie or API credential is needed – the token is a bearer credential; keep it out of logs.
GETreturns the file bytes (application/octet-stream,Content-Disposition: attachment; filename="…", max 30 MiB per file, at most 100 links per payload).- Every download re-checks the subscription and file: the link answers
404once it expires, when the subscription is archived or edited in any way (even renamed – verified), the file was replaced/removed, the company was deactivated, or the invoice is no longerready_to_pay/settled;503on a storage error. Download and store the files you need when you receive the webhook. - Invoice
attachmentsis populated only forready_to_payandsettledinvoices ([]otherwise, e.g. innew_invoice). A file that cannot be shared becomesurl: null(PO) or is omitted (invoice). - If links cannot be prepared (storage outage), the whole delivery fails – no partial body is sent.
report_completedcarriesdownload_url: a pre-signed S3 URL valid 48 h from the moment the payload was built, withdownload_expires_at.
Payload reference
Generic payloads use the serializers below. Note the mixed formats: top-level
created_at/updated_at/issue_date/due_date are unix seconds, while nested
supplier, department, currency and the report payload use ISO-8601 strings with an
offset; most money values are decimal strings ("1541.67") while base_gross_amount and
tax figures are numbers. share_key is the PO's public share key – anyone holding it can
open the PO's share link, so treat payloads as confidential. payments and
purchase_order_item_payments are currently serialized as empty objects ({}) – use the
payments endpoints instead.
new_po – PO created / draft submitted (here a pending PO awaiting a second approver):
{
"event": "new_po",
"object": "PurchaseOrder",
"payload": {
"id": 50,
"company_id": 20000,
"approval_key": null,
"department_id": 3,
"department_name": "Marketing",
"supplier_id": 1,
"supplier_name": "Northwind Office Supplies",
"supplier_email": "orders@northwind.example.com",
"supplier_address": "1 Commerce Way, Chicago, IL 60601",
"supplier_third_party_id_mappings": [
],
"supplier_payment_term_id": null,
"status": "pending",
"creator_id": 40000,
"creator_network_id": null,
"currency_id": 2000,
"currency_iso_code": "USD",
"currency_symbol": "$",
"currency_name": "United States Dollar",
"creator_name": "Jane Doe",
"creator_email": "jane.doe@example.com",
"amount": "840.0",
"created_at": 1792577307,
"updated_at": 1792577347,
"notes": "Printed brochures for Q4 - deliver to Dock B",
"total_net_amount": "840.0",
"total_gross_amount": "914.55",
"base_gross_amount": 914.55,
"delivery_status": "not_delivered",
"payment_status": "unpaid",
"delivered_on": null,
"tax_amount_overridden": false,
"tax_amount_override": null,
"base_tax_amount_override": null,
"effective_tax_amount": 74.55,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"base_shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"custom_fields": {
},
"share_key": "A876578C0C6730448B0C99A77128F2E5E4433C6D",
"archived": false,
"conversion_rate": "1.0",
"keywords": "50 Northwind Office Supplies Jane Doe pending 914.55 Marketing Q4 Campaigns Tri-fold brochure, 2,000 units 914.55 840.0 MKT-Q4-07 Not Set ",
"self_approved": false,
"xero_id": null,
"synced_with_xero": false,
"tax_rates": [
{
"id": 4,
"name": "Sales Tax",
"value": 8.875,
"tax_amount": 74.55
}
],
"custom_field_values": [
{
"name": "Project code",
"value": "MKT-Q4-07"
},
{
"name": "Cost center",
"value": "Not Set"
}
],
"third_party_id_mappings": [
],
"submitted_on_string": "10/21/2026",
"get_approvers": [
{
"name": "Jane Doe",
"pending": false,
"approved": true,
"rejected": false
},
{
"name": "Priya Patel",
"pending": true,
"approved": false,
"rejected": false
}
],
"get_invoices": [
],
"get_po_comments_and_histories": [
{
"comment": "Purchase Order Self Approved by Jane Doe for a total gross amount of USD 914.55.",
"created_at": "Oct 21 05:08",
"creator": "Jane Doe"
}
],
"send_to_supplier_link": "https://app.procurementexpress.com/companies/20000/purchase_orders/50?send_to_supplier=send-to-supplier-link",
"url": "https://app.procurementexpress.com/purchase_orders/50",
"purchase_order_items": [
{
"id": 71,
"description": "Tri-fold brochure, 2,000 units",
"purchase_order_id": 50,
"budget_id": 2,
"budget_summary": "Marketing Q4 Campaigns : MKT-Q4 : Opex",
"gross_amount": "914.55",
"tax_rate": {
"id": 4,
"name": "Sales Tax",
"value": 8.875
},
"vat": "8.875",
"net_amount": "840.0",
"status": "pending",
"quantity": "2000.0",
"unit_price": "0.42",
"item_number": null,
"base_net_amount": "840.0",
"base_gross_amount": "914.55",
"gross_usd_amount": "914.55",
"product_id": 19,
"received_quantity": null,
"sequence_no": 1,
"chart_of_account_id": 3,
"qbo_customer_id": null,
"quickbooks_class_id": null,
"custom_field_values": [
{
"name": "Asset tag",
"value": null
}
],
"third_party_id_mappings": [
],
"chart_of_account": {
"id": 3,
"name": "Advertising & Marketing"
},
"qbo_customer": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
}
}
],
"budgets": [
{
"id": 2,
"company_id": 20000,
"name": "Marketing Q4 Campaigns",
"amount": 20000.0,
"cost_code": "MKT-Q4",
"cost_type": "Opex",
"archived": false,
"currency_id": 2000,
"base_amount": 20000.0,
"base_rate": "1.0",
"allow_anyone_to_approve_a_po": false,
"start_date": 1767225600,
"end_date": 1798675200,
"summary": "Marketing Q4 Campaigns : MKT-Q4 : Opex",
"remaining_amount": 19160.0,
"creator_id": 40000,
"qbo_class": null,
"department_ids": [
3
],
"approver_ids": [
40000,
50000
],
"created_at": "2026-10-21T04:48:43.390-05:00",
"updated_at": "2026-10-21T05:08:28.148-05:00",
"chart_of_account_id": 3,
"chart_of_account_name": "Advertising & Marketing",
"chart_of_account": {
"id": 3,
"name": "Advertising & Marketing"
},
"quickbooks_class": {
"id": 2,
"name": "West Coast"
}
}
],
"purchase_order_comments": [
{
"id": 137,
"comment": "Purchase Order Self Approved by Jane Doe for a total gross amount of USD 914.55.",
"creator_id": 40000,
"creator_name": "Jane Doe",
"purchase_order_id": 50,
"status": "active",
"created_at": 1792577308,
"uploads": [
]
}
],
"payments": [
],
"purchase_order_item_payments": [
],
"uploads": [
],
"department": {
"id": 3,
"name": "Marketing",
"company_id": 20000,
"archived": false,
"contact_person": "Priya Patel",
"tax_number": "US-MK-002",
"phone_number": "+1 555 0120",
"address": "200 Market Street, Springfield, IL 62701",
"email": "marketing@example.com",
"created_at": "2026-10-21T04:48:42.754-05:00",
"updated_at": "2026-10-21T04:48:42.754-05:00",
"supplier_ids": [
5
],
"budget_ids": [
11,
9,
6,
2
]
},
"supplier": {
"id": 1,
"name": "Northwind Office Supplies",
"company_id": 20000,
"notes": "Preferred stationery vendor",
"phone_number": "+1 555 0200",
"address": "1 Commerce Way, Chicago, IL 60601",
"email": "orders@northwind.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Laura Callahan",
"tax_number": "36-1234567",
"payment_terms": "Net 30",
"created_at": "2026-10-21T04:48:43.487-05:00",
"updated_at": "2026-10-21T04:48:43.487-05:00",
"currency_id": 2000,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
}
}
}
po_approved – final approval (an invoice is already linked, so get_invoices and
payment_status: invoice_received are filled):
{
"event": "po_approved",
"object": "PurchaseOrder",
"payload": {
"id": 51,
"company_id": 20000,
"approval_key": "51-10/21/2026",
"department_id": 2,
"department_name": "Engineering",
"supplier_id": 2,
"supplier_name": "Contoso Hardware",
"supplier_email": "sales@contoso.example.com",
"supplier_address": "500 Tech Park, Austin, TX 78701",
"supplier_third_party_id_mappings": [
],
"supplier_payment_term_id": null,
"status": "approved",
"creator_id": 41000,
"creator_network_id": null,
"currency_id": 2000,
"currency_iso_code": "USD",
"currency_symbol": "$",
"currency_name": "United States Dollar",
"creator_name": "Sam Smith",
"creator_email": "sam.smith@example.com",
"amount": "2900.0",
"created_at": 1792577371,
"updated_at": 1792577472,
"notes": "Replacement laptops",
"total_net_amount": "2900.0",
"total_gross_amount": "3157.38",
"base_gross_amount": 3157.38,
"delivery_status": "not_delivered",
"payment_status": "invoice_received",
"delivered_on": null,
"tax_amount_overridden": false,
"tax_amount_override": null,
"base_tax_amount_override": null,
"effective_tax_amount": 257.38,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"base_shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"custom_fields": {
},
"share_key": "69D8CB64F13632489F8FDDB41E3F523F67BF7E20",
"archived": false,
"conversion_rate": "1.0",
"keywords": "51 Contoso Hardware Sam Smith 51-10/21/2026 approved 3157.38 LAP-14-32 Engineering Hardware FY26 14-inch laptop, 32 GB 3157.375 2900.0 Not Set Not Set ",
"self_approved": false,
"xero_id": null,
"synced_with_xero": false,
"tax_rates": [
{
"id": 4,
"name": "Sales Tax",
"value": 8.875,
"tax_amount": 257.38
}
],
"custom_field_values": [
{
"name": "Project code",
"value": "Not Set"
},
{
"name": "Cost center",
"value": "Not Set"
}
],
"third_party_id_mappings": [
],
"submitted_on_string": "10/21/2026",
"get_approvers": [
{
"name": "Priya Patel",
"pending": false,
"approved": true,
"rejected": false
}
],
"get_invoices": [
{
"name": "contoso-inv-88231.pdf"
}
],
"get_po_comments_and_histories": [
{
"comment": "Purchase Order Approved by Priya Patel (api) for a total gross amount of USD 3157.38.",
"created_at": "Oct 21 05:09",
"creator": "Priya Patel"
},
{
"comment": "has created an invoice contoso-inv-88231.pdf",
"created_at": "Oct 21 05:11",
"creator": "Jane Doe"
}
],
"send_to_supplier_link": "https://app.procurementexpress.com/companies/20000/purchase_orders/pex-51-10%2F21%2F2026-1?send_to_supplier=send-to-supplier-link",
"url": "https://app.procurementexpress.com/purchase_orders/51",
"purchase_order_items": [
{
"id": 72,
"description": "14-inch laptop, 32 GB",
"purchase_order_id": 51,
"budget_id": 1,
"budget_summary": "Engineering Hardware FY26 : ENG-HW : Capex",
"gross_amount": "3157.375",
"tax_rate": {
"id": 4,
"name": "Sales Tax",
"value": 8.875
},
"vat": "8.875",
"net_amount": "2900.0",
"status": "approved",
"quantity": "2.0",
"unit_price": "1450.0",
"item_number": "LAP-14-32",
"base_net_amount": "2900.0",
"base_gross_amount": "3157.38",
"gross_usd_amount": "3157.375",
"product_id": 20,
"received_quantity": null,
"sequence_no": 1,
"chart_of_account_id": 2,
"qbo_customer_id": null,
"quickbooks_class_id": null,
"custom_field_values": [
{
"name": "Asset tag",
"value": null
}
],
"third_party_id_mappings": [
],
"chart_of_account": {
"id": 2,
"name": "Computer Equipment"
},
"qbo_customer": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
}
}
],
"budgets": [
{
"id": 1,
"company_id": 20000,
"name": "Engineering Hardware FY26",
"amount": 50000.0,
"cost_code": "ENG-HW",
"cost_type": "Capex",
"archived": false,
"currency_id": 2000,
"base_amount": 50000.0,
"base_rate": "1.0",
"allow_anyone_to_approve_a_po": false,
"start_date": 1767225600,
"end_date": 1798675200,
"summary": "Engineering Hardware FY26 : ENG-HW : Capex",
"remaining_amount": 16819.63,
"creator_id": 40000,
"qbo_class": null,
"department_ids": [
2
],
"approver_ids": [
40000,
50000
],
"created_at": "2026-10-21T04:48:43.335-05:00",
"updated_at": "2026-10-21T05:15:24.351-05:00",
"chart_of_account_id": 2,
"chart_of_account_name": "Computer Equipment",
"chart_of_account": {
"id": 2,
"name": "Computer Equipment"
},
"quickbooks_class": {
"id": 1,
"name": "Northeast"
}
}
],
"purchase_order_comments": [
{
"id": 139,
"comment": "Purchase order fully approved",
"creator_id": 1,
"creator_name": "PX Automate",
"purchase_order_id": 51,
"status": "inactive",
"created_at": 1792577378,
"uploads": [
]
},
{
"id": 140,
"comment": "Purchase Order Approved by Priya Patel (api) for a total gross amount of USD 3157.38.",
"creator_id": 50000,
"creator_name": "Priya Patel",
"purchase_order_id": 51,
"status": "active",
"created_at": 1792577378,
"uploads": [
]
}
],
"payments": [
],
"purchase_order_item_payments": [
],
"uploads": [
],
"department": {
"id": 2,
"name": "Engineering",
"company_id": 20000,
"archived": false,
"contact_person": "Jane Doe",
"tax_number": "US-EN-001",
"phone_number": "+1 555 0110",
"address": "100 Main Street, Springfield, IL 62701",
"email": "engineering@example.com",
"created_at": "2026-10-21T04:48:42.702-05:00",
"updated_at": "2026-10-21T04:48:42.702-05:00",
"supplier_ids": [
4,
5
],
"budget_ids": [
1,
6,
10
]
},
"supplier": {
"id": 2,
"name": "Contoso Hardware",
"company_id": 20000,
"notes": null,
"phone_number": "+1 555 0300",
"address": "500 Tech Park, Austin, TX 78701",
"email": "sales@contoso.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Diego Ramos",
"tax_number": "74-7654321",
"payment_terms": "Net 45",
"created_at": "2026-10-21T04:48:43.563-05:00",
"updated_at": "2026-10-21T04:48:43.563-05:00",
"currency_id": 2000,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
}
}
}
po_delivered – partial delivery (4 of 6 received) with a delivery note attached; note the
48-hour webhook-attachments link in uploads[].url:
{
"event": "po_delivered",
"object": "PurchaseOrder",
"payload": {
"id": 74,
"company_id": 20000,
"approval_key": "74-10/21/2026",
"department_id": 2,
"department_name": "Engineering",
"supplier_id": 2,
"supplier_name": "Contoso Hardware",
"supplier_email": "sales@contoso.example.com",
"supplier_address": "500 Tech Park, Austin, TX 78701",
"supplier_third_party_id_mappings": [
],
"supplier_payment_term_id": null,
"status": "approved",
"creator_id": 41000,
"creator_network_id": null,
"currency_id": 2000,
"currency_iso_code": "USD",
"currency_symbol": "$",
"currency_name": "United States Dollar",
"creator_name": "Sam Smith",
"creator_email": "sam.smith@example.com",
"amount": "1710.0",
"created_at": 1792577759,
"updated_at": 1792577769,
"notes": "Ergonomic chairs for the 3rd floor",
"total_net_amount": "1710.0",
"total_gross_amount": "1861.76",
"base_gross_amount": 1861.76,
"delivery_status": "partially_delivered",
"payment_status": "unpaid",
"delivered_on": null,
"tax_amount_overridden": false,
"tax_amount_override": null,
"base_tax_amount_override": null,
"effective_tax_amount": 151.76,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"base_shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"custom_fields": {
},
"share_key": "F19792E1249853BB01574072A76C56B6D832C97C",
"archived": false,
"conversion_rate": "1.0",
"keywords": "74 Contoso Hardware Sam Smith 74-10/21/2026 approved 1861.76 CHR-ERG Engineering Hardware FY26 Ergonomic task chair 1861.7625 1710.0 PRJ-2026-120 Not Set ",
"self_approved": false,
"xero_id": null,
"synced_with_xero": false,
"tax_rates": [
{
"id": 4,
"name": "Sales Tax",
"value": 8.875,
"tax_amount": 151.76
}
],
"custom_field_values": [
{
"name": "Project code",
"value": "PRJ-2026-120"
},
{
"name": "Cost center",
"value": "Not Set"
}
],
"third_party_id_mappings": [
],
"submitted_on_string": "10/21/2026",
"get_approvers": [
{
"name": "Priya Patel",
"pending": false,
"approved": true,
"rejected": false
}
],
"get_invoices": [
],
"get_po_comments_and_histories": [
{
"comment": "Purchase Order Approved by Priya Patel (api) for a total gross amount of USD 1861.76.",
"created_at": "Oct 21 05:16",
"creator": "Priya Patel"
},
{
"comment": "Jane Doe received 4.0 quantity on this purchase order at <br /> Notes: 4 of 6 chairs received",
"created_at": "Oct 21 05:16",
"creator": "Jane Doe"
}
],
"send_to_supplier_link": "https://app.procurementexpress.com/companies/20000/purchase_orders/pex-74-10%2F21%2F2026-1?send_to_supplier=send-to-supplier-link",
"url": "https://app.procurementexpress.com/purchase_orders/74",
"purchase_order_items": [
{
"id": 97,
"description": "Ergonomic task chair",
"purchase_order_id": 74,
"budget_id": 1,
"budget_summary": "Engineering Hardware FY26 : ENG-HW : Capex",
"gross_amount": "1861.7625",
"tax_rate": {
"id": 4,
"name": "Sales Tax",
"value": 8.875
},
"vat": "8.875",
"net_amount": "1710.0",
"status": "approved",
"quantity": "6.0",
"unit_price": "285.0",
"item_number": "CHR-ERG",
"base_net_amount": "1710.0",
"base_gross_amount": "1861.76",
"gross_usd_amount": "1861.7625",
"product_id": 27,
"received_quantity": "4.0",
"sequence_no": 1,
"chart_of_account_id": 1,
"qbo_customer_id": null,
"quickbooks_class_id": null,
"custom_field_values": [
{
"name": "Asset tag",
"value": null
}
],
"third_party_id_mappings": [
],
"chart_of_account": {
"id": 1,
"name": "Office Expenses"
},
"qbo_customer": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
}
}
],
"budgets": [
{
"id": 1,
"company_id": 20000,
"name": "Engineering Hardware FY26",
"amount": 50000.0,
"cost_code": "ENG-HW",
"cost_type": "Capex",
"archived": false,
"currency_id": 2000,
"base_amount": 50000.0,
"base_rate": "1.0",
"allow_anyone_to_approve_a_po": false,
"start_date": 1767225600,
"end_date": 1798675200,
"summary": "Engineering Hardware FY26 : ENG-HW : Capex",
"remaining_amount": 15109.63,
"creator_id": 40000,
"qbo_class": null,
"department_ids": [
2
],
"approver_ids": [
40000,
50000
],
"created_at": "2026-10-21T04:48:43.335-05:00",
"updated_at": "2026-10-21T05:16:05.047-05:00",
"chart_of_account_id": 2,
"chart_of_account_name": "Computer Equipment",
"chart_of_account": {
"id": 2,
"name": "Computer Equipment"
},
"quickbooks_class": {
"id": 1,
"name": "Northeast"
}
}
],
"purchase_order_comments": [
{
"id": 191,
"comment": "Purchase order fully approved",
"creator_id": 1,
"creator_name": "PX Automate",
"purchase_order_id": 74,
"status": "inactive",
"created_at": 1792577764,
"uploads": [
]
},
{
"id": 192,
"comment": "Purchase Order Approved by Priya Patel (api) for a total gross amount of USD 1861.76.",
"creator_id": 50000,
"creator_name": "Priya Patel",
"purchase_order_id": 74,
"status": "active",
"created_at": 1792577764,
"uploads": [
]
}
],
"payments": [
],
"purchase_order_item_payments": [
],
"uploads": [
{
"id": 10,
"file_file_name": "delivery-note-DN-5521.pdf",
"file_content_type": "application/pdf",
"url": "https://app.procurementexpress.com/webhook-attachments/download?token=eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdGVWtpREhabGNuTnBiMjRHT2daRlZHa0dTU0lQZDJWaWFHOXZhMTlwWkFZN0FGUnBDa2tpRDJOdmJYQmhibmxmYVdRR093QlVhUUlnVGtraUNtVjJaVzUwQmpzQVZFa2lFWEJ2WDJSbGJHbDJaWEpsWkFZN0FGUkpJZzUwWlhOMFgyMXZaR1VHT3dCVVJra2lFSE52ZFhKalpWOTBlWEJsQmpzQVZFa2lFbEIxY21Ob1lYTmxUM0prWlhJR093QlVTU0lPYzI5MWNtTmxYMmxrQmpzQVZHbFBTU0lRY0dGeVpXNTBYM1I1Y0dVR093QlVTU0lTVUhWeVkyaGhjMlZQY21SbGNnWTdBRlJKSWc1d1lYSmxiblJmYVdRR093QlVhVTlKSWhSaGRIUmhZMmh0Wlc1MFgzUjVjR1VHT3dCVVNTSUxWWEJzYjJGa0Jqc0FWRWtpRW1GMGRHRmphRzFsYm5SZmFXUUdPd0JVYVE5SkloSmpiMjVtYVdkMWNtRjBhVzl1QmpzQVZFa2lSV1kzT0dZMVpEWmxNRFJoWmpnMFlXSXdOVFk1WkdSak5EWmhOVE5tTlRFeFlUTmtORGM1T0dRM1pHRTBORFl3TURVM016QmhNV1ZrWmpVNU5USTFaRFlHT3dCVVNTSVJabWxzWlY5MlpYSnphVzl1QmpzQVZFa2lSV0UxTW1Sa056WXhaamRrTldVNU1HSTVPR1poTUROa056azFNREpqTkdZNU4yWXlObVk0WVdZek1URmhORGhqWm1SbE9HTTJNVE5sTmpVME56TXlaVEFHT3dCVVNTSUpaWFJoWndZN0FGUWlKeUl4WW1Kak16STBabVEwT1dGak1XSTJaVFk1TW1ZME5USXdaR05qTkRKa055SkpJZzVwYzNOMVpXUmZZWFFHT3dCVWJDc0hKNUhZYWtraUQyVjRjR2x5WlhOZllYUUdPd0JVYkNzSEp6VGJhZz09IiwiZXhwIjoiMjAyNi0xMC0yM1QxMDoxNzoxMS44MzBaIiwicHVyIjoid2ViaG9va3MuYXR0YWNobWVudC1kb3dubG9hZC52MSJ9fQ%3D%3D--a15582e437bc8863c76d73be077f6c4734add111",
"upload_token": "<upload-token>"
}
],
"department": {
"id": 2,
"name": "Engineering",
"company_id": 20000,
"archived": false,
"contact_person": "Jane Doe",
"tax_number": "US-EN-001",
"phone_number": "+1 555 0110",
"address": "100 Main Street, Springfield, IL 62701",
"email": "engineering@example.com",
"created_at": "2026-10-21T04:48:42.702-05:00",
"updated_at": "2026-10-21T04:48:42.702-05:00",
"supplier_ids": [
4,
5
],
"budget_ids": [
1,
6,
10
]
},
"supplier": {
"id": 2,
"name": "Contoso Hardware",
"company_id": 20000,
"notes": null,
"phone_number": "+1 555 0300",
"address": "500 Tech Park, Austin, TX 78701",
"email": "sales@contoso.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Diego Ramos",
"tax_number": "74-7654321",
"payment_terms": "Net 45",
"created_at": "2026-10-21T04:48:43.563-05:00",
"updated_at": "2026-10-21T04:48:43.563-05:00",
"currency_id": 2000,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
}
}
}
po_paid:
{
"event": "po_paid",
"object": "PurchaseOrder",
"payload": {
"id": 31,
"company_id": 20000,
"approval_key": "31-10/21/2026",
"department_id": 2,
"department_name": "Engineering",
"supplier_id": 2,
"supplier_name": "Contoso Hardware",
"supplier_email": "sales@contoso.example.com",
"supplier_address": "500 Tech Park, Austin, TX 78701",
"supplier_third_party_id_mappings": [
],
"supplier_payment_term_id": null,
"status": "paid",
"creator_id": 41000,
"creator_network_id": null,
"currency_id": 2000,
"currency_iso_code": "USD",
"currency_symbol": "$",
"currency_name": "United States Dollar",
"creator_name": "Sam Smith",
"creator_email": "sam.smith@example.com",
"amount": "1416.0",
"created_at": 1792576990,
"updated_at": 1792577174,
"notes": "Docking stations for the new hires",
"total_net_amount": "1416.0",
"total_gross_amount": "1541.67",
"base_gross_amount": 1541.67,
"delivery_status": "complete_delivered",
"payment_status": "unpaid",
"delivered_on": null,
"tax_amount_overridden": false,
"tax_amount_override": null,
"base_tax_amount_override": null,
"effective_tax_amount": 125.67,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"base_shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"custom_fields": {
},
"share_key": "DA1D8D71BCFC5906EC99C5AEE4172B0A2690863A",
"archived": false,
"conversion_rate": "1.0",
"keywords": "31 Contoso Hardware Sam Smith 31-10/21/2026 paid 1541.67 MON-27 Engineering Hardware FY26 27-inch monitor 716.3975 658.0 DOCK-USB-C Engineering Hardware FY26 USB-C docking station 825.2725 758.0 PRJ-2026-114 Not Set ",
"self_approved": false,
"xero_id": null,
"synced_with_xero": false,
"tax_rates": [
{
"id": 4,
"name": "Sales Tax",
"value": 8.875,
"tax_amount": 125.67
}
],
"custom_field_values": [
{
"name": "Project code",
"value": "PRJ-2026-114"
},
{
"name": "Cost center",
"value": "Not Set"
}
],
"third_party_id_mappings": [
],
"submitted_on_string": "10/21/2026",
"get_approvers": [
{
"name": "Priya Patel",
"pending": false,
"approved": true,
"rejected": false
}
],
"get_invoices": [
],
"get_po_comments_and_histories": [
{
"comment": "Purchase Order Approved by Priya Patel (api) for a total gross amount of USD 1541.67.",
"created_at": "Oct 21 05:03",
"creator": "Priya Patel"
},
{
"comment": "Jane Doe received 2.0 quantity on this purchase order at <br /> Notes: First shipment",
"created_at": "Oct 21 05:04",
"creator": "Jane Doe"
}
],
"send_to_supplier_link": "https://app.procurementexpress.com/companies/20000/purchase_orders/pex-31-10%2F21%2F2026-1?send_to_supplier=send-to-supplier-link",
"url": "https://app.procurementexpress.com/purchase_orders/31",
"purchase_order_items": [
{
"id": 50,
"description": "USB-C docking station",
"purchase_order_id": 31,
"budget_id": 1,
"budget_summary": "Engineering Hardware FY26 : ENG-HW : Capex",
"gross_amount": "825.2725",
"tax_rate": {
"id": 4,
"name": "Sales Tax",
"value": 8.875
},
"vat": "8.875",
"net_amount": "758.0",
"status": "paid",
"quantity": "4.0",
"unit_price": "189.5",
"item_number": "DOCK-USB-C",
"base_net_amount": "758.0",
"base_gross_amount": "825.27",
"gross_usd_amount": "825.2725",
"product_id": 3,
"received_quantity": "4.0",
"sequence_no": 1,
"chart_of_account_id": 2,
"qbo_customer_id": null,
"quickbooks_class_id": null,
"custom_field_values": [
{
"name": "Asset tag",
"value": null
}
],
"third_party_id_mappings": [
],
"chart_of_account": {
"id": 2,
"name": "Computer Equipment"
},
"qbo_customer": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
}
},
{
"id": 51,
"description": "27-inch monitor",
"purchase_order_id": 31,
"budget_id": 1,
"budget_summary": "Engineering Hardware FY26 : ENG-HW : Capex",
"gross_amount": "716.3975",
"tax_rate": {
"id": 4,
"name": "Sales Tax",
"value": 8.875
},
"vat": "8.875",
"net_amount": "658.0",
"status": "paid",
"quantity": "2.0",
"unit_price": "329.0",
"item_number": "MON-27",
"base_net_amount": "658.0",
"base_gross_amount": "716.4",
"gross_usd_amount": "716.3975",
"product_id": 2,
"received_quantity": "2.0",
"sequence_no": 2,
"chart_of_account_id": 2,
"qbo_customer_id": null,
"quickbooks_class_id": null,
"custom_field_values": [
{
"name": "Asset tag",
"value": null
}
],
"third_party_id_mappings": [
],
"chart_of_account": {
"id": 2,
"name": "Computer Equipment"
},
"qbo_customer": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
}
}
],
"budgets": [
{
"id": 1,
"company_id": 20000,
"name": "Engineering Hardware FY26",
"amount": 50000.0,
"cost_code": "ENG-HW",
"cost_type": "Capex",
"archived": false,
"currency_id": 2000,
"base_amount": 50000.0,
"base_rate": "1.0",
"allow_anyone_to_approve_a_po": false,
"start_date": 1767225600,
"end_date": 1798675200,
"summary": "Engineering Hardware FY26 : ENG-HW : Capex",
"remaining_amount": 16819.63,
"creator_id": 40000,
"qbo_class": null,
"department_ids": [
2
],
"approver_ids": [
40000,
50000
],
"created_at": "2026-10-21T04:48:43.335-05:00",
"updated_at": "2026-10-21T05:14:36.535-05:00",
"chart_of_account_id": 2,
"chart_of_account_name": "Computer Equipment",
"chart_of_account": {
"id": 2,
"name": "Computer Equipment"
},
"quickbooks_class": {
"id": 1,
"name": "Northeast"
}
}
],
"purchase_order_comments": [
{
"id": 57,
"comment": "Purchase order fully approved",
"creator_id": 1,
"creator_name": "PX Automate",
"purchase_order_id": 31,
"status": "inactive",
"created_at": 1792577011,
"uploads": [
]
},
{
"id": 58,
"comment": "Purchase Order Approved by Priya Patel (api) for a total gross amount of USD 1541.67.",
"creator_id": 50000,
"creator_name": "Priya Patel",
"purchase_order_id": 31,
"status": "active",
"created_at": 1792577012,
"uploads": [
]
}
],
"payments": [
{
},
{
}
],
"purchase_order_item_payments": [
{
},
{
}
],
"uploads": [
],
"department": {
"id": 2,
"name": "Engineering",
"company_id": 20000,
"archived": false,
"contact_person": "Jane Doe",
"tax_number": "US-EN-001",
"phone_number": "+1 555 0110",
"address": "100 Main Street, Springfield, IL 62701",
"email": "engineering@example.com",
"created_at": "2026-10-21T04:48:42.702-05:00",
"updated_at": "2026-10-21T04:48:42.702-05:00",
"supplier_ids": [
4,
5
],
"budget_ids": [
1,
6,
10
]
},
"supplier": {
"id": 2,
"name": "Contoso Hardware",
"company_id": 20000,
"notes": null,
"phone_number": "+1 555 0300",
"address": "500 Tech Park, Austin, TX 78701",
"email": "sales@contoso.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Diego Ramos",
"tax_number": "74-7654321",
"payment_terms": "Net 45",
"created_at": "2026-10-21T04:48:43.563-05:00",
"updated_at": "2026-10-21T04:48:43.563-05:00",
"currency_id": 2000,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
}
}
}
po_cancelled:
{
"event": "po_cancelled",
"object": "PurchaseOrder",
"payload": {
"id": 49,
"company_id": 20000,
"approval_key": null,
"department_id": 4,
"department_name": "Operations",
"supplier_id": 3,
"supplier_name": "Globex Logistics",
"supplier_email": "billing@globex.example.com",
"supplier_address": "77 Harbor Blvd, Oakland, CA 94607",
"supplier_third_party_id_mappings": [
],
"supplier_payment_term_id": null,
"status": "cancelled",
"creator_id": 41000,
"creator_network_id": null,
"currency_id": 2000,
"currency_iso_code": "USD",
"currency_symbol": "$",
"currency_name": "United States Dollar",
"creator_name": "Sam Smith",
"creator_email": "sam.smith@example.com",
"amount": "640.0",
"created_at": 1792577262,
"updated_at": 1792577267,
"notes": "Courier for trade show crates",
"total_net_amount": "640.0",
"total_gross_amount": "640.0",
"base_gross_amount": 640.0,
"delivery_status": "not_delivered",
"payment_status": "unpaid",
"delivered_on": null,
"tax_amount_overridden": false,
"tax_amount_override": null,
"base_tax_amount_override": null,
"effective_tax_amount": 0.0,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"base_shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"custom_fields": {
},
"share_key": "986F71D0D4F28F97B4E871E2DA461251D89EA145",
"archived": false,
"conversion_rate": "1.0",
"keywords": "49 Globex Logistics Sam Smith cancelled 640.00 Marketing Q4 Campaigns Freight - 3 crates 640.0 640.0 Not Set Not Set ",
"self_approved": false,
"xero_id": null,
"synced_with_xero": false,
"tax_rates": [
{
"id": 5,
"name": "Zero Rated",
"value": 0.0,
"tax_amount": 0.0
}
],
"custom_field_values": [
{
"name": "Project code",
"value": "Not Set"
},
{
"name": "Cost center",
"value": "Not Set"
}
],
"third_party_id_mappings": [
],
"submitted_on_string": "10/21/2026",
"get_approvers": [
],
"get_invoices": [
],
"get_po_comments_and_histories": [
{
"comment": "Sam Smith cancelled the purchase order on 10/21/2026",
"created_at": "Oct 21 05:07",
"creator": "Sam Smith"
}
],
"send_to_supplier_link": "https://app.procurementexpress.com/companies/20000/purchase_orders/49?send_to_supplier=send-to-supplier-link",
"url": "https://app.procurementexpress.com/purchase_orders/49",
"purchase_order_items": [
{
"id": 70,
"description": "Freight - 3 crates",
"purchase_order_id": 49,
"budget_id": 2,
"budget_summary": "Marketing Q4 Campaigns : MKT-Q4 : Opex",
"gross_amount": "640.0",
"tax_rate": {
"id": 5,
"name": "Zero Rated",
"value": 0.0
},
"vat": "0.0",
"net_amount": "640.0",
"status": "cancelled",
"quantity": "1.0",
"unit_price": "640.0",
"item_number": null,
"base_net_amount": "640.0",
"base_gross_amount": "640.0",
"gross_usd_amount": "640.0",
"product_id": 18,
"received_quantity": null,
"sequence_no": 1,
"chart_of_account_id": null,
"qbo_customer_id": null,
"quickbooks_class_id": null,
"custom_field_values": [
{
"name": "Asset tag",
"value": null
}
],
"third_party_id_mappings": [
],
"chart_of_account": {
"id": null,
"name": null
},
"qbo_customer": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
}
}
],
"budgets": [
{
"id": 2,
"company_id": 20000,
"name": "Marketing Q4 Campaigns",
"amount": 20000.0,
"cost_code": "MKT-Q4",
"cost_type": "Opex",
"archived": false,
"currency_id": 2000,
"base_amount": 20000.0,
"base_rate": "1.0",
"allow_anyone_to_approve_a_po": false,
"start_date": 1767225600,
"end_date": 1798675200,
"summary": "Marketing Q4 Campaigns : MKT-Q4 : Opex",
"remaining_amount": 19160.0,
"creator_id": 40000,
"qbo_class": null,
"department_ids": [
3
],
"approver_ids": [
40000,
50000
],
"created_at": "2026-10-21T04:48:43.390-05:00",
"updated_at": "2026-10-21T05:08:28.148-05:00",
"chart_of_account_id": 3,
"chart_of_account_name": "Advertising & Marketing",
"chart_of_account": {
"id": 3,
"name": "Advertising & Marketing"
},
"quickbooks_class": {
"id": 2,
"name": "West Coast"
}
}
],
"purchase_order_comments": [
{
"id": 134,
"comment": "Sam Smith cancelled the purchase order on 10/21/2026",
"creator_id": 41000,
"creator_name": "Sam Smith",
"purchase_order_id": 49,
"status": "active",
"created_at": 1792577267,
"uploads": [
]
}
],
"payments": [
],
"purchase_order_item_payments": [
],
"uploads": [
],
"department": {
"id": 4,
"name": "Operations",
"company_id": 20000,
"archived": false,
"contact_person": "Marco Rossi",
"tax_number": "US-OP-003",
"phone_number": "+1 555 0130",
"address": "300 Dock Road, Springfield, IL 62702",
"email": "ops@example.com",
"created_at": "2026-10-21T04:48:42.762-05:00",
"updated_at": "2026-10-21T04:48:42.762-05:00",
"supplier_ids": [
],
"budget_ids": [
5,
3,
14
]
},
"supplier": {
"id": 3,
"name": "Globex Logistics",
"company_id": 20000,
"notes": null,
"phone_number": "+1 555 0400",
"address": "77 Harbor Blvd, Oakland, CA 94607",
"email": "billing@globex.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Hank Scorpio",
"tax_number": "94-1112223",
"payment_terms": "Due on receipt",
"created_at": "2026-10-21T04:48:43.570-05:00",
"updated_at": "2026-10-21T04:48:43.570-05:00",
"currency_id": 2000,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
}
}
}
po_update (notes edited on a pending PO):
{
"event": "po_update",
"object": "PurchaseOrder",
"payload": {
"id": 50,
"company_id": 20000,
"approval_key": null,
"department_id": 3,
"department_name": "Marketing",
"supplier_id": 1,
"supplier_name": "Northwind Office Supplies",
"supplier_email": "orders@northwind.example.com",
"supplier_address": "1 Commerce Way, Chicago, IL 60601",
"supplier_third_party_id_mappings": [
],
"supplier_payment_term_id": null,
"status": "pending",
"creator_id": 40000,
"creator_network_id": null,
"currency_id": 2000,
"currency_iso_code": "USD",
"currency_symbol": "$",
"currency_name": "United States Dollar",
"creator_name": "Jane Doe",
"creator_email": "jane.doe@example.com",
"amount": "840.0",
"created_at": 1792577307,
"updated_at": 1792577347,
"notes": "Printed brochures for Q4 - deliver to Dock B",
"total_net_amount": "840.0",
"total_gross_amount": "914.55",
"base_gross_amount": 914.55,
"delivery_status": "not_delivered",
"payment_status": "unpaid",
"delivered_on": null,
"tax_amount_overridden": false,
"tax_amount_override": null,
"base_tax_amount_override": null,
"effective_tax_amount": 74.55,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"base_shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"custom_fields": {
},
"share_key": "A876578C0C6730448B0C99A77128F2E5E4433C6D",
"archived": false,
"conversion_rate": "1.0",
"keywords": "50 Northwind Office Supplies Jane Doe pending 914.55 Marketing Q4 Campaigns Tri-fold brochure, 2,000 units 914.55 840.0 MKT-Q4-07 Not Set ",
"self_approved": false,
"xero_id": null,
"synced_with_xero": false,
"tax_rates": [
{
"id": 4,
"name": "Sales Tax",
"value": 8.875,
"tax_amount": 74.55
}
],
"custom_field_values": [
{
"name": "Project code",
"value": "MKT-Q4-07"
},
{
"name": "Cost center",
"value": "Not Set"
}
],
"third_party_id_mappings": [
],
"submitted_on_string": "10/21/2026",
"get_approvers": [
{
"name": "Jane Doe",
"pending": false,
"approved": true,
"rejected": false
},
{
"name": "Priya Patel",
"pending": true,
"approved": false,
"rejected": false
}
],
"get_invoices": [
],
"get_po_comments_and_histories": [
{
"comment": "Purchase Order Self Approved by Jane Doe for a total gross amount of USD 914.55.",
"created_at": "Oct 21 05:08",
"creator": "Jane Doe"
}
],
"send_to_supplier_link": "https://app.procurementexpress.com/companies/20000/purchase_orders/50?send_to_supplier=send-to-supplier-link",
"url": "https://app.procurementexpress.com/purchase_orders/50",
"purchase_order_items": [
{
"id": 71,
"description": "Tri-fold brochure, 2,000 units",
"purchase_order_id": 50,
"budget_id": 2,
"budget_summary": "Marketing Q4 Campaigns : MKT-Q4 : Opex",
"gross_amount": "914.55",
"tax_rate": {
"id": 4,
"name": "Sales Tax",
"value": 8.875
},
"vat": "8.875",
"net_amount": "840.0",
"status": "pending",
"quantity": "2000.0",
"unit_price": "0.42",
"item_number": null,
"base_net_amount": "840.0",
"base_gross_amount": "914.55",
"gross_usd_amount": "914.55",
"product_id": 19,
"received_quantity": null,
"sequence_no": 1,
"chart_of_account_id": 3,
"qbo_customer_id": null,
"quickbooks_class_id": null,
"custom_field_values": [
{
"name": "Asset tag",
"value": null
}
],
"third_party_id_mappings": [
],
"chart_of_account": {
"id": 3,
"name": "Advertising & Marketing"
},
"qbo_customer": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
}
}
],
"budgets": [
{
"id": 2,
"company_id": 20000,
"name": "Marketing Q4 Campaigns",
"amount": 20000.0,
"cost_code": "MKT-Q4",
"cost_type": "Opex",
"archived": false,
"currency_id": 2000,
"base_amount": 20000.0,
"base_rate": "1.0",
"allow_anyone_to_approve_a_po": false,
"start_date": 1767225600,
"end_date": 1798675200,
"summary": "Marketing Q4 Campaigns : MKT-Q4 : Opex",
"remaining_amount": 19160.0,
"creator_id": 40000,
"qbo_class": null,
"department_ids": [
3
],
"approver_ids": [
40000,
50000
],
"created_at": "2026-10-21T04:48:43.390-05:00",
"updated_at": "2026-10-21T05:08:28.148-05:00",
"chart_of_account_id": 3,
"chart_of_account_name": "Advertising & Marketing",
"chart_of_account": {
"id": 3,
"name": "Advertising & Marketing"
},
"quickbooks_class": {
"id": 2,
"name": "West Coast"
}
}
],
"purchase_order_comments": [
{
"id": 137,
"comment": "Purchase Order Self Approved by Jane Doe for a total gross amount of USD 914.55.",
"creator_id": 40000,
"creator_name": "Jane Doe",
"purchase_order_id": 50,
"status": "active",
"created_at": 1792577308,
"uploads": [
]
}
],
"payments": [
],
"purchase_order_item_payments": [
],
"uploads": [
],
"department": {
"id": 3,
"name": "Marketing",
"company_id": 20000,
"archived": false,
"contact_person": "Priya Patel",
"tax_number": "US-MK-002",
"phone_number": "+1 555 0120",
"address": "200 Market Street, Springfield, IL 62701",
"email": "marketing@example.com",
"created_at": "2026-10-21T04:48:42.754-05:00",
"updated_at": "2026-10-21T04:48:42.754-05:00",
"supplier_ids": [
5
],
"budget_ids": [
11,
9,
6,
2
]
},
"supplier": {
"id": 1,
"name": "Northwind Office Supplies",
"company_id": 20000,
"notes": "Preferred stationery vendor",
"phone_number": "+1 555 0200",
"address": "1 Commerce Way, Chicago, IL 60601",
"email": "orders@northwind.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Laura Callahan",
"tax_number": "36-1234567",
"payment_terms": "Net 30",
"created_at": "2026-10-21T04:48:43.487-05:00",
"updated_at": "2026-10-21T04:48:43.487-05:00",
"currency_id": 2000,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
}
}
}
new_invoice (invoice in awaiting_review, so attachments is empty):
{
"event": "new_invoice",
"object": "Invoice",
"payload": {
"id": 29,
"invoice_number": "CH-88305",
"status": "awaiting_review",
"issue_date": 1792558800,
"validation_date": null,
"uploaded_date": 1792577860,
"due_date": 1795154400,
"tax_amount": "101.175",
"tax_amount_overridden": false,
"tax_amount_override": null,
"shipping_amount": "0.0",
"shipping_amount_overridden": false,
"net_amount": "1140.0",
"gross_amount": "1241.175",
"balance_amount": "1241.18",
"created_at": 1792577860,
"updated_at": 1792577879,
"standalone_invoice": false,
"sage_exported": false,
"confidence_score": "0.0",
"digital_invoice": false,
"selected_purchase_order_ids": [
"74"
],
"payment_term_id": null,
"payment_terms_list": null,
"attachments": [
],
"url": "https://app.procurementexpress.com/companies/20000/invoices/29",
"custom_field_values": [
],
"currency": {
"id": 2000,
"iso_code": "USD",
"iso_numeric": "840",
"name": "United States Dollar",
"symbol": "$",
"created_at": "2026-10-21T04:44:44.793-05:00",
"updated_at": "2026-10-21T04:44:44.793-05:00"
},
"supplier": {
"id": 2,
"name": "Contoso Hardware",
"company_id": 20000,
"notes": null,
"phone_number": "+1 555 0300",
"address": "500 Tech Park, Austin, TX 78701",
"email": "sales@contoso.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Diego Ramos",
"tax_number": "74-7654321",
"payment_terms": "Net 45",
"created_at": "2026-10-21T04:48:43.563-05:00",
"updated_at": "2026-10-21T04:48:43.563-05:00",
"currency_id": 2000,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
},
"purchase_orders": [
{
"id": 74,
"approval_key": "74-10/21/2026",
"creator_name": "Sam Smith",
"amount": "1710.0",
"status": "approved",
"supplier_name": "Contoso Hardware",
"supplier_id": 2,
"keywords": "74 Contoso Hardware Sam Smith 74-10/21/2026 approved 1861.76 CHR-ERG Engineering Hardware FY26 Ergonomic task chair 1861.7625 1710.0 PRJ-2026-120 Not Set ",
"created_at": 1792577759000,
"currency_id": 2000,
"currency_symbol": "$",
"currency_iso_code": "USD",
"total_gross_amount": "1861.76",
"total_net_amount": "1710.0",
"base_gross_amount": 1861.76,
"tax_amount_overridden": false,
"tax_amount_override": null,
"effective_tax_amount": 151.76,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"submitted_on": 1792577759000,
"po_date": null,
"share_key": "F19792E1249853BB01574072A76C56B6D832C97C",
"department_id": 2,
"department_name": "Engineering",
"budget_name": "Engineering Hardware FY26",
"compliance_status": null,
"delivered_on": null,
"delivery_status": "partially_delivered",
"payment_status": "invoice_received",
"payment_progress": "unpaid",
"xero_export_status": null,
"synced_with_xero": false,
"xero_is_changed": false
}
],
"npayments": [
],
"invoice_line_items": [
{
"id": 31,
"sequence_no": 1,
"description": "Ergonomic task chair",
"unit_price": "285.0",
"quantity": "4.0",
"vat": "8.875",
"net_amount": "1140.0",
"base_net_amount": "1140.0",
"deleted_at": null,
"billable_status": null,
"tax_rate_id": 4,
"chart_of_account_id": 1,
"qbo_customer_id": null,
"quickbooks_class_id": null,
"invoice_id": 29,
"purchase_order_id": 74,
"purchase_order_item_id": 97,
"markup_info": null,
"created_at": 1792577861,
"updated_at": 1792577879,
"tax_amount": "101.18",
"calculated_gross_amount": "1241.18",
"tax_exception": false,
"chart_of_account": {
"id": 1,
"name": "Office Expenses"
},
"qbo_customer": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
},
"custom_field_values": [
]
}
],
"histories": [
{
"id": 64,
"comment": "has created an invoice contoso-inv-88305.pdf",
"invoice_id": 29,
"creator_id": 40000,
"creator_name": "Jane Doe",
"created_at": 1792577861,
"updated_at": 1792577861,
"comment_formatted_time": "05:17",
"comment_formatted_date": "2026-10-21",
"formatted_comment": "<p>has created an invoice contoso-inv-88305.pdf</p>"
},
{
"id": 66,
"comment": "Changes made to invoice contoso-inv-88305.pdf: <br>Line item 1 Tax Amount from <b>1153.45</b> to <b>101.18</b><br>Line item 1 Gross Amount from <b>2293.45</b> to <b>1241.18</b>",
"invoice_id": 29,
"creator_id": 40000,
"creator_name": "Jane Doe",
"created_at": 1792577879,
"updated_at": 1792577879,
"comment_formatted_time": "05:17",
"comment_formatted_date": "2026-10-21",
"formatted_comment": "<p>Changes made to invoice <a href=\"/companies/20000/invoices/29\" rel=\"noopener noreferrer nofollow\">contoso-inv-88305.pdf</a>: <br>Line item 1 Tax Amount from <b>1153.45</b> to <b>101.18</b><br>Line item 1 Gross Amount from <b>2293.45</b> to <b>1241.18</b></p>"
}
],
"comments": [
]
}
}
invoice_approved (ready_to_pay, with the supplier invoice PDF as a 48-hour link):
{
"event": "invoice_approved",
"object": "Invoice",
"payload": {
"id": 24,
"invoice_number": "CH-88231",
"status": "ready_to_pay",
"issue_date": 1792472400,
"validation_date": 1792558800,
"uploaded_date": 1792577470,
"due_date": 1795068000,
"tax_amount": "0.0",
"tax_amount_overridden": false,
"tax_amount_override": null,
"shipping_amount": "0.0",
"shipping_amount_overridden": false,
"net_amount": "2900.0",
"gross_amount": "2900.0",
"balance_amount": "2900.0",
"created_at": 1792577470,
"updated_at": 1792577483,
"standalone_invoice": false,
"sage_exported": false,
"confidence_score": "0.0",
"digital_invoice": false,
"selected_purchase_order_ids": [
"51"
],
"payment_term_id": null,
"payment_terms_list": null,
"attachments": [
"https://app.procurementexpress.com/webhook-attachments/download?token=eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdGVWtpREhabGNuTnBiMjRHT2daRlZHa0dTU0lQZDJWaWFHOXZhMTlwWkFZN0FGUnBDMGtpRDJOdmJYQmhibmxmYVdRR093QlVhUUlnVGtraUNtVjJaVzUwQmpzQVZFa2lGV2x1ZG05cFkyVmZZWEJ3Y205MlpXUUdPd0JVU1NJT2RHVnpkRjl0YjJSbEJqc0FWRVpKSWhCemIzVnlZMlZmZEhsd1pRWTdBRlJKSWd4SmJuWnZhV05sQmpzQVZFa2lEbk52ZFhKalpWOXBaQVk3QUZScEhVa2lFSEJoY21WdWRGOTBlWEJsQmpzQVZFa2lERWx1ZG05cFkyVUdPd0JVU1NJT2NHRnlaVzUwWDJsa0Jqc0FWR2tkU1NJVVlYUjBZV05vYldWdWRGOTBlWEJsQmpzQVZFa2lHbE4xY0hCc2FXVnlTVzUyYjJsalpWVndiRzloWkFZN0FGUkpJaEpoZEhSaFkyaHRaVzUwWDJsa0Jqc0FWR2tKU1NJU1kyOXVabWxuZFhKaGRHbHZiZ1k3QUZSSklrVTRNbUppT1Rjell6QXlaREkxWmpKaVkyRTRPRFptTkdWbFlUaGhNelkyWXpneE9UTTBNRGt6TmpjME16ZzVNV1UzT0dNNFlqUTRPRFJrWWpoa05EVXpCanNBVkVraUVXWnBiR1ZmZG1WeWMybHZiZ1k3QUZSSklrVTJOVGczWTJWbE5UVTJPV1JtWWpJNE1tSXpaRGxqTUdNMFptTTFaakZsWVROak9Ea3lPVFZsTVdGallUTmhOV1l4Wkdaa09URXdNekZrWXpreU16UTRCanNBVkVraUNXVjBZV2NHT3dCVUlpY2lNV0ppWXpNeU5HWmtORGxoWXpGaU5tVTJPVEptTkRVeU1HUmpZelF5WkRjaVNTSU9hWE56ZFdWa1gyRjBCanNBVkd3ckIzdVIyR3BKSWc5bGVIQnBjbVZ6WDJGMEJqc0FWR3dyQjNzMDIybz0iLCJleHAiOiIyMDI2LTEwLTIzVDEwOjE4OjM1LjI5NVoiLCJwdXIiOiJ3ZWJob29rcy5hdHRhY2htZW50LWRvd25sb2FkLnYxIn19--5be15150da586a866ac7ecc51c8a19d14d3eee20"
],
"url": "https://app.procurementexpress.com/companies/20000/invoices/24",
"custom_field_values": [
],
"currency": {
"id": 2000,
"iso_code": "USD",
"iso_numeric": "840",
"name": "United States Dollar",
"symbol": "$",
"created_at": "2026-10-21T04:44:44.793-05:00",
"updated_at": "2026-10-21T04:44:44.793-05:00"
},
"supplier": {
"id": 2,
"name": "Contoso Hardware",
"company_id": 20000,
"notes": null,
"phone_number": "+1 555 0300",
"address": "500 Tech Park, Austin, TX 78701",
"email": "sales@contoso.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Diego Ramos",
"tax_number": "74-7654321",
"payment_terms": "Net 45",
"created_at": "2026-10-21T04:48:43.563-05:00",
"updated_at": "2026-10-21T04:48:43.563-05:00",
"currency_id": 2000,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
},
"purchase_orders": [
{
"id": 51,
"approval_key": "51-10/21/2026",
"creator_name": "Sam Smith",
"amount": "2900.0",
"status": "approved",
"supplier_name": "Contoso Hardware",
"supplier_id": 2,
"keywords": "51 Contoso Hardware Sam Smith 51-10/21/2026 approved 3157.38 LAP-14-32 Engineering Hardware FY26 14-inch laptop, 32 GB 3157.375 2900.0 Not Set Not Set ",
"created_at": 1792577371000,
"currency_id": 2000,
"currency_symbol": "$",
"currency_iso_code": "USD",
"total_gross_amount": "3157.38",
"total_net_amount": "2900.0",
"base_gross_amount": 3157.38,
"tax_amount_overridden": false,
"tax_amount_override": null,
"effective_tax_amount": 257.38,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"submitted_on": 1792577371000,
"po_date": null,
"share_key": "69D8CB64F13632489F8FDDB41E3F523F67BF7E20",
"department_id": 2,
"department_name": "Engineering",
"budget_name": "Engineering Hardware FY26",
"compliance_status": null,
"delivered_on": null,
"delivery_status": "not_delivered",
"payment_status": "invoice_received",
"payment_progress": "unpaid",
"xero_export_status": null,
"synced_with_xero": false,
"xero_is_changed": false
}
],
"npayments": [
],
"invoice_line_items": [
{
"id": 25,
"sequence_no": 1,
"description": "14-inch laptop, 32 GB",
"unit_price": "1450.0",
"quantity": "2.0",
"vat": "0.0",
"net_amount": "2900.0",
"base_net_amount": "2900.0",
"deleted_at": null,
"billable_status": null,
"tax_rate_id": 4,
"chart_of_account_id": 2,
"qbo_customer_id": null,
"quickbooks_class_id": null,
"invoice_id": 24,
"purchase_order_id": 51,
"purchase_order_item_id": 72,
"markup_info": null,
"created_at": 1792577472,
"updated_at": 1792577472,
"tax_amount": "0.0",
"calculated_gross_amount": "2900.0",
"tax_exception": false,
"chart_of_account": {
"id": 2,
"name": "Computer Equipment"
},
"qbo_customer": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
},
"custom_field_values": [
]
}
],
"histories": [
{
"id": 42,
"comment": "has created an invoice contoso-inv-88231.pdf",
"invoice_id": 24,
"creator_id": 40000,
"creator_name": "Jane Doe",
"created_at": 1792577472,
"updated_at": 1792577472,
"comment_formatted_time": "05:11",
"comment_formatted_date": "2026-10-21",
"formatted_comment": "<p>has created an invoice contoso-inv-88231.pdf</p>"
},
{
"id": 43,
"comment": "has approved an invoice contoso-inv-88231.pdf",
"invoice_id": 24,
"creator_id": 40000,
"creator_name": "Jane Doe",
"created_at": 1792577483,
"updated_at": 1792577483,
"comment_formatted_time": "05:11",
"comment_formatted_date": "2026-10-21",
"formatted_comment": "<p>has approved an invoice contoso-inv-88231.pdf</p>"
}
],
"comments": [
]
}
}
report_completed (download_url is an https://…s3…amazonaws.com/…?X-Amz-Expires=172800…
pre-signed URL):
{
"event": "report_completed",
"object": "PurchaseOrderReport",
"payload": {
"id": 1,
"type": "PurchaseOrderReport",
"report_name": "Purchase Order Report",
"company_id": 20000,
"company_name": "Acme Corporation",
"user_id": 40000,
"user_name": "Jane Doe",
"user_email": "jane.doe@example.com",
"file_name": "purchase_order_report_20000_1_20261021.xls",
"file_format": "xls",
"file_size_bytes": null,
"download_url": "https://pex-files.s3.amazonaws.com/purchase_order_report_20000_1_20261021.xls?response-content-disposition=attachment%3B&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20261021%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20261021T102000Z&X-Amz-Expires=172800&X-Amz-SignedHeaders=host&X-Amz-Signature=03c596091012a119cf29d4f5c51e8cc166bb5ec96c4d5b57f5abbd8f7d6e6bf7",
"download_expires_at": "2026-10-23T05:20:00-05:00",
"date_range": {
"from": "2026-10-01",
"to": "2026-10-31"
},
"filters": {
"status": "approved",
"department_id": 2
},
"requested_at": "2026-10-21T05:19:35-05:00",
"completed_at": "2026-10-21T05:19:39-05:00",
"processing_duration_seconds": 4,
"custom_report": null
}
}
TestWebhook (web-app Test button):
{
"event": "TestWebhook",
"object": "PurchaseOrder",
"payload": {
"id": 51,
"company_id": 20000,
"approval_key": "51-10/21/2026",
"department_id": 2,
"department_name": "Engineering",
"supplier_id": 2,
"supplier_name": "Contoso Hardware",
"supplier_email": "sales@contoso.example.com",
"supplier_address": "500 Tech Park, Austin, TX 78701",
"supplier_third_party_id_mappings": [
],
"supplier_payment_term_id": null,
"status": "approved",
"creator_id": 41000,
"creator_network_id": null,
"currency_id": 2000,
"currency_iso_code": "USD",
"currency_symbol": "$",
"currency_name": "United States Dollar",
"creator_name": "Sam Smith",
"creator_email": "sam.smith@example.com",
"amount": "2900.0",
"created_at": 1792577371,
"updated_at": 1792577472,
"notes": "Replacement laptops",
"total_net_amount": "2900.0",
"total_gross_amount": "3157.38",
"base_gross_amount": 3157.38,
"delivery_status": "not_delivered",
"payment_status": "invoice_received",
"delivered_on": null,
"tax_amount_overridden": false,
"tax_amount_override": null,
"base_tax_amount_override": null,
"effective_tax_amount": 257.38,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"base_shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"custom_fields": {
},
"share_key": "69D8CB64F13632489F8FDDB41E3F523F67BF7E20",
"archived": false,
"conversion_rate": "1.0",
"keywords": "51 Contoso Hardware Sam Smith 51-10/21/2026 approved 3157.38 LAP-14-32 Engineering Hardware FY26 14-inch laptop, 32 GB 3157.375 2900.0 Not Set Not Set ",
"self_approved": false,
"xero_id": null,
"synced_with_xero": false,
"tax_rates": [
{
"id": 4,
"name": "Sales Tax",
"value": 8.875,
"tax_amount": 257.38
}
],
"custom_field_values": [
{
"name": "Project code",
"value": "Not Set"
},
{
"name": "Cost center",
"value": "Not Set"
}
],
"third_party_id_mappings": [
],
"submitted_on_string": "10/21/2026",
"get_approvers": [
{
"name": "Priya Patel",
"pending": false,
"approved": true,
"rejected": false
}
],
"get_invoices": [
{
"name": "contoso-inv-88231.pdf"
}
],
"get_po_comments_and_histories": [
{
"comment": "Purchase Order Approved by Priya Patel (api) for a total gross amount of USD 3157.38.",
"created_at": "Oct 21 05:09",
"creator": "Priya Patel"
},
{
"comment": "has created an invoice contoso-inv-88231.pdf",
"created_at": "Oct 21 05:11",
"creator": "Jane Doe"
}
],
"send_to_supplier_link": "https://app.procurementexpress.com/companies/20000/purchase_orders/pex-51-10%2F21%2F2026-1?send_to_supplier=send-to-supplier-link",
"url": "https://app.procurementexpress.com/purchase_orders/51",
"purchase_order_items": [
{
"id": 72,
"description": "14-inch laptop, 32 GB",
"purchase_order_id": 51,
"budget_id": 1,
"budget_summary": "Engineering Hardware FY26 : ENG-HW : Capex",
"gross_amount": "3157.375",
"tax_rate": {
"id": 4,
"name": "Sales Tax",
"value": 8.875
},
"vat": "8.875",
"net_amount": "2900.0",
"status": "approved",
"quantity": "2.0",
"unit_price": "1450.0",
"item_number": "LAP-14-32",
"base_net_amount": "2900.0",
"base_gross_amount": "3157.38",
"gross_usd_amount": "3157.375",
"product_id": 20,
"received_quantity": null,
"sequence_no": 1,
"chart_of_account_id": 2,
"qbo_customer_id": null,
"quickbooks_class_id": null,
"custom_field_values": [
{
"name": "Asset tag",
"value": null
}
],
"third_party_id_mappings": [
],
"chart_of_account": {
"id": 2,
"name": "Computer Equipment"
},
"qbo_customer": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
}
}
],
"budgets": [
{
"id": 1,
"company_id": 20000,
"name": "Engineering Hardware FY26",
"amount": 50000.0,
"cost_code": "ENG-HW",
"cost_type": "Capex",
"archived": false,
"currency_id": 2000,
"base_amount": 50000.0,
"base_rate": "1.0",
"allow_anyone_to_approve_a_po": false,
"start_date": 1767225600,
"end_date": 1798675200,
"summary": "Engineering Hardware FY26 : ENG-HW : Capex",
"remaining_amount": 15299.13,
"creator_id": 40000,
"qbo_class": null,
"department_ids": [
2
],
"approver_ids": [
40000,
50000
],
"created_at": "2026-10-21T04:48:43.335-05:00",
"updated_at": "2026-10-21T05:17:17.164-05:00",
"chart_of_account_id": 2,
"chart_of_account_name": "Computer Equipment",
"chart_of_account": {
"id": 2,
"name": "Computer Equipment"
},
"quickbooks_class": {
"id": 1,
"name": "Northeast"
}
}
],
"purchase_order_comments": [
{
"id": 139,
"comment": "Purchase order fully approved",
"creator_id": 1,
"creator_name": "PX Automate",
"purchase_order_id": 51,
"status": "inactive",
"created_at": 1792577378,
"uploads": [
]
},
{
"id": 140,
"comment": "Purchase Order Approved by Priya Patel (api) for a total gross amount of USD 3157.38.",
"creator_id": 50000,
"creator_name": "Priya Patel",
"purchase_order_id": 51,
"status": "active",
"created_at": 1792577378,
"uploads": [
]
}
],
"payments": [
],
"purchase_order_item_payments": [
],
"uploads": [
],
"department": {
"id": 2,
"name": "Engineering",
"company_id": 20000,
"archived": false,
"contact_person": "Jane Doe",
"tax_number": "US-EN-001",
"phone_number": "+1 555 0110",
"address": "100 Main Street, Springfield, IL 62701",
"email": "engineering@example.com",
"created_at": "2026-10-21T04:48:42.702-05:00",
"updated_at": "2026-10-21T04:48:42.702-05:00",
"supplier_ids": [
4,
5
],
"budget_ids": [
1,
6,
10
]
},
"supplier": {
"id": 2,
"name": "Contoso Hardware",
"company_id": 20000,
"notes": null,
"phone_number": "+1 555 0300",
"address": "500 Tech Park, Austin, TX 78701",
"email": "sales@contoso.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Diego Ramos",
"tax_number": "74-7654321",
"payment_terms": "Net 45",
"created_at": "2026-10-21T04:48:43.563-05:00",
"updated_at": "2026-10-21T04:48:43.563-05:00",
"currency_id": 2000,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
}
}
}
Custom envelope – subscription with json_wrapper: "data", a root attribute
tenant=acme-corp and header attributes:
{
"event": "po_approved",
"object": "PurchaseOrder",
"tenant": "acme-corp",
"data": {
"id": 51,
"company_id": 20000,
"approval_key": "51-10/21/2026",
"department_id": 2,
"department_name": "Engineering",
"supplier_id": 2,
"supplier_name": "Contoso Hardware",
"supplier_email": "sales@contoso.example.com",
"supplier_address": "500 Tech Park, Austin, TX 78701",
"supplier_third_party_id_mappings": [
],
"supplier_payment_term_id": null,
"status": "approved",
"creator_id": 41000,
"creator_network_id": null,
"currency_id": 2000,
"currency_iso_code": "USD",
"currency_symbol": "$",
"currency_name": "United States Dollar",
"creator_name": "Sam Smith",
"creator_email": "sam.smith@example.com",
"amount": "2900.0",
"created_at": 1792577371,
"updated_at": 1792577472,
"notes": "Replacement laptops",
"total_net_amount": "2900.0",
"total_gross_amount": "3157.38",
"base_gross_amount": 3157.38,
"delivery_status": "not_delivered",
"payment_status": "invoice_received",
"delivered_on": null,
"tax_amount_overridden": false,
"tax_amount_override": null,
"base_tax_amount_override": null,
"effective_tax_amount": 257.38,
"shipping_amount_overridden": false,
"shipping_amount_override": null,
"base_shipping_amount_override": null,
"effective_shipping_amount": "0.0",
"custom_fields": {
},
"share_key": "69D8CB64F13632489F8FDDB41E3F523F67BF7E20",
"archived": false,
"conversion_rate": "1.0",
"keywords": "51 Contoso Hardware Sam Smith 51-10/21/2026 approved 3157.38 LAP-14-32 Engineering Hardware FY26 14-inch laptop, 32 GB 3157.375 2900.0 Not Set Not Set ",
"self_approved": false,
"xero_id": null,
"synced_with_xero": false,
"tax_rates": [
{
"id": 4,
"name": "Sales Tax",
"value": 8.875,
"tax_amount": 257.38
}
],
"custom_field_values": [
{
"name": "Project code",
"value": "Not Set"
},
{
"name": "Cost center",
"value": "Not Set"
}
],
"third_party_id_mappings": [
],
"submitted_on_string": "10/21/2026",
"get_approvers": [
{
"name": "Priya Patel",
"pending": false,
"approved": true,
"rejected": false
}
],
"get_invoices": [
{
"name": "contoso-inv-88231.pdf"
}
],
"get_po_comments_and_histories": [
{
"comment": "Purchase Order Approved by Priya Patel (api) for a total gross amount of USD 3157.38.",
"created_at": "Oct 21 05:09",
"creator": "Priya Patel"
},
{
"comment": "has created an invoice contoso-inv-88231.pdf",
"created_at": "Oct 21 05:11",
"creator": "Jane Doe"
}
],
"send_to_supplier_link": "https://app.procurementexpress.com/companies/20000/purchase_orders/pex-51-10%2F21%2F2026-1?send_to_supplier=send-to-supplier-link",
"url": "https://app.procurementexpress.com/purchase_orders/51",
"purchase_order_items": [
{
"id": 72,
"description": "14-inch laptop, 32 GB",
"purchase_order_id": 51,
"budget_id": 1,
"budget_summary": "Engineering Hardware FY26 : ENG-HW : Capex",
"gross_amount": "3157.375",
"tax_rate": {
"id": 4,
"name": "Sales Tax",
"value": 8.875
},
"vat": "8.875",
"net_amount": "2900.0",
"status": "approved",
"quantity": "2.0",
"unit_price": "1450.0",
"item_number": "LAP-14-32",
"base_net_amount": "2900.0",
"base_gross_amount": "3157.38",
"gross_usd_amount": "3157.375",
"product_id": 20,
"received_quantity": null,
"sequence_no": 1,
"chart_of_account_id": 2,
"qbo_customer_id": null,
"quickbooks_class_id": null,
"custom_field_values": [
{
"name": "Asset tag",
"value": null
}
],
"third_party_id_mappings": [
],
"chart_of_account": {
"id": 2,
"name": "Computer Equipment"
},
"qbo_customer": {
"id": null,
"name": null
},
"quickbooks_class": {
"id": null,
"name": null
}
}
],
"budgets": [
{
"id": 1,
"company_id": 20000,
"name": "Engineering Hardware FY26",
"amount": 50000.0,
"cost_code": "ENG-HW",
"cost_type": "Capex",
"archived": false,
"currency_id": 2000,
"base_amount": 50000.0,
"base_rate": "1.0",
"allow_anyone_to_approve_a_po": false,
"start_date": 1767225600,
"end_date": 1798675200,
"summary": "Engineering Hardware FY26 : ENG-HW : Capex",
"remaining_amount": 16819.63,
"creator_id": 40000,
"qbo_class": null,
"department_ids": [
2
],
"approver_ids": [
40000,
50000
],
"created_at": "2026-10-21T04:48:43.335-05:00",
"updated_at": "2026-10-21T05:15:24.351-05:00",
"chart_of_account_id": 2,
"chart_of_account_name": "Computer Equipment",
"chart_of_account": {
"id": 2,
"name": "Computer Equipment"
},
"quickbooks_class": {
"id": 1,
"name": "Northeast"
}
}
],
"purchase_order_comments": [
{
"id": 139,
"comment": "Purchase order fully approved",
"creator_id": 1,
"creator_name": "PX Automate",
"purchase_order_id": 51,
"status": "inactive",
"created_at": 1792577378,
"uploads": [
]
},
{
"id": 140,
"comment": "Purchase Order Approved by Priya Patel (api) for a total gross amount of USD 3157.38.",
"creator_id": 50000,
"creator_name": "Priya Patel",
"purchase_order_id": 51,
"status": "active",
"created_at": 1792577378,
"uploads": [
]
}
],
"payments": [
],
"purchase_order_item_payments": [
],
"uploads": [
],
"department": {
"id": 2,
"name": "Engineering",
"company_id": 20000,
"archived": false,
"contact_person": "Jane Doe",
"tax_number": "US-EN-001",
"phone_number": "+1 555 0110",
"address": "100 Main Street, Springfield, IL 62701",
"email": "engineering@example.com",
"created_at": "2026-10-21T04:48:42.702-05:00",
"updated_at": "2026-10-21T04:48:42.702-05:00",
"supplier_ids": [
4,
5
],
"budget_ids": [
1,
6,
10
]
},
"supplier": {
"id": 2,
"name": "Contoso Hardware",
"company_id": 20000,
"notes": null,
"phone_number": "+1 555 0300",
"address": "500 Tech Park, Austin, TX 78701",
"email": "sales@contoso.example.com",
"payment_details": null,
"archived": false,
"contact_person": "Diego Ramos",
"tax_number": "74-7654321",
"payment_terms": "Net 45",
"created_at": "2026-10-21T04:48:43.563-05:00",
"updated_at": "2026-10-21T04:48:43.563-05:00",
"currency_id": 2000,
"department_ids": [
],
"external_vendor_id": null,
"uei": null,
"cage_code": null,
"third_party_id_mappings": [
]
}
}
}
send_as_text: true (with json_wrapper: "text") – the object becomes a string:
{
"event": "new_po",
"object": "PurchaseOrder",
"text": "{\"id\":50,\"company_id\":20000,\"approval_key\":null,\"department_id\":3,\"department_name\":\"Marketing\",\"supplier_id\":1,\"supplier_name\":\"Northwind Office Supplies\",\"supplier_email\":\"orders@northwind.example.com\",\"supplier_address\":\"1 Commerce Way, Chicago, IL 60601\",\"supplier_third_party_id_mappings\":[],\"supplier_payment_term_id\":null,\"status\":\"pending\",\"creator_id\":40000,\"creator_network_id\":null,\"currency_id\":2000,\"currency_iso_code\":\"USD\",\"currency_symbol\":\"$\",\"currency_name\":\"United States Dollar\",\"creator_name\":\"Jane Doe\",\"creator_email\":\"jane.doe@example.com\",\"amount\":\"840.0\",\"created_at\":1792577307,\"updated_at\":1792577347,\"notes\":\"Printed brochures for Q4 - deliver to Dock B\",\"total_net_amount\":\"840.0\",\"total_gross_amount\":\"914.55\",\"base_gross_amount\":914.55,\"delivery_status\":\"not_delivered\",\"payment_status\":\"unpaid\",\"delivered_on\":null,\"tax_amount_overridden\":false,\"tax_amount_override\":null,\"base_tax_amount_override\":null,\"effective_tax_amount\":74.55,\"shipping_amount_overridden\":false,\"shipping_amount_override\":null,\"base_shipping_amount_override\":null,\"effective_shipping_amount\":\"0.0\",\"custom_fields\":{},\"share_key\":\"A876578C0C6730448B0C99A77128F2E5E4433C6D\",\"archived\":false,\"conversion_rate\":\"1.0\",\"keywords\":\"50 Northwind Office Supplies Jane Doe pending 914.55 Marketing Q4 Campaigns Tri-fold brochure, 2,000 units 914.55 840.0 MKT-Q4-07 Not Set \",\"self_approved\":false,\"xero_id\":null,\"synced_with_xero\":false,\"tax_rates\":[{\"id\":4,\"name\":\"Sales Tax\",\"value\":8.875,\"tax_amount\":74.55}],\"custom_field_values\":[{\"name\":\"Project code\",\"value\":\"MKT-Q4-07\"},{\"name\":\"Cost center\",\"value\":\"Not Set\"}],\"third_party_id_mappings\":[],\"submitted_on_string\":\"10/21/2026\",\"get_approvers\":[{\"name\":\"Jane Doe\",\"pending\":false,\"approved\":true,\"rejected\":false},{\"name\":\"Priya Patel\",\"pending\":true,\"approved\":false,\"rejected\":false}],\"get_invoices\":[],\"get_po_comments_and_histories\":[{\"comment\":\"Purchase Order Self Approved by Jane Doe for a total gross amount of USD 914.55.\",\"created_at\":\"Oct 21 05:08\",\"creator\":\"Jane Doe\"}],\"send_to_supplier_link\":\"https://app.procurementexpress.com/companies/20000/purchase_orders/50?send_to_supplier=send-to-supplier-link\",\"url\":\"https://app.procurementexpress.com/purchase_orders/50\",\"purchase_order_items\":[{\"id\":71,\"description\":\"Tri-fold brochure, 2,000 units\",\"purchase_order_id\":50,\"budget_id\":2,\"budget_summary\":\"Marketing Q4 Campaigns : MKT-Q4 : Opex\",\"gross_amount\":\"914.55\",\"tax_rate\":{\"id\":4,\"name\":\"Sales Tax\",\"value\":8.875},\"vat\":\"8.875\",\"net_amount\":\"840.0\",\"status\":\"pending\",\"quantity\":\"2000.0\",\"unit_price\":\"0.42\",\"item_number\":null,\"base_net_amount\":\"840.0\",\"base_gross_amount\":\"914.55\",\"gross_usd_amount\":\"914.55\",\"product_id\":19,\"received_quantity\":null,\"sequence_no\":1,\"chart_of_account_id\":3,\"qbo_customer_id\":null,\"quickbooks_class_id\":null,\"custom_field_values\":[{\"name\":\"Asset tag\",\"value\":null}],\"third_party_id_mappings\":[],\"chart_of_account\":{\"id\":3,\"name\":\"Advertising \\u0026 Marketing\"},\"qbo_customer\":{\"id\":null,\"name\":null},\"quickbooks_class\":{\"id\":null,\"name\":null}}],\"budgets\":[{\"id\":2,\"company_id\":20000,\"name\":\"Marketing Q4 Campaigns\",\"amount\":20000.0,\"cost_code\":\"MKT-Q4\",\"cost_type\":\"Opex\",\"archived\":false,\"currency_id\":2000,\"base_amount\":20000.0,\"base_rate\":\"1.0\",\"allow_anyone_to_approve_a_po\":false,\"start_date\":1767225600,\"end_date\":1798675200,\"summary\":\"Marketing Q4 Campaigns : MKT-Q4 : Opex\",\"remaining_amount\":19160.0,\"creator_id\":40000,\"qbo_class\":null,\"department_ids\":[3],\"approver_ids\":[40000,50000],\"created_at\":\"2026-10-21T04:48:43.390-05:00\",\"updated_at\":\"2026-10-21T05:08:28.148-05:00\",\"chart_of_account_id\":3,\"chart_of_account_name\":\"Advertising \\u0026 Marketing\",\"chart_of_account\":{\"id\":3,\"name\":\"Advertising \\u0026 Marketing\"},\"quickbooks_class\":{\"id\":2,\"name\":\"West Coast\"}}],\"purchase_order_comments\":[{\"id\":137,\"comment\":\"Purchase Order Self Approved by Jane Doe for a total gross amount of USD 914.55.\",\"creator_id\":40000,\"creator_name\":\"Jane Doe\",\"purchase_order_id\":50,\"status\":\"active\",\"created_at\":1792577308,\"uploads\":[]}],\"payments\":[],\"purchase_order_item_payments\":[],\"uploads\":[],\"department\":{\"id\":3,\"name\":\"Marketing\",\"company_id\":20000,\"archived\":false,\"contact_person\":\"Priya Patel\",\"tax_number\":\"US-MK-002\",\"phone_number\":\"+1 555 0120\",\"address\":\"200 Market Street, Springfield, IL 62701\",\"email\":\"marketing@example.com\",\"created_at\":\"2026-10-21T04:48:42.754-05:00\",\"updated_at\":\"2026-10-21T04:48:42.754-05:00\",\"supplier_ids\":[5],\"budget_ids\":[11,9,6,2]},\"supplier\":{\"id\":1,\"name\":\"Northwind Office Supplies\",\"company_id\":20000,\"notes\":\"Preferred stationery vendor\",\"phone_number\":\"+1 555 0200\",\"address\":\"1 Commerce Way, Chicago, IL 60601\",\"email\":\"orders@northwind.example.com\",\"payment_details\":null,\"archived\":false,\"contact_person\":\"Laura Callahan\",\"tax_number\":\"36-1234567\",\"payment_terms\":\"Net 30\",\"created_at\":\"2026-10-21T04:48:43.487-05:00\",\"updated_at\":\"2026-10-21T04:48:43.487-05:00\",\"currency_id\":2000,\"department_ids\":[],\"external_vendor_id\":null,\"uei\":null,\"cage_code\":null,\"third_party_id_mappings\":[]}}"
}
While building an integration, GET https://app.procurementexpress.com/purchase_orders/:id/webhook
(V1 authentication_token + app_company_id headers, PO read permission) returns the PO
object exactly as the webhook serializer renders it – without the envelope, and with
ordinary 5-minute attachment links.
E-invoicing events
E-invoicing subscriptions (the 12 invoice_*_pa|pex, invoice_rejected_*,
invoice_payment_status, e_reporting_* keys) are delivered by a separate worker:
- Body – the e-invoicing envelope itself (
event_id,type,occurred_at,buyer,seller,doc,invoice,invoice_id(PA id),pex_invoice_id, plus event extras such asreason_code/free_text/actoror batch fields). There is noevent/object/payloadwrapper;json_wrapper,send_as_text, header/root attributes, Basic and OAuth2 settings are ignored. - Headers –
Accept: application/json,X-Einvoicing-Timestamp(unix seconds),X-Einvoicing-Event-Id, and – only when the company has an active PA connection with a webhook secret –X-Einvoicing-Signature: sha256=<hex HMAC-SHA256(secret, "<timestamp>.<raw body>")>. Verify with a constant-time compare and reject timestamps older than 5 minutes. - Retries – a non-2xx or transport error is retried up to 5 times (backoff 1, 2, 4, 8, 16 s), then dead-lettered.
- De-duplicate on the body's
event_id. (Currently theX-Einvoicing-Event-Idheader can differ from the bodyevent_idfor accept/reject and e-reporting events – use the body value.) e_reporting_ack_receivedis not emitted yet (the PA adapter's e-reporting submission is not implemented), and the internalinvoice_acknowledged_pa/invoice_correctedtransitions cannot be subscribed to.
{
"doc": {
"format": "factur_x",
"hash_sha256": "3b1f0c3a1f5e8d2c4b6a7e9f0d1c2b3a4f5e6d7c8b9a0f1e2d3c4b5a6f7e8d9c"
},
"type": "invoice_accepted_pa",
"actor": {
"user_id": 40000,
"user_email": "jane.doe@example.com"
},
"buyer": {
"siren": "552100554",
"siret": "55210055400013",
"country": "FR"
},
"seller": {
"name": "Fournitures Lumiere SAS",
"siren": "732829320",
"siret": "73282932000074",
"country": "FR"
},
"invoice": {
"number": "FL-2026-0931",
"totals": {
"net": 1250.0,
"vat": 250.0,
"gross": 1500.0
},
"currency": "EUR",
"po_number": "PO-51",
"issue_date": "2026-10-14",
"vat_on_debits": false,
"category_of_operation": "goods"
},
"event_id": "db2757d8-0886-4d13-b92f-a34e7bc56d1a",
"invoice_id": "IRL-2026-000418",
"occurred_at": "2026-10-21T10:28:36Z"
}
{
"doc": {
"format": "factur_x"
},
"type": "invoice_rejected_business",
"actor": {
"user_id": 40000,
"user_email": "jane.doe@example.com"
},
"buyer": {
"siren": "552100554",
"siret": "55210055400013",
"country": "FR"
},
"seller": {
"name": "Fournitures Lumiere SAS",
"siren": "732829320",
"siret": "73282932000074",
"country": "FR"
},
"invoice": {
"number": "FL-2026-0932",
"totals": {
"net": 480.0,
"vat": 96.0,
"gross": 576.0
},
"currency": "EUR",
"issue_date": "2026-10-15",
"vat_on_debits": false,
"category_of_operation": "goods"
},
"event_id": "945e232a-789c-4b5e-b5a2-3f3692db1d50",
"free_text": "Invoice does not quote a purchase order number",
"invoice_id": "IRL-2026-000419",
"occurred_at": "2026-10-21T10:28:37Z",
"reason_code": "MISSING_PO"
}
{
"mode": "api",
"type": "e_reporting_submitted",
"period": "2026-10",
"batch_id": 2,
"event_id": "239ed9b1-ae2c-48f0-be74-f091bf42dcf1",
"deadline_at": "2026-11-10T23:00:00Z",
"occurred_at": "2026-10-21T10:30:47Z",
"reporting_scheme": "standard_4d"
}
Managing subscriptions
All four endpoints require the caller to be a company admin of the company in the
app_company_id header (other roles → 401 Unauthorized access). There is no DELETE
endpoint (DELETE /webhooks/:id → 404 Invalid URL); to stop a subscription set
archived: true with PUT /webhooks/:id (it can be re-enabled with archived: false, which
re-validates it). Hard deletion is web-app only. Secrets (basic_auth_pword, OAuth client
secret, OAuth access token) are write-only and never returned; header-attribute values are
returned in clear text, so anyone with company-admin access can read a secret you put in a
custom header.
Endpoints in this section
| Endpoint | Method | Path | V3 | V1 |
|---|---|---|---|---|
| List webhook subscriptions | GET |
/webhooks |
✓ | ✓ |
| Retrieve a webhook subscription | GET |
/webhooks/:id |
✓ | ✓ |
| Create a webhook subscription | POST |
/webhooks |
✓ | ✓ |
| Update or archive a webhook subscription | PUT |
/webhooks/:id |
✓ | ✓ |
The webhook subscription object
| Field | Type | Description |
|---|---|---|
id |
integer | Subscription id. |
name |
string | Display name (required). |
url |
string | HTTPS destination (see Destination rules). |
archived |
boolean | true = disabled; nothing is delivered. |
event_type |
array[string] | Subscribed event keys, all from one family (see Event types). |
tested |
boolean | true when the last web-app Test delivery got a 2xx. Never changed by real events. |
response_code |
integer | HTTP status of the last web-app Test delivery; null if never tested or the test failed before a response. |
json_wrapper |
string | Name of the key that holds the object in the envelope; null/blank = payload. |
send_as_text |
boolean | Detail responses only. true = the object is sent as a JSON string. |
basic_auth_uname |
string | Detail responses only. Stored Basic username (the password is never returned). |
webhook_attributes |
array[object] | Detail responses only. Custom header/root attributes: id, attrib_type (header or root), key, value (values are returned in clear text). |
List webhook subscriptions
curl "https://app.procurementexpress.com/api/v3/webhooks" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/webhooks" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/webhooks",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/webhooks",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
[
{
"id": 3,
"name": "ERP purchase order feed (v3, webhooks_internal) - approvals only",
"url": "https://erp.acme.test/pex/webhooks",
"archived": false,
"event_type": [
"po_approved",
"po_cancelled"
],
"tested": false,
"response_code": null,
"json_wrapper": "data"
},
{
"id": 2,
"name": "ERP purchase order feed (v1, webhooks_internal) - approvals only",
"url": "https://erp.acme.test/pex/webhooks",
"archived": false,
"event_type": [
"po_approved",
"po_cancelled"
],
"tested": false,
"response_code": null,
"json_wrapper": "data"
}
]
Example: archived (V3,
200 OK) (lists shortened to 2 items):
[
{
"id": 11,
"name": "OAuth ERP (webhooks_internal, web-UI style)",
"url": "https://erp.acme.test/pex/oauth",
"archived": true,
"event_type": [
"po_approved"
],
"tested": false,
"response_code": null,
"json_wrapper": null
},
{
"id": 10,
"name": "Basic-auth ERP (webhooks_internal, web-UI style)",
"url": "https://erp.acme.test/pex/basic",
"archived": true,
"event_type": [
"po_approved"
],
"tested": false,
"response_code": null,
"json_wrapper": null
}
]
Example: archived false (V3,
200 OK) (lists shortened to 2 items):
[
{
"id": 11,
"name": "OAuth ERP (webhooks_internal, web-UI style)",
"url": "https://erp.acme.test/pex/oauth",
"archived": true,
"event_type": [
"po_approved"
],
"tested": false,
"response_code": null,
"json_wrapper": null
},
{
"id": 10,
"name": "Basic-auth ERP (webhooks_internal, web-UI style)",
"url": "https://erp.acme.test/pex/basic",
"archived": true,
"event_type": [
"po_approved"
],
"tested": false,
"response_code": null,
"json_wrapper": null
}
]
V3 V1
Returns the company's webhook subscriptions, non-archived by default.
Returns every subscription of the company in the app_company_id header in one unpaginated
array, newest first (by creation time). By default only active (non-archived)
subscriptions are listed; use archived to list the disabled ones.
The list items are the short form of the subscription (no send_as_text,
basic_auth_uname or webhook_attributes – use GET /webhooks/:id for those).
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/webhooks |
| V1 | GET /api/v1/webhooks |
Permissions
Company admin of the app_company_id company only. Any other role → 401 Unauthorized access.
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
archived |
boolean | No | true lists archived subscriptions instead of active ones. V1 parses the value as a boolean (archived=false → active list). V3 only checks that the parameter is present: any non-empty value, including archived=false, returns the archived list – omit the parameter to list active subscriptions. Default: false. |
Response
200 with a JSON array (no envelope, no pagination). Each item has id, name, url,
archived, event_type, tested, response_code, json_wrapper.
V3 and V1 differences
Same response shape and status codes. Only the archived query parameter differs: V1 casts
it to a boolean, V3 treats any non-empty value as true (captured: archived=false returns
active subscriptions on V1 but archived ones on V3).
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Forbidden | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
Retrieve a webhook subscription
curl "https://app.procurementexpress.com/api/v3/webhooks/3" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
curl "https://app.procurementexpress.com/api/v1/webhooks/3" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID"
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v3/webhooks/3",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :get,
url: "https://app.procurementexpress.com/api/v1/webhooks/3",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID")
}
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 3,
"name": "ERP purchase order feed (v3, webhooks_internal)",
"url": "https://erp.acme.test/pex/webhooks",
"archived": false,
"event_type": [
"new_po",
"po_approved",
"po_delivered",
"po_paid",
"po_cancelled",
"po_update"
],
"tested": false,
"response_code": null,
"json_wrapper": "data",
"send_as_text": false,
"basic_auth_uname": "pex-sync",
"webhook_attributes": [
{
"id": 3,
"attrib_type": "header",
"key": "X-Source-System",
"value": "procurementexpress"
},
{
"id": 4,
"attrib_type": "root",
"key": "tenant",
"value": "acme-corp"
}
]
}
Example: tested (V3,
200 OK):
{
"id": 5,
"name": "PO events - default envelope",
"url": "https://erp.acme.test/pex/po-events",
"archived": false,
"event_type": [
"new_po",
"po_approved",
"po_delivered",
"po_paid",
"po_cancelled",
"po_update"
],
"tested": true,
"response_code": 200,
"json_wrapper": null,
"send_as_text": false,
"basic_auth_uname": null,
"webhook_attributes": [
]
}
V3 V1
Returns one subscription with its delivery options and custom attributes.
Returns the full subscription, including send_as_text, basic_auth_uname and the custom
webhook_attributes. Archived subscriptions can be retrieved too. Write-only secrets
(basic_auth_pword, and the web-app-only OAuth2 client secret/access token) are never
returned, and neither are the web-app-only settings auth_type, client_id and token_url –
the API cannot tell you whether Basic or OAuth2 authentication is active.
HTTP request
| Version | Request |
|---|---|
| V3 | GET /api/v3/webhooks/:id |
| V1 | GET /api/v1/webhooks/:id |
Permissions
Company admin of the app_company_id company only (401 Unauthorized access otherwise).
Subscriptions of other companies answer 404.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Subscription id. |
Response
200 with the webhook object (no envelope). tested/response_code reflect the last
web-app Test (see webhook_show_tested: tested: true, response_code: 200).
V3 and V1 differences
None - identical request and response.
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
| Forbidden | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
Create a webhook subscription
curl -X POST "https://app.procurementexpress.com/api/v3/webhooks" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"webhook": {
"name": "ERP purchase order feed (v3, webhooks_internal)",
"url": "https://erp.acme.test/pex/webhooks",
"event_type": [
"new_po",
"po_approved",
"po_delivered",
"po_paid",
"po_cancelled",
"po_update"
],
"json_wrapper": "data",
"send_as_text": false,
"basic_auth_uname": "pex-sync",
"basic_auth_pword": "s3cr3t-basic-pass",
"webhook_attributes_attributes": [
{
"attrib_type": "header",
"key": "X-Source-System",
"value": "procurementexpress"
},
{
"attrib_type": "root",
"key": "tenant",
"value": "acme-corp"
}
]
}
}'
curl -X POST "https://app.procurementexpress.com/api/v1/webhooks" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"webhook": {
"name": "ERP purchase order feed (v1, webhooks_internal)",
"url": "https://erp.acme.test/pex/webhooks",
"event_type": [
"new_po",
"po_approved",
"po_delivered",
"po_paid",
"po_cancelled",
"po_update"
],
"json_wrapper": "data",
"send_as_text": false,
"basic_auth_uname": "pex-sync",
"basic_auth_pword": "s3cr3t-basic-pass",
"webhook_attributes_attributes": [
{
"attrib_type": "header",
"key": "X-Source-System",
"value": "procurementexpress"
},
{
"attrib_type": "root",
"key": "tenant",
"value": "acme-corp"
}
]
}
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v3/webhooks",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
webhook: {
name: "ERP purchase order feed (v3, webhooks_internal)",
url: "https://erp.acme.test/pex/webhooks",
event_type: [
"new_po",
"po_approved",
"po_delivered",
"po_paid",
"po_cancelled",
"po_update"
],
json_wrapper: "data",
send_as_text: false,
basic_auth_uname: "pex-sync",
basic_auth_pword: "s3cr3t-basic-pass",
webhook_attributes_attributes: [
{
attrib_type: "header",
key: "X-Source-System",
value: "procurementexpress"
},
{
attrib_type: "root",
key: "tenant",
value: "acme-corp"
}
]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :post,
url: "https://app.procurementexpress.com/api/v1/webhooks",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
webhook: {
name: "ERP purchase order feed (v1, webhooks_internal)",
url: "https://erp.acme.test/pex/webhooks",
event_type: [
"new_po",
"po_approved",
"po_delivered",
"po_paid",
"po_cancelled",
"po_update"
],
json_wrapper: "data",
send_as_text: false,
basic_auth_uname: "pex-sync",
basic_auth_pword: "s3cr3t-basic-pass",
webhook_attributes_attributes: [
{
attrib_type: "header",
key: "X-Source-System",
value: "procurementexpress"
},
{
attrib_type: "root",
key: "tenant",
value: "acme-corp"
}
]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1:
{
"id": 3,
"name": "ERP purchase order feed (v3, webhooks_internal)",
"url": "https://erp.acme.test/pex/webhooks",
"archived": false,
"event_type": [
"new_po",
"po_approved",
"po_delivered",
"po_paid",
"po_cancelled",
"po_update"
],
"tested": false,
"response_code": null,
"json_wrapper": "data",
"send_as_text": false,
"basic_auth_uname": "pex-sync",
"webhook_attributes": [
{
"id": 3,
"attrib_type": "header",
"key": "X-Source-System",
"value": "procurementexpress"
},
{
"id": 4,
"attrib_type": "root",
"key": "tenant",
"value": "acme-corp"
}
]
}
Example: unwrapped body (V3,
400 Bad Request):
{
"error": [
"Url can't be blank",
"Event type is invalid"
],
"status": 400
}
V3 V1
Creates a subscription that will receive the selected events.
Creates a subscription for the company in app_company_id. Deliveries start with the next
matching event; nothing is sent at creation time (use the web app's Test button to send a
TestWebhook).
Validation:
* name and url are required; url must satisfy the destination rules (HTTPS on port 443,
public host – see the section intro). Errors are returned together, e.g.
Url must use https://, Url points at an address that is not allowed,
Url must use an allowed port (https on 443).
* event_type must be a non-empty array of known keys from a single family; otherwise
Event type is invalid.
* basic_auth_uname and basic_auth_pword must be given together.
The authentication type cannot be set through the API: Basic credentials sent here are stored
encrypted but are not used until an admin selects Basic in the web app; auth_type,
client_id, client_secret and token_url are silently ignored.
JSON bodies may omit the webhook root (Rails wraps top-level attributes into webhook
automatically); an empty body → 400 Required parameter are missing..
HTTP request
| Version | Request |
|---|---|
| V3 | POST /api/v3/webhooks |
| V1 | POST /api/v1/webhooks |
Permissions
Company admin of the app_company_id company only. Other roles → 401 Unauthorized access.
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
webhook[ |
string | Yes | Display name. |
webhook[ |
string | Yes | HTTPS destination URL, port 443 (or 8443), public host, no credentials/fragment, ≤ 2000 chars. |
webhook[ |
array[string] | Yes | Event keys, all from one family: PO (new_po, po_approved, po_delivered, po_paid, po_cancelled, po_update), invoice (new_invoice, invoice_approved), report (report_completed) or e-invoicing (invoice_received_pa, invoice_validated_pa, invoice_rejected_syntax, invoice_delivered_pa, invoice_accepted_pa, invoice_rejected_business, invoice_payment_status, invoice_received_pex, invoice_accepted_pex, invoice_approved_pex, e_reporting_submitted, e_reporting_ack_received). Empty strings are not stripped – a form-encoded webhook[event_type][]= makes the request invalid. |
webhook[ |
string | No | Key that holds the object in the envelope. Default payload. Do not use event or object. |
webhook[ |
boolean | No | Default false. true sends the object as a JSON string (for chat tools). |
webhook[ |
boolean | No | Default false. true creates the subscription disabled. |
webhook[ |
string | No | Basic username; required when basic_auth_pword is given. Only used once Basic auth is selected in the web app. Returned in responses. |
webhook[ |
string | No | Basic password; required when basic_auth_uname is given. Stored encrypted, never returned. Only used once Basic auth is selected in the web app. |
webhook[ |
string | No | header (HTTP request header) or root (extra top-level key in the JSON envelope). Default header. |
webhook[ |
string | No | Header name or root key. Rows with a blank key are ignored. Header names must be valid HTTP tokens and not reserved (Host, Content-Length, Connection, Transfer-Encoding, Proxy-*, …) or every delivery fails. |
webhook[ |
string | No | Header value / root value (always a string). Returned in clear text by the API. |
webhook[ |
string | No | Accepted by the parameter filter but there is no such attribute: sending it fails with HTTP 500. Do not send. |
Response
200 (not 201) with the created webhook in detail form. basic_auth_pword is not echoed.
Validation failures → 400 {"error": ["…", …], "status": 400}.
V3 and V1 differences
None - identical request and response (same controller code in both versions apart from the list filter).
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Insecure url | 400 |
V3, V1 | {"error":["Url must use https://"],"status":400} |
| Private address | 400 |
V3 | {"error":["Url points at an address that is not allowed"],"status":400} |
| Validation | 400 |
V3, V1 | {"error":["Name can't be blank","Event type is invalid"],"status":400} |
| Mixed groups | 400 |
V3 | {"error":["Event type is invalid"],"status":400} |
| Params missing | 400 |
V3, V1 | {"error":"Required parameter are missing.","status":400} |
| Forbidden | 401 |
V3, V1 | {"error":"Unauthorized access","status":401} |
Update or archive a webhook subscription
curl -X PUT "https://app.procurementexpress.com/api/v3/webhooks/3" \
-H "Authorization: Bearer $PEX_ACCESS_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"webhook": {
"name": "ERP purchase order feed (v3, webhooks_internal) - approvals only",
"event_type": [
"po_approved",
"po_cancelled"
],
"basic_auth_pword": "",
"webhook_attributes_attributes": [
{
"id": 3,
"attrib_type": "header",
"key": "X-Source-System",
"value": "pex-production"
},
{
"attrib_type": "root",
"key": "tenant",
"value": "acme-corp"
}
]
}
}'
curl -X PUT "https://app.procurementexpress.com/api/v1/webhooks/2" \
-H "authentication_token: $PEX_AUTH_TOKEN" \
-H "app_company_id: $PEX_COMPANY_ID" \
-H "Content-Type: application/json" \
-d '{
"webhook": {
"name": "ERP purchase order feed (v1, webhooks_internal) - approvals only",
"event_type": [
"po_approved",
"po_cancelled"
],
"basic_auth_pword": "",
"webhook_attributes_attributes": [
{
"id": 1,
"attrib_type": "header",
"key": "X-Source-System",
"value": "pex-production"
},
{
"attrib_type": "root",
"key": "tenant",
"value": "acme-corp"
}
]
}
}'
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v3/webhooks/3",
headers: {
"Authorization" => "Bearer #{ENV.fetch('PEX_ACCESS_TOKEN')}",
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
webhook: {
name: "ERP purchase order feed (v3, webhooks_internal) - approvals only",
event_type: ["po_approved", "po_cancelled"],
basic_auth_pword: "",
webhook_attributes_attributes: [
{
id: 3,
attrib_type: "header",
key: "X-Source-System",
value: "pex-production"
},
{
attrib_type: "root",
key: "tenant",
value: "acme-corp"
}
]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
require "rest-client"
require "json"
response = RestClient::Request.execute(
method: :put,
url: "https://app.procurementexpress.com/api/v1/webhooks/2",
headers: {
"authentication_token" => ENV.fetch("PEX_AUTH_TOKEN"),
"app_company_id" => ENV.fetch("PEX_COMPANY_ID"),
"Content-Type" => "application/json"
},
payload: {
webhook: {
name: "ERP purchase order feed (v1, webhooks_internal) - approvals only",
event_type: ["po_approved", "po_cancelled"],
basic_auth_pword: "",
webhook_attributes_attributes: [
{
id: 1,
attrib_type: "header",
key: "X-Source-System",
value: "pex-production"
},
{
attrib_type: "root",
key: "tenant",
value: "acme-corp"
}
]
}
}.to_json
)
puts JSON.pretty_generate(JSON.parse(response.body))
Response
200 OK, same structure in V3 and V1 (lists shortened to 2 items):
{
"id": 3,
"name": "ERP purchase order feed (v3, webhooks_internal) - approvals only",
"url": "https://erp.acme.test/pex/webhooks",
"archived": false,
"event_type": [
"po_approved",
"po_cancelled"
],
"tested": false,
"response_code": null,
"json_wrapper": "data",
"send_as_text": false,
"basic_auth_uname": "pex-sync",
"webhook_attributes": [
{
"id": 3,
"attrib_type": "header",
"key": "X-Source-System",
"value": "pex-production"
},
{
"id": 6,
"attrib_type": "header",
"key": "X-Api-Key",
"value": "demo-key-not-real"
}
]
}
Example: archive (V3,
200 OK):
{
"id": 1,
"name": "Retired endpoint (v3, webhooks_internal)",
"url": "https://erp.acme.test/hook",
"archived": true,
"event_type": [
"new_po"
],
"tested": false,
"response_code": null,
"json_wrapper": null,
"send_as_text": false,
"basic_auth_uname": null,
"webhook_attributes": [
]
}
V3 V1
Changes a subscription's settings, or disables it with archived=true.
Updates only the attributes you send; the same validation as create runs on the resulting
record. event_type replaces the whole list.
- Disable / enable: there is no delete endpoint. Send
{"webhook": {"archived": true}}to stop deliveries (queued deliveries are dropped too);archived: falsere-enables after full validation. Hard deletion is only possible in the web app. - Secrets: a blank
basic_auth_pwordkeeps the stored password; clearingbasic_auth_unametogether with a blank password removes Basic credentials. - Custom attributes: add rows without
id; change a row by sending itsidtogether with itskey(a row update withoutkeyis silently ignored); remove a row with{"id": 5, "_destroy": true}. - Any change to a subscription (even a rename) immediately invalidates all attachment download links it has already delivered.
HTTP request
| Version | Request |
|---|---|
| V3 | `PUT |
| V1 | `PUT |
Permissions
Company admin of the app_company_id company only (401 otherwise). Other companies'
subscriptions → 404.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
integer | Subscription id. |
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
webhook[ |
string | No | Display name (cannot be blank). |
webhook[ |
string | No | New HTTPS destination (re-validated). |
webhook[ |
array[string] | No | Replaces the subscribed events; one family only. |
webhook[ |
string | No | Envelope key for the object; blank resets to payload. |
webhook[ |
boolean | No | Send the object as a JSON string. |
webhook[ |
boolean | No | true disables delivery, false re-enables (re-validated). |
webhook[ |
string | No | Basic username (only used when Basic auth is selected in the web app). |
webhook[ |
string | No | Basic password; blank keeps the stored one. Never returned. |
webhook[ |
integer | No | Existing attribute to change or delete. |
webhook[ |
string | No | header or root. |
webhook[ |
string | No | Required for new rows and for updates of existing rows (rows without a key are ignored). |
webhook[ |
string | No | Attribute value. |
webhook[ |
boolean | No | true removes the attribute with that id. |
Response
200 with the updated webhook in detail form; 400 {"error": [...]} on validation errors.
V3 and V1 differences
None - identical request and response. PATCH behaves like PUT in both versions.
DELETE /webhooks/:id is not routed in either version (404 Invalid URL).
Errors
| Case | Status | Versions | Response body |
|---|---|---|---|
| Validation | 400 |
V3, V1 | {"error":["Event type is invalid"],"status":400} |
| Not found | 404 |
V3, V1 | {"error":"Invalid id passed","status":404} |
| Delete not routed | 404 |
V3, V1 | {"error":"Invalid URL","status":404} |
Changelog
Changes to the API and to this reference. Dates are when the documentation was updated.
2026-10-21
Documentation rebuilt and re-verified. Every public endpoint is now documented for both V3 and V1, with request and response examples recorded from real calls against the current release.
- V3 authentication: third-party integrations obtain tokens with the OAuth2
client credentials grant. The password grant previously shown here is
limited to ProcurementExpress's own apps and answers
401 unauthorized_clientfor other applications. Client credentials tokens last two hours and have no refresh token. - Endpoints that were missing from this reference are now documented, including budget archiving, QuickBooks Online item budget mappings, custom field management, supplier approvals, compliance policies and policy templates, approval flow publishing and export, and the purchase order receiving, delivery, forwarding and PDF actions.
- Request and response details that no longer matched the API were corrected,
for example:
created_atandupdated_atare ISO 8601 strings, not Unix numbers; several list endpoints have a different shape in V3 than in V1 (each endpoint now shows both); and permission denials answer401 Unauthorized access. - Webhooks now documents exactly when each event fires, the request format and authentication options, and what deliveries do not provide (signatures, retries), so you can build a safe receiver.
- Every endpoint shows which versions it exists in; V3 and V1 has the full availability table and the response-shape differences between the two versions.
- New guides: Quickstart, Requests and Responses, Errors and Rate Limits.
- Sections are ordered alphabetically. Features that depend on an integration or a parent resource are grouped under it, for example QuickBooks Online: Chart of Accounts and Purchase Orders: Comments.
- The reference is also available as an OpenAPI 3.1 description and a Postman collection.
- Links to the previous section anchors keep working.