---
name: distru-api
description: Build, review, or debug an integration with the Distru public API — the ERP for the cannabis supply chain (orders, invoices, inventory, purchases, manufacturing, Metrc/BioTrack compliance). Use when writing code that calls app.distru.com/public/v1, generating a client from Distru's OpenAPI spec, or diagnosing Distru API errors, pagination, webhooks, or compliance sync behavior.
---

# Distru API

REST API at `https://app.distru.com/public/v1/`. JSON in, JSON out. API access must be enabled for the account by a Distru representative; a sandbox environment is available on request.

For anything not covered here, fetch the references — do not guess:

- Full documentation (one Markdown file): https://apidocs.distru.dev/llms-full.txt
- OpenAPI 3.0 spec (exact schemas): https://apidocs.distru.dev/openapi.json

## Every request

- Headers: `Authorization: Bearer <API_TOKEN>`, `Content-Type: application/json`, `Accept: application/json`.
- Tokens are created by an admin in the Distru app (Settings → Integrations → Distru API) and carry that admin's permissions. Keep them server-side only.
- Treat every id as an opaque string. Never parse, construct, or guess ids.

## Reading data

- Request a list endpoint with no page parameter to get the first page, then paginate by following the `next_page` URL in each paginated response — pass it back exactly as given — until it is null. Never build a page selector yourself and never assume a page size; the server may change it at any time.
- Array filters repeat a bracketed key: `?ids[]=a&ids[]=b` (max 200 values). A bare `?ids=a` is a 400.
- Datetime filters are inclusive comma ranges: `?updated_datetime=<min>,<max>`. Omit one side for open-ended (`?updated_datetime=2025-05-04T00:00:00.000000Z,` means on-or-after).
- Webhooks (create/edit/delete events pushed to your endpoint) exist only for the main entity types — orders, invoices, purchases, and the like; check the webhooks docs for the current list.
- To keep a local copy of Distru records, the recommended approach is to sync incrementally (https://apidocs.distru.dev/#incremental-sync). Take every time from the `Date` header of Distru's responses, not the local clock.
  1. Load each list once with no `updated_datetime` filter. The time of its first response is the starting point.
  2. On every run, request `updated_datetime=<starting point minus 5 minutes>,`, walk every page, and upsert by `id`. Note the time of the run's first response. Keep the overlap; it catches changes still being saved when the previous run started.
  3. Avoid other filters (status, location, ids, …) on sync requests; filter locally. A record that stops matching another filter never comes back and is not deleted. Response options such as `include_costs` are fine.
  4. Prefer an API key whose user has no team restrictions; under restrictions, records move in and out of view without being changed or deleted.
  5. In the same run, walk `GET /public/v1/deleted-records?record_type=...&deleted_datetime=<same lower bound>,` once per synced type and delete those ids; it lists every deleted record of every type with its own list or get-by-id endpoint (the Metrc lists, which mirror Metrc, aside). Deletions are kept 90 days; after a longer gap, reconcile (step 7) first.
  6. A run's time becomes the starting point only after it walked every page.
  7. Once a week (every page of a full reload counts against the rate limit, so avoid doing it more often): load each list in full, upsert every record, then remove the ones it didn't return and no run upserted since the reload started. Send the same parameters as the runs. Keep runs going as normal; when the reload finishes, set the starting point back to the time of its first response. This picks up what `updated_datetime` does not reflect (referenced records' names, the order on an invoice, `combined_order`, computed values such as product quantities, company balances and costs, tasks, custom field definitions, nested data on types other than orders, invoices, packages and assemblies) and anything a run missed.
- Look records up in the local copy rather than with list calls: every call to a rate-limited list without a recent `updated_datetime` lower bound costs one from the budget. Packages have no get-by-id endpoint.
- Lists that aren't paginated or have no `updated_datetime` filter (inventory levels, licenses, payment methods, most Metrc lists, …) are not rate limited and can't sync incrementally: reload them in full as often as needed.

