Changed on 2026-11-02: draft rules now apply by default
Behavior change. Until now only lawyer-reviewed (verified) rules decided answers, and draft rules were a preview. Since no rule has been reviewed yet, almost nothing was restricted. Draft rules, written from the official law text but not yet reviewed by a lawyer, now apply by default everywhere: /v1/check, /v1/check/batch, /v1/matrix, /v1/rules verdicts, purchase caps, the storefront widget, the website and the Shopify app's checkout rules. A cart or product that used to come back allow may now be block or age_verify.
- Every reason, item, matrix cell and cap carries
basis:"draft"or"reviewed". The response's ownbasisis"verified+drafts"by default. - To keep the old behavior, send
"includeDrafts": false(orincludeDrafts=falseonGET /v1/rules): reviewed rules only, with adraftspreview per item. Shopify stores can turn off "Apply draft rules at checkout" in the app. - If a rule is wrong for you, report it: the change you ask for applies to your own keys right away.
We don't guarantee the accuracy of any rule, draft or reviewed. See the terms.
Authentication
Send your key in the x-api-key header. Base URL: https://statelawcheck.com.
curl "https://statelawcheck.com/v1/rules?zip=10001&category=folding_knife&mechanism=automatic&text=0" \ -H "x-api-key: YOUR_KEY"
Limits
| Plan | Requests / month | Per second |
|---|---|---|
| Free | 100 | 1 |
| Paid | 50,000 | 20 |
Over the limit returns 429. Quotas reset monthly.
Endpoints
GET /v1/rules
Everything that applies to an address. Add a product for a verdict. On the free plan each rule's when lists only its carrier and interstate conditions (the response says "conditions": "summary"); paid plans get the full condition. Verdicts from /v1/check and /v1/matrix are complete on every plan.
zip or state · category, mechanism, bladeLengthIn, flags · originState, carriers, licenses · text=0 · includeDrafts=false
With a product, verdict.enforced is the answer (draft rules included unless includeDrafts=false), verdict.reviewedOnly the answer from reviewed rules alone, and each has a basis. Every rule says its own basis.
POST /v1/check
Verdicts for up to 50 products to one destination. Draft rules apply unless you send "includeDrafts": false; see Answers.
{
"destination": {"country": "US", "province": "NY", "zip": "10001"},
"originState": "TX",
"carriers": ["fedex"],
"items": [{"ref": "sku-1", "attributes": {"category": "folding_knife", "mechanism": "automatic", "bladeLengthIn": 3.4}}]
}
Add ?strict=true to get HTTP 422 (same body) when anything is blocked.
{
"rulesetVersion": 12,
"rulesAsOf": "2026-10-24",
"disclaimer": "Information, not legal advice. Accuracy isn't guaranteed. …",
"basis": "verified+drafts",
"decision": "age_verify",
"message": "You must be 21 or older to buy this item in New York.",
"actions": {
"ship": true,
"minimumAge": 21,
"signature": {"usps": "ADULT_SIGNATURE_RESTRICTED", "fedex": "ADULT_SIGNATURE", "ups": "ADULT_SIGNATURE",
"easypost": {"delivery_confirmation": "ADULT_SIGNATURE"}},
"avoidCarriers": [],
"notes": ["Carrier adult-signature services check that the recipient is 21 or older."]
},
"results": [{
"ref": "sku-1", "decision": "age_verify", "minimumAge": 21, "basis": "draft",
"message": "You must be 21 or older to buy this item in New York.",
"actions": { "...": "as above, for this item" },
"reasons": [{"ruleId": "…", "code": "AGE_21", "effect": "age21", "jurisdiction": "US-NY",
"summary": "…", "citation": "…", "sourceUrl": "…", "lastVerified": "", "basis": "draft"}]
}]
}
Example shape only. Top-level decision, message and actions cover the whole cart; each result has its own.
POST /v1/check/batch
Up to 100 checks, each with its own address, product, origin and carrier. Counts as one request; max 100 items. A bad item returns {"id", "error"} and the rest still answer.
{"items": [
{"id": "o-1", "zip": "30019", "category": "folding_knife", "mechanism": "automatic", "bladeLengthIn": 3.4, "origin": "TX", "carrier": "ups"},
{"id": "o-2", "state": "CA", "category": "pepper_spray", "sprayOz": 2}
]}
POST /v1/ingredients
Cosmetic ingredient restrictions by state. Each finding has the ingredient, jurisdiction, effect, effective date, timing (in_force or upcoming), how it matched, summary, citation, source URL and the full law text.
{"ingredients": "Water, Glycerin, PTFE, Formaldehyde", "state": "CA"}
POST /v1/ingredients/batch
Up to 100 ingredient lists in one request, each with its own optional state and id. Each result has the same findings as /v1/ingredients; a bad item returns {"id", "error"}.
{"items": [
{"id": "sku-1", "ingredients": "Water, Glycerin, PTFE"},
{"id": "sku-2", "ingredients": "Water, Formaldehyde", "state": "CA"}
]}
POST /v1/matrix
A restriction table for shipping platforms: up to 200 products against all 50 states and DC, plus the city jurisdictions (by ZIP prefix) where the answer differs from the state's. Same inputs and engine as /v1/check: productType and/or attributes per item, originState, carriers, licenses, platforms, storeRules, includeDrafts. Counts as one request. See Integrations for ShipperHQ and Zonos.
{"originState": "TX", "carriers": ["ups"], "items": [{"ref": "sku-1", "attributes": {"category": "folding_knife", "mechanism": "automatic", "bladeLengthIn": 3.4}}]}
{
"states": ["AK", "AL", "…"],
"cities": [{"id": "US-NY-NYC", "name": "New York City", "state": "NY", "zipPrefixes": ["100", "…"]}],
"results": [{
"ref": "sku-1",
"cells": {
"TX": {"decision": "allow", "minimumAge": null, "ship": true, "codes": [], "coverage": "restricted", "basis": "reviewed"},
"NY": {"decision": "block", "minimumAge": null, "ship": false, "codes": ["BLOCKED_AUTOMATIC_KNIFE", "BLOCKED_INTERSTATE"], "coverage": "restricted", "basis": "draft"}
},
"cities": [{"id": "…", "name": "…", "state": "…", "zipPrefixes": ["…"], "decision": "block", "minimumAge": null, "ship": false, "codes": ["…"], "coverage": "…"}]
}]
}
Example shape only. As in /v1/check, a block caused only by a carrier rule has ship: true and the carrier in avoidCarriers; send carriers for an exact answer. A cell's licensesNeeded lists license ids that would lift a LICENSE_REQUIRED block. coverage of not_checked means "allow" there is not clearance. Each cell has a basis, and adjustedBy with original when your own rule feedback changed it. Add ?format=csv for one row per product and jurisdiction:
ref,jurisdiction,name,level,state,zip_prefixes,decision,minimum_age,ship,avoid_carriers,licenses_needed,codes,coverage,error sku-1,US-NY,New York,state,NY,,block,,false,,,BLOCKED_AUTOMATIC_KNIFE BLOCKED_INTERSTATE,restricted,,draft, sku-1,US-TX,Texas,state,TX,,allow,,true,,,,restricted,,reviewed,
GET /v1/laws · GET /v1/laws/{id}
The law library: exact statute text, official source and date copied.
POST /v1/classify
Product attributes from a listing title and description.
Test mode
Create a test key on your account page (lts_test_…). Test keys get 1,000 requests a month of their own and see fictional data only: states ZZ ("Testland") and YY ("Otherland"), with a blocked automatic knife, an 18+ pepper spray, a license-gated wine rule (capped at 2 nine-liter cases a month, for purchase caps), a future-dated vape rule and a USPS-only ammo rule, so every response field can be exercised. Responses carry "mode": "test" and the header Legal-To-Ship-Mode: test. Every response also has X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (approximate; usage data lags a little).
GET /v1/product-types · GET /v1/flags
Product types you can send as productType instead of raw attributes (e.g. wine_producer, vape_flavored, handgun_ammo), and the flag vocabulary behind them.
GET /v1/coverage?topic=&state=
For each topic and state: restricted, no_restriction_found or not_checked. Check results also carry coverage and a warning when a state wasn't checked, so "allow" there never reads as clearance.
GET /v1/changes?since=
Rule changes in every version published after the one you last saw: added, removed, and changes to outcome, products covered, law text or start date.
GET /v1/checks
Your decision log. Every /v1/check and /v1/check/batch response carries a checkId; the log keeps the inputs, ruleset version, reason codes and citations for each one for 90 days, newest first. Page with ?limit= (max 100) and ?before= (the next value).
GET /v1/checks/export?from=&to=&format=
Your decision log between two dates (YYYY-MM-DD, UTC, inclusive), oldest first, as json (default) or csv. CSV has one row per item with columns at, checkId, endpoint, ref, decision, codes, ruleIds, citations, rulesetVersion, request, adjustedBy, originalDecision (the last two only when your own rule feedback changed the answer: the log keeps the original); lists are joined with ; . At most 10,000 checks per call: when an export is cut off, JSON has a next value and CSV a Legal-To-Ship-Export-Next header; pass it as ?after= for the rest.
GET /v1/ruleset
Current ruleset version.
Answers
allow, block or age_verify (with minimumAge). Every rule cites its statute and source URL and says its basis: "reviewed" (checked by a lawyer) or "draft" (written from the official law text, not yet reviewed). Each result's basis is "draft" when any rule behind it is. Accuracy isn't guaranteed either way.
Each reason has a stable code such as BLOCKED_AUTOMATIC_KNIFE, BLOCKED_SPRAY_SIZE, BLOCKED_INTERSTATE, CARRIER_RESTRICTED or AGE_21 (full list in the OpenAPI spec). message is one plain sentence to show a shopper, or null when allowed.
actions says what to do: ship, minimumAge, adult-signature options per carrier, avoidCarriers and notes. Carrier adult signature checks 21+, so 18+ and 19+ rules also need age verification at checkout; USPS adult signature is only suggested for 21+. A block caused only by a carrier rule (e.g. USPS) comes back with ship: true and that carrier in avoidCarriers; send carriers to get an exact decision.
Each result also has upcoming (rules that start later, with effectiveDate), alsoCheck (rules that apply only if the product has a property you didn't send, with ifFlags, e.g. not on a state's vape directory), and, with "includeDrafts": false, drafts: what the draft rules would decide. Draft rules decide by default (response basis "verified+drafts"); "includeDrafts": false uses reviewed rules only ("verified"). A result your own rule feedback changed has adjustedBy ("your feedback fb_…") and original (the answer without it); so does each reason it changed.
Send platforms (e.g. ["shopify_payments", "walmart"]) to add the prohibited and restricted-product policies of the marketplaces and payment processors you use: Shopify, Shopify Payments, Stripe, PayPal, Square, Walmart Marketplace and eBay (partial). Those reasons carry code PLATFORM_POLICY; "allowed with approval" policies come back as licenses you can declare once approved.
Some sales are legal only with a license: a state shipper permit you hold, or a buyer credential you collect before shipping (e.g. a copy of an Illinois FOID card for ammunition). Those come back blocked with code LICENSE_REQUIRED and the license in actions.licensesNeeded. Send its id in licenses (e.g. ["il-foid"]) once you have it, and the rule no longer blocks.
Errors: {"error": "message"} with status 400, 403, 404, 422 (strict mode), 429 or 500.
Store policy rules
Add your own restrictions on top of the law with storeRules in the body of /v1/check or /v1/check/batch (up to 50, applied to that request only). Each has an id, a label, a product condition when (categories, flagsAny, flagsAll, mechanisms, blade or spray size; {} matches everything), where it applies (states: "all" or a list, and/or zipPrefixes), and an effect: block, age16, age18, age19 or age21.
"storeRules": [
{ "id": "no-stun-ny", "label": "We don't ship stun guns to NY or NJ",
"when": { "categories": ["stun_gun"] }, "states": ["NY", "NJ"], "effect": "block" },
{ "id": "spray-21", "label": "21+ for pepper spray",
"when": { "categories": ["pepper_spray"] }, "states": "all", "effect": "age21" },
{ "id": "no-995", "label": "Nothing to ZIP 995", "when": {}, "zipPrefixes": ["995"], "effect": "block" }
]
Matches come back like any rule, with code STORE_POLICY, citation Store policy and your label as the summary. In the Shopify app, set them under Store policy.
Purchase caps
Some laws limit how much one buyer may buy across orders: "12 nine-liter cases of wine per resident per year", "2 cases per month", "no more than two spray devices per sale". A cap never blocks shipping by itself (use /v1/check for that); it limits quantity. Record each completed sale, and before the next one ask how much the buyer has left.
POST /v1/purchases/check
{"buyer": "cust_8812",
"destination": {"country": "US", "province": "NY", "zip": "10001"},
"items": [{"ref": "sku-9", "productType": "wine_producer", "quantity": 2, "unit": "cases_9l"}]}
{
"ok": true, "priorOrders": 3, "basis": "verified+drafts",
"results": [{"ref": "sku-9", "ok": true, "basis": "draft", "caps": [{
"ruleId": "ny-alcohol-wine-producer-permit", "capIndex": 0, "ok": true, "code": null,
"remaining": 30, "used": 6, "requested": 2,
"cap": {"quantity": 36, "unit": "cases_9l", "period": "year", "per": "buyer"},
"windowStart": "2026-01-01T00:00:00.000Z", "citation": "N.Y. Alco. Bev. Cont. Law 79-c", "…": "…"
}]}]
}
Example shape only. remaining is what the buyer may still buy in the window, in the cap's unit; an item that doesn't fit has ok: false and code OVER_PURCHASE_CAP. Items in one request count as one order.
POST /v1/purchases · DELETE /v1/purchases/{orderId}
Record a completed sale with the same body plus orderId (letters, digits, . _ : -) and, for a past sale, soldAt (ISO time, within 400 days). The response is the caps as of that sale; an over-cap sale is still recorded. Recording an orderId twice returns 409, so a retried call is safe. On a refund or cancellation, DELETE /v1/purchases/{orderId} takes it back out. Records are kept 400 days.
- buyer: your own customer id, or a sha256 hex of the trimmed, lowercased email (
hashBuyerEmail()in the Node SDK,hash_buyer_email()in Python). Never send the email: values that look like one are rejected. We hashbuyeragain with a random salt per account (or workspace) before storing anything, and keep the delivery address only as a salted hash. - unit:
items,bottles,cases_9l,liters,gallons,fl_oz(fluid ounces),gramsorounces(weight). Volumes convert to each other (a case is 9 liters), as do weights and counts. A unit that can't convert to the cap's (bottles against a liters cap) comes backok: falsewith codeCAP_UNIT_MISMATCH: send the volume. - measures (optional): the same line in other dimensions, for laws that cap the count and the volume together (West Virginia spirits: 2 bottles and 3 liters a month).
{"quantity": 2, "unit": "bottles", "measures": [{"quantity": 1.5, "unit": "liters"}]}counts 2 against the bottle cap and 1.5 L against the liter cap. Each cap usesquantity+unitwhen it converts, else the first measure that does;CAP_UNIT_MISMATCHonly when none does (a sale recorded without the measure makes that cap fail closed too). At most two measures, one unit per dimension (count, volume, weight). Recorded with the sale. - Periods:
order,day,month,quarteroryear. Calendar periods are UTC; caps worded "any 12-month period" use a rolling window ("window": "rolling"; forday, the preceding 24 hours)."months": 2with periodmonthis a two-month window ("any two-month period"). - Several caps: a rule may carry more than one (Texas: 9 gallons a calendar month and 36 gallons in any 12 months); every one must pass, and each result names its
ruleIdandcapIndex. A cap withappliesTo(productTypes,flagsAny,flagsNone) covers only those products of its rule, such as wine on a wine-and-beer rule, or wine but not cider (Oregon caps cider with beer); other products neither get it nor count toward it. Hard cider carries thewineandciderflags (product typescider_producer,cider_retailer). SendproductType(or the flags) for each item so these match. - Per address: household caps (
"per": "buyer_address") count this buyer's purchases to the same address. Senddestination.line1to tell addresses apart; without it, every purchase to the ZIP counts. - Caps count sales recorded with any live key on the same account, or the same client workspace, so rotating a key keeps the history. Test keys have a history of their own. Caps on draft rules apply unless you send
"includeDrafts": false; each cap says itsbasis.
Saved catalog
Save your products once and every law-change alert and webhook names the ones a change affects: “3 of your SKUs can no longer ship to Minnesota.” One catalog per account and per client workspace; every key of the account (or workspace) sees the same one. You can also upload a CSV on your account page.
PUT /v1/catalog
{"items": [
{"sku": "OTF-39", "title": "OTF auto 3.9in", "productType": "folding_knife", "attributes": {"mechanism": "otf", "bladeLengthIn": 3.9}},
{"sku": "PS-05", "productType": "pepper_spray", "attributes": {"sprayOz": 0.5}},
{"sku": "LP-1", "attributes": {"category": "other", "mechanism": "none"}, "flags": ["laser_pointer"]}
],
"originState": "TX", "carriers": ["ups", "fedex"]}
Replaces the whole catalog: up to 5,000 items and 5 MB. Each item needs a sku (up to 128 characters, unique) and a productType or attributes, checked exactly as in /v1/check; flags add to the attributes' flags. title is optional (up to 300 characters). A bad item fails the whole request with a 400 naming its index and SKU, and the saved catalog stays as it was. Optional originState, carriers and licenses are the shipping context the catalog is checked with (as in /v1/check; omitted means unknown). We keep product facts only: other fields are dropped, never stored. Response: {"count", "updatedAt", "replaced": true, …context}.
GET /v1/catalog?offset=&limit= · DELETE /v1/catalog
GET pages through the saved items (limit up to 1,000, default 500): {"count", "updatedAt", "offset", "limit", "items", "next", "lastImpact"}; pass next as the next offset until it is null. lastImpact summarizes what the latest ruleset did to the catalog (version, skus, total, draftTotal, lines), or is null. DELETE removes the catalog and its impact report ({"deleted": true}, or false if there was none). Each call counts as one request. Test keys have a catalog of their own that alerts never read. Keys created before saved catalogs existed get 403: rotate the key on your account page.
What the alerts say
After each ruleset publish we compare the old and new rules for every saved product, in every state (and in each city whose rule changed, at a ZIP inside it), with your shipping context. A product is listed when its answer changes: blocked (can no longer ship), license (now needs a license or permit), carrier_only (now restricted with some carriers), age (new or higher age check), upcoming (a rule that starts on a later date) or eased. Draft rules count like reviewed ones; a line that rests on a draft rule says “(draft rule; not yet reviewed)”, and the webhook entry has "basis": "draft" (else "reviewed"). If your alerts are limited to some states, only those states are listed. The email lists up to 20 groups and links here for the rest; accounts without a saved catalog get the same email as before. Changes you made with rule feedback count too, as they do in your checks.
Report a problem with a rule
If a rule is wrong for you (it doesn't apply to what you sell, the restriction should be an age check rather than a ban, or the law changed), report it. The change you ask for applies right away to your own account (or client workspace, or Shopify store), and we review the report. If we agree, we fix the rule for everyone in a later ruleset; your own change is then marked superseded and stops applying, since the fix covers it.
POST /v1/feedback
{"ruleId": "ny-switchblade-block",
"kind": "wrong_effect",
"change": {"type": "effect", "effect": "age21"},
"note": "We only sell to licensed collectors.",
"contact": "compliance@yourstore.example"}
- ruleId: a
reasons[].ruleIdfrom a check (or a rule fromGET /v1/rules). Your own store rules can't be reported; change them instead. - kind:
doesnt_apply,wrong_effect,outdatedorother. - change (optional; without it the report is for review only):
{"type": "disable"}turns the rule off for you;{"type": "effect", "effect": "age21"}uses another restriction (block,age16,age18,age19,age21);{"type": "only_for", "productTypes": ["fixed_blade"], "flags": ["bowie"]}keeps the rule only for those product types or flags. A newer report on the same rule replaces your earlier change. - note: up to 1,000 characters on what's wrong. No customer data: notes with email addresses are refused. contact (optional): how to reach you.
Returns 201 with the report: id (fb_…), status (pending, approved, rejected or superseded), applied and adjustedBy. From then on your checks, batches, matrix, rules and purchase caps use the change: those answers carry adjustedBy: "your feedback fb_…" and original, and your decision log keeps the original verdict. Other accounts are never affected. Up to 20 reports a day and 100 changes in force.
GET /v1/feedback · DELETE /v1/feedback/{id}
List your reports and where they stand, newest first, with our review note once decided. DELETE undoes the change at once (the report stays in review, marked undone). A rejected report keeps applying to you until you undo it: we didn't change the rule for everyone, but the choice for your store is yours.
On the website, the same is on your account page (per account and per client workspace), and every lookup and catalog answer has "Report a problem", which sends a review-only report and changes nothing by itself. In the Shopify app, open "Why" under a product and choose "This is wrong for my store".
Test keys' reports stay with test data: they're never reviewed and never change live answers.
Webhooks
Add up to 3 https endpoints on your account page to hear about law changes as soon as a new ruleset is published. They get the same changes as alert emails: the products and states in your alert settings, or every change if you haven't set any. Each endpoint has its own signing secret (whsec_…), shown once when you add it.
POST https://your-endpoint
Legal-To-Ship-Signature: t=1793000000,v1=5f0c…
Legal-To-Ship-Event-Id: evt_…
{
"id": "evt_…",
"type": "ruleset.changed",
"version": 19,
"from": 18,
"publishedAt": "2026-10-25T14:00:00.000Z",
"changes": [{"ruleId": "…", "jurisdiction": "US-CA", "topic": "alcohol", "citation": "…", "kinds": ["added"],
"after": {"effect": "license", "summary": "…"}}],
"changesUrl": "https://statelawcheck.com/v1/changes?since=18",
"affected": [{"sku": "OTF-39", "jurisdiction": "US-MN", "change": "blocked", "ruleId": "…", "basis": "reviewed",
"before": {"decision": "allow", "minimumAge": null},
"after": {"decision": "block", "minimumAge": null, "blockedBy": "law"}}],
"affectedTotal": 1,
"affectedTruncated": false
}
With a saved catalog, affected lists each SKU and jurisdiction (a state, or a city such as US-CA-SF) whose answer changed, at most 1,000 per event (affectedTruncated says when there were more; GET /v1/catalog has the summary). after.blockedBy is law, license or carrier (with avoidCarriers); upcoming entries carry effectiveDate. Without a catalog these fields are absent. An event is sent when a change matches your alert settings or touches one of your products.
Answer with any 2xx within 10 seconds. Timeouts, network errors, 429 and 5xx are retried twice more (after about 1 and 4 seconds) with the same event id, so use it to drop duplicates. The account page shows each endpoint's last delivery, and Send test posts a signed event with "test": true about the fictional state ZZ.
The signature is v1 = hex HMAC-SHA256 of <t>.<raw body>, keyed with the whole secret. Check it against the raw body (before any JSON parsing) and reject old timestamps:
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return parts.v1?.length === expected.length && timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
}
// Express: app.post("/lts", express.raw({ type: "application/json" }), (req, res) => {
// if (!verify(req.body.toString(), req.get("Legal-To-Ship-Signature") ?? "", process.env.LTS_WEBHOOK_SECRET)) return res.sendStatus(400);
// const event = JSON.parse(req.body); …; res.sendStatus(204); });
Or use the SDKs: await verifyWebhook(rawBody, header, secret) in Node, verify_webhook(raw_body, header, secret) in Python.
SDKs and tools
Coming to npm and PyPI soon. Each one sends the version header and retries 429 and 5xx with backoff.
- Node:
@statelawcheck/sdk. No dependencies, typed responses, typed errors.check,checkStrict,checkBatch,rules,laws,law,ingredients,classify,exportChecks, andverifyWebhook. - Node:
@statelawcheck/sdk. No dependencies, typed responses, typed errors.check,checkStrict,checkBatch,matrix,matrixCsv,rules,laws,law,ingredients,ingredientsBatch,classify,catalogPut,catalogGet,catalogDelete. - Python:
statelawcheck. Standard library only, Python 3.9+, same methods in snake_case. - MCP server:
@statelawcheck/mcp. Lets Claude and other AI assistants check shipping, ingredients and laws, with citations. - EasyPost:
@statelawcheck/easypost. A check to run before buying a label. It blocks shipments the law forbids and returns thedelivery_confirmationoption when adult signature is needed.
import { StateLawCheck } from "@statelawcheck/sdk";
const lts = new StateLawCheck({ apiKey: process.env.STATELAWCHECK_API_KEY });
const res = await lts.check({
destination: { province: "NY", zip: "10001" },
items: [{ attributes: { category: "folding_knife", mechanism: "automatic" } }],
});
console.log(res.decision, res.message);
Storefront widget
A small “Can I ship this here?” box for your product pages: the shopper types a ZIP code and sees whether the item can ship there, any minimum age, and the laws behind the answer. No backend work needed.
- On your account page, create a widget key (
pk_live_…) and list the exact hostnames your store runs on, e.g.shop.example.comandwww.example.com(up to 10; no wildcards). - Paste the embed code into your product page template:
<div data-lts-widget data-key="pk_live_…" data-product-type="folding_knife"
data-blade="3.5" data-mechanism="assisted"></div>
<script src="https://statelawcheck.com/widget.js" async></script>
| Attribute | Meaning |
|---|---|
data-key | Your widget key. Required. |
data-product-type | A product type id from GET /v1/product-types, e.g. folding_knife, fixed_blade, balisong, pepper_spray, stun_gun. Required. |
data-blade | Blade length in inches. |
data-mechanism | manual, assisted, automatic, otf, gravity, fixed or none. |
data-spray-oz, data-edge, data-style, data-flags | Optional, as in /v1/check attributes; flags are comma separated ids from GET /v1/flags. |
data-label | Your own label for the ZIP field. |
Answers are ok, age (with the minimum age), license, blocked or not_covered (we haven't checked that state's law on this product yet), with a short message, citations linking to the law, and basis. Draft rules apply, as everywhere; when one decided the answer the widget adds "not yet reviewed by a lawyer". The widget never shows rule conditions or law text.
Domains. The widget calls https://statelawcheck.com/widget/check, which answers only when the browser's Origin is one of the key's domains (https only; localhost may also use http for testing). Widget keys aren't secret; the domain list is what stops other sites from using yours.
Your CSP. If your store sends a Content-Security-Policy, allow script-src https://statelawcheck.com and connect-src https://statelawcheck.com. The widget uses no cookies, storage, inline scripts, inline styles or eval, and renders in a Shadow DOM, so your theme's CSS doesn't affect it. Pages that add products after load can call StateLawCheck.scan().
Limits. Per key: 600 checks a minute and 20,000 a day. Per shopper IP: 20 a minute. Widget checks don't use your API quota. Over a limit, the widget says to try again later.
Calling the endpoint directly: POST /widget/check?pk=pk_live_… with JSON {"pk", "zip" or "state", "productType", "attributes"} from an allowed origin; the response carries Access-Control-Allow-Origin for that origin and Vary: Origin.
Agencies and platforms
If you run checks for several merchants (an agency, a 3PL, a marketplace or an ecommerce platform), give each one a client workspace on your account page. You can have up to 25.
- Own keys. Each workspace has its own live and test keys (up to 5 live and 2 test, as for an account). Rotate or revoke one client's keys without touching anyone else's.
- Own decision log.
GET /v1/checksand/v1/checks/exportreturn only the checks made with the key you call them with, so a workspace's keys only ever see that client's checks. - Own alerts and webhooks. Each workspace can set its own law-change topics and states and its own webhook endpoints (
/api/workspaces/{id}/alertsand/api/workspaces/{id}/webhooks, same shapes as the account's). Alert emails go to you, with the workspace's name in the subject. - One bill. Workspace keys use your account's plan: upgrade once and every workspace's live keys move to the paid plan; each key keeps its own monthly quota. Test keys stay on the sandbox plan.
- Own catalog. Each workspace can save its client's products (
PUT /v1/catalogwith the workspace's key, or/api/workspaces/{id}/saved-catalog), so its alerts and webhooks name that client's affected SKUs. - Archiving a workspace revokes all its keys at once, stops its alerts and webhooks and deletes its saved catalog. It can't be undone.
Only you can manage your workspaces; clients don't get a sign-in. Hand each client its key, or keep the keys on your side and call the API for them.
Versioning
Every response has a Legal-To-Ship-Version: 2026-10-24 header. You may send the same header to pin a version; unknown values are ignored for now.
/v1 only gets additive changes. Anything breaking ships as /v2, with at least 6 months' notice. Rule data changes with each ruleset, and on 2026-11-02 draft rules started to apply by default (see above; "includeDrafts": false keeps the earlier behavior).
Legacy names
State Law Check was called Legal to Ship (legaltoship.com) until November 2026. Some identifiers keep the old name so existing integrations don't break:
- Headers
Legal-To-Ship-Version,Legal-To-Ship-Mode,Legal-To-Ship-Export-Next,Legal-To-Ship-SignatureandLegal-To-Ship-Event-Id. - Key prefixes
lts_live_,lts_test_andpk_live_, and the account page'slts_sessioncookie. - The
legaltoship-store-rulesformat name in store rule files, and the widget'sLegalToShip.scan()global (same asStateLawCheck.scan()).
API, widget and Shopify paths on https://legaltoship.com still answer during the transition; use https://statelawcheck.com for anything new.
Information, not legal advice.