# What a capture endpoint owes you.

*31 July 2026 · the launch journal*

Integrations against under-specified endpoints all die the same death: a payload that worked in the test environment fails in production, the error is a prose string nobody's code can branch on, and half the batch was stored before the failure — nobody is sure which half. The EPCIS 2.0 capture interface, as pinned in GS1's official OpenAPI description, is the antidote, because it specifies exactly which failure surfaces where. This post is that contract, door by door, with the bodies as the wire returns them.

## Two doors, two atomicity stories

**`POST /events` — synchronous single-event capture.** One event in the body. The answer is `201` (validated, stored) or a `400` problem document (rejected, not stored) — in the response, immediately. There is no job to poll. Use it for interactive tools, low-rate producers, and anywhere you want the truth in the status code.

**`POST /capture` — the capture-job door for documents.** A full `EPCISDocument` in, `202` + a `Location` header out: `/capture/{captureID}`. The job document at that location carries the outcome — `success`, per-event `errors[]`, timestamps — and `GET /capture` lists your jobs, paginated, scoped to your key. The advertised limits are discoverable before you send anything: `OPTIONS /capture` answers with the full header set — `GS1-EPCIS-Version: 2.0.1`, `GS1-EPCIS-Capture-Limit: 1000` events per call, `GS1-EPCIS-Capture-File-Size-Limit: 6291456` bytes. Exceed either and the answer is `413` with the typed exception `epcisException:CaptureLimitExceededException`, limits restated in the headers.

The spec leaves one semantics choice to the implementation, and this one is law here: **no partial acceptance**. The standard's `GS1-Capture-Error-Behaviour` header lets a server declare whether a failing document is rolled back or partially proceeds. This spine supports exactly `rollback`. Request `proceed` and you get a `400` refusal, not a silent downgrade:

```
HTTP/1.1 400
content-type: application/problem+json

{ "type": "epcisException:ValidationException",
  "title": "unsupported GS1-Capture-Error-Behaviour",
  "detail": "this spine never partially accepts a capture job; only \"rollback\" is supported (got \"proceed\")" }
```

Half-stored batches are how "which half?" enters your incident channel. A job is accepted whole or rejected whole, and the sender's retry logic gets to be one line.

## The failure contract, verbatim

Every failure is an RFC 7807 `application/problem+json` body carrying the standard's own exception vocabulary in `type`. Here is a real rejection — the golden-corpus fixture with a missing `action`, pushed through the same pipeline the HTTP door runs:

```json
{
  "accepted": false,
  "status": 400,
  "problem": {
    "type": "epcisException:ValidationException",
    "title": "capture job rejected — no partial acceptance",
    "status": 400,
    "detail": "every EPCIS event in the payload is rejected; see errors[]",
    "captureID": "0819a3c9-bf35-4d34-b891-71c74c78e361",
    "errors": [
      { "type": "epcisException:ValidationException",
        "title": "must have required property 'action'",
        "status": 400,
        "instance": "/epcisBody/eventList/0" }
    ]
  }
}
```

Note the three properties your sender can rely on. The `type` is machine-branchable — no regex over prose. The `instance` is a JSON Pointer to the exact failing path in *your* payload. And the rejected job is still **persisted**: `GET /capture/{captureID}` returns the identical errors afterward, so the failure survives for the postmortem instead of evaporating with the response. Determinism here is a tested property, not a habit.

## Idempotency: retries are a non-event

Field reality is retries — offline buffers flushing, agents re-firing, batch loads replayed. The dedupe law rides on computed identity: every event's identity is the CBV 2.0 §8.9 hash of its own content. A duplicate `eventID` whose hash matches the stored row is a **benign no-op**, acknowledged without a second insert. The same `eventID` with a *different* hash — two claims wearing one name — is rejected. Both behaviors are pinned corpus fixtures (`sequences/duplicate-benign-replay.json`, `sequences/duplicate-different-content.json`). Your sender needs no sequence numbers, no client-side ledger of what was sent: replay the whole buffer, the store converges.

One more boundary worth knowing before you build: `recordTime` is server-stamped at the door, and caller-supplied values for trust-boundary fields are stripped, never trusted. What you assert is the observation; what the door stamps is the record of receiving it.

## What to build into your sender

The failure table compiles directly into retry policy:

| Response | Meaning | Sender action |
|---|---|---|
| `201` / `202` + Location | stored / job accepted whole | done; record `captureID` |
| `400 ValidationException` | payload wrong, deterministically | fix at `instance` path; do not retry as-is |
| `409` on `POST /events` | same `eventID`, different content | escalate — two claims, one name |
| `413 CaptureLimitExceededException` | over 1,000 events / 6 MB | split the document; resend |
| `415` | wrong media type | send `application/json` |
| network failure / timeout | unknown outcome | resend as-is — idempotency makes it safe |

The last row is the one that pays rent. Because identity is content-derived, "unknown outcome" costs nothing: the retry either inserts or no-ops.

## Try the pipeline before you point production at it

The identical pipeline — strip, validate against the sha256-pinned official schema, hash, dedupe, stamp, append — ships in the npm package and runs entirely on your machine:

```
$ npx epcis.dev capture your-document.json
accepted	captureID=142dcbee-06e8-48ea-8252-985e8ecb68cf	events=1
eventID	urn:uuid:7a000039-0001-4000-8000-000000000001
```

Same laws, same error bodies, exit codes `0/1/2` an agent can branch on. Validate a decade of payloads locally before the first production POST; the schema pins are on [the conformance page](/conformance/) and the local toolkit is on [run it yourself](/run-it-yourself/).

When you are ready to point a WMS, an ERP, or a line at the hosted door, [say what you will capture](/get-a-key/?segment=integration) — capture keys are scoped to exactly that. The read side of the contract — queries, traces, and the pagination law — is [the query interface's own entry](/journal/simple-event-query-working-subset/) in this journal, and the document layer those events join to (purchase orders, despatch advices) is [/business-transactions/](/business-transactions/).

---

**Proof, not adjectives.** This build could not verify the gateway's test suite green, so no sentence on this page claims a passing run — the last verified run is recorded in the repository's own CI, not here. Live on this origin, no key: POST /translate, /validate, /hash. No conformance attestation has ever been issued. The dated ledger is /what-ships-today/.
