# The Aperta Agent API, step by step (curl)

Every call an agent makes, in order, with the real response bodies. The machine-readable contract is the
OpenAPI document at `https://aperta-designs.com/openapi/agent-v1.json`; this file is the walkthrough.

> The bodies below were produced by the API's own code (routes, validation, framing, views) with its
> database and tracer replaced by in-memory fakes that replay real tracer outputs; ids, secrets and tokens
> are fixed example values. Numbers are a snapshot at contract `2026-09` — read `GET /capabilities` for
> the live ones. Live calls need the API switched on: until it is, every path answers the site's own
> `404` page, like any unknown path.

```sh
BASE=https://aperta-designs.com/api/agent/v1
```

Conventions:

- JSON in, JSON out, UTF-8. SVG files come back as `image/svg+xml`.
- Job routes (`/jobs/{jobId}…`) need `Authorization: Bearer <jobSecret>` (the scheme is case-insensitive).
  `GET /capabilities` and `POST /jobs` need nothing.
- `HEAD` works wherever `GET` does. Any other unsupported method answers `405 METHOD_NOT_ALLOWED` with an
  `Allow` header. A path that is not an endpoint answers `404 NOT_FOUND` with the message
  `"No such endpoint."`.
- Every error the API sends is `{ "error": { "code", "message", "agent_instruction"?, "retryable"? } }` —
  except the `422` of a submission, which carries the whole `submission`. The codes: [errors.md](errors.md).
  A request the hosting platform cuts off can instead end in a `5xx` HTML page (no JSON): see §8.
- Use an HTTP client. Without an Aperta session a browser tab on `/api/*` is redirected to the home page;
  signed in, it shows the site's 404 page even while the API is open.

## 1. Read the capabilities

```sh
curl -sS "$BASE/capabilities"
```

No authentication, no rate limit. The body is large; the parts you need first (snapshot):

```json
{
  "apiVersion": "v1",
  "contractVersion": "2026-09",
  "manufacturing": {
    "material": "C260 brass, annealed (O60)",
    "thicknessMm": 1.5,
    "minOpeningMm": 0.2,
    "minMetalMm": 0.6,
    "despeckleAreaFraction": 0.0004,
    "note": "Minimum opening and minimum metal are the two enforced floors. Openings smaller than 0.04% of the piece's area are filled before tracing; the per-job value is drawing.minOpeningAreaMm2."
  },
  "limits": {
    "submissionsPerJob": 10,
    "jobsPerHourPerIp": 30,
    "globalJobsPerDay": 200,
    "submissionsPerHourPerIp": 60,
    "globalSubmissionsPerMinute": 12,
    "globalSubmissionsPerDay": 600,
    "adoptsPerHourPerAccount": 20,
    "jobTtlDays": 7,
    "declinedTtlHours": 24,
    "syncTimeoutSec": 120,
    "pollIntervalSec": 30
  },
  "approvalPage": {
    "language": "he",
    "note": "The approval page (approval.url) is a human web page in Hebrew, right-to-left, like the rest of the site. Tell the person before handing over the link; the page shows the design, the price and two buttons (approve / decline). While you wait for their decision, read GET /api/agent/v1/jobs/{jobId} at most once every 30 seconds (limits.pollIntervalSec): approval.status changes from pending to approved or declined. A person takes minutes or hours, so polling faster only spends requests."
  }
}
```

The rest: `products` (length and width limits, defaults, price per product), `sizing` (accepted size
inputs), `image` (the file limits and the drawing rules — [image-contract.md](image-contract.md) explains
each), `links` and `errorCodes`. Fetch it once per session and use its numbers, not the ones in this file.

## 2. Open a job

Pick exactly one size form:

| `size` | For | Meaning |
| --- | --- | --- |
| `{ "wristMm": 165, "fit": "comfort" }` | bracelet | wrist circumference in **millimetres**; `fit` is `snug`, `comfort` (default) or `loose` |
| `{ "usRingSize": 7 }` | ring | US ring size, whole or half |
| `{ "lengthMm": 160 }` | either | the flat blank length itself |

`widthMm` is **required**, inside `capabilities.products[productType].widthRangeMm`. It must be the real
width: the tracer measures the 0.2 mm floor at this scale. If you want the width to follow a drawing you
already have, compute it: blank length ÷ drawn ratio.

