State Law Check

API

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.

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

PlanRequests / monthPer second
Free1001
Paid50,00020

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.

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"}

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.

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.

  1. On your account page, create a widget key (pk_live_…) and list the exact hostnames your store runs on, e.g. shop.example.com and www.example.com (up to 10; no wildcards).
  2. 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>
AttributeMeaning
data-keyYour widget key. Required.
data-product-typeA product type id from GET /v1/product-types, e.g. folding_knife, fixed_blade, balisong, pepper_spray, stun_gun. Required.
data-bladeBlade length in inches.
data-mechanismmanual, assisted, automatic, otf, gravity, fixed or none.
data-spray-oz, data-edge, data-style, data-flagsOptional, as in /v1/check attributes; flags are comma separated ids from GET /v1/flags.
data-labelYour 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.

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:

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.