## Writing data

- Writes are upserts: one `POST` serves create and update. `id` absent → create, `id` present → update. Same URL, same request shape.
- Updates are sparse: an omitted field is left unchanged; a field sent as `null` is cleared (required fields reject `null` with a 400). Creates are not sparse — send every required field.
- Nested collections (`items`, `charges`) are optional: omit the collection entirely to leave existing rows untouched. But if you DO send it, you replace the whole set: an existing row whose `id` you omit is DELETED, an entry with just `{"id": "..."}` keeps that row unchanged, an entry with an existing `id` plus fields patches that row, and an entry without an `id` is added. Each endpoint's docs spell out its exact collection semantics — read them before writing.
- Enums are SCREAMING_CASE in requests and responses (`"COMPLETED"`, not `"completed"`), with a few exceptions — check the OpenAPI spec for each field's exact values rather than converting case blindly.
- Some writes sync to 3rd-party systems (Metrc/QuickBooks Online/BioTrack) asynchronously: a 200 means the change was accepted in Distru, not that it synced to the 3rd party system yet. Poll the corresponding GET endpoint to observe the synced result (for a list, with a recent `updated_datetime` lower bound so it doesn't cost from the rate limit).

## Errors

Every error, on every endpoint, has one shape:

```json
{
  "errors": [
    {"message": "Human-readable reason", "pointer": ["items", 0, "quantity"]}
  ]
}
```

`pointer` is the path to the offending request field; `["base"]` means the whole request. Write one error parser and reuse it everywhere.

Status codes — the complete set:

| Code    | Meaning                                                            | Handle by                                          |
| ------- | ------------------------------------------------------------------ | -------------------------------------------------- |
| 200/201 | success                                                            | —                                                  |
| 400     | bad input (validation, malformed filter, business rule)            | fix the field at `pointer`; do not retry unchanged |
| 401     | missing/invalid token                                              | check the `Authorization` header                   |
| 403     | token lacks the required permission                                | grant the permission in Distru; not a retry case   |
| 404     | no such record for this account (unknown id, or another account's) | treat as not-found, not as a transient error       |
| 429     | rate limited                                                       | wait `Retry-After` seconds, then retry             |

The API never returns 422 — if your client special-cases it, remove that.

## Rate limits

- **Paginated list endpoints with an `updated_datetime` filter** (orders, invoices, packages, assemblies, products, companies and the rest — the full list is at https://apidocs.distru.dev/#list-rate-limits): one budget of 3,000 calls per account, shared by all these endpoints and all API keys, refilling at 5 per minute. Every page costs one call. A call whose `updated_datetime` lower bound is within the last 30 days is free and never refused; every other call costs one, including lookups, upper-bound-only calls, and calls rejected with 400 or 403. Responses carry `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (seconds until full). Getting a record by id is not limited.
- **PDF endpoints** (paths ending in `/pdf`): 20/minute and 1,000/day per account, aggregated across all PDF endpoints. Only successful downloads count; the window is sliding.

A 429 carries a `Retry-After` header — wait exactly that long, then retry the same URL (a `next_page` URL stays valid). Fixed-schedule retries waste quota.

## Go-live checklist

Before pointing the integration at production:

- Whoever owns the integration is subscribed to the [API email list](https://share.hsforms.com/1ui8lKXQ2RAaytYEyEphCiQ2usrt) — the only channel for breaking-change announcements.
- The integration was tested against the sandbox environment, not production data.
- One shared error parser handles the `{ "errors": [ { "message", "pointer" } ] }` envelope on every endpoint.
- Every list read follows `next_page` until it is null — no page-size or page-count assumptions anywhere.
- Rate-limited lists are read in full rarely enough to fit the budget; repeat reads use an `updated_datetime` lower bound within the last 30 days.

## Staying current

Breaking changes are announced ONLY via the API email list (see the go-live checklist). New endpoints and fields appear in the [changelog](https://apidocs.distru.dev/#changelog), newest first.