```sh
KEY=$(uuidgen)   # a fresh UUID per design
curl -sS -X POST "$BASE/jobs" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d '{"productType":"bracelet","size":{"wristMm":165,"fit":"comfort"},"widthMm":18,"attribution":{"agentPlatform":"openclaw","integration":"gift-concierge","skillVersion":"1.0.0","apiVersion":"v1"}}' | tee job.json

JOB=$(jq -r .job.id job.json)
SECRET=$(jq -r .credentials.jobSecret job.json)
```

```http
HTTP/1.1 201
content-type: application/json

{
  "job": {
    "id": "1b4e7c2a-5d3f-4e6a-9b8c-2d1e0f3a4b5c",
    "status": "open",
    "productType": "bracelet",
    "dims": { "lengthMm": 160.4, "widthMm": 18, "gapMm": 25.4, "thicknessMm": 1.5 },
    "sizeInput": { "wristMm": 165, "fit": "comfort" },
    "drawing": {
      "ratio": 8.91,
      "recommendedPixels": "1536x1024",
      "orientation": "landscape",
      "minOpeningAreaMm2": 1.155
    },
    "limits": { "submissionsLeft": 10 },
    "latestSubmission": null,
    "approval": null,
    "price": {
      "currency": "ILS",
      "base": 399,
      "shipping": 35,
      "total": 434,
      "vatIncluded": true,
      "note": "Referral codes and pickup are applied by the person at checkout."
    },
    "attribution": {
      "agentPlatform": "openclaw",
      "integration": "gift-concierge",
      "skillVersion": "1.0.0",
      "apiVersion": "v1"
    },
    "createdAt": "2026-09-25T12:00:00.000Z",
    "expiresAt": "2026-10-02T12:00:00.000Z"
  },
  "credentials": { "jobSecret": "ajs_RXhhbXBsZVNlY3JldE9uZUZvclRoZURvY3NfX19fXzE" }
}
```

- **`credentials.jobSecret` is returned once, in this `201` only.** Store it before anything else. It is
  stored hashed; nobody can give it back to you.
- `job.drawing.ratio` is what you draw (length ÷ width); `job.drawing.minOpeningAreaMm2` is the smallest
  opening area that survives tracing for this piece.
- `attribution` is optional and about **your** platform (≤ 64 characters each). Send
  `skillVersion` so submissions made under older instructions can be told apart. Never put the person's
  data there.

### `Idempotency-Key`

Optional but recommended: 8–128 printable ASCII characters — use a UUID. The key is scoped to your IP
address (Aperta stores a keyed hash of address + key), so other agents' keys never collide with yours.

- **Same key, same body** → `200` with a reduced body: identity, status, dims, drawing hints and
  `submissionsLeft` — **no** `credentials`, no `approval`, no `latestSubmission`:

```http
HTTP/1.1 200
content-type: application/json

{
  "job": {
    "id": "6d1f3a2e-9c4b-4b7e-8a2d-1f0c5e7b9a31",
    "status": "ready",
    "productType": "bracelet",
    "dims": { "lengthMm": 160.4, "widthMm": 18, "gapMm": 25.4, "thicknessMm": 1.5 },
    "drawing": {
      "ratio": 8.91,
      "recommendedPixels": "1536x1024",
      "orientation": "landscape",
      "minOpeningAreaMm2": 1.155
    },
    "limits": { "submissionsLeft": 8 },
    "createdAt": "2026-09-24T12:00:00.000Z",
    "expiresAt": "2026-10-01T12:00:00.000Z"
  }
}
```

  A replay confirms the job exists; it cannot give you the secret. If you resent `POST /jobs` because the
  `201` never arrived, the secret of that job is lost: open a new job with a **fresh** key (the orphan
  expires by itself). A `200` for a key you never used before means a collision inside your own network —
  use a fresh key.
- **Same key, different body** →

```http
HTTP/1.1 409
content-type: application/json

{
  "error": {
    "code": "IDEMPOTENCY_CONFLICT",
    "message": "This Idempotency-Key was already used with a different request body.",
    "agent_instruction": "Use a fresh Idempotency-Key for a new request, or resend exactly the same body to replay the original response."
  }
}
```

- **Same key and body, but that job was cancelled or has expired** →

```http
HTTP/1.1 409
content-type: application/json

{
  "error": {
    "code": "IDEMPOTENCY_CONFLICT",
    "message": "This Idempotency-Key belongs to a job that is cancelled or expired.",
    "agent_instruction": "Use a fresh Idempotency-Key for a new job; this one stays bound to the dead job until it is purged."
  }
}
```

- **A malformed key** →

