Managing Expenses via API

Expenses API Overview

This guide explains how to list, retrieve, create, approve, decline, and delete expenses using the Oyster API. All expense operations follow Oyster's standard on-cycle flow — there is no off-cycle concept in the API. See Expenses at Oyster for a full explanation of the monthly cut-off and reimbursement cycle.

For the full endpoint reference, see the Expenses API reference.

Authentication and scopes

All write operations (create, approve, decline, delete) require the manage OAuth scope. Read operations (list, retrieve) work with the default read scope.

When authenticating via client credentials (machine-to-machine), write operations additionally require an X-Oyster-Actor-Email header identifying the company admin performing the action. Without it the API returns 400 Bad Request. Read operations do not require this header.

Example request with actor header

curl --request POST \
     --url https://api.oysterhr.com/v1/expenses/{id}/approve \
     --header 'accept: application/json' \
     --header 'authorization: Bearer BEARER_TOKEN_GOES_HERE' \
     --header 'content-type: application/json' \
     --header 'X-Oyster-Actor-Email: [email protected]'

Review state flow

Expenses expose a reviewState field with the following values:

reviewStateMeaning
UNREVIEWEDSubmitted, awaiting approval
APPROVEDApproved, queued for reimbursement
DECLINEDDeclined, will not be reimbursed

UNREVIEWEDAPPROVED (via approve)

UNREVIEWEDDECLINED (via decline)


1. List expenses

Retrieve all expenses using the Retrieve all expenses endpoint. Use the engagement_id query parameter to filter results for a specific team member.

Example request

curl --request GET \
     --url https://api.oysterhr.com/v1/expenses \
     --header 'accept: application/json' \
     --header 'authorization: Bearer BEARER_TOKEN_GOES_HERE' \
     --header 'content-type: application/json'

Response

{
  "data": [
    {
      "expenseId": "exp_AbCd1234",
      "name": "Laptop stand",
      "description": "Home office equipment for remote work",
      "incurredOn": "2026-08-15",
      "category": "HOME_OFFICE",
      "reviewState": "APPROVED",
      "submittedAt": "2026-08-16T10:30:00Z",
      "reviewedAt": "2026-08-17T09:00:00Z",
      "declineReason": null,
      "receiptAmount": {
        "decimal": "89.99",
        "currencyCode": "GBP"
      },
      "engagement": {
        "engagement_id": "eng_XyZ9876",
        "name": "Jane Smith",
        "engagement_type": "EMPLOYMENT"
      }
    }
  ],
  "meta": {
    "page": 1,
    "pages": 1,
    "firstUrl": "/v1/expenses?page=1&per_page=100",
    "lastUrl": "/v1/expenses?page=1&per_page=100"
  }
}

2. Retrieve an expense

Fetch full details for a single expense using the Retrieve an expense endpoint.

Example request

curl --request GET \
     --url https://api.oysterhr.com/v1/expenses/{id} \
     --header 'accept: application/json' \
     --header 'authorization: Bearer BEARER_TOKEN_GOES_HERE' \
     --header 'content-type: application/json'

Response

{
  "data": {
    "expenseId": "exp_AbCd1234",
    "name": "Laptop stand",
    "description": "Home office equipment for remote work",
    "incurredOn": "2026-08-15",
    "category": "HOME_OFFICE",
    "reviewState": "UNREVIEWED",
    "submittedAt": "2026-08-16T10:30:00Z",
    "reviewedAt": null,
    "declineReason": null,
    "revertReason": null,
    "receiptUrl": "https://storage.oysterhr.com/receipts/abc123.pdf",
    "receiptAmount": {
      "decimal": "89.99",
      "currencyCode": "GBP"
    },
    "engagement": {
      "engagement_id": "eng_XyZ9876",
      "name": "Jane Smith",
      "engagement_type": "EMPLOYMENT"
    }
  }
}

3. Create an expense

Submit a new expense on behalf of a team member using the Create an expense endpoint.

By default, if the actor creating the expense (identified by X-Oyster-Actor-Email) is not the team member themselves and has expense approval permissions, the expense is automatically approved on creation — it will have reviewState: APPROVED immediately without a separate approve call.

To keep the expense in UNREVIEWED for manual review, pass pendingApproval: true in the request body.

