Resources
Expenses
An expense is a purchase or a mileage entry, optionally linked to a project, optionally carrying a photographed receipt. Expenses are the job-cost side of the workspace: the endpoints here exist so an outside system can react to a purchase the moment it is captured in the field, read everything about it including the receipt image, and record back that it has been handled.
Amounts include tax
amount_tax_inclusive is the grand total actually paid, with sales tax already in it. It is not a subtotal. If your system applies its own tax, apply it to zero, or you will inflate the record by the tax rate. When the receipt printed a separate tax line,tax_amount_included tells you how much of the total that was, so a $35.70 expense with $2.72 of tax had a $32.98 subtotal.
Permissions
Expenses are financial records, so an API key does not read them just because it is a valid key. Every endpoint here checks the permissions of the workspace member who generated the key, exactly as the app would for that member, and every endpoint requires the workspace to be on the Professional plan.
| Endpoint | Type | Description |
|---|---|---|
| GET /v1/expenses | permission | expenses.view and expenses.view_all. A key whose owner can only see their own expenses is refused rather than served a silently partial workspace. |
| GET /v1/expenses/{id} | permission | expenses.view, plus either expenses.view_all or authorship of that expense. |
| GET /v1/expenses/{id}/receipt | permission | Same as retrieving the expense. |
| PUT / DELETE external-references | permission | expenses.edit_all, or expenses.edit on an expense the key owner created. |
/v1/expensesList expenses
Returns expenses newest first by expense_date, with id as a tiebreak so page boundaries stay stable between requests. See pagination and rate limits for how to page a large result set safely.
Query parameters
| Name | Type | Description |
|---|---|---|
| project_id | uuid | Only expenses linked to this project. |
| date_from | YYYY-MM-DD | Only expenses on or after this date. |
| date_to | YYYY-MM-DD | Only expenses on or before this date. |
| reconciled | boolean | true returns expenses that at least one system has stamped; false returns expenses nothing has handled yet. |
| reconciled_by | string | Only expenses carrying an external reference in this namespace. |
| unreconciled_by | string | Only expenses with no external reference in this namespace. This is the sweep query: "what have I not handled yet". |
| expense_type | enum | purchase or mileage. |
| has_receipt | boolean | Only expenses that do (or do not) have a receipt attached. |
| include_archived | boolean | Set true to include archived expenses. Defaults to false. |
| limit | integer | Rows per page, 1 to 100. Defaults to 25. |
| offset | integer | Rows to skip. Defaults to 0. |
Returns
An object with data (an array of expense objects), has_more, limit, and offset. has_more is true when the page came back full; it is not an exact remaining count, so keep paging until it is false.
/v1/expenses/{id}Retrieve an expense
Returns a single expense object. An expense your key may not read returns 404 not_found rather than 403, so a key cannot use this endpoint to prove a record exists.
The expense object
| Field | Type | Description |
|---|---|---|
| id | string | Unique id of the expense. |
| object | string | Always expense. |
| expense_type | enum | purchase or mileage. |
| expense_date | string | Date of the purchase, YYYY-MM-DD. |
| amount_tax_inclusive | number | The total actually paid, INCLUDING sales tax. This is not a pre-tax subtotal. Do not apply tax on top of this figure. |
| tax_amount_included | number | null | The sales tax portion contained within amount_tax_inclusive, when the receipt broke it out separately. Never an addition to the amount. Null when unknown. |
| currency | string | Always USD. Coastline is single-currency today. |
| vendor | object | null | The merchant, as { id, name }. id is null when the expense carries only a free-text merchant name. |
| category | object | null | The expense category, as { id, name }. |
| description | string | null | What was purchased. |
| notes | string | null | Free-form internal note on the expense. |
| reference_number | string | null | Receipt or transaction number printed on the document. |
| payment_method | enum | company_card, company_check, company_cash, personal_funds, or mileage. |
| reimbursement_status | enum | none, requested, approved, paid, or rejected. |
| billable | boolean | Whether the expense is marked billable to the customer. |
| is_archived | boolean | Archived expenses are excluded from list responses unless include_archived=true. |
| mileage | object | null | For mileage expenses, { miles_driven, rate }. Null for purchases. |
| project | object | null | The linked project, including its address and its resolved custom fields. Null when the expense is not project-linked. |
| receipt | object | null | The captured receipt, as { url, expires_at, mime_type, size_bytes }. See Receipts below. |
| external_references | object | Namespaced record of which outside systems have already handled this expense. See Reconciliation below. |
| created_by | string | Id of the workspace member who captured the expense. |
| created_at | string | ISO 8601 timestamp. |
| updated_at | string | ISO 8601 timestamp. |
Receipts
A receipt often carries information the structured record does not: the store location, the time of purchase, and the subtotal and tax broken out separately. When an expense has one, the receipt object on the payload carries a signed URL valid for 24 hours that needs no authentication to fetch. That URL is minted fresh every time the expense is serialized, including on each webhook delivery attempt, so a retried delivery never carries an expired link.
Treat the signed URL as a credential: anyone holding it can fetch the image until it expires. Do not log it or forward it outside your integration.
If you would rather hold a stable URL and resolve it on demand, GET /v1/expenses/{id}/receipt authenticates with your API key and responds 307 to a freshly signed URL. It returns 404 not_found when the expense has no receipt.
Projects and custom fields
A project-linked expense embeds the project on the payload, including its address and its resolved custom fields. Custom fields are how a workspace stores its own data on a project, and they are usually the only reliable way to match a Coastline project to a record in another system, since customer names rarely agree across systems. Fields are addressed by key, which is stable, rather than by label, which a workspace admin can rename at any time. See the custom fields reference.
/v1/expenses/{id}/external-references/{namespace}Reconciliation write-back
Webhook delivery is at-least-once, jobs get retried, and nightly sweeps overlap. Without a durable record on the expense itself that it has already been handled, a duplicate delivery creates a duplicate record in your system. Stamp the expense as soon as you have an identifier for it, then filter it out on the next pass with ?unreconciled_by={namespace}.
The namespace is yours to choose: 1 to 64 lowercase letters, digits, hyphens or underscores. It keeps integrations from colliding, so an accounting sync and an ordering sync can both stamp the same expense and neither overwrites the other.
The write is idempotent. Repeating an identical stamp changes nothing at all, not even updated_at. Changing the identifier updates it in place and preserves the original created_at. A write-back never fires an expense.updated webhook, so stamping an expense in response to a delivery cannot feed that delivery back to you.
Body parameters
| Name | Type | Description |
|---|---|---|
| external_id | string | Required. The identifier the outside system assigned, 255 characters or fewer. |
| url | string | Absolute http or https link to the record in the outside system. |
| metadata | object | Arbitrary JSON your integration wants to keep alongside the reference. 4 KB or less when serialized. |
/v1/expenses/{id}/external-references/{namespace}Clear a reference
Removes one namespace and leaves the others untouched, so the expense returns to the unhandled pool for that integration only. Clearing a namespace that was never set succeeds, which makes a rollback safe to retry.
Events
Rather than polling, subscribe to an expense event. Each delivery carries the same object documented above, receipt URL and project custom fields included, so you can act on it without a follow-up call. See the webhooks reference for the envelope, signature scheme, and retry behaviour.
| Trigger | Type | Description |
|---|---|---|
| expense.created | event | A new expense row exists. |
| expense.receipt_attached | event | A receipt landed on an expense that had none. |
| expense.updated | event | A business field changed, or the receipt was replaced or removed. |
| expense.deleted | event | The expense was removed. data is null; the fields are in metadata. |
If you need the receipt, listen for expense.receipt_attached
Every write path creates the expense row before it links the receipt, because the file has to reach storage before it can reference an expense id. That means expense.created usually fires with receipt: null, even when somebody photographed a receipt while filling in the form. expense.receipt_attached fires the moment the image actually lands, and its payload carries the signed URL. Expenses entered without a receipt never fire it, which is the correct behaviour for a workflow that needs one.
A reconciliation write-back never fires expense.updated. Stamping an expense in response to a delivery cannot feed that delivery back to you.