```http
HTTP/1.1 400
content-type: application/json

{
  "error": {
    "code": "INVALID_REQUEST",
    "message": "The request is malformed. Idempotency-Key must be 8–128 printable ASCII characters; a UUID is recommended.",
    "agent_instruction": "Fix the request to match the OpenAPI document: a JSON body with the documented fields and types, or a PNG upload with the documented content type. The message names the offending field."
  }
}
```

### Request errors

`widthMm` missing:

```http
HTTP/1.1 400
content-type: application/json

{
  "error": {
    "code": "INVALID_REQUEST",
    "message": "The request is malformed. widthMm: Required",
    "agent_instruction": "Fix the request to match the OpenAPI document: a JSON body with the documented fields and types, or a PNG upload with the documented content type. The message names the offending field."
  }
}
```

A misspelled field (unknown fields are rejected, never ignored) — `widthmm` instead of `widthMm` is two
problems, the missing key first:

```http
HTTP/1.1 400
content-type: application/json

{
  "error": {
    "code": "INVALID_REQUEST",
    "message": "The request is malformed. widthMm: Required; body: Unrecognized key(s) in object: 'widthmm'",
    "agent_instruction": "Fix the request to match the OpenAPI document: a JSON body with the documented fields and types, or a PNG upload with the documented content type. The message names the offending field."
  }
}
```

A mistake inside `size` names the key and the three forms `size` takes:

```http
HTTP/1.1 400
content-type: application/json

{
  "error": {
    "code": "INVALID_REQUEST",
    "message": "The request is malformed. size: expected { wristMm, fit? } (fit: snug | comfort | loose), { usRingSize } or { lengthMm }; received 'wrist_mm'",
    "agent_instruction": "Fix the request to match the OpenAPI document: a JSON body with the documented fields and types, or a PNG upload with the documented content type. The message names the offending field."
  }
}
```

A width outside the product's range, and a size whose blank is outside the product's limits. A width
too narrow for its blank length is the same `WIDTH_OUT_OF_RANGE`: the drawing would be thinner than
`capabilities.image.cropAspect.max`, which the tracer cannot trace, and the message names the minimum
(`"Width 5 mm is outside 6.1–80 mm for a bracelet. A 280 mm blank needs at least 6.1 mm: …"`).

```http
HTTP/1.1 400
content-type: application/json

{
  "error": {
    "code": "WIDTH_OUT_OF_RANGE",
    "message": "Width 81 mm is outside 5–80 mm for a bracelet.",
    "agent_instruction": "Send widthMm between 5 and 80 mm. widthMm is required: to let the width follow your drawing, compute it yourself as the blank length divided by the drawn length-to-width ratio (capabilities lists defaultWidthMm per product) and send that number."
  }
}
```

```http
HTTP/1.1 400
content-type: application/json

{
  "error": {
    "code": "LENGTH_OUT_OF_RANGE",
    "message": "Blank length 24 mm is outside 70–280 mm for a bracelet. Blank length 24mm is outside what a bracelet can be (70–280mm) — check the measurement that produced it.",
    "agent_instruction": "Check the measurement: wristMm is the wrist circumference in millimetres (not centimetres), usRingSize is a US ring size, lengthMm is the flat blank length. Send a value whose blank falls between 70 and 280 mm."
  }
}
```

## 3. Submit a PNG

Raw body (preferred):

```sh
curl -sS -X POST "$BASE/jobs/$JOB/submissions" \
  -H "Authorization: Bearer $SECRET" \
  -H "Content-Type: image/png" \
  --data-binary @design.png \
  --max-time 150 \
  -D headers.txt -o submission.json -w '%{http_code}\n'
```

Or multipart, with the file in a field named `image`. Type the part `image/png`, or leave it untyped
(no type, `application/octet-stream` or `text/plain` — what many clients send): the PNG signature then
decides. A part that declares another type (`image/jpeg`, `image/webp`) is `415 UNSUPPORTED_FORMAT`.

```sh
curl -sS -X POST "$BASE/jobs/$JOB/submissions" \
  -H "Authorization: Bearer $SECRET" \
  -F "image=@design.png;type=image/png" \
  --max-time 150
```

The call is synchronous: tracing, framing and validation run before it answers (inside
the `limits.syncTimeoutSec` window, 120 s — a window, not a guaranteed ceiling: give your client a little
more, and if it gives up, resend the same bytes — `409 SUBMISSION_IN_PROGRESS` names the upload to read). A passing design:

```http
HTTP/1.1 200
content-type: application/json

{
  "submission": {
    "no": 1,
    "status": "ready",
    "validation": { "valid": true, "errors": [], "warnings": [] },
    "geometry": {
      "lengthMm": 160.4,
      "widthMm": 18.04,
      "drawnRatio": 8.89,
      "stretch": 1,
      "cuts": 12,
      "openAreaPct": 9.25,
      "estWeightGrams": 33.6
    },
    "files": {
      "cutoutsSvg": "https://aperta-designs.com/api/agent/v1/jobs/1b4e7c2a-5d3f-4e6a-9b8c-2d1e0f3a4b5c/submissions/1/cutouts.svg",
      "previewSvg": "https://aperta-designs.com/api/agent/v1/jobs/1b4e7c2a-5d3f-4e6a-9b8c-2d1e0f3a4b5c/submissions/1/preview.svg"
    },
    "approval": {
      "url": "https://aperta-designs.com/a/Rk3vW9pQ2sLm",
      "status": "pending",
      "expiresAt": "2026-10-02T12:00:00.000Z"
    },
    "price": {
      "currency": "ILS",
      "base": 399,
      "shipping": 35,
      "total": 434,
      "vatIncluded": true,
      "note": "Referral codes and pickup are applied by the person at checkout."
    },
    "createdAt": "2026-09-25T12:00:25.000Z",
    "durationMs": 6200
  }
}
```

- `status: "ready"`, `validation.valid: true`. `geometry` is the strip as it will be cut (its `widthMm` may
  differ from the declared width by up to 5% when your drawing's own width is that close — the drawing's
  width is then kept). `geometry.cuts` counts the cut contours of `cutouts.svg`, not the openings: the
  material cut away outside the piece's outline at shaped ends counts too (this nine-opening bracelet reports
  `12`).
- `files.previewSvg` / `files.cutoutsSvg` are absolute URLs; they need the same Bearer secret.
- `approval.url` is the page for the person (§7). A job has one link, and it shows the job's newest passing
  submission: `approval` is filled only on that submission. Read an older `ready` one later and its
  `approval` is `null` — the link no longer shows it.

Before the bytes leave Aperta's edge worker only the file is checked — size, PNG signature, the IHDR
header. Those failures:

```http
HTTP/1.1 415
content-type: application/json

{
  "error": {
    "code": "UNSUPPORTED_FORMAT",
    "message": "Only PNG is accepted.",
    "agent_instruction": "Send the image as image/png: either a raw body with Content-Type: image/png, or multipart/form-data with an `image` field. Do not send JPEG or WebP; lossy ringing corrupts the black/white boundary the tracer relies on."
  }
}
```

```http
HTTP/1.1 400
content-type: application/json

{
  "error": {
    "code": "INVALID_FILE",
    "message": "The file is not a valid PNG. not a PNG: signature mismatch",
    "agent_instruction": "Send a well-formed PNG file: the 8-byte PNG signature followed by an IHDR chunk. Re-export the image from your image model or converter as PNG; do not rename a JPEG and do not send a data URL."
  }
}
```

```http
HTTP/1.1 400
content-type: application/json

{
  "error": {
    "code": "IMAGE_TOO_SMALL",
    "message": "The image is too small to trace. short side is 150px; minimum 256px",
    "agent_instruction": "Export at least 256 px on the short side; 1536x1024 landscape is recommended."
  }
}
```

```http
HTTP/1.1 413
content-type: application/json

{
  "error": {
    "code": "FILE_TOO_LARGE",
    "message": "The file exceeds 6 MB.",
    "agent_instruction": "Re-export the PNG under 6 MB. A flat black-on-white 1536x1024 8-bit PNG is about 200 KB: drop the alpha channel and 16-bit depth, and do not embed colour profiles."
  }
}
```

## 4. When the design fails (`422`)

A `422` carries the whole submission, with `status: "failed_validation"` and the reasons in
`validation.errors[]`:

