NAV
cURL · V3 cURL · V1 Ruby · V3 Ruby · V1
  • Introduction
  • Getting started
  • Quickstart
  • Authentication
  • Requests and Responses
  • Errors
  • Rate Limits
  • V3 and V1
  • API reference
  • Approval Flows
  • Budgets
  • Companies
  • Compliance
  • Currencies
  • Custom Fields
  • Departments
  • Invoices
  • Payments
  • Products
  • Purchase Orders
  • Purchase Orders: Attachments
  • Purchase Orders: Comments
  • Purchase Orders: Receiving & Delivery
  • Purchase Orders: Sending to Suppliers
  • QuickBooks Online
  • QuickBooks Online: Chart of Accounts
  • QuickBooks Online: Classes
  • QuickBooks Online: Customers
  • QuickBooks Online: Item Budget Mappings
  • SAM.gov Supplier Audit
  • Suppliers
  • Suppliers: Approvals
  • Tax Rates
  • Users
  • Webhooks
  • More
  • Changelog
  • 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

    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>"
    

    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

    1. Sign in to ProcurementExpress as a company admin.
    2. Open Integrations and choose Custom OAuth Applications → Integrate, then Add OAuth Application.
    3. 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.
    4. 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:

    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):

    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.

    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.

    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).

    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 }
        ]
      }
    }
    

    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"

    Records and tenancy

    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 429 and 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

    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

    Section Endpoint Method and path V3 V1
    Authentication Get an access token POST /oauth/token ✓ —
    Authentication Check an access token GET /oauth/token/info ✓ —
    Authentication Introspect an access token POST /oauth/introspect ✓ —
    Authentication Revoke an access token POST /oauth/revoke ✓ —
    Authentication Sign in (V1) POST /login — ✓
    Authentication Verify a one-time code POST /verify_otp ✓ ✓
    Authentication Resend the one-time code POST /resend_otp ✓ ✓
    Authentication Sign out (V1) POST /logout — ✓
    Authentication Request a password reset e-mail POST /reset_password ✓ ✓
    Authentication Authentication errors GET /budgets ✓ ✓
    Approval Flows List approval flows GET /approval_flows ✓ ✓
    Approval Flows Get an approval flow GET /approval_flows/:id ✓ ✓
    Approval Flows Create an approval flow POST /approval_flows ✓ ✓
    Approval Flows Update an approval flow PUT /approval_flows/:id ✓ ✓
    Approval Flows Delete an approval flow DELETE /approval_flows/:id ✓ ✓
    Approval Flows Archive an approval flow PUT /approval_flows/:id/archive ✓ ✓
    Approval Flows Publish an approval flow PATCH /approval_flows/:id/publish — ✓
    Approval Flows Unpublish an approval flow PATCH /approval_flows/:id/unpublish — ✓
    Approval Flows List the runs of an approval flow GET /approval_flows/:id/runs ✓ ✓
    Approval Flows Get one run of an approval flow GET /approval_flows/:id/show_entity ✓ ✓
    Approval Flows List the versions of an approval flow GET /approval_flows/:id/versions ✓ ✓
    Approval Flows Get a version of an approval flow GET /approval_flows/:id/version_details ✓ ✓
    Approval Flows Rerun approval flows for documents POST /approval_flows/rerun_approval_flows ✓ ✓
    Approval Flows Export approval flows to Excel GET /approval_flows/export — ✓
    Budgets List budgets GET /budgets ✓ ✓
    Budgets Get a budget GET /budgets/:id ✓ ✓
    Budgets Create a budget POST /budgets ✓ ✓
    Budgets Update a budget PUT /budgets/:id ✓ ✓
    Budgets Archive a budget POST /budgets/:id/archive ✓ —
    Budgets Unarchive a budget POST /budgets/:id/unarchive ✓ —
    Companies List companies GET /companies ✓ ✓
    Companies Get the current company GET /companies/details ✓ ✓
    Companies Get a company GET /companies/:id ✓ ✓
    Companies Create a company POST /companies ✓ ✓
    Companies List approvers GET /companies/approvers ✓ ✓
    Companies List all approvers GET /companies/all_approvers ✓ ✓
    Companies List employees GET /companies/employees ✓ ✓
    Companies Get the invite limit GET /companies/invite_limit_left ✓ ✓
    Companies List the company's chart of accounts GET /companies/chart_of_accounts ✓ ✓
    Companies Invite a user POST /companies/send_user_invite ✓ ✓
    Compliance List policies GET /policies — ✓
    Compliance Get a policy GET /policies/:id — ✓
    Compliance Create a policy POST /policies — ✓
    Compliance Update a policy PUT /policies/:id — ✓
    Compliance Delete a policy DELETE /policies/:id — ✓
    Compliance List policy templates GET /policy_templates — ✓
    Compliance Scan a purchase order POST /compliance/check — ✓
    Compliance Scan several purchase orders POST /compliance/bulk_check — ✓
    Compliance Get your latest bulk scan GET /compliance/bulk_check_status — ✓
    Compliance List bulk scan history GET /compliance/scan_history — ✓
    Compliance Get a bulk scan GET /compliance/scan_history/:id — ✓
    Compliance Justify a violation POST /compliance/justify — ✓
    Compliance Generate a price reasonableness memo POST /compliance/generate_memo — ✓
    Compliance Create an evidence pack POST /compliance/evidence_packs — ✓
    Compliance Get an evidence pack GET /compliance/evidence_packs/:id — ✓
    Compliance Download an evidence pack GET /compliance/evidence_packs/:id/download — ✓
    Compliance Rebuild an evidence pack POST /compliance/evidence_packs/:id/regenerate — ✓
    Currencies List the company's currencies GET /currencies ✓ ✓
    Currencies List all currencies GET /all_currencies ✓ ✓
    Custom Fields List custom fields GET /custom_fields — ✓
    Custom Fields Get a custom field GET /custom_fields/:id — ✓
    Custom Fields Create a custom field POST /custom_fields — ✓
    Custom Fields Update a custom field PUT /custom_fields/:id — ✓
    Custom Fields Delete (archive) a custom field DELETE /custom_fields/:id — ✓
    Custom Fields Reorder custom fields PATCH /custom_fields/update_positions — ✓
    Custom Fields Set a custom field value POST /custom_field_values ✓ ✓
    Departments List departments GET /departments ✓ ✓
    Departments Get a department GET /departments/:id ✓ ✓
    Departments Create a department POST /departments ✓ ✓
    Departments Update a department PUT /departments/:id ✓ ✓
    Invoices List invoices GET /invoices ✓ ✓
    Invoices Get an invoice GET /invoices/:id ✓ ✓
    Invoices Create an invoice POST /invoices ✓ ✓
    Invoices Update an invoice PUT /invoices/:id ✓ ✓
    Invoices List purchase orders to invoice GET /invoices/purchase_order_list ✓ ✓
    Invoices List purchase order lines to invoice GET /invoices/purchase_order_item_list ✓ ✓
    Invoices Comment on an invoice POST /invoices/:id/create_comment ✓ ✓
    Invoices Accept an invoice awaiting review PUT /invoices/:id/accept ✓ ✓
    Invoices Approve an invoice PUT /invoices/:id/approve ✓ ✓
    Invoices Reject an invoice PUT /invoices/:id/reject ✓ ✓
    Invoices Cancel an invoice PUT /invoices/:id/cancel ✓ ✓
    Invoices Archive an invoice PUT /invoices/:id/archive ✓ ✓
    Invoices Unarchive an invoice PUT /invoices/:id/dearchive ✓ ✓
    Invoices Rerun the approval flow of an invoice POST /invoices/:id/rerun_approval_flow — ✓
    Invoices Scan an invoice document POST /digital_invoices ✓ ✓
    Payments Get a payment GET /npayments/:id ✓ ✓
    Payments Record a payment POST /npayments ✓ ✓
    Payments Mark a purchase order as paid POST /purchase_orders/:purchase_order_id/payments ✓ ✓
    Products List products GET /products ✓ ✓
    Products Get a product GET /products/:id ✓ ✓
    Products List product SKUs GET /products/skus ✓ ✓
    Products Create a product POST /products ✓ ✓
    Products Bulk-create products for a supplier POST /products/bulk_create ✓ ✓
    Products Update a product PUT /products/:id ✓ ✓
    Purchase Orders List purchase orders GET /purchase_orders ✓ ✓
    Purchase Orders Get a purchase order GET /purchase_orders/:id ✓ ✓
    Purchase Orders New purchase order template GET /purchase_orders/new ✓ ✓
    Purchase Orders Create a purchase order POST /purchase_orders ✓ ✓
    Purchase Orders Update a purchase order PUT /purchase_orders/:id ✓ ✓
    Purchase Orders Create purchase orders in bulk POST /purchase_orders/bulk_save ✓ ✓
    Purchase Orders Approve a purchase order POST /purchase_orders/:id/approve ✓ ✓
    Purchase Orders Reject a purchase order POST /purchase_orders/:id/reject ✓ ✓
    Purchase Orders Override and approve a purchase order POST /purchase_orders/:id/override_and_approve ✓ ✓
    Purchase Orders Cancel a purchase order POST /purchase_orders/:id/cancel ✓ ✓
    Purchase Orders Archive a purchase order POST /purchase_orders/:id/archive ✓ ✓
    Purchase Orders Delete a draft purchase order DELETE /purchase_orders/:id/delete ✓ ✓
    Purchase Orders Count my pending approvals GET /purchase_orders/pending_request_count ✓ ✓
    Purchase Orders Preview budget approvers GET /purchase_orders/auto_approvers_list ✓ ✓
    Purchase Orders Preview approval-flow approvers POST /purchase_orders/approver_list ✓ ✓
    Purchase Orders: Attachments Upload a file to a purchase order POST /uploads/po ✓ ✓
    Purchase Orders: Attachments Upload a file to a PO comment POST /uploads/poc ✓ ✓
    Purchase Orders: Attachments Get upload status GET /uploads/status ✓ ✓
    Purchase Orders: Comments Comment on a purchase order POST /purchase_orders/:purchase_order_id/comments ✓ ✓
    Purchase Orders: Receiving & Delivery Receive items POST /purchase_orders/:id/receiving_items ✓ ✓
    Purchase Orders: Receiving & Delivery Cancel received items POST /purchase_orders/:id/cancel_receiving_items ✓ ✓
    Purchase Orders: Receiving & Delivery Complete delivery POST /purchase_orders/:id/complete_delivery ✓ ✓
    Purchase Orders: Sending to Suppliers Send a purchase order to a supplier POST /purchase_orders/:id/forward ✓ ✓
    Purchase Orders: Sending to Suppliers Save or update a send-to-supplier template POST /purchase_orders/:id/forward ✓ ✓
    Purchase Orders: Sending to Suppliers List send-to-supplier templates GET /send_to_supplier_templates ✓ ✓
    Purchase Orders: Sending to Suppliers Generate a purchase order PDF GET /purchase_orders/:id/generate_pdf ✓ ✓
    QuickBooks Online: Chart of Accounts List chart of accounts GET /chart_of_accounts ✓ ✓
    QuickBooks Online: Chart of Accounts Get a chart of account GET /chart_of_accounts/:id ✓ ✓
    QuickBooks Online: Classes List QuickBooks classes GET /qbo_classes ✓ ✓
    QuickBooks Online: Classes Get a QuickBooks class GET /qbo_classes/:id ✓ ✓
    QuickBooks Online: Customers List QuickBooks customers GET /qbo_customers ✓ ✓
    QuickBooks Online: Customers Get a QuickBooks customer GET /qbo_customers/:id ✓ ✓
    QuickBooks Online: Item Budget Mappings List item budget mappings GET /qbo_item_budget_mappings ✓ —
    QuickBooks Online: Item Budget Mappings Get an item budget mapping GET /qbo_item_budget_mappings/:id ✓ —
    QuickBooks Online: Item Budget Mappings Create an item budget mapping POST /qbo_item_budget_mappings ✓ —
    QuickBooks Online: Item Budget Mappings Update an item budget mapping PUT /qbo_item_budget_mappings/:id ✓ —
    QuickBooks Online: Item Budget Mappings Delete an item budget mapping DELETE /qbo_item_budget_mappings/:id ✓ —
    QuickBooks Online: Item Budget Mappings Bulk create or update item budget mappings PUT /qbo_item_budget_mappings/bulk_update ✓ —
    SAM.gov Supplier Audit Check a supplier against SAM.gov POST /sam_gov/check ✓ ✓
    SAM.gov Supplier Audit Get a supplier's latest SAM.gov check GET /sam_gov/status/:supplier_id ✓ ✓
    Suppliers List suppliers GET /suppliers ✓ ✓
    Suppliers List the caller's most used suppliers GET /suppliers/top ✓ ✓
    Suppliers Get a supplier GET /suppliers/:id ✓ ✓
    Suppliers Create a supplier POST /suppliers ✓ ✓
    Suppliers Update a supplier PUT /suppliers/:id ✓ ✓
    Suppliers: Approvals List supplier approval requests GET /supplier_approvals — ✓
    Tax Rates List tax rates GET /tax_rates ✓ ✓
    Tax Rates Get a tax rate GET /tax_rates/:id ✓ ✓
    Tax Rates Create a tax rate POST /tax_rates ✓ ✓
    Tax Rates Update a tax rate PUT /tax_rates/:id ✓ ✓
    Users Get the current user GET /currentuser ✓ ✓
    Users Update the current user PUT /currentuser ✓ ✓
    Users Update the current user (by id) PUT /users/:id ✓ ✓
    Webhooks List webhook subscriptions GET /webhooks ✓ ✓
    Webhooks Retrieve a webhook subscription GET /webhooks/:id ✓ ✓
    Webhooks Create a webhook subscription POST /webhooks ✓ ✓
    Webhooks Update or archive a webhook subscription PUT /webhooks/:id ✓ ✓

    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

    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

    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

    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.

    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[].id integer Step id (use it to update or _destroy the step).
    approval_steps[].step_no integer Position of the step; steps run in ascending step_no.
    approval_steps[].all_should_approve boolean false = any one approver completes the step; true = all approvers, asked one at a time.
    approval_steps[].approval_flow_id integer Parent flow.
    approval_steps[].created_at timestamp Unix seconds.
    approval_steps[].updated_at timestamp Unix seconds.
    approval_steps[].approval_step_approvers[].id integer Approver assignment id (use it to _destroy the assignment).
    approval_steps[].approval_step_approvers[].approval_step_id integer Parent step.
    approval_steps[].approval_step_approvers[].user_id integer Approver's user id.
    approval_steps[].approval_step_approvers[].user 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[].approval_step_approvers[].created_at timestamp Unix seconds.
    approval_steps[].approval_step_approvers[].updated_at timestamp Unix seconds.
    approval_steps[].approval_conditions[] array[object] Step conditions - same object as approval_conditions[], with approval_step_id set and approval_flow_id null.
    approval_conditions[].id integer Condition id.
    approval_conditions[].property string Property, see the Conditions table in the intro.
    approval_conditions[].operator string equals, not_equals, greater_than, less_than, is_any_of, is_none_of, contains, not_contains, exists, not_exists.
    approval_conditions[].value string Value as sent (ids are comma-separated for lists).
    approval_conditions[].custom_field_id 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[].approval_flow_id integer Set for flow-level conditions, null for step conditions.
    approval_conditions[].approval_step_id integer Set for step conditions, null for flow-level conditions.
    approval_conditions[].human_readable_description string e.g. Supplier is any of Northwind Office Supplies, Contoso Hardware. Note greater_than reads "greater than" but is inclusive.
    approval_conditions[].property_label string Display name of the property.
    approval_conditions[].value_label string Display value (record names for id properties).
    approval_conditions[].created_at timestamp Unix seconds.
    approval_conditions[].updated_at 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[name] string Yes Flow name, max 255 characters.
    approval_flow[document_type] string No purchase_order (default), invoice or rfq. Any other value causes HTTP 500.
    approval_flow[self_approval_allowed] boolean No Default false. See intro.
    approval_flow[approval_conditions_attributes][] array[object] Yes Flow conditions (all must match). At least one.
    approval_flow[approval_conditions_attributes][][property] string Yes See the Conditions table, e.g. department, gross_amount, custom_field_12.
    approval_flow[approval_conditions_attributes][][operator] string Yes One of the operators in the intro. Any other value causes HTTP 500.
    approval_flow[approval_conditions_attributes][][value] string Yes Value as a string; comma-separated ids for is_any_of / is_none_of.
    approval_flow[approval_conditions_attributes][][custom_field_id] integer No Legacy; prefer property: "custom_field_<id>" (matching uses the property).
    approval_flow[approval_conditions_attributes][][approval_flow_id] integer No Ignored on create (set automatically).
    approval_flow[approval_steps_attributes][] array[object] Yes Steps. At least one.
    approval_flow[approval_steps_attributes][][step_no] integer Yes Order of the step (1, 2, 3 ...).
    approval_flow[approval_steps_attributes][][all_should_approve] boolean No Default false (any one approver).
    approval_flow[approval_steps_attributes][][approval_step_approvers_attributes][] array[object] Yes Approvers of the step. At least one.
    approval_flow[approval_steps_attributes][][approval_step_approvers_attributes][][user_id] integer Yes An active member of the company (any role).
    approval_flow[approval_steps_attributes][][approval_conditions_attributes][] 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:

    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[name] string No New name.
    approval_flow[document_type] string No purchase_order, invoice or rfq. Changing the type of a flow that has runs is not prevented - avoid it.
    approval_flow[self_approval_allowed] boolean No See intro.
    approval_flow[approval_conditions_attributes][][id] integer No Existing flow condition to change or remove.
    approval_flow[approval_conditions_attributes][][property] string No Required for a new condition.
    approval_flow[approval_conditions_attributes][][operator] string No Required for a new condition.
    approval_flow[approval_conditions_attributes][][value] string No Required for a new condition.
    approval_flow[approval_conditions_attributes][][custom_field_id] integer No Legacy custom field link.
    approval_flow[approval_conditions_attributes][][_destroy] boolean No true removes the condition.
    approval_flow[approval_steps_attributes][][id] integer No Existing step to change or remove.
    approval_flow[approval_steps_attributes][][step_no] integer No Required for a new step.
    approval_flow[approval_steps_attributes][][all_should_approve] boolean No Approval rule.
    approval_flow[approval_steps_attributes][][_destroy] boolean No true removes the step with its approvers and conditions.
    approval_flow[approval_steps_attributes][][approval_step_approvers_attributes][][id] integer No Existing approver assignment.
    approval_flow[approval_steps_attributes][][approval_step_approvers_attributes][][user_id] integer No Approver (active company member).
    approval_flow[approval_steps_attributes][][approval_step_approvers_attributes][][_destroy] boolean No true removes the approver from the step.
    approval_flow[approval_steps_attributes][][approval_conditions_attributes][] 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[].id integer Run id (use with show_entity).
    approval_entities[].approval_flow_id integer The flow.
    approval_entities[].entity_id integer Id of the purchase order or invoice.
    approval_entities[].entity_type string PurchaseOrder or Invoice.
    approval_entities[].status string in_progress, completed (approved), rejected, cancelled (document cancelled).
    approval_entities[].created_at timestamp Unix seconds - when the flow was attached.
    approval_entities[].updated_at timestamp Unix seconds.
    approval_entities[].approval_flow object Flow summary (list format).
    approval_entities[].entity 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[].id integer Version id.
    versions[].name string Flow name before the change.
    versions[].created_at timestamp Unix seconds - when the change was made.
    versions[].conditions_count integer Number of flow-level conditions before the change.
    versions[].steps_count 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

    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:

    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:

    V1 (authentication token) - returns a bare JSON array of every matching budget, without pagination (page, per_page, search and only_active are ignored):

    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

    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[name] string Yes 1-255 characters.
    budget[amount] number Yes Budget amount, at most 10,000,000,000,000.
    budget[cost_code] string No Max 255 characters; whitespace is stripped.
    budget[cost_type] string No Max 255 characters; whitespace is stripped.
    budget[start_date] 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[end_date] string No End date (inclusive) in the company date format. Required when start_date is given.
    budget[department_ids][] array[integer] No Departments of the company. Empty / omitted = company-wide budget. Required when make_department_required is on.
    budget[approver_ids][] array[integer] No User ids of live members of the company who approve POs on this budget (classic routing).
    budget[allow_anyone_to_approve_a_po] boolean No Let team-member-only requesters self-approve POs on this budget within their limit. Default: false.
    budget[chart_of_account_id] integer No Chart of account of the company to link.
    budget[qbo_class] string No Legacy free-text class label. Does not link a QuickBooks class (quickbooks_class_id is not accepted).
    budget[custom_field_values_attributes][] 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[xero_id] string No Budget's id in Xero (creates a xero third-party mapping).
    budget[zapier_id] string No Id in Zapier (creates a zapier mapping).
    budget[quickbooks_id] string No Id in QuickBooks (creates a quickbooks mapping).
    budget[third_party_id_mappings_attributes][] array[object] No Explicit mappings {service, third_party_id, realm_id}.
    budget[currency_id] integer No Ignored on create - the company base currency is always used.
    budget[creator_id] 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[name] string No 1-255 characters.
    budget[amount] number No At most 10,000,000,000,000. base_amount is not recalculated.
    budget[cost_code] string No Max 255 characters.
    budget[cost_type] string No Max 255 characters.
    budget[start_date] string No Company date format; empty string clears. Keep start <= end and both-or-neither.
    budget[end_date] string No Company date format; empty string clears.
    budget[department_ids][] array[integer] No Replaces the budget's departments.
    budget[approver_ids][] array[integer] No Replaces the budget's approvers (live members of the company).
    budget[allow_anyone_to_approve_a_po] boolean No See create.
    budget[chart_of_account_id] integer No Chart of account of the company.
    budget[currency_id] integer No Changes the budget currency (base_amount / base_rate are not recalculated).
    budget[creator_id] integer No Reassign the creator; must be a live member of the company (Creator is invalid).
    budget[qbo_class] string No Legacy free-text label.
    budget[custom_field_values_attributes][] array[object] No {id, custom_field_id, value}.
    budget[xero_id] string No Adds a xero mapping.
    budget[zapier_id] string No Adds a zapier mapping.
    budget[quickbooks_id] string No Adds a quickbooks mapping.
    budget[third_party_id_mappings_attributes][] 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:

    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

    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

    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:

    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[name] string Yes Company name, max 80 characters.
    company[department] 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[company_setting_attributes][currency_id] integer Yes Default currency id. Without it: Company setting currency can't be blank and Budgets currency can't be blank.
    company[gross_or_net_config] string No gross or net (or 0 / 1). Defaults to gross. Any other value answers 500.
    company[default_tax_rate] number No Legacy default tax rate percent, greater than -1 and less than 101. Defaults to 0.
    company[supported_currency_ids][] array[integer] No Currency ids to enable. The default currency is added automatically.
    company[budgets_attributes][][name] string No Additional budget to create (the Sample Budget is always created too).
    company[budgets_attributes][][amount] number No Additional budget amount.
    company[budgets_attributes][][creator_id] integer No Ignored - the caller is always the creator.
    company[budgets_attributes][][currency_id] integer No Ignored - the company currency is used. Sending any budget also forces tax_label to Tax.
    company[company_setting_attributes][date_format] string No strftime pattern, e.g. %Y-%m-%d.
    company[company_setting_attributes][tax_label] string No Tax label, e.g. VAT (overridden to Tax when budgets_attributes are sent).
    company[company_setting_attributes][departments_alias] string No Custom label for departments.
    company[company_setting_attributes][budgets_alias] string No Custom label for budgets.
    company[company_setting_attributes][pdf_font_size] integer No PDF font size.
    company[company_setting_attributes][custom_field_to_include_in_po_number_id] integer No Custom field whose value is appended to PO numbers.
    company[company_setting_attributes][approvals_require_login] boolean No Approvers must log in to approve.
    company[company_setting_attributes][show_po_item_number] boolean No Show item numbers on PO lines.
    company[company_setting_attributes][fixed_supplier_list] boolean No Requesters must pick an existing supplier.
    company[company_setting_attributes][display_remaining_budget_amount] boolean No Show remaining budget amounts.
    company[company_setting_attributes][display_budget_on_pdf] boolean No Print the budget on PO PDFs.
    company[company_setting_attributes][pdf_background] string No PDF background.
    company[company_setting_attributes][pdf_footer] string No PDF footer text.
    company[company_setting_attributes][logo] string No Logo.
    company[company_setting_attributes][logo_alignment] string No Logo alignment on PDFs.
    company[company_setting_attributes][logo_content_type] string No Logo MIME type.
    company[company_setting_attributes][fixed_product_list] boolean No PO lines must use catalogue products.
    company[company_setting_attributes][show_tax_column] boolean No Show the tax column.
    company[company_setting_attributes][show_company_name] boolean No Show the company name on PDFs.
    company[company_setting_attributes][show_approver_name] boolean No Show the approver name on PDFs.
    company[company_setting_attributes][user_can_add_supplier] boolean No Non-admin users may add suppliers.
    company[company_setting_attributes][manually_approve_by_lowest_available_approvers] boolean No Restrict manual approver choice to the lowest sufficient approvers.
    company[company_setting_attributes][share_po_access_by_department] boolean No Users see POs of their departments.
    company[company_setting_attributes][allow_budget_overruns] boolean No Ignored on create - always set to false.
    company[company_setting_attributes][auto_expire_session] boolean No Expire idle web sessions.
    company[company_setting_attributes][allocations_enabled] boolean No Enable budget allocations.
    company[company_setting_attributes][link_budgets_to_qbo_class] boolean No Link budgets to QuickBooks classes.
    company[company_setting_attributes][po_fields][] array[string] No PO list/export fields.
    company[company_setting_attributes][mandatory_supplier_fields][] array[string] No Supplier fields that must be filled in.
    company[company_setting_attributes][id] integer No Permitted but meaningless on create; do not send.
    company[company_currency_exchange_rates][] 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.

    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[email] string Yes Invitee email; must be a valid address.
    invite_user[name] string Yes Invitee full name - at least two words (Please provide a full name otherwise).
    invite_user[roles][] array[string] No Roles to grant on acceptance (see description). Default ["teammember"].
    invite_user[department_ids][] array[integer] No Departments of the header company to assign on acceptance. Other ids are ignored.
    invite_user[approval_limit] 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.

    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

    1. Request (synchronous). POST /compliance/check or POST /compliance/bulk_check checks your access to each order. It sets the order's compliance_status to running, adds a scan initiated comment to the order, queues a background job and answers 202 Accepted straight away. A second scan of an order that is already running is refused with 409.
    2. 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 is gemini-2.5-flash and the fallback is gpt-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.
    3. Outcome. A verdict of compliant, with nothing rated high or critical, 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:

    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:

    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[].id integer Version id
    versions[].item_type string Always Policy
    versions[].item_id integer Policy id
    versions[].event string create, update or destroy
    versions[].whodunnit string Id of the user who made the change, as a string, or null
    versions[].whodunnit_name string That user's name, or null
    versions[].object string The policy as it was before this change, as a YAML string (null for create). Treat it as opaque.
    versions[].object_changes object Changed attributes as {attribute: [old, new]}. Enum attributes show their names (for example "status": ["draft", "active"]).
    versions[].created_at 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[name] string Yes Policy name, at most 255 characters
    policy[content] 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[description] string No Short summary for people. Not sent to the AI.
    policy[status] string No draft or active Default: draft.
    policy[scope] string No global or budget_specific Default: global.
    policy[category] string No federal_regulation, threshold, exception or custom Default: custom.
    policy[budget_ids] array[integer] No Budgets of your company that a budget_specific policy applies to. Do not send with global scope.
    policy[min_amount] number No Lower bound (0 or more) of the order amount for the quote-count rule
    policy[max_amount] number No Upper bound for the quote-count rule. Must be at least min_amount.
    policy[min_quotes_required] integer No Whole number of 0 or more. Stored only. State the quote requirement in content for it to be checked.
    policy[required_attachments] array[string] No Labels such as quote or invoice. Stored only, not evaluated.
    policy[archived] 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.

    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[name] string No At most 255 characters, not blank
    policy[content] string No Rule text evaluated by the AI
    policy[description] string No Short summary
    policy[status] string No draft or active
    policy[scope] string No global or budget_specific
    policy[category] string No federal_regulation, threshold, exception or custom
    policy[budget_ids] array[integer] No Replaces the linked budgets. Ignored for global policies.
    policy[min_amount] number No 0 or more, or null
    policy[max_amount] number No At least min_amount, or null
    policy[min_quotes_required] integer No Whole number of 0 or more, or null
    policy[required_attachments] array[string] No Replaces the list
    policy[archived] 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[].id string Stable template key
    templates[].name string Suggested policy name
    templates[].category string federal_regulation, threshold or exception
    templates[].description string Short summary
    templates[].content string Policy text, ready to use as content
    templates[].default_thresholds object {min_amount, max_amount, min_quotes_required} for the threshold templates (max_amount may be null), otherwise null
    templates[].scope 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[purchase_order_id] integer No Purchase order to scan. Echoed back in the response as sent.
    compliance_check[budget_id] 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[budget_name] string No Budget name shown to the AI when no budget can be read from the order
    compliance_check[total_amount] number No Metadata-only mode: amount to evaluate. With an order: compared with the stored amount only.
    compliance_check[supplier] string No Metadata-only mode: supplier name. With an order: compared with the stored supplier only.
    compliance_check[items][][description] string No Line item description (metadata-only mode)
    compliance_check[items][][quantity] number No Line item quantity
    compliance_check[items][][unit_price] number No Line item unit price
    compliance_check[attachments][][name] string No Accepted and stored with the request, but not evaluated. Documents are read from the order's real attachments.
    compliance_check[attachments][][type] string No Accepted, not evaluated
    compliance_check[attachments][][size] 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[].purchase_order_id integer Order
    results_data[].status string passed, failed or error
    results_data[].scanned_at timestamp ISO-8601
    results_data[].compliance_check_id integer Compliance check created for the order (passed and failed only)
    results_data[].approval_key string Order number, or null if none has been assigned yet
    results_data[].supplier_name string Supplier
    results_data[].amount string Order amount as a decimal string
    results_data[].passed_policy_count integer Passed only: number of policies checked
    results_data[].violation_count integer Failed only: number of violations
    results_data[].policy_names array[string] Failed only: up to 3 violated policy names
    results_data[].risk_levels array[string] Failed only: distinct risk levels found
    results_data[].error 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[].id integer Bulk scan id
    scans[].status string completed or failed
    scans[].total_count integer Orders queued
    scans[].passed_count integer Orders that passed
    scans[].failed_count integer Orders with violations
    scans[].error_count integer Orders that could not be scanned
    scans[].completed_at timestamp ISO-8601
    scans[].initiated_by 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[item_description] string No What is being bought
    memo_request[selected_vendor] string No Chosen supplier
    memo_request[selected_price] number No Chosen supplier's price
    memo_request[selection_rationale] string No lowest_price, best_value, sole_source, emergency, or free text Default: best_value.
    memo_request[market_basis] string No catalog_pricing, historical_pricing, competitive_quotes, market_research, or free text Default: market_research.
    memo_request[alternatives_considered][][vendor] string No Alternative supplier
    memo_request[alternatives_considered][][price] number No Its price
    memo_request[alternatives_considered][][notes] string No Why it was not chosen
    memo_request[violation_id] 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.

    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[name] string Yes Field label (truncated to 255 characters).
    custom_field[field_type] string Yes label, string, select, date, tel, text, decimal, boolean, url, long_list or computed.
    custom_field[option_list] string No Options of a select, one per line ("Standard\nExpress\nOvernight"). Required for select.
    custom_field[default_value] string No Default for new POs; must be one of the options for select.
    custom_field[required] boolean No Value required on submit. Default: false.
    custom_field[active] boolean No Inactive fields are hidden and not added to new POs. Default: true.
    custom_field[access_level] string No everyone, approvers_and_finance or finance_only; any other value is ignored. Default: everyone.
    custom_field[on_line_item] boolean No true for a PO line item field. Default: false.
    custom_field[display_on_pdf] boolean No Print on the PO PDF. Default: true.
    custom_field[display_on_pdf_even_if_value_is_nil] boolean No Print even when empty. Default: true.
    custom_field[editable_after_approval] boolean No Allow changing the value after approval.
    custom_field[formula_builder] string No Formula for computed fields, e.g. {{Annual budget}} / 12.
    custom_field[precision_display] integer No Decimals displayed for numeric fields. Default: 2.
    custom_field[show_on_receiving] boolean No Show on the receiving screen. Default: false.
    custom_field[archived] 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[custom_field_id] integer Yes Custom field id (400 "custom_field_id is required" when missing/unknown).
    custom_field_value[purchase_order_id] integer No Purchase order id - required for header fields.
    custom_field_value[purchase_order_item_id] integer No Purchase order item id - required for line item fields.
    custom_field_value[id] integer No Existing value id to update (recommended).
    custom_field_value[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[name] string Yes 1-255 characters, unique in the company (case-sensitive).
    department[archived] boolean No Create already archived. Default false.
    department[contact_person] string No Contact name.
    department[phone_number] string No Phone number (free text).
    department[email] string No Email (free text, not validated).
    department[address] string No Address.
    department[tax_number] string No Tax/VAT number.
    department[budget_ids][] array[integer] No Budgets to link; each must belong to the header company (Budget ids is invalid otherwise).
    department[user_ids][] 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[name] string No New name, 1-255 characters, unique in the company.
    department[archived] boolean No true archives, false restores.
    department[contact_person] string No Contact name.
    department[phone_number] string No Phone number (free text).
    department[email] string No Email (free text, not validated).
    department[address] string No Address.
    department[tax_number] string No Tax/VAT number.
    department[budget_ids][] array[integer] No Replaces the linked budgets; each must belong to the header company.
    department[user_ids][] 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

    1. Find approved POs of the supplier that are not fully invoiced yet: GET /invoices/purchase_order_list?supplier_id=....
    2. Get their open lines, with the remaining quantity and amount: GET /invoices/purchase_order_item_list?purchase_order_ids[]=....
    3. Create the invoice. Each line in invoice_line_items_attributes carries the purchase_order_id and purchase_order_item_id it 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:

    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[].vat string Tax percentage of the line; drives tax_amount.
    invoice_line_items[].tax_amount / calculated_gross_amount string Line tax and line gross.
    invoice_line_items[].purchase_order_id / purchase_order_item_id 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[].chart_of_account / qbo_customer / quickbooks_class object {id, name} pairs, with null values when not set.
    invoice_line_items[].tax_exception 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:

    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[query] 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

    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:

    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:

    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[invoice_number] 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[supplier_id] integer Yes Supplier id. Optional only when the invoice is created as awaiting_review.
    invoice[currency_id] integer Yes Currency id (currency_id can't be blank otherwise).
    invoice[issue_date] date No Invoice date, YYYY-MM-DD. Defaults to now.
    invoice[due_date] date No Due date, YYYY-MM-DD. Defaults to now.
    invoice[received_date] date No Date received, YYYY-MM-DD. Stored but not returned by the API.
    invoice[uploaded_date] date No Recorded date, YYYY-MM-DD. Defaults to now.
    invoice[validation_date] date No Approval date. Normally set automatically when the invoice is approved.
    invoice[gross_amount] number No Ignored in practice: recomputed from the line items when saved.
    invoice[standalone_invoice] boolean No Mark the invoice as not PO-backed.
    invoice[payment_term_id] integer No V1 only (V3 ignores it). Payment term id.
    invoice[digital_invoice] boolean No Marks a scanned invoice. Changes the tax calculation to use the per-line tax amounts.
    invoice[confidence_score] number No Extraction confidence for scanned invoices.
    invoice[sage_exported] boolean No Sage export marker.
    invoice[selected_purchase_order_ids][] array[integer] No PO ids picked on the form (informational; the PO links come from the line items).
    invoice[custom_field_values_attributes][][custom_field_id] integer No Invoice-level custom field id. Required custom fields must be filled in.
    invoice[custom_field_values_attributes][][value] string No Custom field value.
    invoice[custom_field_values_attributes][][id] integer No Existing value id (update only).
    invoice[invoice_line_items_attributes][][description] string No Line description.
    invoice[invoice_line_items_attributes][][sequence_no] integer No Line order. Assigned automatically when missing.
    invoice[invoice_line_items_attributes][][quantity] number No Quantity.
    invoice[invoice_line_items_attributes][][unit_price] number No Unit price.
    invoice[invoice_line_items_attributes][][net_amount] number No Line net amount (normally quantity x unit_price).
    invoice[invoice_line_items_attributes][][vat] number No Tax percentage of the line. This drives the invoice tax.
    invoice[invoice_line_items_attributes][][base_net_amount] number No Net amount in the company base currency.
    invoice[invoice_line_items_attributes][][tax_rate_id] integer No Tax rate id (stored; the tax uses vat).
    invoice[invoice_line_items_attributes][][purchase_order_id] integer No PO this line bills.
    invoice[invoice_line_items_attributes][][purchase_order_item_id] integer No PO line this line bills.
    invoice[invoice_line_items_attributes][][chart_of_account_id] integer No Chart of account id.
    invoice[invoice_line_items_attributes][][qbo_customer_id] integer No QuickBooks customer id.
    invoice[invoice_line_items_attributes][][quickbooks_class_id] integer No QuickBooks class id.
    invoice[invoice_line_items_attributes][][qbo_line_description] string No Line description sent to QuickBooks.
    invoice[invoice_line_items_attributes][][billable_status] string No QuickBooks billable status: NotBillable, Billable, HasBeenBilled.
    invoice[invoice_line_items_attributes][][custom_field_values_attributes][] array[object] No Line-level custom field values: custom_field_id, value, id.
    invoice[invoice_line_items_attributes][][id] integer No Existing line id (update only).
    invoice[invoice_line_items_attributes][][_destroy] boolean No true with id deletes the line (update only).
    invoice[supplier_invoice_uploads_attributes][][file] 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[supplier_invoice_uploads_attributes][][id] integer No Existing upload id (update only).
    invoice[supplier_invoice_uploads_attributes][][_destroy] boolean No true with id removes the file (update only).
    invoice[company_id] 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

    V3 and V1 differences

    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:

    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

    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[invoice_line_items_attributes][][id] integer No Id of the line to change or delete.
    invoice[invoice_line_items_attributes][][_destroy] boolean No true deletes the line given by id.
    invoice[supplier_invoice_uploads_attributes][][id] integer No Id of the file to remove (with _destroy: true).
    invoice[custom_field_values_attributes][][id] 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

    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:

    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
    [].id integer PO line id (use as purchase_order_item_id).
    [].purchase_order_id integer PO id.
    [].approval_key string PO reference.
    [].sequence_no / description integer / string Line order and text.
    [].quantity / unit_price number Quantity and unit price still to invoice.
    [].vat string Effective tax percentage of the PO line. It is derived back from tax overrides when the PO line has one.
    [].net_amount / gross_amount string Remaining net and gross.
    [].budget_name / budget_summary / budget_cost_code / budget_cost_type string Budget of the PO line.
    [].iso_code string PO currency: its symbol when the company shows currency symbols, otherwise its ISO code.
    [].tax_rate_id / chart_of_account_id / qbo_customer_id / quickbooks_class_id / qbo_line_description integer / string Accounting coding of the PO line.
    [].custom_field_values_json 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

    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[comment] 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:

    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[po_overrun_reason] 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

    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:

    Path parameters

    Parameter Type Description
    id integer Invoice id.

    Response

    200 {"success": true}.

    Side effects

    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:

    HTTP request

    Version Request
    V3 PUT /api/v3/invoices/:id/reject
    V1 PUT /api/v1/invoices/:id/reject

    Permissions

    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:

    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:

    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:

    Previous approvals stay in the history.

    The invoice is eligible only when all of these hold:

    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:

    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

    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:

    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:

    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[amount] number Yes Total paid. Must equal the sum of the invoice allocations (normal, within 0.01) or of the PO amounts (advance).
    npayment[date] date Yes Payment date, in the company date format or YYYY-MM-DD.
    npayment[reference] string No Your payment reference.
    npayment[ptype] string No Payment type: bank_transfer, card, credit_card, check, cash, one_time_card, letter_of_credit, other, batch_payment. Default: bank_transfer.
    npayment[payment_mode] string No normal (pay invoices) or advance (pay POs). Default: normal.
    npayment[supplier_id] 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[currency_id] integer No V1 only. Payment currency. Defaults to the company currency. V3 derives it.
    npayment[npayment_invoices_attributes][][invoice_id] integer No Normal payments: an invoice of the company. At least one invoice is required for a normal payment.
    npayment[npayment_invoices_attributes][][gross_amount] number No The amount of this payment allocated to that invoice (not the invoice total). Each invoice may appear once.
    npayment[npayment_invoices_attributes][][_destroy] boolean No Accepted for compatibility; use false or leave it out.
    npayment[npayment_invoices_attributes][][id] integer No V1 only; not meaningful on create.
    npayment[npayment_link_orders_attributes][][purchase_order_id] integer No Advance payments: a payable PO of the company. At least one PO is required for an advance payment.
    npayment[npayment_link_orders_attributes][][budget_id] integer No Budget the amount is booked against (one of the PO's budgets).
    npayment[npayment_link_orders_attributes][][gross_amount] number No Amount paid on that PO (and budget).
    npayment[npayment_link_orders_attributes][][_destroy] boolean No Accepted for compatibility; leave it out.
    npayment[npayment_link_orders_attributes][][id] integer No V1 only; not meaningful on create.
    npayment[npayment_comments_attributes][][comment] 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:

    Side effects

    V3 and V1 differences

    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:

    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[purchase_order_item_payments_attributes][][purchase_order_item_id] integer Yes A line of this PO. A line of another PO is rejected (purchase_order_item_payments is invalid).
    payment[purchase_order_item_payments_attributes][][amount] number Yes Amount paid on that line (gross).
    payment[amount] 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[note] string No Note appended to the PO comment and saved on the created npayment.
    payment[purchase_order_item_payments_attributes][][id] 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

    V3 and V1 differences

    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.

    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

    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.

    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).

    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[description] string Yes Description (trimmed). Max 60 characters for Sage-integrated companies.
    product[sku] string No SKU. Required, unique and max 30 characters for Sage-integrated companies (Sage 50/200 also forbid some characters).
    product[unit_price] number No Unit price (decimal).
    product[supplier_id] 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[budget_id] integer No Default budget id of the company.
    product[tax_rate_id] 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

    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[product_item_attributes][] array[object] Yes The products to create. Omitting this array while sending another product key currently causes a 500.
    product[product_item_attributes][][description] string Yes Description.
    product[product_item_attributes][][sku] string No SKU.
    product[product_item_attributes][][unit_price] 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[description] string No Description (cannot be blank).
    product[sku] string No SKU.
    product[unit_price] number No Unit price.
    product[budget_id] integer No Company budget id.
    product[tax_rate_id] integer No Company tax rate id.
    product[supplier_id] 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:

    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:

    1. 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_list is not used. See the Approval Flows section and Preview approval-flow approvers.
    2. 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.
    3. 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."
    4. 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" and self_approved: true, and a "Self Approved" history comment is added. If the company has auto-approve self requests on, a requester who lists themselves in approver_list has 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:

    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].

    Currencies and exchange rates

    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

    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[].currency_symbol string Symbol of the PO currency (list only).
    purchase_orders[].budget_name string Budget name(s) used by the lines (list only).
    purchase_orders[].compliance_status 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).

    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[supplier_id] integer No Supplier id. Required when the company uses a fixed supplier list. One of supplier_id/supplier_name is required.
    purchase_order[supplier_name] 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[new_supplier_name] string No Overrides supplier_name and creates a supplier with this name when no supplier_id is set.
    purchase_order[department_id] integer No Department of the company (Invalid department otherwise).
    purchase_order[currency_id] integer Yes PO currency; must be a company-supported currency. Required unless iso_code is given (Currency can't be blank).
    purchase_order[iso_code] 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[notes] string No Free-text notes.
    purchase_order[submitted_on] string No Submission timestamp (any parseable date/time); defaults to now. Reset to now when a draft is later sent.
    purchase_order[on_behalf_of] 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[approver_list][] 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[aff_link] string No Affiliate link to store on the PO.
    purchase_order[custom_fields] object No Legacy free-form key/value hash stored in custom_fields. Prefer custom_field_values_attributes.
    purchase_order[custom_field_values_attributes][][custom_field_id] integer No PO-level custom field id.
    purchase_order[custom_field_values_attributes][][value] string No Value (for select fields one of the options; dates in the company date format).
    purchase_order[custom_field_values_attributes][][id] integer No Existing value id (update).
    purchase_order[purchase_order_items_attributes][][description] string Yes Line description (required on submit). A description matching a product of the supplier links that product automatically.
    purchase_order[purchase_order_items_attributes][][quantity] number Yes Quantity (required on submit).
    purchase_order[purchase_order_items_attributes][][unit_price] number Yes Unit price in the PO currency (required on submit).
    purchase_order[purchase_order_items_attributes][][budget_id] integer Yes Budget the line is charged to (required on submit: Please select a budget).
    purchase_order[purchase_order_items_attributes][][tax_rate_id] integer Yes Tax rate (required on submit: Tax rate can't be blank), unless the advanced tax model is used.
    purchase_order[purchase_order_items_attributes][][vat] number No Tax percentage (< 100). Derived from tax_rate_id when omitted.
    purchase_order[purchase_order_items_attributes][][net_amount] number No Ignored: recomputed as quantity x unit_price.
    purchase_order[purchase_order_items_attributes][][item_number] string No SKU / item number.
    purchase_order[purchase_order_items_attributes][][product_id] integer No Catalogue product.
    purchase_order[purchase_order_items_attributes][][sequence_no] integer No Display order; assigned automatically when omitted.
    purchase_order[purchase_order_items_attributes][][department_id] integer No Line-level department.
    purchase_order[purchase_order_items_attributes][][chart_of_account_id] integer No GL account; required on submit when QuickBooks Online is connected (account-based).
    purchase_order[purchase_order_items_attributes][][qbo_customer_id] integer No QuickBooks customer (billable to).
    purchase_order[purchase_order_items_attributes][][quickbooks_class_id] integer No QuickBooks class.
    purchase_order[purchase_order_items_attributes][][qbo_line_description] string No Description sent to QuickBooks.
    purchase_order[purchase_order_items_attributes][][xero_id] string No Xero line id.
    purchase_order[purchase_order_items_attributes][][quickbooks_id] string No QuickBooks line id.
    purchase_order[purchase_order_items_attributes][][zapier_id] string No Zapier line id.
    purchase_order[purchase_order_items_attributes][][archived] boolean No Archive flag of the line.
    purchase_order[purchase_order_items_attributes][][custom_field_values_attributes][][custom_field_id] integer No Line-level custom field id.
    purchase_order[purchase_order_items_attributes][][custom_field_values_attributes][][value] string No Line-level custom field value.
    purchase_order[purchase_order_items_attributes][][custom_field_values_attributes][][id] integer No Existing line value id (update).
    purchase_order[purchase_order_items_attributes][][purchase_order_item_allocations_attributes][][department_id] integer No Allocation department (detailed allocations).
    purchase_order[purchase_order_items_attributes][][purchase_order_item_allocations_attributes][][budget_id] integer No Allocation budget.
    purchase_order[purchase_order_items_attributes][][purchase_order_item_allocations_attributes][][gl_code] string No Allocation GL code.
    purchase_order[purchase_order_items_attributes][][purchase_order_item_allocations_attributes][][percentage] number No Share of the line in percent.
    purchase_order[purchase_order_items_attributes][][purchase_order_item_allocations_attributes][][amount] number No Share of the line as an amount.
    purchase_order[purchase_order_items_attributes][][purchase_order_item_allocations_attributes][][allocation_template_name] string No Name of the allocation template used.
    purchase_order[purchase_order_items_attributes][][purchase_order_item_allocations_attributes][][id] integer No Existing allocation id (update); _destroy: true removes it.
    purchase_order[purchase_order_items_attributes][][third_party_id_mappings_attributes][][service] string No External system of a line id mapping (e.g. quickbooks).
    purchase_order[purchase_order_items_attributes][][third_party_id_mappings_attributes][][third_party_id] string No Id of the line in that system.
    purchase_order[purchase_order_items_attributes][][third_party_id_mappings_attributes][][realm_id] string No Tenant/realm in that system.
    purchase_order[third_party_id_mappings_attributes][][service] string No External system of a PO id mapping.
    purchase_order[third_party_id_mappings_attributes][][third_party_id] string No Id of the PO in that system.
    purchase_order[third_party_id_mappings_attributes][][realm_id] string No Tenant/realm in that system.
    purchase_order[purchase_order_comments_attributes][][comment] 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[purchase_order_comments_attributes][][uploads_attributes][][file] file No File attached to that comment (multipart requests only).
    purchase_order[uploads_attributes][][file] file No File to attach to the PO (multipart only; for JSON clients use the Uploads endpoints).
    purchase_order[uploads_attributes][][id] integer No Existing upload; with _destroy: true removes it if the caller may remove attachments. Existing files cannot be replaced.
    purchase_order[requests_attributes][][approver_id] integer No Low-level approval request rows (id, approver_id, _destroy); prefer approver_list.
    purchase_order[allocations_attributes][] 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[creator_id] integer No Not accepted (ignored). The requester is the caller, or on_behalf_of.
    purchase_order[po_date] 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[purchase_order_items_attributes][][id] integer No Existing line to change.
    purchase_order[purchase_order_items_attributes][][_destroy] boolean No true deletes that line.
    purchase_order[custom_field_values_attributes][][id] integer No Existing custom field value to change.
    purchase_order[approver_list][] 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[data][] array[object] Yes Rows to create.
    purchase_order[data][][_id] string No Client reference echoed in done[]._id / failed[]._id.
    purchase_order[data][][commit] string No send (case-insensitive) submits the row; otherwise draft.
    purchase_order[data][][...] 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 ()" history comment is added.

    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 ()." Add an explanation afterwards with the PO comments endpoint.

    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 " cancelled the purchase order on " history comment.

    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[purchase_order_id] integer No Id of the PO being edited, if any.
    purchase_order[total_gross_amount] number No Gross total to use when the lines give zero.
    purchase_order[total_net_amount] 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[upload_token] 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] 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[upload_token] string Yes Client-generated de-duplication token, at least 7 characters, unique among your uploads.
    uploads_attributes[file] 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):

    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[items] 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[items][][id] integer Yes Purchase order item id (purchase_order_items[].id).
    purchase_order[items][][quantity] number Yes Quantity received now (decimals allowed; negative to reverse a previous receipt).
    purchase_order[delivered_on] 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[notes] 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:

    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
    [].id integer Template id.
    [].company_id integer Owning company.
    [].label string E-mail subject (pass as email_subject).
    [].text string E-mail body (pass as note).
    [].is_default boolean true for the company's default template.
    [].template_name 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:

    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

    1. are not archived (QBO "inactive" records are archived; all of them are archived when QuickBooks is disconnected),
    2. are mapped to the currently connected QuickBooks company (realm) - records imported from a previously connected QBO company are hidden, and
    3. 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).

    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:

    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[product_id] integer Yes Product of the company.
    qbo_item_budget_mapping[department_id] integer Yes Department of the company.
    qbo_item_budget_mapping[budget_id] 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[product_id] integer No Product of the company.
    qbo_item_budget_mapping[department_id] integer No Department of the company.
    qbo_item_budget_mapping[budget_id] 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.

    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[][product_id] integer Yes Product of the company.
    mappings[][department_id] integer Yes Department of the company.
    mappings[][budget_id] 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:

    1. the Policies module is enabled for the company (feature flag enable_policies, enabled by ProcurementExpress);
    2. the company has opted in to SAM.gov (Company settings > Supplier tab, setting sam_gov_enabled);
    3. 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 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:

    1. If prerequisites are not met (Policies module, SAM.gov opt-in, API key), it returns 200 {"status": "unchecked", "supplier_id": <id>} and stores nothing.
    2. Without force: the latest clear/flagged check younger than 24 h is returned, else an error check younger than 5 minutes is returned.
    3. 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.

    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

    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):

    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[name] string Yes Name, 1-255 characters, unique per company (case-insensitive).
    supplier[email] string No Main email (the PO is sent here). Must be a valid address. Required when listed in mandatory supplier fields.
    supplier[cc_emails] string No Comma-separated CC addresses, max 255 characters. Not returned in the response.
    supplier[address] string No Free-text address.
    supplier[notes] string No Notes.
    supplier[payment_details] string No Free-text payment details.
    supplier[phone_number] string No Phone number, max 255.
    supplier[contact_person] string No Contact name, max 255.
    supplier[tax_number] string No Tax/VAT number, max 255. Required when the company requires tax numbers.
    supplier[currency_id] integer No Default currency id. Must be an existing currency (an unknown id currently causes a 500).
    supplier[payment_terms] string No Free-text payment terms label, returned as payment_terms.
    supplier[payment_term_id] 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[archived] boolean No Create directly as archived. Default: false.
    supplier[external_vendor_id] string No ERP vendor number, unique per company, max 64.
    supplier[uei] string No SAM.gov Unique Entity Identifier (12 characters).
    supplier[cage_code] string No CAGE code (5 characters).
    supplier[department_ids][] array[integer] No Departments to restrict the supplier to. Empty or omitted = available to all departments. An unknown id answers 404.
    supplier[third_party_id_mappings_attributes][][service] 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[third_party_id_mappings_attributes][][third_party_id] string No The supplier's id in that service.
    supplier[third_party_id_mappings_attributes][][realm_id] string No Realm / tenant id in that service (e.g. QuickBooks realm).
    supplier[xero_id] string No Shortcut: adds a xero mapping with this id.
    supplier[zapier_id] string No Shortcut: adds a zapier mapping with this id.
    supplier[quickbooks_id] string No Shortcut: adds a quickbooks mapping with this id.
    supplier[supplier_payment_details_attributes][][bank_name] 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[supplier_payment_details_attributes][][payment_provider_ids][] 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

    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[archived] boolean No true archives, false restores.
    supplier[department_ids][] 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):

    1. The company setting Supplier approval (company_setting.add_supplier_approval, Company settings > Supplier tab) must be on. Otherwise the endpoint answers 400 Please enable supplier approval setting from your company setting, Supplier tab.
    2. 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 answers 400 Please 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:

    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[name] string Yes Display name.
    tax_rate[value] number Yes Percentage, at most 100 (Value Not greater than 100).
    tax_rate[archived] boolean No Create the rate already archived. Default: false.
    tax_rate[company_id] 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[name] string No Display name (cannot be blank).
    tax_rate[value] number No Percentage, at most 100. Ignored for combined rates.
    tax_rate[archived] boolean No true archives, false restores. Not allowed for the company default rate.
    tax_rate[company_id] 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[].id integer Company id (use as app_company_id).
    companies[].name string Company name.
    companies[].external_user_id 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[].membership_archived boolean true when the user's membership is archived; requests with this app_company_id answer 401.
    companies[].is_locked boolean true when the company is not paid up; requests with this app_company_id answer 402.
    companies[].is_removed boolean true when the company is inactive (removed).
    companies[].approval_limit number The user's approval limit in that company (1000000.0 when none is set).
    companies[].in_trial boolean Company is on the trial plan.
    companies[].trial_expired boolean Company trial has ended without a subscription.
    companies[].remaining_trial_days integer Days until the current subscription period ends (negative when past).
    companies[].roles array[string] The user's roles in that company: companyadmin, approver, finance, teammember.
    companies[].has_unassigned_budgets 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:

    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

    1. The model callback enqueues a background job only if at least one non-archived subscription in the company lists the event.
    2. 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.
    3. 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", "...": "..." }
    }
    

    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:

    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>

    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:

    {
      "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[name] string Yes Display name.
    webhook[url] string Yes HTTPS destination URL, port 443 (or 8443), public host, no credentials/fragment, ≤ 2000 chars.
    webhook[event_type][] 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[json_wrapper] string No Key that holds the object in the envelope. Default payload. Do not use event or object.
    webhook[send_as_text] boolean No Default false. true sends the object as a JSON string (for chat tools).
    webhook[archived] boolean No Default false. true creates the subscription disabled.
    webhook[basic_auth_uname] 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[basic_auth_pword] 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[webhook_attributes_attributes][][attrib_type] string No header (HTTP request header) or root (extra top-level key in the JSON envelope). Default header.
    webhook[webhook_attributes_attributes][][key] 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[webhook_attributes_attributes][][value] string No Header value / root value (always a string). Returned in clear text by the API.
    webhook[authentication_header] 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.

    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[name] string No Display name (cannot be blank).
    webhook[url] string No New HTTPS destination (re-validated).
    webhook[event_type][] array[string] No Replaces the subscribed events; one family only.
    webhook[json_wrapper] string No Envelope key for the object; blank resets to payload.
    webhook[send_as_text] boolean No Send the object as a JSON string.
    webhook[archived] boolean No true disables delivery, false re-enables (re-validated).
    webhook[basic_auth_uname] string No Basic username (only used when Basic auth is selected in the web app).
    webhook[basic_auth_pword] string No Basic password; blank keeps the stored one. Never returned.
    webhook[webhook_attributes_attributes][][id] integer No Existing attribute to change or delete.
    webhook[webhook_attributes_attributes][][attrib_type] string No header or root.
    webhook[webhook_attributes_attributes][][key] string No Required for new rows and for updates of existing rows (rows without a key are ignored).
    webhook[webhook_attributes_attributes][][value] string No Attribute value.
    webhook[webhook_attributes_attributes][][_destroy] 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.