---
title: "Upsert a test result"
method: POST
path: "/public/v1/test-results"
tags: ["TestResult"]
---

# Upsert a test result

`POST /public/v1/test-results`

Create or update a single test result (lab result / Certificate of Analysis) for one package or batch. Omit `id` to create; pass an existing test result's `id` to update it. Same URL and request shape for both.

On create, attach the result to exactly one material by sending either `package_id` or `batch_id` (never both, and at least one is required). On update, send neither — the attachment is fixed once created, and passing `package_id` or `batch_id` on an update is rejected. Only non-compliance-tracked test results can be updated through this endpoint; results synced from Metrc or BioTrack are read-only here and any update attempt is rejected.

Updates are sparse: only the fields you send are changed, and any field you omit keeps its stored value. To clear an optional potency or lab field back to null, send it explicitly as `null`. Potency percentage fields accept at most 4 decimal places and must be between 0 and 100.

Results created or updated here are Distru-only records — this endpoint never pushes to Metrc or BioTrack. Compliance-tracked results only ever arrive by syncing from those systems, which is why they are read-only here.

Effects beyond the test result row:
• Creating a result attaches it to the given package or batch and propagates it down to that material's child packages where applicable.
• `is_primary: true` makes this the primary result for its package/batch: it unsets whatever result was previously primary there, propagates to child packages, and repoints those materials' inventory/stock records at this result (so it becomes the potency shown for that inventory). A material always keeps exactly one primary — the first result created for a material becomes primary automatically even if you send `is_primary: false`.
• You cannot flip an existing primary result from `is_primary: true` to `false` directly; instead mark a different result on the same package/batch as primary, which demotes this one automatically.

Required permission: `products_permissions_edit`.

## Request body

- object
  - `id` string — The test result's ID. Omit to create a new result; include an existing result's ID to update it (an ID that doesn't exist for your company returns 404). Only non-compliance-tracked results can be updated — updating one that is synced from Metrc or BioTrack is rejected. When present, `package_id` and `batch_id` must be omitted.
  - `package_id` string — The ID of the package this result is attached to. Provide exactly one of `package_id` or `batch_id` on create; sending both, or neither, is rejected. Must be omitted on update (the attachment cannot be moved).
  - `batch_id` string — The ID of the batch this result is attached to. Provide exactly one of `package_id` or `batch_id` on create; sending both, or neither, is rejected. Must be omitted on update. Batch-attached results cannot be compliance-tracked.
  - `additional_test_results` object — Required on create; on update, omit to leave the stored object unchanged. A key/value object of extra lab fields beyond the built-in THC/CBD ones (e.g. terpenes, pesticides, moisture). Which keys are valid depends on the attached material's product-category test result settings; an unrecognized key is rejected, and keys valid overall but outside that material's configured set are dropped from responses. Each value must be a valid decimal within that field's configured decimal-place, digit, and range limits (percentage fields 0–100). Null values are ignored. When you do send it on update it replaces the entire object, not merged per-key — include every entry you want to keep; send `{}` to clear them all. See [here](#additionaltestresult) for the valid options.
  - `mg_per_unit_type` 'mg/g' | 'mg/mL' — Required on create; on update, omit to leave unchanged. The unit the `*_mg_per_unit` fields are expressed in. One of `mg/g` or `mg/mL`.
  - `is_primary` boolean — Required on create; on update, omit to leave unchanged. Whether this is the primary result for its package/batch. Setting `true` makes it primary — it demotes any other primary on the same material, propagates to child packages, and repoints that material's inventory/stock potency at this result. You cannot change an existing primary result from `true` to `false` directly; mark a different result on the same package/batch as primary instead, which demotes this one automatically. Note: the first result created for a material always becomes primary regardless of this flag.
  - `release_date` string — The lab's release date for this result, as an ISO8601 date (`YYYY-MM-DD`). Optional; omitting it on update leaves it unchanged. Send `null` to clear it.
  - `lab_license_number` string — The license number of the testing lab. Optional; omitting it on update leaves it unchanged. Send `null` to clear it.
  - `lab_name` string — The name of the testing lab. Optional; omitting it on update leaves it unchanged. Send `null` to clear it.
  - `name` string — Required on create; on update, omit to leave unchanged. A display name for this result (250 characters or fewer).
  - `thc_percentage` number — THC as a percentage of the material (0–100, at most 4 decimal places). Optional; omitting it on update leaves it unchanged. Send `null` to clear it.
  - `total_thc_percentage` number — Total THC as a percentage of the material, accounting for THCA conversion (0–100, at most 4 decimal places). Optional; omitting it on update leaves it unchanged. Send `null` to clear it.
  - `thc_mg_per_unit` number — THC content per unit, expressed in the unit named by `mg_per_unit_type`. Must be 0 or greater; unlike the percentage fields it has no upper bound and no decimal-place limit. Optional; omitting it on update leaves it unchanged. Send `null` to clear it.
  - `total_thc_mg_per_unit` number — Total THC content per unit (accounting for THCA conversion), expressed in the unit named by `mg_per_unit_type`. Must be 0 or greater; no upper bound and no decimal-place limit. Optional; omitting it on update leaves it unchanged. Send `null` to clear it.
  - `cbd_percentage` number — CBD as a percentage of the material (0–100, at most 4 decimal places). Optional; omitting it on update leaves it unchanged. Send `null` to clear it.
  - `total_cbd_percentage` number — Total CBD as a percentage of the material, accounting for CBDA conversion (0–100, at most 4 decimal places). Optional; omitting it on update leaves it unchanged. Send `null` to clear it.
  - `cbd_mg_per_unit` number — CBD content per unit, expressed in the unit named by `mg_per_unit_type`. Must be 0 or greater; unlike the percentage fields it has no upper bound and no decimal-place limit. Optional; omitting it on update leaves it unchanged. Send `null` to clear it.
  - `total_cbd_mg_per_unit` number — Total CBD content per unit (accounting for CBDA conversion), expressed in the unit named by `mg_per_unit_type`. Must be 0 or greater; no upper bound and no decimal-place limit. Optional; omitting it on update leaves it unchanged. Send `null` to clear it.

## Response `200`

A single test result

## Other responses

- `400` — Invalid parameters
- `401` — Missing or invalid API token
- `403` — The API token lacks the required permission
- `404` — Not Found

---

[API](https://skmtc.dev/distru/apis/distru-api.md) · [All operations](https://skmtc.dev/distru/apis/distru-api/llms.txt) · [OpenAPI document](https://skmtc-service-production.skmtc.workers.dev/v1/apis/distru/distru-api/revisions/f8930b2c2924/schema)