```http
HTTP/1.1 422
content-type: application/json

{
  "submission": {
    "no": 1,
    "status": "failed_validation",
    "validation": {
      "valid": false,
      "errors": [
        {
          "code": "OPENING_TOO_SMALL",
          "severity": "error",
          "message": "1 opening(s) are narrower than 0.2 mm.",
          "agent_instruction": "Widen each marked opening to at least 0.2 mm at its narrowest point, or remove it. The cutter cannot open anything finer.",
          "locations": [{ "x": 28.07000000000008, "y": 9.020000000000078, "r": 3.5067 }],
          "details": "1 cutout(s) smaller than the minimum opening 0.2mm at (mm): (28.1, 9.0). Enlarge or remove them."
        }
      ],
      "warnings": []
    },
    "geometry": null,
    "files": null,
    "approval": null,
    "price": {
      "currency": "ILS",
      "base": 399,
      "shipping": 35,
      "total": 434,
      "vatIncluded": true,
      "note": "Referral codes and pickup are applied by the person at checkout."
    },
    "createdAt": "2026-09-26T12:00:30.000Z",
    "durationMs": 4900
  }
}
```

Read each error:

- `agent_instruction` — what to change, with the live numbers.
- `locations` — millimetres on the strip: `x` along the length from the left end, `y` across the width from
  the top edge, `r` a rough radius. The strip is the bounding box of the black region in your PNG, scaled to
  the job's length and width — to find a spot in your image, crop to the black pixels' bounding box and
  scale.
- `details` — the validator's own sentence.

Fix **every** error in a new image and submit it to the **same** job. A failed submission counts against
`limits.submissionsPerJob`; resending the same bytes does not help (it replays the stored `422`).

A passing design can still carry **warnings** — what the engine changed:

```http
HTTP/1.1 200
content-type: application/json

{
  "submission": {
    "no": 2,
    "status": "ready",
    "validation": {
      "valid": true,
      "errors": [],
      "warnings": [
        {
          "code": "DISCONNECTED_REGIONS",
          "severity": "warning",
          "message": "The design has more than one separate black region.",
          "agent_instruction": "Connect every black region to the main body with a bridge of metal, or remove the stragglers. Only the largest region is traced; the rest vanish.",
          "details": "2 separate regions; the largest holds 96.39% of the ink."
        }
      ]
    },
    "geometry": {
      "lengthMm": 160.4,
      "widthMm": 18.04,
      "drawnRatio": 8.89,
      "stretch": 1,
      "cuts": 12,
      "openAreaPct": 9.25,
      "estWeightGrams": 33.6
    },
    "files": {
      "cutoutsSvg": "https://aperta-designs.com/api/agent/v1/jobs/8e2d4f6a-1c3b-4a5d-8e7f-9a0b1c2d3e4f/submissions/2/cutouts.svg",
      "previewSvg": "https://aperta-designs.com/api/agent/v1/jobs/8e2d4f6a-1c3b-4a5d-8e7f-9a0b1c2d3e4f/submissions/2/preview.svg"
    },
    "approval": {
      "url": "https://aperta-designs.com/a/Tn5bX8cJ4hGd",
      "status": "pending",
      "expiresAt": "2026-10-03T12:00:00.000Z"
    },
    "price": {
      "currency": "ILS",
      "base": 399,
      "shipping": 35,
      "total": 434,
      "vatIncluded": true,
      "note": "Referral codes and pickup are applied by the person at checkout."
    },
    "createdAt": "2026-09-26T12:01:34.900Z",
    "durationMs": 5940
  }
}
```

Look at `files.previewSvg` and decide whether to accept or redraw. `ISLAND_BRIDGED`, `ISLAND_REMOVED`,
`NECK_THICKENED`, `SPURS_SHAVED`, `OPENING_REMOVED`, `PROPORTION_MISMATCH` (as a warning),
`DISCONNECTED_REGIONS`, `DESIGN_TOUCHES_EDGE` and `NOT_TWO_TONE` all mean "it passed, but not exactly as
drawn".

## 5. The same image twice

- **Already answered** (`ready` or `failed_validation` in this job): the stored answer comes back with its
  original status — not processed again, not counted, not rate-limited. That is how you recover a lost
  response.
- **Still running** (you retried while the first upload is being processed):

```http
HTTP/1.1 409
content-type: application/json
retry-after: 10

{
  "error": {
    "code": "SUBMISSION_IN_PROGRESS",
    "message": "The same image is still being processed in this job. Submission 2 is processing these bytes.",
    "agent_instruction": "Do not resubmit these bytes: an earlier upload of the same image is still running as submission 2. Wait 10 seconds, then read GET /api/agent/v1/jobs/<jobId>/submissions/2 (the number in this message) until its status leaves processing; that response is this image's result. The running attempt counts once, not twice.",
    "retryable": true
  }
}
```

  Do not upload again. Wait `Retry-After` seconds and read the submission named in the message until its
  status leaves `processing`:

