Ownware
Home›Packora›API
Packora · API

Packora API, as shipped in the download

Packora REST API & Webhooks (v2.0)

Packora ships a JSON REST API and signed webhooks so your packaging register can be fed by whatever already knows your bill of materials — a PLM system, an ERP export, a spreadsheet pipeline, Power Automate, Zapier, n8n, or your own code.

The important part: **an EPR return is a legal declaration, so the API does not do its own arithmetic.** Every tonnage and every fee estimate comes from the same milligram maths the register screen renders and the CSV exports write. The number you file and the number on your screen cannot diverge, because there is only one calculation.

Authentication

Create a key in API & Webhooks. A key belongs to a user and can do what a signed-in user can do. Send it on every call:

Authorization: Bearer apk_...

The key is shown once at creation and stored as a SHA-256 hash. Revoke any time.

Weights are integer milligrams internally. You may send grams (weight_g, decimals fine) or milligrams (weight_mg); both come back as both. Fees are integer cents, and rates are cents per kilogram.

Endpoints

Method & pathWhat it does
GET /healthzLiveness check — no auth. {"ok":true,"app":"packora","version":"2.0.0"}
GET /api/pingVerify a key
GET /api/materialsThe material reference list — use these exact names
GET /api/skusEvery SKU with its packaging components and total weight. ?q= · `?active=0\1 · ?limit=`
GET /api/skus/{id}One SKU with its components
POST /api/skusCreate or update a SKU together with its material weights
GET /api/report?state=CA&year=2026The compliance output — totals by material, per-SKU detail, fee estimate, warnings
GET /api/deadlinesEvery jurisdiction with its filing deadline and how close it is
GET /api/openapi.jsonOpenAPI 3 spec — no auth

Register a SKU with its packaging

curl -X POST https://your-install/api/skus \
  -H "Authorization: Bearer apk_..." -H "Content-Type: application/json" \
  -d '{"sku": "SKU-001", "name": "Bottle 500ml", "category": "Beverage",
       "components": [
         {"name": "Bottle", "material": "PET (rigid)", "weight_g": 18.4, "recyclable": true},
         {"name": "Label",  "material": "Paper",       "weight_g": 0.9}
       ]}'

Materials are referenced by name and must already exist. An unknown one is refused:

422  {"error": "unprocessable", "detail": "Unknown material \"Unobtainium\" — add it first."}

That refusal is deliberate. Silently creating a material would put a weight into your register under a category the regulator never sees, and the filing would be wrong in a way nobody notices. Add materials on the Materials page first, or read GET /api/materials.

Posting an existing sku updates it and replaces its component list wholesale, so the call is idempotent — re-running your sync does not accumulate duplicate components. sku.created fires only the first time.

Every material is resolved before anything is written, so a bad component cannot leave a half-built SKU behind.

The compliance output

GET /api/report?state=CA&year=2026
{
  "jurisdiction": {"code": "CA", "name": "California", "deadline_date": "2027-01-03"},
  "year": 2026,
  "deadline": {"date": "2027-01-03", "status": "ok", "days": 150},
  "totals": {
    "total_mg": 31798140800, "total_kg": 31798.1408,
    "estimated_fee_cents": 639605, "materials_without_a_rate": 0
  },
  "materials": [
    {"material": "Corrugated cardboard", "total_mg": 22119555000, "total_kg": 22119.555,
     "recyclable_mg": 22119555000, "rate_cents_per_kg": 12, "fee_cents": 265435}
  ],
  "by_sku": [ {"sku": "SKU-001", "component": "Bottle", "material": "PET (rigid)",
               "units": 12000, "weight_mg": 18400, "line_mg": 220800000} ],
  "warnings": {"skus_without_components": 1, "unmatched_sales_rows": 0}
}

The material totals sum to total_mg, and so do the by_sku line totals — the same identity the register screen relies on. warnings is the part worth wiring an alert to: skus_without_components are SKUs you are selling but not declaring, and unmatched_sales_rows are sales whose SKU code is not in your list. Both mean your filing is understated.

materials_without_a_rate counts materials with tonnage but no fee rate for that jurisdiction — the estimate excludes them rather than guessing.

Deadlines

GET /api/deadlines

Returns every jurisdiction with status of overdue, soon (within 60 days), ok, or none (no deadline set), plus days_until. This is the endpoint to poll from a real scheduler if you have one.

MCP — the agent surface (new in 3.0)

Packora speaks MCP (Model Context Protocol) at POST /mcp, so an AI assistant can read your register and keep it up to date without anyone writing an integration. Same server, same data, same rules — an agent is just another client.

