Incremental Sync
Load everything once
curl --location 'https://app.distru.com/public/v1/orders' \
--header 'Authorization: Bearer ********* API KEY HERE *********'Every run after that: what changed since your previous run started, minus 5 minutes
curl --location 'https://app.distru.com/public/v1/orders?updated_datetime=2025-05-04T04:35:00.000000Z,' \
--header 'Authorization: Bearer ********* API KEY HERE *********'And what was deleted since the same time
curl --location 'https://app.distru.com/public/v1/deleted-records?record_type=ORDER&deleted_datetime=2025-05-04T04:35:00.000000Z,' \
--header 'Authorization: Bearer ********* API KEY HERE *********'To keep a copy of your Distru data in another system, load it once and then
fetch only what changed. We recommend every integration that keeps a copy to
work this way: the paginated list endpoints with an updated_datetime filter
are rate limited, and only a call whose updated_datetime lower bound is within
the last 30 days is free (see List rate limits). Looking
records up in your copy, rather than with those list calls, keeps lookups out of
your rate-limit budget.
Lists that aren't paginated or have no updated_datetime filter, such as
inventory levels, licenses, payment methods and most Metrc lists, are not rate
limited: reload them in full as often as you need.
Recommended sync approach
- Load everything once. Walk the list endpoint with no
updated_datetimefilter, followingnext_pageuntil it is null. The time of its first response is your starting point. Take every time from theDateheader of Distru's responses, not your own clock. - Then fetch only changes. Request the list with
updated_datetime=<starting point minus 5 minutes>,, walk every page, and upsert each record into your copy byid. Note the time of the run's first response. Keep the 5-minute overlap: it catches changes that were still being saved when your previous run started. - Avoid other filters. Filter by status, location, ids or anything else in
your copy, not on sync requests. A record that stops matching another filter
would never come back, and it isn't deleted, so it would stay stale in your
copy. Options that only shape the response, such as
include_costson packages, are fine. - Prefer an unrestricted key. An API key whose user has no team restrictions sees every record its permissions allow. Under team restrictions, a record that moves out of the user's teams stops appearing without being deleted, and one that moves in can be older than your starting point.
- Remove deleted records. In the same run, walk
GET /public/v1/deleted-records once per record type
you sync with the same lower bound in
deleted_datetime, and delete those ids from your copy. It lists every deleted record of every type with its own list or get-by-id endpoint (the Metrc lists, which mirror Metrc, aside), kept for 90 days; if your starting point is older than that, reconcile (step 7) before your next run. - Move your starting point only on success. A run's time becomes the new starting point only after it walked every page. If a run fails, the next one starts from the same point.
- Reconcile once a week. Load each list in full, noting the time of its
first response. Upsert every record it returns, then remove the ones your
copy has that it didn't return and that no run upserted since the reload
started (a record created during a long reload can land behind its cursor).
The reload sends the same parameters as your runs.
Keep your runs going as normal; when the reload finishes, set your starting
point back to the reload's time, so the next run fetches again whatever
changed while it ran.
This picks up the changes
updated_datetimedoes not reflect (below) and anything a run missed. Avoid reloading a rate-limited list in full more often than that: every page of a full reload counts against the rate limit.
What moves updated_datetime
On orders, invoices, packages and assemblies, updated_datetime moves whenever
the record's own data changes, nested data included (packages offer it as a
filter only, not as a field):
- Orders: the order's own fields, its line items and charges, and the invoices and returns shown on it (including each line's returned quantity).
- Invoices: the invoice's own fields, its line items and charges (including the order line details shown on each), its payments, credit uses and overpayment credits, and its paid and remaining amounts.
- Packages: the package's own fields, the bins it is in, its primary test
result, its owner, its compliance fields, and its actual cost
(
total_cost_actualandcost_per_unit_actualwithinclude_costs). - Assemblies: the assembly's own fields, its inputs, outputs, costs and bins,
its inputs' and outputs' actual costs (
total_cost_actualandcost_per_unit), and its outputs' compliance fields.
On other types, nested data can change without moving updated_datetime; the
weekly reconcile picks those changes up. On every type, it does not move for:
- Changes to other records the record points at, for example the name of a product, location, bin or company, or the current quantity of a package used in an assembly. Sync those records from their own endpoints.
- The order shown on an invoice, or the combined order shown on a merged order. Sync orders from GET /public/v1/orders.
- Values Distru computes from other records, such as a product's quantities, a company's balances, or the default costs of packages and assemblies. For stock levels, use GET /public/v1/inventory, which is not rate limited.
- Tasks linked to the record, and adding, renaming or deleting a custom field definition.
- Deleting a record: see step 5.
List rate limits
A list call over the limit
HTTP/1.1 429 Too Many Requests
retry-after: 12
x-ratelimit-limit: 3000
x-ratelimit-remaining: 0
x-ratelimit-reset: 36000
content-type: application/json{
"errors": [
{
"message": "List rate limit exceeded: 3000 calls per account, refilling at 5 per minute. Calls with an updated_datetime lower bound within the last 30 days are not limited. Retry after the Retry-After period.",
"pointer": ["base"]
}
]
}These rules apply to the paginated list endpoints with an updated_datetime
filter:
- Sales: orders, invoices, payments, credits, returns, menus, price tiers, charge presets, taxes.
- Purchasing and manufacturing: purchases, assemblies, cost types.
- Inventory and compliance: packages, batches, bins, adjustments, locations, test results, Metrc tags.
- Catalog: products, product categories, product subcategories, product groups, strains, unit types, tags.
- Companies and people: companies, company groups, contacts, users, drivers, vehicles.
- Other: tasks.
Getting a single record by id is not limited. Packages have no get-by-id
endpoint: look them up in your copy if you keep one; otherwise list them by
ids or label (each call costs one).
- Your account has one budget of 3,000 calls, shared by all these endpoints and all your API keys. It refills at 5 calls per minute (300 per hour), up to 3,000.
- Each page costs one call: the first page and every
next_pagealike. - A call whose
updated_datetimelower bound is within the last 30 days is free: it costs nothing and is never refused. Every other call costs one, including lookups byids, number or label, calls with only an upper bound, and calls rejected with a400or403. - Responses from these endpoints carry your budget:
X-RateLimit-Limit(3,000),X-RateLimit-Remaining(calls left) andX-RateLimit-Reset(seconds until the budget is full again). - Past the budget, a call returns
429 Too Many Requestswith aRetry-Afterheader: the seconds until your next call is available. Wait that long, then retry the same URL. A first load longer than the budget carries on at the refill rate, resuming from the samenext_page.
Pagination
First page: request the endpoint with no page parameter
curl --location 'https://app.distru.com/public/v1/products' \
--header 'Authorization: Bearer ********* API KEY HERE *********'Next page URL in the response body (null when there is no next page)
"next_page": "https://app.distru.com/public/v1/products?page[after]=<opaque cursor>"Following it: pass the URL back exactly as given
curl --location --globoff 'https://app.distru.com/public/v1/products?page[after]=<opaque cursor>' \
--header 'Authorization: Bearer ********* API KEY HERE *********'Request a list endpoint with no page parameter to get the first page. Paginated
responses carry a next_page URL; call it as given — it already carries the
filters you sent — and repeat until next_page is null. That is the whole
pagination contract.
Do not assume a fixed number of items per page. Distru may change the page size
of any GET endpoint at any time without notice, so always walk a list by
following next_page.
Filtering by datetime parameters
On or after May 4th, 2025
products?updated_datetime=2025-05-04T04:40:21.817570Z,On or before May 4th, 2025
products?updated_datetime=,2025-05-04T04:40:21.817570ZBetween May 4th and Sept 18th, 2025 (inclusive)
products?updated_datetime=2025-05-04T04:40:21.817570Z,2025-09-18T16:27:44.946871ZDatetimes use the format YYYY-MM-DDTHH:MM:SS.MSZ.
The comma position controls the direction of the filter. All bounds are inclusive: a record matching the exact datetime in the query is returned.
- Comma after the datetime = records on or after that datetime.
- Comma before the datetime = records on or before that datetime.
- Datetimes on both sides of the comma = records between the two datetimes, inclusive of both ends.