```sh
curl -sS "$BASE/jobs/$JOB/submissions/2" -H "Authorization: Bearer $SECRET"
```

```http
HTTP/1.1 200
content-type: application/json

{
  "submission": {
    "no": 2,
    "status": "processing",
    "validation": { "valid": false, "errors": [], "warnings": [] },
    "geometry": null,
    "files": null,
    "approval": null,
    "price": {
      "currency": "ILS",
      "base": 299,
      "shipping": 35,
      "total": 334,
      "vatIncluded": true,
      "note": "Referral codes and pickup are applied by the person at checkout."
    },
    "createdAt": "2026-09-27T12:00:00.000Z",
    "durationMs": null
  }
}
```

- **Interrupted** (the worker processing it was cut off — after 3 minutes a `processing` row is closed as
  `PROCESSING_INTERRUPTED`): send the same bytes again; they are processed from scratch and the interrupted
  attempt is not counted. See §8.

## 6. Read the job and its files

```sh
curl -sS "$BASE/jobs/$JOB" -H "Authorization: Bearer $SECRET"
```

```http
HTTP/1.1 200
content-type: application/json

{
  "job": {
    "id": "1b4e7c2a-5d3f-4e6a-9b8c-2d1e0f3a4b5c",
    "status": "ready",
    "productType": "bracelet",
    "dims": { "lengthMm": 160.4, "widthMm": 18, "gapMm": 25.4, "thicknessMm": 1.5 },
    "sizeInput": { "wristMm": 165, "fit": "comfort" },
    "drawing": {
      "ratio": 8.91,
      "recommendedPixels": "1536x1024",
      "orientation": "landscape",
      "minOpeningAreaMm2": 1.155
    },
    "limits": { "submissionsLeft": 9 },
    "latestSubmission": {
      "no": 1,
      "status": "ready",
      "errorCodes": [],
      "warningCodes": [],
      "createdAt": "2026-09-25T12:00:25.000Z"
    },
    "approval": {
      "url": "https://aperta-designs.com/a/Rk3vW9pQ2sLm",
      "status": "pending",
      "expiresAt": "2026-10-02T12:00:00.000Z"
    },
    "price": {
      "currency": "ILS",
      "base": 399,
      "shipping": 35,
      "total": 434,
      "vatIncluded": true,
      "note": "Referral codes and pickup are applied by the person at checkout."
    },
    "attribution": {
      "agentPlatform": "openclaw",
      "integration": "gift-concierge",
      "skillVersion": "1.0.0",
      "apiVersion": "v1"
    },
    "createdAt": "2026-09-25T12:00:00.000Z",
    "expiresAt": "2026-10-02T12:00:00.000Z"
  }
}
```

`latestSubmission` summarises the newest upload (codes only); `limits.submissionsLeft` counts what you
may still send (`0` once the job is approved, expired or cancelled); `approval` is `null` until a
submission passes.

```sh
curl -sS "$BASE/jobs/$JOB/submissions/1" -H "Authorization: Bearer $SECRET"             # the full report, same body as the POST
curl -sS "$BASE/jobs/$JOB/submissions/1/preview.svg" -H "Authorization: Bearer $SECRET"  # the silhouette, for the person or for you
curl -sS "$BASE/jobs/$JOB/submissions/1/cutouts.svg" -H "Authorization: Bearer $SECRET"  # the canonical cutting file, in mm
```

```http
HTTP/1.1 200
content-type: image/svg+xml; charset=utf-8

<svg xmlns="http://www.w3.org/2000/svg" viewBox="-1 -1 162.4 20.04" role="img"><path d="M0,7.8L0.2,7.4L0.3,6.9L0.6,5.8L1.1,4.8L1.7,3.8L2.4,3L3.2,2.2L4.4,1.3L5.8,0.6L6.9,0.3L7.4,0.2L7.8,0L152.5,0L153,0.2L153.5,0.3L154.6,0…  (2445 bytes in all)
```

```http
HTTP/1.1 200
content-type: image/svg+xml; charset=utf-8

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160.4 18.04"><g id="cutouts"><path d="M0 0L7.842 0L7.353 0.208L6.859 0.332L5.792 0.624L4.421 1.304L3.164 2.183L2.365 2.955L1.674 3.831L1.093 4.784L0.624 5.791L0.333 6.…  (3502 bytes in all)
```

Files exist only for `ready` submissions; a wrong number says so:

