# 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 & path | What it does |
|---|---|
| `GET  /healthz` | Liveness check — no auth. `{"ok":true,"app":"packora","version":"2.0.0"}` |
| `GET  /api/ping` | Verify a key |
| `GET  /api/materials` | The material reference list — use these exact names |
| `GET  /api/skus` | Every SKU with its packaging components and total weight. `?q=` · `?active=0\|1` · `?limit=` |
| `GET  /api/skus/{id}` | One SKU with its components; each carries `from_year` / `to_year` (`null` = open) and `recycled_pct`, and `total_weight_mg` is the unit weight for `weight_year`, your default reporting year; `recyclable_share_pct` and `recycled_content_pct` are by weight (3.2.0) |
| `POST /api/skus` | Create **or** update a SKU together with its material weights |
| `GET  /api/report?state=CA&year=2026` | **The compliance output** — totals by material, per-SKU detail, fee estimate, warnings |
| `GET  /api/deadlines` | Every jurisdiction with its filing deadline and how close it is |
| `GET  /api/openapi.json` | OpenAPI 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

```json
{
  "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 address** | `https://your-install/mcp` |
| **The key** | header `Authorization: Bearer apk_xxxx` |
| **The transport** | MCP over streamable HTTP, stateless |

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

```sh
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):

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

**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](https://code.visualstudio.com/docs/agents/reference/mcp-configuration)
(read 6 September 2026) puts servers in `.vscode/mcp.json` under a **`"servers"` object** — *"an
object that maps server names to their configurations"*. Pasted as-is,
a block written for another client 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:

```sh
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

| Tool | Writes? | What it does |
|---|---|---|
| `list_skus` | no | SKUs with component counts and total unit weight. `search`, `active_only`, `limit` |
| `sku_detail` | no | One SKU with every packaging component. `sku` or `sku_id` |
| `upsert_sku` | **YES** | Creates or updates a SKU and **replaces** its packaging components |
| `tonnage_report` | no | The per-material tonnage table + fee estimate for a `state` and `year` |
| `states_summary` | no | Every state with units on file, its tonnage and deadline status |
| `data_gaps` | no | The 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.
- **years travel with the rows** (3.2.0) — a component may carry `from_year` and `to_year`, the
  reporting years it counts for (omitted or `null` = open on that side). A SKU whose rows already have
  years is refused (`409 conflict`) unless each component states its years, so a sync that knows
  nothing about years cannot overwrite a filed year.

`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:

```php
$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.


## Connect it to Zapier, Make or n8n

Packora talks to other tools without a plugin: it sends a webhook (an HTTPS message) the moment something happens, and it accepts calls to the API above. Zapier, Make and n8n can use both.

### Before you start

- In Packora, open **API & Webhooks**. Mint an API key (choose **Read only** if the other tool only reads), and add a webhook when you have its URL (below).
- Use your install's full address, for example `https://packora.yourcompany.com`.

### When something happens in Packora: send it to another tool

1. In the other tool, create the trigger that gives you a URL to receive webhooks:
   - **Zapier:** the "Webhooks by Zapier" app, trigger **Catch Hook** (Webhooks by Zapier needs a paid Zapier plan).
   - **Make:** module **Webhooks → Custom webhook**.
   - **n8n:** the **Webhook** node.
2. Copy that URL into Packora under **API & Webhooks**, and choose the event or events below.
3. Make the event happen once in Packora, so the other tool receives a sample and you can map its fields.

| Event | Fires when |
|---|---|
| `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 |

Every delivery carries `X-Packora-Signature`, an HMAC-SHA256 signature over the raw body. Zapier and Make accept the delivery without checking it, so keep the webhook URL private. In n8n you can check it in a Code node before using the data.

### When something happens elsewhere: act in Packora

- **Zapier:** "Webhooks by Zapier", action **POST** (or **Custom Request**).
- **Make:** module **HTTP → Make a request**.
- **n8n:** the **HTTP Request** node.

Send the request to your install's address plus the path from the Endpoints table above, with the header `Authorization: Bearer <your API key>` and a JSON body. A read-only key cannot write.

The endpoints that change something:

- `POST /api/skus`: Create **or** update a SKU together with its material weights

### Example recipes

- **sku.created → a row in Google Sheets:** webhook trigger on `sku.created` (A new SKU was registered through the API, with its full packaging breakdown), then the Google Sheets "Create row" action with the fields you want.
- **report.generated → a message in Slack or Teams:** webhook trigger on `report.generated` (A register or detail CSV was exported for filing, with the jurisdiction, year and the totals actually exported), then the Slack or Microsoft Teams "Send message" action.
- **A new form entry or row elsewhere → Packora:** the other tool's trigger (for example a new Google Sheets row or a form submission), then an HTTP action calling `POST /api/skus` (Create **or** update a SKU together with its material weights).

These recipes use only the events and endpoints documented above. We have not run them inside Zapier, Make or n8n ourselves; Packora's own automated tests check its API.