Authentication is the API key you already have. Send it exactly as you would to /api/*:

Authorization: Bearer <your api key>

The transport is stateless streamable-HTTP: one JSON-RPC request per POST, no session to keep alive. GET /mcp answers 405 with Allow: POST so a misconfigured client is told what to do rather than left guessing.

Connect it to an assistant

Mint the key in the app first: Settings → API keys. Choose Read only when the assistant should answer questions but never change anything — the endpoint then lists only the read tools and refuses the rest by name, so a careless prompt cannot write. Full access behaves as before.

Every client needs the same three facts, and nothing in the handshake is vendor-specific:

The addresshttps://your-install/mcp
The keyheader Authorization: Bearer apk_xxxx
The transportMCP over streamable HTTP, stateless

Claude — one command, or the same URL and header as a custom connector in the desktop and web apps:

claude mcp add --transport http packora https://your-install/mcp \
  --header "Authorization: Bearer apk_xxxx"

ChatGPT and the OpenAI API — one entry in the Responses API's tools array (in ChatGPT itself, the same URL and key go in as a connector):

{
  "type": "mcp",
  "server_label": "packora",
  "server_url": "https://your-install/mcp",
  "authorization": "apk_xxxx",
  "require_approval": "never"
}

Own Your AI reads a list of servers in this shape:

{
  "mcpServers": [
    { "id": "packora", "name": "Packora", "url": "https://your-install/mcp",
      "token": "apk_xxxx", "enabled": true }
  ]
}

**Every other client spells the same three facts differently — copy the shape from its own documentation, not from here.** VS Code is the clearest example of why: its configuration reference (read 6 September 2026) puts servers in .vscode/mcp.json under a "servers" object — *"an object that maps server names to their configurations"* — not an mcpServers array. Pasted as-is, the block above will not load there. The id, the URL and the token are what travel; the JSON around them belongs to whichever client you are configuring.

A local model, n8n, or your own code — n8n's MCP Client node takes the URL and the same Authorization: Bearer header; a model running on your own machine reaches it through any MCP client, so nothing leaves your network at all. Writing it yourself is one POST of JSON-RPC 2.0:

curl -X POST https://your-install/mcp \
  -H "Authorization: Bearer apk_xxxx" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

Any client that speaks MCP's streamable-HTTP transport works, including ones that do not exist yet: Packora implements the protocol, not an integration with a particular vendor.

Tools

ToolWrites?What it does
list_skusnoSKUs with component counts and total unit weight. search, active_only, limit
sku_detailnoOne SKU with every packaging component. sku or sku_id
upsert_skuYESCreates or updates a SKU and replaces its packaging components
tonnage_reportnoThe per-material tonnage table + fee estimate for a state and year
states_summarynoEvery state with units on file, its tonnage and deadline status
data_gapsnoThe filing-readiness check: what is missing, by name

Every description in tools/list states plainly whether the tool writes, because that is what an agent needs in order to decide whether it is allowed to call it.

The rules do not relax for agents

upsert_sku goes through packora_sku_upsert() — the same function POST /api/skus calls. So:

  • an unknown material is refused, never created:
  {"content":[{"type":"text","text":"Unknown material \"Unobtainium\" — add it first."}],
   "isError":true}
  • every material resolves before anything is written, so a bad component cannot leave a half-built SKU behind.
  • components are declared wholesale — sending components replaces the SKU's list, which makes a repeated sync idempotent instead of cumulative.

tonnage_report and data_gaps call the same register functions the screen and the CSV exports call. The API cannot disagree with the page about what you are filing.

Argument hygiene mirrors the REST API: every argument must be a single value unless the tool declares otherwise (upsert_sku allows components to be a list). An unknown argument is refused by name — an agent that typos serach is told, not silently half-obeyed.

Domain refusals come back as tool errors (isError: true) rather than protocol errors, because those are the ones a model can read and correct. Protocol mistakes — bad JSON, an unknown tool — are JSON-RPC errors.

Quick check

curl -s -X POST http://your-install/mcp \
  -H "Authorization: Bearer <key>" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

Calendar feed (new in 3.0)

GET /deadlines.ics?t=<token> is a read-only iCal feed of every active state's filing deadline. Mint the token in Settings; only its hash is stored, exactly like an API key. Subscribe to the URL in Outlook, Google Calendar or Apple Calendar and your compliance dates live where your other dates live.

Webhooks

Add receiver URLs in API & Webhooks. Events:

  • sku.created — a new SKU was registered through the API, with its full packaging breakdown
  • report.generated — a register or detail CSV was exported for filing, with the jurisdiction, year and the totals actually exported

Each delivery is an HTTP POST with a JSON body and:

X-Packora-Event: report.generated
X-Packora-Signature: sha256=<hex HMAC-SHA256 of the RAW body, keyed with the endpoint's secret>

Verify the signature over the raw body before trusting a payload:

$raw = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $raw, $YOUR_SECRET);
if (!hash_equals($expected, $_SERVER['HTTP_X_PACKORA_SIGNATURE'] ?? '')) { http_response_code(401); exit; }

One attempt, 5-second timeout; the Recent deliveries log shows every attempt and response code. Design receivers to be idempotent.

Bulk data entry (the real pain)

The API is one way in; the Import CSV page is the other, and for a first load it is usually faster. 2.0 adds a packaging weights importer — sku, material, grams (plus optional component and recyclable) — which is the bulk route for the numbers the whole register rests on. Every importer now runs a dry run first: you see exactly which rows would land and why each of the rest would not, and nothing is written until you confirm.

Limits, honestly

  • Packora has one user type, so a key is not scoped more narrowly than a login.
  • One delivery attempt per webhook event (log + idempotent receivers, not a retry queue).
  • There is no POST /api/sales: annual units-sold data is bulk by nature and belongs in the CSV importer, where the dry run shows you what a mistake would do before it happens.
  • Materials, jurisdictions and fee rates are managed on their own screens — they change rarely and each one changes what you owe.
  • GET /api/skus caps at 1,000 rows.
  • Fee figures are estimates from the rates you entered. Packora does not know your jurisdiction's current schedule; you do.

← Back to Packora · Manual · Quickstart · Test run

Affiliate program
Recommend tools people own — earn 35% on every sale. 90-day tracking, instant delivery, payouts by Lemon Squeezy.
Become an affiliate →