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/strainslistStrainsSearch the platform library plus this shop’s own strains
RLS decides which rows come back — platform rows plus your own — so this route carries no tenant filter and must not grow one.
/api/strainscreateShopStrainCreate a strain this shop owns
For a cultivar the platform library has never heard of. It starts UNPROFILED and therefore never matches: there is no chemistry in the request body and there must not be, because typed-in percentages would be a guess wearing the costume of a measurement. Chemistry arrives later, from a lab report.
/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. The object is stored before the row is created, so no batch can exist without its document, and its path is derived from the file's own content — re-uploading the same file for the same product reuses the stored object rather than making a second copy, and says so with `reusedUpload`.
/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.
/api/transitionrecordTransitionDecisionRecord what the retailer decided about a product
Append only: the decision trail has no update or delete, so a decision that turned out badly stays in the record. Stores what the rule said at the time, so a decision made against an earlier rule is still readable as such. The product reports a status and never acts on it — this is where a person decides.
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/shop/activesetActiveShopSwitch which location the dashboard is acting on
Names a location by id. The id is checked against the caller's own list before anything is written, and the answer for a location that does not exist is the same as for one that is not theirs. The resulting cookie is a HINT: every read re-resolves the shop through row level security and falls back when it resolves to nothing, so the cookie grants no access on its own. Any member may switch; choosing which location you are looking at is navigation rather than a privilege, and every read behind it is still scoped by role.
/api/memberssetMemberRoleMake a team member a manager, or return them to staff
Owner-only. Manager is TerpIQ’s role, not Clerk’s — Clerk’s plan allows only its two built-in roles, so this writes members.role, which is the column can_write_shop() reads. Owner is not an accepted value.
/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.
/api/shop/withdrawwithdrawShopExposureTurn off directory sharing or unpublish the finder
The two switches that only ever REDUCE what the public can see, and the one write a shop locked out for billing may still make. The schema accepts false and no other value, so it cannot restore exposure: publishing again needs a live subscription. Directory consent is owner-only because giving it was; unpublishing a finder is any writer, as it always was. Staff reach neither. Both actions are audited.
connectors
Inventory sources, and the machine endpoints that drive them.
/api/connectorscreateConnectorConnect an inventory source
Validated against the source BEFORE anything is stored, so a key that does not work is never written and a connection that exists is one that worked at least once. Credentials are encrypted on the way in; the response carries `credentialsConfigured`, a boolean, and no representation of the secret in any form.
/api/connectors/validatevalidateConnectorTry a connection without storing it
Stores nothing. `connectorId` re-tests a saved connection using its stored secret, which is the only way to test one — nothing is ever sent back to the browser to be resent. Answers 200 either way: a refusal by the source is a successful check with a negative answer, and the adapter’s own sentence is what the owner needs to see.
/api/connectors/{id}updateConnectorChange a connection’s settings or replace its key
An omitted secret keeps what is stored — the form has no copy to resend. Re-validated before the write for the same reason as create: a working connection must not be edited into a broken one.
/api/connectors/{id}deleteConnectorRemove a connection
Deleted through the caller’s own client, so RLS is what refuses a staff session rather than a check above it. Products already imported are kept.
/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/cron/digestsendOperatorDigestWhat changed across every customer shop
A separate endpoint from /api/cron/notify so the two cannot share a failure: a shop’s trial email must not go unsent because the digest threw. Sends only when something changed, plus a Monday heartbeat — a daily “nothing happened” email is one its reader stops opening. Every count excludes internal tenants.
/api/cron/reportreportCronOutcomeTell the operator that half the nightly sweep failed
Called by the scheduled function with its own outcome. It is a third call rather than a flag on the digest because the digest is one of the two things that can break, and a flag only covers the case where it still works. Mails nothing when nothing failed.
/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 sweep
TerpIQ trials are its own, not Stripe’s, so no webhook can see one running. Every shop still on a trial is considered, and the day of that trial decides which message is due: a setup nudge on day 3 and day 7, a notice three days out, and one the day after the date passes. Each is sent once per trial end date, and the response counts every skip by reason. 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/insights/worksheetgetBuyingWorksheetThe gap report worked into a buying list
Each gap with the demand behind it, the chemistry and format asked for, a suggested quantity RANGE with every input to the derivation beside it, the price range the shop already charges for that format, and a sourcing sentence. The quantities rest on two stated assumptions — a restock window and a take-up range — and are suggestions, not instructions. `format=csv` downloads the worksheet; `format=text` downloads a plain-text order request with dismissed rows left out.
/api/insights/worksheetrecordGapDecisionRecord what the buyer decided about a gap
Append only: a change of mind writes another row and the worksheet shows the latest, so the history stays readable. Owners and managers only. The key names a computed gap, so it is validated against the report vocabulary rather than a foreign key.
/api/analytics/paperworkgetPaperworkHow complete the shop’s lab paperwork is, and what to chase
A score built from the per-batch provenance records, with every component returned beside it — the score is never meaningful without them. `asks` is the distributor list: the specific products and batches whose paperwork is missing, sample-only, undated, old, unreviewed after drift, or uncertain. `format=csv` returns that list as a download. 503 when the shop has more records than one read returns (a score from a subset is refused, not estimated) or when the paperwork could not be read at all.
/api/exportexportShopDataEverything the shop has, as a zip
Owner only, and deliberately still available to a shop whose subscription has lapsed — a canceled shop keeps read access to its own records. Contains JSON and CSV for the shop profile, products, shop-created strains, batches with their extraction confidence, a COA manifest of short-lived signed download links, connector settings with every secret removed, finder sessions, alerts and the audit trail. Never contains keys, card details or kiosk PINs. Rate limited per shop.
/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.
platform
Endpoints the browser or the platform calls rather than a person. Unauthenticated by necessity and self-guarding.
/api/cron/gap-closurerunGapClosureRollupRoll up whether received gaps have closed
For every gap a buyer marked received, decides whether the shelf can now serve that request and how many people have asked since: closed, still open, or not enough data. Writes one row per shop per ISO week into `gap_reports`, which is unique on (shop_id, period) — so the write replaces the current week and running it twice changes nothing. Authenticated by the shared cron secret, not a session.
/api/cron/product-outcomesrunShelfOutcomeRollupRoll up how each product performed on the shelf
Counts, per product and per day, how often the finder matched it, how many counter codes carried it, and how many of those were redeemed — the last only for shops that opted into redemption attribution. Shelf level only: the rows it writes carry no session, no answers and no clock finer than a date. Recomputes the last seven shop-days rather than incrementing, so a missed night repairs itself and a double run changes nothing. Authenticated by the shared cron secret, not a session.
/api/public/shops/{slug}/snapshotgetShopSnapshotPublic shop snapshot for The Strain Finder
Contract 1.0 (docs/INTEGRATION-CONTRACT.md). Counts and a finder link, and nothing else: no product names, prices, chemistry, batch ids or COA links. Read at BUILD time by the consumer, which is a static export with no server. TWO refusals, and a consumer MUST tell them apart: 410 Gone means the retailer reversed something already published (consent withdrawn, or a published finder taken down) and any snapshot already held must be DELETED; 404 means an unknown slug, a shop that never opted in, or a lapsed subscription, and the consumer keeps its last known good. A 5xx is never a withdrawal. Neither refusal names which case it was. Strong ETag over the body, and the 200 must be revalidated so a cached copy cannot outlive a withdrawal; cookie-free; rate limited.
/api/public/handoffmintHandoffCarry a shopper’s directory answers into a shop finder
Contract 1.0. Returns an opaque id and an expiry and nothing else: the answers stay here and the id travels, because a URL is written to browser history, referrer headers and every access log in between. Ten minutes, single use, no identity of any kind. Guarded by an Origin allowlist and the rate limiter — the caller is a static site with nowhere to keep a secret.
/api/csp-reportreportCspViolationContent-Security-Policy violation report
Where browsers send CSP violations while the policy runs in report-only mode. Stores the violated directive and the scheme and host of what was blocked, and nothing else — no page address, no referrer, no query string, because on this product those name the shop a person was looking at. Always answers 204.