```http
HTTP/1.1 404
content-type: application/json

{
  "error": {
    "code": "NOT_FOUND",
    "message": "No such file for this submission.",
    "agent_instruction": "Submission 1 has no cutouts.svg: its status is failed_validation; only ready submissions have files."
  }
}
```

```http
HTTP/1.1 404
content-type: application/json

{
  "error": {
    "code": "NOT_FOUND",
    "message": "No such submission.",
    "agent_instruction": "No submission 7 in this job; read GET /jobs/{id} → latestSubmission for the newest number."
  }
}
```

An unknown job id and a wrong secret are the same answer, on purpose:

```http
HTTP/1.1 404
content-type: application/json

{
  "error": {
    "code": "NOT_FOUND",
    "message": "No such job.",
    "agent_instruction": "Check the job id and the Authorization: Bearer secret from the job's creation response. Unknown ids and wrong secrets are indistinguishable by design; if the secret is lost, open a new job."
  }
}
```

## 7. Hand off, then follow the decision

Give the person `approval.url`. It is a web page **in Hebrew** that shows the design, the price and two
buttons; approving asks them to sign in on Aperta and continues to the order. The page calls
`/handoff/{token}/adopt` and `/handoff/{token}/decline` itself — **agents never call them**.

Follow the decision with `GET /jobs/{jobId}`. While the person decides, wait at least
`capabilities.limits.pollIntervalSec` seconds (30) between two reads — a person takes minutes or hours, and faster
polling only spends requests:

| `approval.status` | Meaning | Do |
| --- | --- | --- |
| `pending` | not decided yet | wait; the link lives until `approval.expiresAt` (7 days after the job was created; submissions do not extend it) |
| `approved` | the design is in the person's account | done — the job is locked |
| `declined` | the person said no | ask what to change and submit a new design to the same job; the same link comes back to life. A declined job expires 24 hours after the decline, but never past 7 days after creation (see `approval.expiresAt`); a new passing submission returns it to creation + 7 days. |

After approval the job takes no more submissions:

```http
HTTP/1.1 409
content-type: application/json

{
  "error": {
    "code": "JOB_LOCKED",
    "message": "The job is approved and locked.",
    "agent_instruction": "Do not resubmit to this job: the person already approved a design and it now lives in their account. Open a new job for a new design."
  }
}
```

## 8. Retries and `Retry-After`

| What happened | Retry? | How |
| --- | --- | --- |
| No response to `POST /jobs` | yes | resend with the **same** `Idempotency-Key` and body; a `200` replay means the job exists but its secret is lost — open a new job with a fresh key |
| No response to `POST …/submissions` | yes | read `GET /jobs/{jobId}` → `latestSubmission` → `GET …/submissions/{no}` — that works in every state. Or, while the job still takes submissions, resend the **same** bytes: you get the stored answer, `409 SUBMISSION_IN_PROGRESS` (then poll), or a fresh run if the first was interrupted; on an approved, expired or cancelled job the resend answers that state's `409` instead |
| A `5xx` that is not JSON (an HTML page: the hosting platform cut the request off, e.g. Cloudflare 1102) | once | treat it as a lost response: wait about 30 s, read `GET /jobs/{jobId}`, and resend the same bytes once if `latestSubmission` does not show the upload |
| `429 RATE_LIMITED` | yes | wait `Retry-After` seconds: the blocking bucket's window (60 per minute, 3600 per hour, the seconds to midnight UTC for a daily one — daily buckets count per UTC day), or 30 when the limiter itself was unavailable |
| `502 VECTORIZER_UNAVAILABLE` / `502 PROCESSING_FAILED` | once | wait `Retry-After` (30), resend the same bytes; if `PROCESSING_FAILED` repeats, re-export the PNG (8-bit, no alpha) and stop looping |
| `500 PROCESSING_FAILED` | once | wait `Retry-After` (30), resend the same request; then stop and tell the person |
| `409 SUBMISSION_IN_PROGRESS` | no upload | wait `Retry-After` (10), read `GET …/submissions/{no}` |
| `PROCESSING_INTERRUPTED` in a stored submission | yes | resend the same bytes |
| anything else (`400`, `404`, `405`, `409`, `413`, `415`, `422`) | no | fix the request, the image or the job, as the instruction says |

Technical failures (`502`, `PROCESSING_INTERRUPTED`) are never counted against the job's submissions and do
not extend its expiry. A tracer outage looks like this:

```http
HTTP/1.1 502
content-type: application/json
retry-after: 30

{
  "error": {
    "code": "VECTORIZER_UNAVAILABLE",
    "message": "The tracing service is unavailable.",
    "agent_instruction": "Wait 30 seconds and resubmit the same bytes to the same job. Attempts that fail this way do not count against the job's submissions.",
    "retryable": true
  }
}
```

and the job shows it without counting it:

```json
{
  "limits": { "submissionsLeft": 10 },
  "latestSubmission": {
    "no": 1,
    "status": "failed_processing",
    "errorCodes": ["VECTORIZER_UNAVAILABLE"],
    "warningCodes": [],
    "createdAt": "2026-09-27T12:00:00.000Z"
  }
}
```

An interrupted upload, read back:

```http
HTTP/1.1 200
content-type: application/json

{
  "submission": {
    "no": 2,
    "status": "failed_processing",
    "validation": {
      "valid": false,
      "errors": [
        {
          "code": "PROCESSING_INTERRUPTED",
          "severity": "error",
          "message": "The previous submission was interrupted before it finished.",
          "agent_instruction": "Resubmit the same bytes to the same job; the interrupted attempt is not counted and the image is processed again from scratch. Only an image that already reached ready or failed_validation is answered from its stored result instead of being processed again.",
          "retryable": true
        }
      ],
      "warnings": []
    },
    "geometry": null,
    "files": null,
    "approval": null,
    "price": {
      "currency": "ILS",
      "base": 299,
      "shipping": 35,
      "total": 334,
      "vatIncluded": true,
      "note": "Referral codes and pickup are applied by the person at checkout."
    },
    "createdAt": "2026-09-27T12:00:00.000Z",
    "durationMs": null
  }
}
```

A rate limit (the per-IP hourly submissions bucket):

```http
HTTP/1.1 429
content-type: application/json
retry-after: 3600

{
  "error": {
    "code": "RATE_LIMITED",
    "message": "Too many requests: the per-IP limit of 60 submissions per hour was reached.",
    "agent_instruction": "Wait 3600 seconds (the Retry-After header carries the same number), then retry. Space submissions out and never resubmit an unchanged image.",
    "retryable": true
  }
}
```

The limits themselves are in `capabilities.limits` (snapshot in §1): per IP 30 `POST /jobs` requests and
60 submissions per hour (a sliding hour); globally 200 `POST /jobs` requests per UTC day, 12 submissions per
minute and 600 per UTC day (the daily counts start again at midnight UTC); 10 counted submissions per job.
The job buckets count requests, not jobs: a `POST /jobs` refused afterwards with `400`, and an idempotent
replay, take a slot too. A `429` is not counted by the bucket that sent it, but the buckets checked before
it (per-IP first) already counted the request.

## 9. Cancel a job

When the person abandons the design before approving, cancel it — their images are deleted at once instead
of 180 days after the job expires:

```sh
curl -sS -X DELETE "$BASE/jobs/$JOB" -H "Authorization: Bearer $SECRET"
```

```http
HTTP/1.1 200
content-type: application/json

{ "ok": true }
```

The job stays readable with its secret (`status: "cancelled"`, no submissions, `approval: null`), a second
`DELETE` is `200` again, and uploads are refused. An upload that was still processing when you cancelled is
finished first and deleted by the next sweep (within about 10 minutes); until then it can show as
`latestSubmission`. The job's `expiresAt` is now when the bare job row is deleted, 30 days later. An
expired job is different: it keeps its submissions and files, still readable with the secret, and its
`expiresAt` is when the whole job is deleted, 180 days after it expired.

```http
HTTP/1.1 409
content-type: application/json

{
  "error": {
    "code": "JOB_CANCELLED",
    "message": "The job was cancelled.",
    "agent_instruction": "Open a new job: this one was cancelled with DELETE and accepts no submissions. Nothing submitted to it can be recovered: the cancel deleted its submissions and images, and GET with its secret only shows status cancelled."
  }
}
```

An approved job cannot be cancelled (`409 JOB_LOCKED`); an expired one answers `409 JOB_EXPIRED`. Both carry
an instruction for a cancel — there is nothing left to do, and no new job to open unless the person wants
a new design:

```http
HTTP/1.1 409
content-type: application/json

{
  "error": {
    "code": "JOB_EXPIRED",
    "message": "The job has expired.",
    "agent_instruction": "Nothing to cancel: the job already expired; Aperta keeps its images for 180 days and then deletes them. Do not open a new job unless the person wants a new design."
  }
}
```
