# RegCheck360 Public API — v1 Spec

> Status: **draft** · Last updated: 2026-05-23 · Owner: Moe Ahmad
>
> This document is the contract between RegCheck360 and external integrators
> (Katana MRP, BatchMaster SAP B1 add-on, Zapier/Make recipes, embedded
> widgets, customer-built tooling). Anything not in this document is implementation
> detail and may change without notice.

---

## 1. Goals

1. **Stable** — `/api/v1/*` paths and response shapes don't break without a 6-month
   deprecation window per [RFC 8594](https://datatracker.ietf.org/doc/html/rfc8594).
2. **ERP-friendly** — idempotent POSTs, predictable error envelope, request IDs,
   bulk endpoints, async webhooks. Built for systems that retry on timeout.
3. **Discoverable** — OpenAPI 3.1 spec auto-generates SDKs and Zapier/Make apps.
4. **Multi-market in one call** — every endpoint accepts `region: "AU" | "BR" | "CA" | "NZ"`.

---

## 2. Base URL & versioning

- **Production:** `https://regcheck360.com/api/v1`
- **Path-versioned** — additive changes stay in `v1`; breaking changes ship as `v2`
- **Deprecation policy** — when an endpoint is sunset, responses carry
  `Deprecation: true` and `Sunset: <RFC 1123 date>` headers for ≥ 6 months
- **Changelog:** [`docs/api-changelog.md`](./api-changelog.md) (to be created
  alongside the first published release)

---

## 3. Authentication

Two equivalent forms — pick whichever the client library makes easier:

```
X-API-Key: rc360_<48 hex chars>
Authorization: Bearer rc360_<48 hex chars>
```

- Keys are issued at `https://regcheck360.com/account/api-keys`
- Format: `rc360_` + 24 random bytes hex-encoded (48 chars)
- Stored hashed (sha256) — full key shown ONCE at creation
- Revoke any time; revocation is immediate
- Per-key origin allowlist (optional, future) for browser-side widgets

Existing implementation: [`lib/api-key-auth.ts`](../lib/api-key-auth.ts),
[`app/api/keys/route.ts`](../app/api/keys/route.ts).

### 3.1 Scopes

Scopes are an array on each key. Calls with insufficient scope return `403
insufficient_scope`.

| Scope | Grants |
|---|---|
| `check:read` | `POST /v1/check`, `POST /v1/check/batch`, `GET /v1/ingredient/{slug}` |
| `formula:read` | `POST /v1/formula` |
| `pathway:read` | `POST /v1/pathway` |
| `scan:read` | `POST /v1/scan` |
| `webhook:write` | `POST/GET/DELETE /v1/webhooks` |
| `usage:read` | `GET /v1/usage`, `GET /v1/whoami` |

**Backward compatibility:** the current flat scopes
(`["check","formula","pathway","scan"]`) map 1:1 to `*:read` and continue to work.

---

## 4. Request conventions

### 4.1 Content type

All POSTs: `Content-Type: application/json`. Bodies up to 1 MB; `/v1/scan`
accepts up to 8 MB (base64 image payload).

### 4.2 Idempotency

POSTs accept an `Idempotency-Key` header — any client-chosen string,
UUID v4 recommended, max 255 chars. Same key + same body within 24 h returns
the cached response (and re-emits `X-Idempotent-Replayed: true`). Same key +
different body returns `409 idempotency_conflict`.

> Why: ERP systems retry aggressively on network blips. Without idempotency,
> a retried check burns quota and may double-fire downstream webhooks.

Backing table (to be added):

```sql
create table api_idempotency (
  key_id uuid not null references api_keys(id) on delete cascade,
  idempotency_key text not null,
  request_hash text not null,           -- sha256 of canonical JSON body
  response_status int not null,
  response_body jsonb not null,
  created_at timestamptz default now(),
  primary key (key_id, idempotency_key)
);
create index on api_idempotency (created_at);  -- for 24h GC
```

### 4.3 Request ID

Every response carries `X-Request-Id: req_<26-char ULID>`. Clients may supply
their own (must match `^[A-Za-z0-9_-]{8,64}$`); otherwise the server generates
one. The ID is also embedded in the error envelope and tagged on Sentry events
for cross-system correlation.

### 4.4 Region

All compliance endpoints take a `region` field — one of `"AU" | "BR" | "CA" | "NZ"`.
Default is `"AU"` when omitted. Unsupported region → `400 unsupported_region`.
(NZ verdicts are SELF-CERTIFIED — a `PERMITTED` result means "compliant to
self-certify" under the Dietary Supplements Regulations 1985 / joint FSANZ Code,
not a Medsafe pre-approval. `scan` is AU/BR/CA only — there is no NZ label scan.)

---

## 5. Response conventions

### 5.1 Success

```json
{
  "data": { ... },
  "meta": {
    "request_id": "req_01HXY...",
    "region": "AU",
    "api_version": "v1"
  }
}
```

> **Current state:** existing v1 routes return the internal payload directly
> at the top level. The wrapped `{ data, meta }` shape is proposed for v1.1
> and will be opt-in via `Accept: application/vnd.regcheck360.v1.1+json` for
> one release, then default.

### 5.2 Errors

```json
{
  "error": {
    "code": "limit_reached",
    "message": "Monthly check quota exceeded for this account.",
    "type": "rate_limit",
    "request_id": "req_01HXY...",
    "doc_url": "https://docs.regcheck360.com/errors/limit_reached"
  },
  "meta": {
    "plan": "free",
    "usage": 5,
    "limit": 5,
    "upgrade_url": "https://regcheck360.com/pricing"
  }
}
```

| HTTP | `error.code` | `error.type` | Meaning |
|---|---|---|---|
| 400 | `invalid_request` | `validation` | Body failed validation |
| 400 | `unsupported_region` | `validation` | `region` ≠ AU/BR/CA/NZ (scan: ≠ AU/BR/CA) |
| 401 | `invalid_api_key` | `authentication` | Missing / malformed / revoked key |
| 403 | `insufficient_scope` | `authorization` | Key lacks required scope |
| 402 | `limit_reached` | `rate_limit` | Monthly plan quota exhausted |
| 409 | `idempotency_conflict` | `idempotency` | Same key, different body |
| 429 | `rate_limited` | `rate_limit` | Per-minute or per-day key limit hit |
| 502 | `upstream_error` | `internal` | Anthropic / DB / OCR upstream failed |
| 500 | `internal_error` | `internal` | Anything else |

### 5.3 Rate-limit headers

Every authenticated response carries:

```
X-RateLimit-Limit-Minute: 30
X-RateLimit-Remaining-Minute: 27
X-RateLimit-Limit-Day: 1000
X-RateLimit-Remaining-Day: 842
X-RateLimit-Reset: 1748028000          # Unix epoch when minute window resets
```

429 responses additionally include `Retry-After: <seconds>`.

---

## 6. Endpoint surface (v1)

Status legend: ✅ shipped · 🔨 to build · 🧹 needs hardening (envelope, headers, idempotency)

| Method | Path | Scope | Status | Purpose |
|---|---|---|---|---|
| POST | `/v1/check` | `check:read` | 🧹 | Single-ingredient permissibility check |
| POST | `/v1/check/batch` | `check:read` | 🔨 | Up to 100 ingredients in one call (BOM import) |
| POST | `/v1/formula` | `formula:read` | 🧹 | Full formula check, verdict + per-ingredient breakdown |
| POST | `/v1/pathway` | `pathway:read` | 🔨 | Pathway advisor (TGA Listed vs FSANZ vs RDC 658, etc.) |
| POST | `/v1/scan` | `scan:read` | 🧹 | Label OCR + compliance scan |
| GET | `/v1/ingredient/{slug}` | `check:read` | 🔨 | Read-only cached lookup (no AI call, returns last verdict) |
| GET | `/v1/usage` | `usage:read` | 🔨 | Current month usage, plan, limits |
| GET | `/v1/whoami` | `usage:read` | 🔨 | Key metadata, scopes, owner email (masked) |
| POST | `/v1/webhooks` | `webhook:write` | 🔨 | Register a webhook URL + events |
| GET | `/v1/webhooks` | `webhook:write` | 🔨 | List registered webhooks |
| DELETE | `/v1/webhooks/{id}` | `webhook:write` | 🔨 | Remove webhook |
| GET | `/v1/webhooks/{id}/deliveries` | `webhook:write` | 🔨 | Last 50 delivery attempts + status |

### 6.1 `POST /v1/check`

```json
// Request
{
  "ingredient": "Magnesium glycinate",
  "dose": "300mg",
  "region": "AU",
  "format": "capsule",        // optional: capsule | gummy | softgel | powder | liquid
  "claims": ["sleep"]         // optional, advisory only
}

// Response 200 — body shape per current internal route, wrapped per §5.1 in v1.1
{
  "verdict": "PERMITTED" | "CONDITIONAL" | "NOT_PERMITTED" | "PROHIBITED",
  "permissibility": { "listed_medicine": true, "max_dose_mg": 750, ... },
  "monograph_ref": "...",
  "warnings": ["..."],
  "sources": [{"name":"FSANZ Schedule 29","url":"..."}]
}
```

### 6.2 `POST /v1/check/batch` (new)

```json
{
  "region": "AU",
  "ingredients": [
    {"ingredient":"Magnesium glycinate","dose":"300mg"},
    {"ingredient":"Vitamin D3","dose":"1000IU"},
    ...   // max 100
  ]
}
```

Response: parallel array of per-ingredient verdicts; failures appear as
`{"index": 3, "error": {...}}` rather than failing the whole batch. Charged
as N checks against the monthly quota; rejected with 402 if quota would be
exceeded mid-batch (atomic, all-or-nothing).

### 6.3 `POST /v1/pathway` (new)

Wraps the existing per-market pathway advisor (`/api/pathway`,
`/api/br/pathway`, `/api/ca/pathway`). Returns recommended export pathway
plus required evidence/timeline.

### 6.4 `POST /v1/webhooks` (new)

```json
// Request
{
  "url": "https://example.com/webhooks/regcheck",
  "events": ["regulatory_alert.created", "ingredient.status_changed"],
  "description": "Katana MRP item-master sync"
}

// Response 201
{
  "id": "whk_01HXY...",
  "secret": "whsec_<48 hex>",     // shown ONCE
  "events": [...],
  "url": "...",
  "created_at": "2026-05-23T..."
}
```

---

## 7. Webhooks

### 7.1 Events

| Event | Fires when |
|---|---|
| `regulatory_alert.created` | New TGA/ANVISA/HC alert lands in `regulatory_alerts` (daily scrub) |
| `ingredient.status_changed` | An ingredient's permissibility flips (e.g. moved to Schedule 4, banned, monograph removed) |
| `check.completed` | Optional — fires on every `/v1/check` call for the key's owner (audit trail) |
| `formula.completed` | Optional — same, for `/v1/formula` |

### 7.2 Delivery

- `POST` with `Content-Type: application/json`
- Headers: `X-RegCheck360-Event: <event>`, `X-RegCheck360-Delivery: <ulid>`,
  `X-RegCheck360-Signature: sha256=<hmac>`
- Body: `{ "event": "...", "data": {...}, "occurred_at": "ISO8601" }`
- Retry: 5 attempts over 24h with exponential backoff (1m, 5m, 30m, 4h, 12h)
- Treat any 2xx as success; otherwise retry

### 7.3 Signature verification

```python
import hmac, hashlib
expected = "sha256=" + hmac.new(
    secret.encode(),
    raw_body_bytes,
    hashlib.sha256
).hexdigest()
hmac.compare_digest(expected, request.headers["X-RegCheck360-Signature"])
```

---

## 8. OpenAPI 3.1 spec

Machine-readable spec lives at:

- `docs/openapi.yaml` (source of truth, committed)
- `https://regcheck360.com/api/openapi.json` (served by a Next.js route, generated at build)

Used by:

- Zapier/Make app generators
- Postman collection import
- `@regcheck360/sdk-typescript` and `regcheck360` (Python) — auto-generated from the spec

---

## 9. CORS

- Public POST endpoints: `Access-Control-Allow-Origin: *` (current behaviour
  is fine — keys are origin-agnostic, paywall enforced server-side)
- Per-key origin allowlist (`allowed_origins` text array on `api_keys`) is a
  future tightening for browser-embedded widgets that ship a key client-side

---

## 10. SLA & operational commitments

These move from "informal" → "contractual" once we sign the first ERP
integrator. For now they're the targets we operate to:

| Metric | Target |
|---|---|
| Uptime (rolling 30d) | 99.5% |
| p50 latency `/v1/check` | < 2 s |
| p95 latency `/v1/check` | < 6 s |
| p50 latency `/v1/check/batch` (n=10) | < 8 s |
| Webhook delivery (first attempt) | < 60 s after event |
| Breaking-change notice | 180 days via Deprecation/Sunset headers + email |

Status page (existing): `https://uptime.healthicons.ai/status` — add a
dedicated `regcheck360-api` monitor.

---

## 11. What's already implemented vs to build

### ✅ Already shipped

- `POST /v1/check`, `POST /v1/check/batch`, `POST /v1/formula`,
  `POST /v1/pathway`, `POST /v1/scan`
- `GET /v1/whoami`, `GET /v1/usage`
- `GET /api/openapi.{json,yaml}` (served from `docs/openapi.yaml`)
- API-key auth via `X-API-Key` **and** `Authorization: Bearer` (`lib/api-key-auth.ts`)
- Per-minute + per-day key rate limits (atomic RPC) with correct
  `X-RateLimit-{Limit,Remaining}-{Minute,Day}` + `X-RateLimit-Reset` headers
- Monthly per-user paywall against the key owner (PR #72, retained on v1/*)
- Atomic batch quota: `/v1/check/batch` rejects with 402 before running if
  the whole batch can't fit
- Idempotency on every v1 POST (`Idempotency-Key` header, 24h dedup,
  `X-Idempotent-Replayed: true` on cache hit, 409 on body conflict;
  see `lib/v1-idempotency.ts` and `db/api_idempotency_migration.sql`)
- `X-Request-Id` echo end-to-end (client-supplied or auto-generated)
- Region validation (`AU` / `BR` / `CA` / `NZ`; `scan` is `AU` / `BR` / `CA`) on all multi-market endpoints
- Standard CORS preflight + expose-headers via `lib/v1-helpers.ts`
- Key CRUD UI at `/account/api-keys` (`app/api/keys/route.ts`)
- Sentry tagging on errors

### ✅ Webhooks v1 — shipped 2026-05-23

- `POST/GET /api/v1/webhooks`, `DELETE /api/v1/webhooks/{id}`,
  `GET /api/v1/webhooks/{id}/deliveries` — full CRUD under the `webhook` scope
- Outbound HMAC-SHA256 signature (`X-RegCheck360-Signature: sha256=<hex>` over
  raw body, signed with the raw secret returned ONCE at registration)
- 5-attempt retry policy with exponential backoff (60s, 5m, 30m, 4h, 12h)
- Cron dispatcher `/api/cron/webhook-dispatch` runs every minute, drains up
  to 25 due-pending deliveries per tick
- **`regulatory_alert.created` is wired** via a Postgres `AFTER INSERT`
  trigger on `regulatory_alerts` (`db/webhooks_v1_regulatory_alert_trigger.sql`).
  Trigger traps exceptions so webhook fan-out failures never block the
  upstream alert insert.

### ✅ All four webhook events live as of 2026-05-23

| Event | Trigger | Source |
|---|---|---|
| `regulatory_alert.created` | Postgres trigger on `regulatory_alerts` INSERT | scrub agents |
| `ingredient.status_changed` | Postgres triggers on `tga_/anvisa_/nhpid_ingredients` UPDATE OF status | scrub agents |
| `check.completed` | `enqueueWebhookEvent` in `/v1/check` + per-item in `/v1/check/batch` | API calls |
| `formula.completed` | `enqueueWebhookEvent` in `/v1/formula` | API calls |

### ✅ v1.1 error envelope + GET /v1/ingredient/{slug} — shipped 2026-05-23

- v1.1 wrapped error shape opt-in via `Accept: application/vnd.regcheck360.v1.1+json`.
  Backward compat preserved on the flat default. `Vary: Accept` set.
- `GET /v1/ingredient/{slug}?region=AU` — cache-only lookup, no AI call,
  no monthly quota burn. Reads from `v1_ingredient_cache` (UPSERTed by
  successful `/v1/check` calls). 404 on cache miss. Hit count + verified
  timestamps tracked per (region, ingredient).

### 🔨 To build (ranked, smallest → largest)

1. **`docs/api-changelog.md`** — empty file with policy on top.
2. **Postman collection** generated from `/api/openapi.json` — hand-to-customer artifact.
3. **First integrator demo** — Katana connector (REST-to-REST, ~1 day) or
   BatchMaster SAP B1 add-on PoC (multi-day, sticky).

### 🪲 Known issues / follow-ups

- **Internal anon-gate leak.** v1 routes proxy to `/api/{check,formula,
  pathway}` without an `Authorization` header, so the internal route runs
  its anonymous per-IP daily gate (5/2/1) against the proxying Vercel
  function's IP. In prod, function IPs rotate so the limit rarely binds —
  but the failure surfaces as `upstream_error: "anon_limit_reached"` which
  leaks internal paywall semantics to API consumers. Fix: pass a service
  bypass header (e.g. `X-Internal-Service-Bypass`) signed with `CRON_SECRET`
  from the v1 layer, and skip the anon gate in internal routes when that
  header is present.

---

## 12. Out of scope for v1

- GraphQL surface (REST only)
- Per-key org/team RBAC beyond `team_id` already on `api_keys`
- Streaming responses (Server-Sent Events for long checks) — defer to v2
- Submitting batch-record / CoA documents (that's a separate "compliance gate
  at batch release" product, not the read-only API)
- ANVISA / Health Canada filing submission (we read regulatory state, we
  don't write to regulator portals)

---

## 13. Open questions (decisions needed before v1.1 ships)

1. **Wrapped response envelope or flat?** Wrapped `{ data, meta }` is cleaner
   for SDKs; flat keeps backward-compat with existing v1 callers. Proposal:
   ship both via `Accept` header for one release.
2. **Idempotency window** — 24h proposed. Stripe uses 24h. Ship that.
3. **Webhook retry budget** — 5 attempts / 24h proposed. GitHub uses ~8 over
   3 days. 5/24h is enough for most ERP failure modes.
4. **Free-tier API access?** — current paywall lets free users hit the API
   with their 5 checks/mo quota. Keep, or lock API behind Pro? Recommend keep
   — it's a key conversion lever.
5. **Region in path vs body?** — currently body. Could expose `/v1/au/check`,
   `/v1/br/check`, `/v1/ca/check` for cleaner SDK ergonomics. Tradeoff:
   3× the OpenAPI surface area.
