Product photo background removal and resize API
Node.js and Postgres API that removes photo backgrounds, outputs five sizes, with prepaid credits, per-customer API keys, usage dashboard and batch webhooks.
nodejstypescriptpostgrespg-bosssharps3
Built 1 time, downloaded 0 times.
Install from your terminal
Run this in your project folder. It signs you in, shows what changes and asks before it overwrites a file.
npx claude-lab add product-photo-background-removal-and-resize-api-c82759CLAUDE.md# Product photo background API
## Purpose
This API takes a product photo and returns a clean background-removed image plus five resized copies. Customers buy prepaid credits, authenticate with per-customer API keys, check usage on a dashboard, and submit batches that report back through signed webhooks.
## Stack
- Node.js with TypeScript (ESM).
- Postgres for data, plus pg-boss as the job queue (backed by Postgres).
- sharp for resizing and format conversion.
- Background removal sits behind an interface so the provider can be swapped.
- Object storage through an S3-compatible client.
## Commands
Check these against package.json after `npm init` and update any that differ.
- `npm run dev`: start the API locally.
- `npm test`: unit tests.
- `npm run test:integration`: tests against real Postgres (needs docker compose).
- `npm run typecheck`: TypeScript check with no emit.
- `npm run lint`: linter.
- `npm run build`: production build.
- `npm run migrate`: apply migrations to the database in `.env`.
- `docker compose up -d db`: start local Postgres.
## Layout
- `src/api`: routes and request validation.
- `src/jobs`: queue workers.
- `src/billing`: credits ledger and deduction logic.
- `src/auth`: API keys and auth middleware.
- `src/images`: upload checks and the image pipeline.
- `src/webhooks`: signing, delivery and retries.
- `src/dashboard`: usage endpoints and page.
- `migrations/`: forward-only SQL migrations.
## Credits rules
- The credit ledger is append-only. Never update or delete rows.
- Balance is the sum of ledger entries.
- Deduct inside one DB transaction with a unique idempotency key.
- Refund when processing fails.
- Never let the balance go below zero.
- Credits are integers. Never use floats.
## API key rules
- Store only a SHA-256 hash and a short prefix.
- Show the full key once, at creation.
- Never log keys or Authorization headers.
- Compare hashes in constant time.
## Webhook rules
- Sign each request with HMAC-SHA256 in a header.
- Include a timestamp so receivers can reject replays.
- Retry with exponential backoff.
- Record every delivery attempt in `webhook_deliveries`.
- Block private, loopback and link-local addresses, and require https.
## Image rules
- Validate file type by magic bytes, not the file extension.
- Cap file size and pixel count.
- Strip EXIF metadata.
- Run image work in workers, never in request handlers.
- Define the five output sizes in one config constant.
## Gotchas
- Tests use a real Postgres from docker compose. Never mock the ledger.
- The background removal provider is faked in tests.
- No secrets in the repo. Use `.env.example` with placeholders such as `YOUR_API_KEY` and `YOUR_STORAGE_BUCKET`.
- Never commit `.env`.
## Workflow
- Explore, plan, code, then run the verify skill before saying a task is done.
- Use `/clear` between unrelated tasks and `/compact` at natural breaks.
- Use `/usage` to check token spend.
## Review
@REVIEW.md
## More detail
- Full API behavior and data model: SPEC.md.
- Phase-by-phase build plan with /goal prompts: PLAN.md.
.claude/agents/test-runner.md---
name: test-runner
description: Use to run the test suites and summarize failures. Returns only failing test names, first error lines and file paths, so the main context stays small.
model: haiku
tools: Bash, Read
disallowedTools: Edit, Write
maxTurns: 8
---
You run this project's test suites and report failures. You do not fix anything.
Steps:
1. Run `npm test` and then `npm run test:integration`. Run both even if the first one fails.
2. If a command fails to start (for example, Postgres is not reachable), report that as the failure and stop. Do not try to repair Docker or the environment.
3. For each failing test, report:
- the test name
- the file path
- the first 3 lines of the error output
Output format:
- `npm test`: PASS or FAIL, then the failures
- `npm run test:integration`: PASS or FAIL, then the failures
- If everything passes, say so in one line and stop.
Rules:
- Never edit, create or delete files.
- Do not suggest code changes or explain fixes. Report what failed and where.
- Do not print full logs. Keep the report under 40 lines.
.claude/settings.json{
"permissions": {
"allow": [
"Bash(npm run *)",
"Bash(npm test*)",
"Bash(npx tsc*)",
"Bash(npx eslint*)",
"Bash(docker compose up*)",
"Bash(docker compose down*)",
"Bash(git status)",
"Bash(git diff*)",
"Bash(git log*)"
],
"ask": [
"Bash(git push*)",
"Bash(npm install*)",
"Bash(npm publish*)",
"Bash(docker compose down -v*)"
],
"deny": [
"Read(./.env)",
"Read(./.env.local)",
"Read(./.env.production)",
"Read(./.env.staging)",
"Read(./.env.test)",
"Bash(rm -rf*)",
"Bash(curl * | sh)",
"Bash(psql * DROP*)"
],
"blockWritesOutsideWorkingDirectories": true
},
"env": {
"CLAUDE_CODE_SUBAGENT_MODEL": "haiku"
},
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path // empty' | xargs -r npx prettier --write"
}
]
}
]
}
}
.claude/skills/credits-ledger/SKILL.md---
name: credits-ledger
description: Guide for any change that deducts, refunds, tops up or reads credits. Covers ledger invariants, transaction pattern and required tests.
when_to_use: Load when editing src/billing or any code that deducts, refunds or tops up credits, or when a change touches the credit_ledger table.
allowed-tools: Bash, Read, Edit
---
# Credits ledger
Money bugs are the most expensive bugs in this project. Follow this guide exactly.
## Invariants
- The `credit_ledger` table is append-only. Never UPDATE or DELETE a row.
- Balance equals the sum of ledger entries for a customer. Do not store a balance you cannot recompute.
- Credits are integers. Never use floats.
- Every entry has a unique idempotency key. A repeated request must not create a second entry.
- The balance never goes below zero.
- Each image costs 1 credit (TBD in SPEC.md, confirm before changing).
## Schema shape
Each ledger row has: id, customer_id, delta (signed integer), reason (`purchase`, `deduct`, `refund`), reference (image or batch id), idempotency_key (unique), created_at.
## Transaction pattern
Do deductions in one database transaction:
1. Begin a transaction.
2. Lock the customer's balance row with `SELECT ... FOR UPDATE`, or use a balance row that has a `CHECK (balance >= 0)` constraint.
3. Compute the current balance from the ledger (or read the cached balance row).
4. If the balance is less than the cost, roll back and return 402.
5. Insert the ledger row with the idempotency key.
6. Commit.
On a unique violation of the idempotency key, return the original result. Do not deduct again.
Refunds follow the same pattern. Refund when image processing fails, using the failed image's id as the reference and a key derived from it, so a refund happens at most once.
## Required tests
Add or update tests in the concurrency test file. These cases must pass against real Postgres:
- Double submit: the same idempotency key twice creates one deduction.
- Concurrent deducts: many parallel deducts never push the balance below zero.
- Refund after failure: a failed job refunds exactly once, even if the refund is retried.
- Insufficient credits: the request returns 402 and writes no ledger row.
Do not mock the ledger. Use the real database.
## Before finishing
1. Run the concurrency test file.
2. Run the verify skill.
3. Confirm no code path updates or deletes `credit_ledger` rows.
.claude/skills/verify/SKILL.md---
name: verify
description: Use before declaring any task done or before a commit. Runs the real checks for this project and reports pass or fail for each step.
allowed-tools: Bash, Read
---
# Verify
Run these steps in order. Do not skip steps. If a step fails, stop, report it, and fix it before continuing.
## Step 1: Database
```bash
docker compose up -d db
npm run migrate
```
Migrations must run cleanly against the test database.
## Step 2: Types and lint
```bash
npm run typecheck
npm run lint
```
## Step 3: Tests
```bash
npm test
npm run test:integration
```
- `npm test` runs unit tests.
- `npm run test:integration` runs against real Postgres. The background removal provider is the fake, never the real provider.
- Never mock the credits ledger.
## Step 4: Build
```bash
npm run build
```
## Step 5: Smoke run
1. Start the server with `npm run dev` in the background.
2. Create a test API key through the auth flow. Use a key from `.env`, never a real customer key.
3. POST a sample fixture image from `test/fixtures/` to `POST /v1/images` with the test key.
4. Assert:
- the response includes a cleaned image with background removed;
- all five sizes are returned (256, 512, 1024, 1600, 2048 px longest side, unless SPEC.md changed them);
- the credit balance dropped by exactly 1 (check `GET /v1/credits` before and after).
5. Create a batch with `POST /v1/batches`. Confirm a signed `batch.completed` webhook reaches the local listener.
6. Stop the server.
## Report format
For each step, write one line:
- `PASS` or `FAIL`, the step name, and the command.
For a failure, show only the first failing lines of output. Do not paste full logs.
## Rules
- Never claim the task is done if any step failed.
- If a step was skipped, say which one and why.
- If the database is not running, that is a failure of Step 1, not a reason to skip.
.claude/skills/webhook-delivery/SKILL.md---
name: webhook-delivery
description: Guide for building and testing signed webhooks, retries, delivery logs and SSRF protection for batch events.
when_to_use: Load when editing src/webhooks or batch completion logic, including batch.completed and batch.failed events, signing, retries or customer endpoint validation.
allowed-tools: Bash, Read, Edit
---
# Webhook delivery
## Signature format
Send one header on every delivery:
```
Webhook-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256>
```
Compute the HMAC over the string `<t>.<raw body>` using the endpoint secret. Sign the exact bytes sent.
Customer verifier (Node, for reference in docs):
```ts
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyWebhook(secret: string, header: string, rawBody: string, toleranceSec = 300): boolean {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=") as [string, string]));
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const sig = Buffer.from(parts.v1 ?? "", "hex");
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
return sig.length === expected.length && timingSafeEqual(sig, expected);
}
```
The timestamp check stops replays. Keep the tolerance short.
## Retries
Default schedule (confirm in SPEC.md if changed):
- Max 6 attempts.
- Wait between attempts: 1 min, 5 min, 30 min, 2 h, 12 h, using exponential backoff with jitter.
- After the last failed attempt, set status `dead`. Stop retrying and keep the record.
Any 2xx response is success. Anything else, including timeouts, is a failure.
## Delivery log
Table `webhook_deliveries` records each attempt:
- id, endpoint_id, event, payload_ref
- attempt (integer), status (`pending`, `delivered`, `failed`, `dead`)
- response_code, error (short text, no secrets)
- next_attempt_at, created_at
Never store the endpoint secret in this table or in logs.
## SSRF rules
Customer URLs are untrusted. Enforce all of these:
- Require `https`.
- Resolve DNS first, then check the resolved IP.
- Block private, loopback and link-local ranges (IPv4 and IPv6).
- Use a short request timeout.
- Do not follow redirects.
Check the resolved address at send time, not only when the endpoint is saved.
## Test recipe
Use a local HTTP listener in the integration tests that fails the first two requests with HTTP 500 and accepts the third.
1. Create an endpoint pointing at the listener (allow localhost only in the test config, never in production).
2. Trigger `batch.completed`.
3. Assert three attempts in `webhook_deliveries`, status `delivered` on the third.
4. Assert the signature verifies with the test secret.
5. Add a case where all attempts fail, and assert status `dead` after the last attempt.
Run the verify skill before finishing.
INSTALL.md# Setup for a small team
Follow these steps once per clone. Commit everything except `.claude/settings.local.json` and `.env`.
## Where files go
- `CLAUDE.md`, `SPEC.md`, `PLAN.md`, `REVIEW.md`, `INSTALL.md`: repo root.
- `.claude/settings.json`, `.claude/skills/`, `.claude/agents/`: committed, shared by the team.
- `.claude/settings.local.json`: personal overrides, never committed.
- `.env`: local secrets, never committed. Copy it from `.env.example`.
## Prerequisites
- Node LTS.
- Docker, for the Postgres container.
- Claude Code, latest version.
## Install plugins
Run `claude` in the repo, then run:
- `/plugin install code-review@claude-plugins-official`: structured review of credits, auth and webhook code.
- `/plugin install feature-dev@claude-plugins-official`: guided explore, design and build flow for each phase.
- `/plugin install security-guidance@claude-plugins-official`: flags common security mistakes in keys, uploads and webhooks.
- `/plugin install commit-commands@claude-plugins-official`: consistent commits and pull requests.
## Fill in the real commands
After `npm init` and the first scripts exist, open `CLAUDE.md` and replace the placeholder command list with the script names from `package.json`.
Run `/init` once to compare. Merge only the lines that help. Keep `CLAUDE.md` under 110 lines.
## Environment
1. Run `cp .env.example .env`.
2. Replace the placeholders, such as YOUR_API_KEY and YOUR_STORAGE_BUCKET.
3. Start Postgres with `docker compose up -d db`.
4. Once phase 1 has added the scripts, run `npm run migrate`.
## Start building
1. Paste the Phase 1 kickoff `/goal` from `PLAN.md` to start.
2. Run each later phase prompt from `PLAN.md`, in order.
3. Read the result before starting the next phase. A goal stops after its turn bound and reports blockers.
4. Run `/code-review` on each PR and check it against `REVIEW.md`.
## Token habits
- `/clear` between unrelated tasks and between phases.
- `/compact` at natural breaks.
- `/usage` to see where tokens went.
- Ask the `test-runner` agent to run the suites, so long test output stays out of the main context.
PLAN.md# Build plan
Five phases. Each ends with a `/goal` prompt. Run one phase at a time.
## Before you start
- Resolve or accept the TBD values in SPEC.md open questions. Defaults in the spec work for phase 1.
- Commit after each phase so `/goal` checks run against a clean diff.
## Between phases
1. Run `/clear` between phases so the next one starts with a small context.
2. Run `/compact` at natural breaks inside a phase.
3. Read the phase summary before pasting the next prompt.
## Phase 1: Foundation
Scope: project scaffold, Postgres migrations, API key auth, health route, test harness with Docker Postgres.
```
/goal Phase 1 is done when: (1) a TypeScript ESM Node project exists with src/api, src/auth, src/jobs, src/images, src/billing, src/webhooks, migrations/ and a docker-compose.yml running Postgres 16; (2) migrations create every table in SPEC.md data model; (3) API key auth accepts Bearer keys, stores only the SHA-256 hash and prefix, shows the full key once at creation, rejects revoked or unknown keys with 401; (4) GET /health and GET /v1/credits work, the latter behind auth; (5) npm scripts dev, test, test:integration, typecheck, lint, build and migrate exist; (6) integration tests on real Postgres cover key creation, valid auth, revoked key and cross-customer isolation. Check: run the verify skill and show typecheck, lint, npm test, npm run test:integration and build passing in the conversation. Constraints: write a short plan before coding; no credits deduction, image processing or webhooks yet; add a dependency only if you list it with a one-line reason; no secrets in the repo, use .env.example placeholders only; no changes outside this scope; fill the real command names into CLAUDE.md at the end. Stop after 40 turns and report blockers or open questions instead of guessing.
```
## Phase 2: Credits ledger
Scope: transactional deduct, refund, top-up, idempotency, concurrency tests.
```
/goal Phase 2 is done when: (1) credit_ledger is append-only, with no UPDATE or DELETE in app code and a database rule or trigger that blocks them; (2) deduct, refund and top-up each run in one transaction, lock the customer balance row with SELECT ... FOR UPDATE or an equivalent, and use a unique idempotency key; (3) balance can never go below zero, enforced in code and by a check constraint; (4) insufficient credits returns 402 with balance in the body; (5) a refund for an image is recorded once even when called twice; (6) integration tests cover a double submit with the same key, 20 concurrent deducts against a balance of 5 with exactly 5 succeeding, a refund after failure and the 402 path. Check: run the verify skill and the concurrency test file, and show the output in the conversation. Constraints: use the credits-ledger skill; use integer credits only; real Postgres in tests, never mock the ledger; no image or webhook code; add a dependency only with a one-line reason; change only src/billing, migrations and their tests. Stop after 40 turns and report blockers.
```
## Phase 3: Image pipeline
Scope: upload validation, background removal interface with a fake, sharp for five sizes, storage, queue worker.
```
/goal Phase 3 is done when: (1) POST /v1/images accepts a multipart file, checks magic bytes for JPEG, PNG and WebP, and rejects files over 20 MB or 50 MP with 422 and no charge; (2) EXIF is stripped from every output; (3) background removal sits behind an interface, with a fake provider used in tests and a real adapter stub that fails clearly when no key is set; (4) sharp produces the five sizes from the single config constant (longest side 256, 512, 1024, 1600, 2048); (5) cleaned image and sizes are stored through the S3-compatible client under generated keys, with no user-supplied path segments and temp files removed; (6) the 1-credit charge happens on accept inside the billing transaction, a pg-boss job is queued, and the worker refunds on failure; (7) GET /v1/images/:id returns status and outputs. Check: run the verify skill. Integration tests use the fake provider and show one image reaching done with six outputs (cleaned plus five sizes) and balance down by 1. Constraints: processing runs in the worker, never in the request handler; no network calls in tests; list each new dependency with a reason (sharp, pg-boss and the S3 client are expected); no changes to webhooks or the dashboard. Stop after 40 turns and report blockers.
```
## Phase 4: Batches and webhooks
Scope: batch endpoint, completion detection, signed webhooks with retries.
```
/goal Phase 4 is done when: (1) POST /v1/batches accepts up to 100 files and charges one credit per file all or nothing in one transaction, returns 202 with a batch id, and returns 402 with no rows created when credits are short; (2) batch completion is detected once every image is done or failed, and batch status becomes completed or failed as SPEC.md defines; (3) batch.completed and batch.failed events are enqueued; (4) POST, GET and DELETE /v1/webhooks work, URLs must be https, and an SSRF guard blocks private, loopback and link-local addresses after DNS resolution and refuses redirects; (5) deliveries are signed per SPEC.md (X-Webhook-Timestamp, X-Webhook-Signature with v1 HMAC-SHA256 of timestamp.body), retried with exponential backoff up to the max attempts then marked dead, and every attempt is written to webhook_deliveries; (6) tests cover a local listener that fails twice then succeeds, the documented verifier accepting a valid signature, and a private IP URL being rejected. Check: run the verify skill and follow the webhook-delivery skill test recipe; show passing output. Constraints: use the webhook-delivery skill; never log the signing secret; no dashboard changes; add a dependency only with a one-line reason. Stop after 40 turns and report blockers.
```
## Phase 5: Usage dashboard and hardening
Scope: usage endpoints and page, rate limits, security review, load smoke test.
```
/goal Phase 5 is done when: (1) GET /v1/usage, GET /v1/api-keys, GET /v1/batches (list) and the key create and revoke routes return only the caller's data; (2) a read-only /dashboard page shows daily usage, balance, key list (prefix only) and recent batches, and never shows a full key; (3) per-key rate limits from SPEC.md return 429 with Retry-After; (4) Idempotency-Key works on POST /v1/images and POST /v1/batches, where the same key and body return the original response and a different body returns 422; (5) a security review against REVIEW.md is written up with each finding marked must-fix or nit, and every must-fix is fixed with a test; (6) scripts/load-smoke.ts sends 50 concurrent requests to the local server, finishes with no 5xx responses, and prints p95 latency. Check: run the verify skill, npm test, npm run test:integration and the load smoke script, and show all output in the conversation. Constraints: no new public endpoints beyond SPEC.md; no secrets in the repo; billing logic changes only as bug fixes with tests; add a dependency only with a one-line reason. Stop after 40 turns and report blockers.
```
## After phase 5
- Run `/code-review` on the full diff and fix must-fix items.
- Update SPEC.md open questions with the decisions made, and update CLAUDE.md if any gotcha was found.
REVIEW.md# Review checklist
Use this list for every review, including `/code-review`. Money, keys and images come first.
Label each finding **must-fix** (blocks merge) or **nit** (optional). Skip style issues the linter handles.
## Credits
- Every deduction and refund runs in one DB transaction with a unique idempotency key.
- No race can take the balance below zero. Look for missing row locks or check constraints.
- The credit ledger is never updated or deleted. Corrections are new entries.
- Credits are integers. Any float or decimal is must-fix.
- A refund happens on failed processing, and only once per image.
## Auth
- API keys are stored as SHA-256 hashes plus a prefix. Full keys never reach the database or logs.
- Key comparison is constant-time.
- No key or Authorization header appears in logs, errors or responses.
- Every query is scoped to the caller's customer. A cross-tenant read is must-fix.
- Revoked and unknown keys return 401.
## Webhooks
- Each delivery carries the HMAC-SHA256 signature and timestamp.
- The timestamp is checked so replays are rejected.
- Retries use exponential backoff, a fixed attempt cap, then a dead state.
- Customer URLs: https only, DNS resolved, private, loopback and link-local ranges blocked, no redirects, short timeout.
## Images
- Size and pixel limits are enforced before decoding.
- File type is checked by magic bytes, not by extension or Content-Type.
- EXIF is stripped from outputs.
- Storage keys are generated by the server. No user-supplied path reaches storage.
- Temp files are removed on success and on failure.
- Heavy work runs in workers, not in request handlers.
## Tests and migrations
- New behavior has tests.
- Credit changes include concurrency tests against real Postgres.
- The ledger is never mocked. The background removal provider is faked.
- Migrations are forward-safe and reversible where practical. Destructive changes need a note in the PR.
SPEC.md# Product photo API spec
## Goals
- Accept one product photo and return a cleaned image with the background removed, plus five resized outputs.
- Sell prepaid credits. Each accepted image costs credits.
- Give each customer API keys, a usage dashboard, and batch processing with signed webhooks.
## Non-goals (v1)
- Editing UI for masks or touch-ups.
- Custom background colors or background images.
- User-uploaded output sizes.
## Output
- Cleaned image as PNG (default) or WebP, background transparent. Format parameter `format=png|webp` (TBD).
- Five sizes by longest side: 256, 512, 1024, 1600, 2048 px (TBD, confirm).
- Sizes live in one config constant in `src/images`.
- If the source is smaller than a size, skip that size and list it as `skipped` in the result (TBD).
- EXIF metadata is stripped from all outputs.
## Upload rules
- Accepted types: JPEG, PNG, WebP. Checked by magic bytes, not file extension.
- Max file size 20 MB (TBD). Max pixel count 50 MP (TBD).
- Rejected uploads return 422 and are not charged.
## Auth
- `Authorization: Bearer <key>` on every `/v1` route.
- Each customer can hold several active keys.
- Full key is shown once at creation. Only the SHA-256 hash and a short prefix are stored.
- Revoke: key stops working immediately (401).
- Rotate: create a new key, switch clients over, then revoke the old key. No automatic expiry in v1.
## Endpoints
- `POST /v1/images`: multipart field `file`. Returns 202 with image id and status `queued`. Charges 1 credit on accept.
- `GET /v1/images/:id`: status `queued`, `processing`, `done` or `failed`. When done, returns `cleaned` and `outputs` (size, URL, expiry TBD).
- `POST /v1/batches`: multipart field `files`, up to 100 files (TBD). Returns 202 with batch id. Charges all or nothing in one transaction.
- `GET /v1/batches` and `GET /v1/batches/:id`: list batches, or get one with counts by status and image ids.
- `GET /v1/credits`: balance and the 50 most recent ledger entries.
- `GET /v1/usage?from=YYYY-MM-DD&to=YYYY-MM-DD`: per-day usage for the customer.
- `POST /v1/api-keys`, `GET /v1/api-keys`, `DELETE /v1/api-keys/:id`: create, list (prefix only), revoke.
- `POST /v1/webhooks`, `GET /v1/webhooks`, `DELETE /v1/webhooks/:id`: endpoint config. Body is `{url, events}`.
- `GET /health`: no auth, returns database status.
## Credits
- Prepaid packs bought through a payment provider placeholder (PAYMENT_PROVIDER, TBD). Pack sizes and prices TBD.
- 1 credit per accepted image (TBD).
- Deduction happens on accept, in one database transaction, with a unique idempotency key on each ledger entry.
- Failed processing refunds the credit with a ledger entry linked to the image. A refund is recorded once.
- Insufficient balance returns 402 with `balance` and `required` in the body. No image row is created.
- Balance never goes below zero. Credits are integers.
## Idempotency
- `Idempotency-Key` header is accepted on all POST create routes (max 255 characters).
- Same key and same body within 24 hours returns the original response with no new charge.
- Same key with a different body returns 422.
## Rate limits
- Per API key: 60 requests per minute and 10 uploads in flight (TBD).
- Over the limit returns 429 with a `Retry-After` header in seconds.
## Error format
All errors use one shape:
```json
{"error": {"code": "insufficient_credits", "message": "Balance is 0, 1 required.", "request_id": "req_..."}}
```
- Codes: `invalid_api_key` (401), `not_found` (404), `validation_failed` (422), `insufficient_credits` (402), `rate_limited` (429), `idempotency_conflict` (422), `internal_error` (500).
- Messages never contain keys, Authorization headers or signing secrets.
## Data model
- `customers`: id, name, created_at.
- `api_keys`: id, customer_id, prefix, key_hash (unique), created_at, revoked_at.
- `credit_ledger`: id, customer_id, amount (integer, signed), reason, image_id (nullable), idempotency_key (unique), created_at. Append-only.
- `images`: id, customer_id, batch_id (nullable), status, source_key, cleaned_key, error_code, created_at, updated_at.
- `batches`: id, customer_id, status, total, completed, failed, created_at, completed_at.
- `jobs`: id, image_id, kind, status, attempts, last_error. App-level tracking next to the pg-boss queue.
- `webhook_endpoints`: id, customer_id, url, events, signing secret (stored encrypted, method TBD), created_at.
- `webhook_deliveries`: id, endpoint_id, event_id, attempt, status, response_code, next_attempt_at, created_at.
- `usage_daily`: customer_id, day, images_accepted, images_failed, credits_used. Primary key (customer_id, day).
## Webhooks
- Events:
- `batch.completed`: every image in the batch is done or failed, and at least one is done.
- `batch.failed`: every image in the batch failed.
- Payload:
```json
{"id": "evt_...", "type": "batch.completed", "created_at": "2026-11-06T10:00:00Z",
"data": {"batch_id": "bat_...", "total": 3, "completed": 3, "failed": 0, "image_ids": ["img_..."]}}
```
- Headers:
- `X-Webhook-Id`: event id.
- `X-Webhook-Timestamp`: Unix seconds.
- `X-Webhook-Signature`: `v1=` plus hex HMAC-SHA256 of `timestamp.body`, keyed with the endpoint secret.
- Customers reject timestamps older than 5 minutes (TBD).
- Retries use exponential backoff with a maximum number of attempts (TBD, e.g. 8). After that the delivery is marked dead.
- URLs must be https and must resolve to public addresses. See the webhook-delivery skill for the SSRF rules.
## Dashboard
- v1 is JSON endpoints plus a read-only page at `/dashboard` (scope TBD).
- Shows per-customer daily usage, balance, key list (prefix, created, last used) and recent batches.
- Never shows a full key.
## Acceptance criteria
- `POST /v1/images` with a valid key and enough credits returns 202, balance drops by 1, one ledger row and one queued job exist.
- Non-image bytes return 422 with no charge and no image row.
- Processing failure sets status `failed`, writes a refund entry and restores the balance.
- Insufficient credits returns 402 and creates no image row.
- Same `Idempotency-Key` twice produces one charge and the same response.
- Fetching another customer's image or batch returns 404.
- Batch accept charges all or nothing.
- Webhook signature verifies with the documented example verifier.
- Webhook retries on 5xx and does not deliver to private or loopback addresses.
- A revoked key returns 401 on the next request.
## Open questions
- Pack sizes and prices, and the payment provider.
- Credit cost per image (1 assumed). Is a failed upload ever charged?
- Output size list and default format.
- Retention period for source and output images (days).
- Rate limit numbers and maximum batch size.
- Webhook retry count and signing secret encryption method.
- Scope of the read-only dashboard page.