3.1. Getting expense categories

Before creating an expense, retrieve the list of valid categories using the Retrieve expense categories endpoint. The category field in the create request must match one of the values returned here.

Example request

curl --request GET \
     --url https://api.oysterhr.com/v1/expenses/sources/categories \
     --header 'accept: application/json' \
     --header 'authorization: Bearer BEARER_TOKEN_GOES_HERE' \
     --header 'content-type: application/json'

Response

{
  "data": [
    { "value": "BENEFITS", "title": "Benefits" },
    { "value": "CAR", "title": "Car" },
    { "value": "EQUIPMENT", "title": "Equipment" },
    { "value": "HOME_OFFICE", "title": "Home Office" },
    { "value": "LEARNING_AND_DEVELOPMENT", "title": "Learning And Development" },
    { "value": "FOOD", "title": "Food" },
    { "value": "OFFICE_SUPPLIES", "title": "Office Supplies" },
    { "value": "RENT", "title": "Rent" },
    { "value": "TRAVEL", "title": "Travel" },
    { "value": "OTHER", "title": "Other" }
  ]
}

3.2. Creating the expense

Example request

curl --request POST \
     --url https://api.oysterhr.com/v1/expenses \
     --header 'accept: application/json' \
     --header 'authorization: Bearer BEARER_TOKEN_GOES_HERE' \
     --header 'content-type: application/json' \
     --data '
{
  "engagementId": "eng_XyZ9876",
  "name": "Laptop stand",
  "description": "Home office equipment for remote work",
  "incurredOn": "2026-08-15",
  "category": "HOME_OFFICE",
  "receiptUrl": "https://storage.oysterhr.com/receipts/abc123.pdf",
  "receiptAmount": {
    "decimal": "89.99",
    "currencyCode": "GBP"
  }
}
'

Response

{
  "data": {
    "expenseId": "exp_AbCd1234",
    "name": "Laptop stand",
    "reviewState": "UNREVIEWED",
    "submittedAt": "2026-08-16T10:30:00Z"
  }
}

Example error

{
  "error": {
    "message": "The expense could not be created. Ensure all required fields are present and valid."
  }
}

4. Approve an expense

Approve an UNREVIEWED expense using the Approve an expense endpoint to queue it for reimbursement in the next eligible payroll cycle. If the monthly cut-off has already passed, the expense is held and reimbursed in the following month's cycle.

Once reimbursed, payslip visibility depends on engagement type: expenses for EOR (employment) and Global Payroll (GP) team members are processed through payroll and appear on the payslip; expenses for contractors are settled via a separate invoice and do not appear on a payslip.

Example request

curl --request POST \
     --url https://api.oysterhr.com/v1/expenses/{id}/approve \
     --header 'accept: application/json' \
     --header 'authorization: Bearer BEARER_TOKEN_GOES_HERE' \
     --header 'content-type: application/json'

Response

When successful, response code is 200 with an empty body.

Error response (422)

{
  "errors": [
    { "message": "Expense has been approved already" }
  ]
}

5. Decline an expense

Decline an UNREVIEWED expense using the Decline an expense endpoint. A declined expense is not reimbursed and the team member is notified.

Example request

curl --request POST \
     --url https://api.oysterhr.com/v1/expenses/{id}/decline \
     --header 'accept: application/json' \
     --header 'authorization: Bearer BEARER_TOKEN_GOES_HERE' \
     --header 'content-type: application/json'

Response

When successful, response code is 200 with an empty body.

Error response (422)

{
  "errors": [
    { "message": "Expense has been declined already" }
  ]
}

6. Delete an expense

Delete an expense using the Delete an expense endpoint. Only expenses with reviewState: UNREVIEWED can be deleted — once an expense has been approved or declined it cannot be removed.

Example request

curl --request DELETE \
     --url https://api.oysterhr.com/v1/expenses/{id} \
     --header 'accept: application/json' \
     --header 'authorization: Bearer BEARER_TOKEN_GOES_HERE' \
     --header 'content-type: application/json'

Response

When successful, response code is 200 with an empty body.

Error response (422)

{
  "errors": [
    { "message": "This expense has already been submitted. It cannot be deleted." }
  ]
}

Did this page help you?