Skip to content

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.

post/api/importscreateImport

Open 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.

get/api/importslistImports

Recent imports

get/api/imports/{id}getImport

Import status and report

post/api/imports/{id}/commitcommitImportChunk

Apply 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.

get/api/productslistProducts

List catalog products

post/api/productscreateProduct

Add a product by hand

patch/api/products/{id}updateProduct

Edit a product

delete/api/products/{id}deleteProduct

Remove a product

get/api/strainslistStrains

Search 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.

post/api/strainscreateShopStrain

Create 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.

get/api/products/{id}/straingetStrainSuggestions

Ranked strain candidates

post/api/products/{id}/strainlinkProductStrain

Confirm or reject a strain link

coas

Lab reports and the batches they produce.

post/api/batchescreateBatch

Upload 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`.

get/api/batcheslistBatches

Review queue and batch history

get/api/batches/{id}getBatch

One batch, with a signed link to its document

delete/api/batches/{id}discardBatch

Discard a pending review

post/api/batches/{id}/commitcommitBatch

Commit 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.

post/api/transitionrecordTransitionDecision

Record 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.

post/api/finder/sessionstartFinderSession

Open a finder session

Called when the shopper starts the quiz, so completion rate can be measured. Records no identity of any kind. Unauthenticated.

post/api/matchmatchShopper

Match 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.

post/api/finder/codecreateCounterCode

Issue a counter code for a finished session

post/api/kiosk/exitverifyKioskPin

Check 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.

get/api/counter-codes/{code}lookupCounterCode

Look up a code at the counter

post/api/counter-codes/{code}/redeemredeemCounterCode

Mark a code redeemed

shop

Shop-level settings.

post/api/shop/activesetActiveShop

Switch 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.

patch/api/memberssetMemberRole

Make 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.

patch/api/shopupdateShopSettings

Publish 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.

post/api/shop/withdrawwithdrawShopExposure

Turn 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.

post/api/connectorscreateConnector

Connect 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.

post/api/connectors/validatevalidateConnector

Try 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.

patch/api/connectors/{id}updateConnector

Change 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.

delete/api/connectors/{id}deleteConnector

Remove 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.

post/api/connectors/{id}/syncsyncConnectorNow

Run 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.

post/api/cron/syncrunScheduledSync

Machine 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.

post/api/cron/digestsendOperatorDigest

What 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.

post/api/cron/reportreportCronOutcome

Tell 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.

post/api/inboundreceiveInboundInventoryEmail

Inventory 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.

post/api/cron/notifyrunScheduledNotices

Machine 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.

post/api/stripe/checkoutstartCheckout

Begin a $149/mo subscription

Returns a Stripe Checkout URL. No card data touches this app. Owner only.

post/api/stripe/portalopenBillingPortal

Open the Stripe billing portal

post/api/stripe/webhookstripeWebhook

Subscription 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.

get/api/analytics/gapsgetAssortmentGaps

Requests 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.

get/api/insights/worksheetgetBuyingWorksheet

The 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.

post/api/insights/worksheetrecordGapDecision

Record 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.

get/api/analytics/paperworkgetPaperwork

How 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.

get/api/exportexportShopData

Everything 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.

post/api/tagsrenderShelfTags

Printable 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.

patch/api/alerts/{id}updateAlert

Mark 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.

post/api/contactsubmitContactForm

Send 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.

post/api/cron/gap-closurerunGapClosureRollup

Roll 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.

post/api/cron/product-outcomesrunShelfOutcomeRollup

Roll 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.

get/api/public/shops/{slug}/snapshotgetShopSnapshot

Public 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.

post/api/public/handoffmintHandoff

Carry 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.

post/api/csp-reportreportCspViolation

Content-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.