Endpoint Reference
Grouped listing of REST endpoints available via the Sporeline API.
developerEndpoint Reference
All endpoints require a valid Authorization: ApiKey <api_key> header (see Authentication) and are tenant-scoped. Base URL: https://9uvbgolte1.execute-api.us-east-1.amazonaws.com/prod.
This reference groups endpoints by domain. For request/response schemas, examples, and auth setup, see the API Quickstart. To see live calls, sign in to the web app and watch the Network tab — every UI action uses the same REST API.
Production
Blocks
GET /blocks— list blocks (paginated, 15/page)GET /blocks/{id}— get block detailPOST /blocks— create a blockPUT /blocks/{id}— update block metadataPOST /blocks/{id}/harvest— log a harvest event (singular path — not/harvests)GET /blocks/{id}/environment— per-stage env retrospective (temp/RH/VPD/CO₂ stats, outlet on-time, cohort median). Returnsenv_available: falsefor stages with no AC Infinity room mapping. Data available for blocks whose locations haveenv_room_idset.POST /blocks/{id}/label— generate/retrieve label URL
Harvests
-
GET /harvests— filterable list of terminal blocks (COMPLETED / CONTAMINATED / DISCARDED) for the Harvest Review page. Includesenv_attribution_availableflag for each block. -
GET /harvests?view=lot-yields— lot harvests by species and strain (each group and lot row carriesstrain), one row per lot, filed under its most recent entry’s species and strain (blocks, wet and dry totals, average dry weight per block, dehydration ratio); optionalfrom/to(YYYY-MM-DD)Query params:
recipe_id,species,bag_type_id,from_date(ISO date),to_date,outcome(harvested|discarded|all, defaultall),limit(default 50, max 200),cursor(opaque pagination token).
Batches
GET /batches/GET /batches/{id}POST /batches— create batchPUT /batches/{id}— update batchPATCH /batches/{id}— lot action on a lot-tracked batch (move,discard,sell,return,adjust,void,transitionin the body’saction); send anIdempotency-Keyheader- A
voidthe lot no longer qualifies for answers 409 witherror.codeLOT_NOT_PRISTINEanderror.details.reasonsaying why:NOT_OPEN,ALLOCATED,HAS_ACTIVITY,TOO_OLD, orHAS_YIELDwhen the lot has harvests recorded. Delete its yield entries (delete_yield) first, then void it.
- A
PATCH /batches/{id}withactionrecord_yield(Worker+),edit_yieldordelete_yield(Manager+) — a lot’s harvest:harvested_on(YYYY-MM-DD),wet_weight_g, optionaldry_weight_g,block_count(defaults to the units on hand) andflush_number. The lot’sGET /batches/{id}lists them underyieldswithavg_dry_per_block_ganddehydration_ratioDELETE /batches/{id}— archive a batch. A lot with units on hand, or with harvests recorded, is refused with 400 and a message saying which; delete a lot’s yield entries first.POST /batches/plan-day— plan a day of lots (dry_run: truepreviews; a create needs anIdempotency-Keyheader)POST /batches/{id}/bulk-transition— transition all blocks in a batchPOST /batches/{id}/labels— generate labels for every block in the batch
Recipes
GET /recipes/GET /recipes/{id}POST /recipes— createPUT /recipes/{id}— update
Events
POST /events— log a generic block/batch event (notes, state changes)
Locations
GET /locations/POST /locations/PUT /locations/{id}/DELETE /locations/{id}GET /locations/{id}/environment?from=&to=— room-scoped env dashboard: per-metric time-series (temp/RH/VPD inside+outside, CO₂) with avg/min/max over the window, plus blocks/batches currently in the room. Window defaults to the trailing 7 days; series are bucket-downsampled to ≤500 points. Returns emptymetricswhen no readings landed for the location.
Bag Types
GET /bag-types/POST /bag-types/PUT /bag-types/{id}/DELETE /bag-types/{id}
Lab
Gated on the lab_tracking setting. Returns 403 if the tenant has not enabled lab features.
GET|POST /cultures,GET|PUT|DELETE /cultures/{id}GET|POST /plates,GET|PUT|DELETE /plates/{id},POST /plates/{id}/observationsGET|POST /liquid-cultures,GET|PUT|DELETE /liquid-cultures/{id}GET|POST /trials,GET|PUT|DELETE /trials/{id}GET|POST /strains,GET|PUT|DELETE /strains/{id}
Extraction
Available on every plan. An open extraction lot counts toward the plan’s
active lab-item limit, like a culture or plate, so creating a lot or a
transform child returns 402 UPGRADE_REQUIRED only when that limit is full.
An extraction lot tracks source material steeping in a menstruum — a maceration, tincture, decoction or blend. Two rules govern the whole resource:
- On-hand is derived, never stored. It is the sum of the lot’s ledger
deltas. There is no endpoint that sets a quantity;
POST .../ledgeris the only way a quantity ever moves. - Lots carry two identifiers.
xlot_idis a ULID and is what every path takes.lot_code(for exampleLM-0902-M) is the human string on the jar; it is unique per tenant, generated server-side, and is never a path parameter.
Endpoints:
GET|POST /extraction-lots— list (filters:type,status,parent) and createGET|PUT|DELETE /extraction-lots/{id}— fetch one (with derivedon_hand), update metadata, soft-closeGET|POST /extraction-lots/{id}/ledger— paginated movements, and append onePOST /extraction-lots/{id}/transform— strain or blend: draw from one or more parents into a new child lot
Notes:
GET /extraction-lotsomitson_handby default, because deriving it means one ledger query per lot. Passinclude=on_handto get it on every row, orlot_code=LM-0902-Mto look up one jar by its code.PUT /extraction-lots/{id}updatesname,notes,metered_abv,shelf_life_days,dates,status,ratio,menstruum(re-solved on save) andsource_material(species,form,dry_g,material_kind). It never changeslot_codeorlot_type.- Ledger deltas are signed. A draw larger than the lot’s on-hand is rejected with 409, as is a request that races a concurrent movement on the same lot.
- RBAC follows the lab items:
WORKERreads,MANAGERwrites.
Scanning & Movement
POST /scan-progress— move a single resource to a new location/stagePOST /scan-progress/bulk— bulk moveGET /resources/{id}/movements— history for a single resource
Reports & Analytics
GET /reports/dashboard— dashboard summary (blocks by stage, tasks, recent activity)GET /reports/dashboard?view=inventory— live and all-time inventory (Worker+; revenue is zeroed below Manager). Eachby_productrow has ameasure:units(blocks or cultures) orweight_gfor a HARVEST product, sold by weight, whoseavailable,allocatedandon_open_ordersare grams of unsold harvest.harvest_truncated: true(with aharvest_hint) means the farm has too many unsold harvests to count, and HARVEST rows carrynullstock rather than zero;lab_truncateddoes the same for culture products. A row withoutmeasure(an older API) counts units.GET /reports/sessions/{session_id}— aggregated session report (batches grouped under a named session)GET /kpi/summary?period=30d— financial KPI tilesGET /data-health/score— tenant data health scoreGET /contamination/GET /revenue-by-customer/GET /yield-by-batch/GET /yield-by-recipePOST /analytics/snapshot— presigned URLs to the tenant’s Parquet export, for DuckDB-WASM (Manager+)GET|POST /analytics/saved-queries,GET|DELETE /analytics/saved-queries/{id}(Manager+)
The /analytics endpoints require MANAGER or higher on every method. An API
key with no role set authenticates as MANAGER, so key-based integrations are
unaffected.
Traceability & Compliance
Available on every plan.
GET /lots/{id}— lot detailPOST /lots— register a lotPOST /lots/{id}/link— link parent lots to an existing lotGET /lots/{id}/trace-forward— where this lot’s material ended upGET /lots/{id}/trace-reverse— where this lot’s material came fromGET /lots/{id}/recall-scope— affected blocks, harvests, deliveries and customers (Manager+)POST /lots/{id}/recall-export— start the complete recall walk for a lot, or join the one already running (Manager+)GET /lots/{id}/recall-export— the newest recall walk for a lot (Manager+)GET /lots/{id}/recall-export/{job_id}— one recall walk (Manager+)GET /audit-report?block_id=...— enriched block audit data for branded reports
Traversal limits
The three walking endpoints above are capped so one lot graph cannot run unbounded, and each response says whether its cap was reached:
{
"truncated": false,
"traversal": {
"truncated": false,
"limit_hit": null,
"entities_visited": 4002,
"entity_limit": 10000,
"depth_limit": 25
}
}
limit_hit is entities, depth, time, edges, customers,
missing_records, customer_records, batch_records, or null.
missing_records means the walk followed a link to a lot id that has no
record of its own (for example a parent id passed to POST /lots that was
never registered), so that lot cannot be listed.
recall-scope walks up to 10,000 linked lots and 25 levels, and names up to
2,000 distinct customers; trace-forward and trace-reverse stop at 500 lots
and 10 levels, because they render a nested tree.
customers is capped separately because buyers hang off the affected
deliveries rather than being nodes in the lot graph, so the entity cap does not
bound them. time means the request’s shared 20s budget ran out — the walk and
the customer lookups spend from the same clock, so the endpoint returns a
labelled partial instead of exceeding the gateway timeout.
Treat truncated: true as “this list is incomplete.” The arrays beside it
are a floor, not a total, and must not be used as the authoritative recall
scope. When recall-scope stops at the lot, depth, page, time or customer
limit, it also starts the complete recall walk below (or joins the one
already running for that lot) and returns it as job, next to the same
truncated and traversal fields it has always returned. Poll that job for
the whole list.
The complete recall walk
A recall walk runs in the background and has no limit on how many lots,
levels or buyers it follows. A long one carries on across several runs, and
it never finishes short: it ends done with exact counts and its files, or
failed with a reason. There is no truncated field on a job, on purpose.
POST /lots/{id}/recall-export Manager+ (requireAccess(auth,"traceability","recall"))
-> 202 { data: TraceJobView } joins an in-flight job for the same lot (joined_existing: true)
GET /lots/{id}/recall-export Manager+ -> 200 { data: TraceJobView } newest job for the lot | 404 when none
GET /lots/{id}/recall-export/{job_id} Manager+ -> 200 { data: TraceJobView } | 404 (unknown, or another tenant's id)
GET /lots/{id}/recall-scope unchanged, plus optional `job: TraceJobView` whenever truncated is true
(the handler started or joined the job before answering)
TraceJobView {
job_id: string; lot_id: string; kind: "recall_export";
status: "queued" | "running" | "done" | "failed";
requested_at: string; requested_by: string; // user id, never an email
started_at: string | null; finished_at: string | null;
attempt: number; max_attempts: number; // 3
progress: { lots_read: number; level: number; counts_so_far: RecallExportCounts; updated_at: string } | null;
counts: RecallExportCounts | null; // exact, set only when status is "done"
error: string | null; // set only when "failed"
download_url: string | null; // report ZIP, 15-minute signed URL minted per GET for Manager+
customers_download_url: string | null; // call sheet CSV, same rule
download_expires_at: string | null;
joined_existing: boolean; // POST response only; a poll always says false
}
RecallExportCounts { lots_walked, blocks, harvests, deliveries, pallets, orders, customers, lab_items, extraction_lots, other_lots } // all numbers
What the counts mean:
lots_walkedis every lot the walk read, the starting lot included.ordersis every order the walk reached;deliveriesis the part of those whose product left the farm (shipped, delivered, invoiced or paid).customersis every distinct buyer on those orders. Each one is on the call sheet, including a buyer whose customer record was deleted, who is named from the contact saved on their order.lab_itemscounts cultures, agar plates and liquid cultures,extraction_lotscounts macerations, tinctures and blends, andother_lotsis everything else the walk read: batches, spawn, ingredients and any linked lot with no record of its own.
progress is null until the walk starts, then shows how far it has got.
Between two runs of a long walk the status reads queued again while
progress keeps the numbers it had reached. attempt counts the tries of
the current run; a run that fails is tried again up to max_attempts times
before the job stays failed.
The download links are signed fresh on every GET, last 15 minutes, and are
only given for a done job. The ZIP holds the call sheet
(affected_customers.csv), every affected order, pallet, block, harvest, lab
item and extraction lot, the lot graph and a manifest.json with the same
counts; the call sheet is also offered on its own.
Logistics
GET|POST /deliveries,GET|PUT /deliveries/{id}PUTalso accepts anINVOICEDdelivery (Manager+); the response’soutcomesays whether its QuickBooks invoice took the change (updated,stale,not_in_quickbooks)GET ?include=quickbooks— the QuickBooks connection as far as the delivery needs it;?include=history— edits, voids, credit memos and refunds, newest first;?include=qbo_invoice— the invoice’s lines as QuickBooks holds them (Manager+)
PATCH /deliveries/{id}withaction:qbo.push— send to QuickBooks now, or retry (Manager+)qbo.voidwithreason— void the delivery and its QuickBooks invoice (Manager+)qbo.credit_memowithreasonandfull: trueorlines— issue a credit memo (Manager+,Idempotency-Keyheader required)qbo.refundwithreasonandfull: trueorlines— refund a paid delivery (Manager+,Idempotency-Keyheader required)
GET|POST /customers,GET|PUT|DELETE /customers/{id}POST /deliveries/{id}/invoice— generate invoicePOST /deliveries/{id}/packing-list— generate packing list
Labels & Printing
POST /blocks/{id}/label— single block labelPOST /batches/{id}/labels— batch labelsGET|POST /label-templates,GET|PUT|DELETE /label-templates/{id}POST /label-templates/{id}/render— render a template against contextPOST /label-templates/{id}/preview— preview render without persisting
Marketplace
GET /marketplace— public marketplace listings (no auth required)GET /suppliers/{id}— supplier profileGET|POST /reservations,POST /reservations/{id}/cancel
Settings & Admin
GET|PUT /tenant/settings— tenant settingsGET|POST /tenant/team— team membersPOST /tenant/invite— invite a userGET|POST /api-keys,DELETE /api-keys/{id}— manage API keys (Manager or Owner, signed in; not available to API keys themselves)GET|POST /cleaning-logsGET|POST /enumerables
Import / Export
GET /export?entity=<type>— CSV exportPOST /import— bulk import with transform pipelineGET /config/export/POST /config/import— tenant configuration
Privacy (DSAR)
Available on every plan. Part of GDPR compliance workflow.
GET|POST /privacy/dsar— submit/read data subject access requestsPOST /privacy/consent— record consent preferencesGET|POST /privacy/data-sharing— data sharing preferences
AI Insights
GET /ai/insights— list active insightsPATCH /ai/insights/{id}— dismiss an insightPOST /ai/label-design— generate a label layout from a promptGET /ai/label-design?job_id=...— poll async job status
Notes
- Pagination: List endpoints return
{ data: [...], next_token?: "..." }. Passnext_tokenas a query param for the next page. - Response envelope: Every success is wrapped in
{ "data": ... }. - Errors:
{ "error": { "code": "...", "message": "...", "request_id": "...", "retry": true|false } }. Retry errors withretry: trueusing exponential backoff. - Idempotency: Send an
Idempotency-Keyheader on a write (any unique string of up to 200 characters; a UUID is a good choice) and send the same key again on every retry of that write. The first request with a key claims it and runs. A retry with the same key and the same body gets the first answer back (with the headerIdempotent-Replay: true) and does not run again. The same key with a different body is refused with422and codeIDEMPOTENCY_KEY_REUSED. A retry that arrives while the first request is still running gets409withretry: true: wait a moment and send it again with the same key. A retry of a request that already completed but whose answer was too large to store gets409with codeCOMPLETED_ANSWER_NOT_KEPT(retry: false): the request did run, once, so read the record back instead of treating it as a refusal. A count’ssubmitand a transfer’sclosenever get that answer: their retries answer200with a shorter body (the record without itslines, andlines_omittedgiving how many were left out). An error answer (4xx or 5xx) releases the key, so a corrected request may use it again, with one exception:POST /batches/plan-daycreates a day in groups, and when it saves some lots and then stops (a409with codeLOT_CODE_TAKENorUPGRADE_REQUIRED, or a500with codeINTERNAL_ERROR, each listing the saved lots inerror.details.created), it keeps the key and stores that error answer. A retry with the same key and body gets the same error back (withIdempotent-Replay: true) and saves nothing more; if that answer was too large to store, the retry gets a409saying the request was already completed. To finish the day, resubmit whaterror.details.remaining_rowsnames (each request row’srow_indexand theunitsstill to make) with thesession_idfromerror.details, under a new key. When no lot was saved, the key is released as usual. Keys belong to your farm only and expire after 7 days. Without a key, a write runs every time it is sent, so a blind retry of an order create can make a second order. Keys are honoured on:POST /deliveries(create an order),PATCH /deliveries/{id}(fulfillment actions such asallocateandallocate_batch),POST /deliveries/{id}/complaints,POST /batches,PATCH /batches/{id}(lot actions),POST /batches/plan-day(a key is required),POST /transfers,PATCH /transfers/{id}(close,updateandcancel),POST /blocks/{id}/harvest,POST /blocks/{id}/transition,POST /blocks/bulk-transition,POST /scan-progress,POST /scan-progress/bulk,POST /agar-plates/batch-create,POST /materialsandPOST /materials/{id}/ledger. On other routes the header is ignored. - Idempotency on transfer
shipandreceive:PATCH /transfers/{id}withactionshiporreceiveworks differently from the rule above. A key is required; without one the request is refused with400and codeIDEMPOTENCY_KEY_REQUIRED. The key is recorded with the scan itself, for that transfer only, and does not expire. A retry with the same key and the same scan (line_id, the location andquantity) records nothing again and answers200with the transfer as it is now (not as it was after the first scan), the scan’sleg,movedandledger_entry, and"replayed": truein the body; there is noIdempotent-Replayheader. Two copies sent at the same moment usually resolve the same way, with one recording the scan and the other replaying it. But if the first copy used up everything left on that line, the second can instead get409with codeCONFLICT(retry: false) even though the scan was recorded. Before treating that409as a refusal, read the transfer back (GET /transfers/{id}) or resend once with the same key: a recorded scan answers with"replayed": true. The same key with a different scan on the same transfer is refused with409and codeIDEMPOTENCY_KEY_REUSED. A scan that was refused recorded nothing, so its key may be used again. - Rate limits: See Rate Limits.
Need something not listed here? File a request at /admin/feedback from inside the app.