TerpIQ API
Multi-tenant API for hemp retailers. Every request is scoped to the caller’s shop by Postgres row level security; there is no cross-tenant access path. Shopper-facing surfaces are unauthenticated and resolve the tenant from the shop slug.
Machine-readable spec: /openapi.json · version 0.4.0
imports
CSV and spreadsheet inventory ingestion.
/api/importscreateImportOpen an import job
The spreadsheet is parsed in the browser, so this takes the headers and row count rather than a file. Returns a guessed column mapping and any saved template whose headers match.
/api/importslistImportsRecent imports
/api/imports/{id}getImportImport status and report
/api/imports/{id}/commitcommitImportChunkApply one chunk of rows
Called repeatedly so large files import with progress. Partial success is the contract: invalid rows are reported and every valid row still lands. Set final=true on the last chunk.
products
The shop’s catalog.
/api/productslistProductsList catalog products
/api/productscreateProductAdd a product by hand
/api/products/{id}updateProductEdit a product
/api/products/{id}deleteProductRemove a product
/api/products/{id}/straingetStrainSuggestionsRanked strain candidates
/api/products/{id}/strainlinkProductStrainConfirm or reject a strain link
coas
Lab reports and the batches they produce.
/api/batchescreateBatchUpload a COA and open a batch for review
Stores the document privately and opens a batch in pending_review with whatever extraction could work out. Never fails because extraction did: a scan, an exhausted monthly budget, or a model error all land on the same review screen with fields left empty.
/api/batcheslistBatchesReview queue and batch history
/api/batches/{id}getBatchOne batch, with a signed link to its document
/api/batches/{id}discardBatchDiscard a pending review
/api/batches/{id}/commitcommitBatchCommit the reviewed values
The human review gate. These values — not the extraction’s proposal — become the active batch, supersede the previous one, and drive matching. Returns a drift note when the new numbers differ from the old ones past the alert thresholds.
finder
The shopper surfaces. These routes are UNAUTHENTICATED: the shop is resolved from its slug and an unpublished finder answers 404 to everyone but its own team.
/api/finder/sessionstartFinderSessionOpen a finder session
Called when the shopper starts the quiz, so completion rate can be measured. Records no identity of any kind. Unauthenticated.
/api/matchmatchShopperMatch a shopper against the shop’s shelf
The deterministic engine, scoped to in-stock profiled products of this shop. Exclusion rules REMOVE products and the removal survives every fallback, so a shopper who declared a sensitivity cannot be shown an excluded product by any route. Unauthenticated.
/api/finder/codecreateCounterCodeIssue a counter code for a finished session
/api/kiosk/exitverifyKioskPinCheck the kiosk exit PIN
Yes or no. The hash is not readable by tenant roles at all, so the comparison happens server-side under the service role.
counter
Counter codes, looked up by staff.
/api/counter-codes/{code}lookupCounterCodeLook up a code at the counter
/api/counter-codes/{code}/redeemredeemCounterCodeMark a code redeemed
shop
Shop-level settings.
/api/shopupdateShopSettingsPublish state, brand color, kiosk PIN
Entitlement columns (plan_status, trial_ends_at) are outside the tenant column grant entirely, so no request shaped like this one can reach them.
connectors
Inventory sources, and the machine endpoints that drive them.
/api/connectors/{id}/syncsyncConnectorNowRun one connection immediately
The connector is found through the caller’s own session, so RLS decides ownership; only then does the sync run as the service role, which it must, because credentials are outside the tenant column grant.
/api/cron/syncrunScheduledSyncMachine endpoint for the nightly scheduled function
Authenticated by a shared secret compared in constant time, not by a session — a cron job has no user. One connector per call, because a serverless function has a wall clock.
/api/inboundreceiveInboundInventoryEmailInventory export received by email
Posted by the email provider when a shop mails its export to inv-<token>@in.terpiq.app. The token in the address is the tenant boundary; a shared secret authenticates the provider.
billing
Subscription lifecycle. TerpIQ sells software, never hemp products.
/api/cron/notifyrunScheduledNoticesMachine endpoint for the daily trial-ending sweep
TerpIQ trials are its own, not Stripe’s, so no webhook can see one ending. Sent once per trial end date. Shared-secret authenticated, compared in constant time.
/api/stripe/checkoutstartCheckoutBegin a $149/mo subscription
Returns a Stripe Checkout URL. No card data touches this app. Owner only.
/api/stripe/portalopenBillingPortalOpen the Stripe billing portal
/api/stripe/webhookstripeWebhookSubscription lifecycle from Stripe
The only writer of entitlement. Signature is verified before anything else, the event is recorded before it is applied so a retry cannot double-apply, and a recorded event always answers 200 so a poison event is not retried forever.
insights
What the finder saw, and what the shelf could answer.
/api/analytics/gapsgetAssortmentGapsRequests the shelf could not answer well
Ranked by how many people asked, measured against the shelf as it is now rather than as it was when the session ran. `format=csv` returns the buying list as a download.
/api/tagsrenderShelfTagsPrintable shelf tags for the chosen products
Returns a PDF. The QR points at the public batch page when a committed batch backs the numbers, and at the shop finder when it does not — a tag never sends a phone to a missing page. Products with no chemistry are skipped rather than printed as 0.0%.
alerts
Batch drift, sync failures, and COA reminders for the shop.
/api/alerts/{id}updateAlertMark an alert read or unread
Alerts are never deleted — the record that a batch drifted or a connector broke outlives the owner clearing their feed.
marketing
Public pages. TerpIQ sells software to retailers, never products.
/api/contactsubmitContactFormSend an enquiry from the marketing site
Unauthenticated: the sender does not have an account yet. Guarded by the shopper rate limiter, a honeypot field, and schema length caps. The sender becomes reply-to, never from.