---
title: "Register in Batches"
description: "Open a slot, upload images in batches, resume after a failure, fix up entries, then commit. The slot conversation for registering many images."
published: 2026-09-21T23:43:37.841266+00:00
updated: 2026-09-21T23:43:37.841266+00:00
tags: ["parallax", "registration", "rest", "slots"]
url: https://xiobjects.com/docs/xio/parallax/register-in-batches
source: XI Objects
---

<!-- xion:doctype xion+markdown -->
<!-- xion:metadata
{
  "version": "1.0",
  "content_type": "application/xion\u002Bmarkdown",
  "source_type": "xi-content/doc",
  "generator": "xio-content-publisher/1.0.0",
  "generated": "2026-09-21T23:42:05.5805019\u002B00:00",
  "encoding": "utf-8",
  "render_intent": "markdown",
  "title": "Register in Batches",
  "slug": "xio/parallax/register-in-batches",
  "copyright": "\u00A9 2026 XI Objects Inc"
}
-->

# Register in Batches

A slot is a workspace. You open one, upload images into it in as many requests as you like, check what it holds, remove or amend entries, then commit. Commit is the only step that registers anything.

Nothing you upload is registered until you commit. Abandon the slot and it is as if you never uploaded.

## The conversation

1. `POST /slots` opens a slot.
2. `POST /slots/{slotId}/uploads` uploads images. Repeat as needed.
3. `POST /slots/{slotId}/uploads/missing` tells you which of your hashes the slot does not hold, so a retry does not re-send everything.
4. `GET /slots/{slotId}/manifest` reports what the slot holds.
5. `PUT /slots/{slotId}/entries/{imageHash}/manifests` replaces one entry's manifests.
6. `DELETE /slots/{slotId}/entries/{imageHash}` removes one entry.
7. `POST /slots/{slotId}/commit` registers everything held. This is terminal.
8. `GET /slots/{slotId}/progress` reports per entry state, before or after commit.
9. `DELETE /slots/{slotId}` abandons the slot and everything in it.

## Open a slot

```bash
curl -sS -X POST "$BASE/slots" -H "Authorization: Bearer $TOKEN"
```

`201 Created`, with a `Location` header pointing at the new slot.

```json
{
  "slotId": "sl_8x2k9f3m1q7w5e0r4t6y8u",
  "expiresAt": "2026-09-21T14:32:05.118Z"
}
```

Your account may hold a limited number of open slots at once. Past that, `POST /slots` answers `409 Open-slot cap reached` with a `cap` extension naming the limit. Commit or abandon a slot and try again.

## Upload images

`POST /slots/{slotId}/uploads` takes a `multipart/form-data` body carrying several images. Each image is a file part named `image`. The `manifest[<kind>]` parts that immediately precede an `image` part belong to that image.

```bash
curl -sS -X POST "$BASE/slots/$SLOT_ID/uploads" \
  -H "Authorization: Bearer $TOKEN" \
  -F 'manifest[xi-manifest]={"note":"v1"};type=application/json' \
  -F "image=@first.png;type=image/png" \
  -F "image=@second.jpg;type=image/jpeg" \
  -F "image=@third.png;type=image/png"
```

The response is one outcome per image part, in request order. One bad image never fails the request.

```json
{
  "outcomes": [
    { "partIndex": 1, "fileName": "first.png", "accepted": true, "imageHash": "3b8f1a...9e4c", "rejectionReason": null, "registrationRemaining": null, "lookupRemaining": null, "registrationId": null },
    { "partIndex": 2, "fileName": "second.jpg", "accepted": true, "imageHash": "a17c05...2f91", "rejectionReason": null, "registrationRemaining": null, "lookupRemaining": null, "registrationId": null },
    { "partIndex": 3, "fileName": "third.png", "accepted": false, "imageHash": null, "rejectionReason": "This image is already registered.", "registrationRemaining": null, "lookupRemaining": null, "registrationId": "6f0d4d6e-9a0b-4c2e-9b1a-2d3c4e5f6a7b" }
  ]
}
```

| Field | Type | Description |
|-------|------|-------------|
| `partIndex` | `int` | The 1-based position of this image among the image parts you sent. Your handle back to the part. |
| `fileName` | `string?` | The filename you declared on the part, if any. |
| `accepted` | `bool` | Whether the slot now holds this image. |
| `imageHash` | `string?` | The SHA-256 of the accepted bytes. Always `null` on a rejection, even when the service knows the hash. |
| `rejectionReason` | `string?` | A fixed sentence saying why the image was refused. `null` when accepted. |
| `registrationRemaining` | `int?` | Your remaining registration grant. Carried only on a quota rejection. |
| `lookupRemaining` | `int?` | Your remaining look-up grant. Carried only on a quota rejection. |
| `registrationId` | `uuid?` | The registration that already covers this image, when that registration is yours. |

### Uploads are idempotent by image hash

An image is keyed inside the slot by the SHA-256 of its bytes. Uploading the same bytes again answers the same hash, replaces that entry's manifests with whatever the new request carried, and adds no second entry and no second reservation.

A re-upload carrying no manifest part clears that entry's manifests. Whatever the request carries is the whole list afterwards.

### What the engine is asked at upload

Every genuinely new image is checked against what is already registered, once, at upload. Nothing is registered by that check. It decides whether the slot may hold the image at all.

An image that clears the check is held. An image that does not is refused and remembered as an **errata**, described below.

### When a batch straddles your grant

If some images in one request fit your remaining registration grant and some do not, the request still answers `200 OK`. The ones that fit are held. Each one that does not is refused on its own, carrying `rejectionReason`, `registrationRemaining` and `lookupRemaining`.

Only when **none** of a request's images fit does the whole request answer `429 Quota exceeded`.

## Errata

An errata is an image the slot remembers but will never register, because an original of it is already registered.

An errata entry holds nothing, reserves no grant, costs no registration, and is never sent at commit. The slot keeps it so that a repeat upload of the same bytes is answered from memory without asking the engine again.

Where it shows up:

| Surface | What you see |
|---------|--------------|
| Upload outcome | `accepted: false`, `rejectionReason` naming why, `registrationId` when the match is yours. |
| `GET /slots/{slotId}/manifest` | `state: "errata"` and `notRegisteredReason` carrying the sentence. This route never names a registration id, on any entry. |
| `GET /slots/{slotId}/progress` | `state: "errata"`, counted in `counts.errata`, with `registrationId` and `failureReason` populated. |
| `POST /slots/{slotId}/commit` | `state: "errata"`, with `failureReason` and, when the matched registration is yours, `registrationId`. |
| `POST /slots/{slotId}/uploads/missing` | Nothing. An errata hash is never reported missing, because the slot already knows it. |

To re-ask about an errata image, remove the entry with `DELETE /slots/{slotId}/entries/{imageHash}` and upload it again. That is how you retry after taking the matching registration down.

A slot will only remember so many errata. Past that ceiling, a further errata image is refused with `This slot already remembers the maximum number of images it will not register.` Reaching that ceiling costs you nothing: the engine is not called and no look-up is reserved.

## Resume

`POST /slots/{slotId}/uploads/missing` takes the hashes you mean to upload and answers the ones the slot does not already hold. Compute the SHA-256 of each file yourself, lowercase hex.

```bash
curl -sS -X POST "$BASE/slots/$SLOT_ID/uploads/missing" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"hashes":["3b8f1a...9e4c","a17c05...2f91","ffff...ffff"]}'
```

```json
{ "missing": ["ffff...ffff"] }
```

Upload only what comes back. The declared list has a cap: past it, the call answers `400 Too many declared hashes`. Each hash must be 64 lowercase hex characters and appear once, or the call answers `400 Invalid declared hash` naming the value.

## Report what the slot holds

`GET /slots/{slotId}/manifest` lists the entries. It never returns a manifest body and never returns image bytes.

```bash
curl -sS "$BASE/slots/$SLOT_ID/manifest" -H "Authorization: Bearer $TOKEN"
```

```json
{
  "entries": [
    {
      "imageHash": "3b8f1a...9e4c",
      "manifestsHash": "c41d7b...80ae",
      "byteLength": 184320,
      "contentType": "image/png",
      "createdAt": "2026-09-21T14:12:41.882Z",
      "state": "held",
      "notRegisteredReason": null
    }
  ]
}
```

`manifestsHash` is a digest of the stored manifest list, or `null` when the entry carries none. Compare it across calls to confirm that what you uploaded is what the slot holds. The list itself is not returned here.

`GET /slots/{slotId}` reports the slot rather than its entries:

```json
{
  "slotId": "sl_8x2k9f3m1q7w5e0r4t6y8u",
  "status": "open",
  "entryCount": 3,
  "openedAt": "2026-09-21T14:12:05.118Z",
  "expiresAt": "2026-09-21T14:32:05.118Z"
}
```

`status` is `open` before commit and `committed` after. An abandoned slot no longer exists, so it answers `404`.

## Amend an entry before commit

`PUT /slots/{slotId}/entries/{imageHash}/manifests` replaces one entry's whole manifest list. It takes the same multipart idiom the upload does: zero or more `manifest[<kind>]` parts and nothing else.

```bash
curl -sS -X PUT "$BASE/slots/$SLOT_ID/entries/$IMAGE_HASH/manifests" \
  -H "Authorization: Bearer $TOKEN" \
  -F 'manifest[xi-manifest]={"note":"replaced"};type=application/json'
```

It is a whole list replace. A well formed multipart body carrying no manifest part clears the list. The response is that one entry, in the manifest report's entry shape.

`DELETE /slots/{slotId}/entries/{imageHash}` removes an entry and answers `204 No Content`. It is idempotent: removing an entry that is already gone still answers `204`. Removing an entry gives back whatever grant it was holding.

## Commit

`POST /slots/{slotId}/commit` is the only call that registers anything. It is terminal.

```bash
curl -sS -X POST "$BASE/slots/$SLOT_ID/commit" -H "Authorization: Bearer $TOKEN"
```

```json
{
  "slotId": "sl_8x2k9f3m1q7w5e0r4t6y8u",
  "status": "committed",
  "entries": [
    { "imageHash": "3b8f1a...9e4c", "state": "registered", "registrationId": "6f0d4d6e-9a0b-4c2e-9b1a-2d3c4e5f6a7b", "failureReason": null },
    { "imageHash": "a17c05...2f91", "state": "errata", "registrationId": null, "failureReason": "This image is a derivative of an image that is already registered." },
    { "imageHash": "b90e33...41cd", "state": "failed", "registrationId": null, "failureReason": "The engine refused this item: StoredRegistrationPacketUnreadable. The stored registration packet could not be read." }
  ]
}
```

| `state` | Meaning |
|---------|---------|
| `registered` | The image is registered. `registrationId` names it. |
| `errata` | The image was not registered because an original of it already is. Charged nothing. |
| `failed` | This entry could not be committed. `failureReason` says why. Charged nothing. |

One entry's outcome never affects its siblings. A failed entry does not stop the rest of the slot registering.

A commit answers a whole-request refusal instead of a body when it cannot proceed at all:

| Status | Title | When |
|--------|-------|------|
| `404` | `Slot not found` | The slot id is unknown, or it is not yours. |
| `409` | `Slot is no longer open` | The slot is already committed or abandoned, or a concurrent commit won the race. |
| `429` | `Quota exceeded` | The number of images that would be billed exceeds your remaining registration grant. Nothing is registered and the slot stays open. |
| `503` | `Engine not configured` | The deployment has no engine wired. |

The service also refuses at commit any image your account already holds a registration for, before the engine is asked. Those entries land as `errata` naming your own registration id, and are charged nothing.

## Progress

`GET /slots/{slotId}/progress` reports the slot's state and every entry's own, before or after commit. It is polling only. Reading progress does not keep the slot alive.

```json
{
  "slotId": "sl_8x2k9f3m1q7w5e0r4t6y8u",
  "status": "committed",
  "counts": { "total": 3, "held": 0, "registered": 1, "answered": 0, "failed": 1, "errata": 1 },
  "entries": [
    { "imageHash": "3b8f1a...9e4c", "state": "registered", "registrationId": "6f0d4d6e-9a0b-4c2e-9b1a-2d3c4e5f6a7b", "failureReason": null },
    { "imageHash": "a17c05...2f91", "state": "errata", "registrationId": "2c1e7f80-5d3b-4a92-8e11-0f4c6b7d8a90", "failureReason": "This image is already registered." },
    { "imageHash": "b90e33...41cd", "state": "failed", "registrationId": null, "failureReason": "The engine refused this item: StoredRegistrationPacketUnreadable. The stored registration packet could not be read." }
  ]
}
```

`counts.answered` belongs to the look-up conversation. On a registration slot it is always `0`.

## Abandon

`DELETE /slots/{slotId}` throws the slot away, entries and all. It answers `204 No Content`. Nothing was registered, so nothing is undone and nothing is charged. Any grant the slot was holding comes back.

A committed slot cannot be abandoned: the call answers `409 Slot is no longer open`. Let it expire instead.

## How long a slot lives

`expiresAt` on every slot response tells you when the slot dies. It is the earlier of two clocks:

- An idle window that moves forward every time you issue a command against the slot.
- An absolute ceiling fixed when the slot opened, which never moves.

Commands that push the idle window forward: uploading images, asking what is missing, replacing an entry's manifests, removing an entry, and a commit's own completion. Plain reads do not: `GET /slots/{slotId}`, `GET /slots/{slotId}/manifest` and `GET /slots/{slotId}/progress` leave the clock alone.

After a commit, the slot and its results stay readable until `expiresAt`. Then the slot is purged and every route answers `404`. Read what you need from the commit response itself, or poll progress before the slot expires.

If a slot expires before you commit, everything in it is gone and nothing was registered.

## Ceilings on one slot

| Ceiling | What happens |
|---------|---------------|
| Errata remembered per slot | A further errata image is refused per image with `This slot already remembers the maximum number of images it will not register.` |
| Uncommitted entries across all your open slots | Bounded by your remaining registration grant. A new image past it is refused per image with `The account's remaining registration grant does not cover this image.` |

## Ownership

A slot belongs to the account whose token opened it. Every `/slots` route scopes its read by that account. A slot id belonging to another account answers exactly the same `404 Slot not found` body as an id that never existed. Nothing tells a caller that an id it guessed is real.
<!-- xion:trust
{
  "v": 1,
  "canon_v": 1,
  "ctx": "xiobjects.com/content",
  "hash_blake3_hex": "efcab725638e39eb39eb4b60a591aa1dbb37fb2acd5458aa092e588a9d8e3040",
  "hash_sha256_hex": null,
  "sig_alg": "ed25519",
  "sig_b64": "rJpHxvEDqVqk3cG2VQ_Vkl_rG9knQhRtu9UEBLvqaWf3pgIRNK84Sm9d_Cmjx84o0Ln4_Vsp0kZDcqLigw0QDw",
  "pubkey_b64": "VrV0rqYGnIqutEwPTr11jfrjH9wmtkOlL68wL0W6ed0",
  "x509_chain_pem": [
    "-----BEGIN CERTIFICATE-----\nMIIB9DCCAaagAwIBAgIQbmGqXUijS3XuRUHDuVME8jAFBgMrZXAwLjEsMCoGA1UE\nAwwjWEkgT2JqZWN0cyBJbmMgQ29udHJvbCBJbnRlcm1lZGlhdGUwHhcNMjYwOTIx\nMjMzNjU1WhcNMjYxMDIxMjMzNjU1WjBLMR4wHAYDVQQDDBV4aW8tY29udGVudC1w\ndWJsaXNoZXIxFzAVBgNVBAoMDlhJIE9iamVjdHMgSW5jMRAwDgYDVQQLDAdDb250\nZW50MCowBQYDK2VwAyEAVrV0rqYGnIqutEwPTr11jfrjH9wmtkOlL68wL0W6ed2j\ngbwwgbkwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYI\nKwYBBQUHAyQwZQYDVR0jBF4wXIAUOym3mFmw/qs1fgKrujCkxhrTk7KhLqQsMCox\nKDAmBgNVBAMMH0luc3RpdHV0ZSBvZiBQcm92ZW5hbmNlIFJvb3QgQ0GCFFJgN/ix\nQn72H6h3T5lEr9f8lJQFMB0GA1UdDgQWBBTnptRqwN8T\u002B5J0zUSRl65iscaPUzAF\nBgMrZXADQQBTzG1wSuUk70ymEN3Mj6XxsS1c5egjDoy\u002BW/V2kko5c2a1Cs7c/kiD\n6H2y9z1DNSH5qjzLZcm9JKPN0mjCy8MO\n-----END CERTIFICATE-----\n",
    "-----BEGIN CERTIFICATE-----\nMIIByDCCAXqgAwIBAgIUUmA3\u002BLFCfvYfqHdPmUSv1/yUlAUwBQYDK2VwMCoxKDAm\nBgNVBAMMH0luc3RpdHV0ZSBvZiBQcm92ZW5hbmNlIFJvb3QgQ0EwHhcNMjUxMTAy\nMDMxNzEyWhcNMzAxMTAxMDMxNzEyWjAuMSwwKgYDVQQDDCNYSSBPYmplY3RzIElu\nYyBDb250cm9sIEludGVybWVkaWF0ZTAqMAUGAytlcAMhAFSS/pggSRmTcAMko7uc\nATH8OHgxVymd5mBFlPXbJkgio4GtMIGqMBIGA1UdEwEB/wQIMAYBAf8CAQAwDgYD\nVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBQ7KbeYWbD\u002BqzV\u002BAqu6MKTGGtOTsjBlBgNV\nHSMEXjBcgBQAZRTDswSVORu\u002BkUOKX6WvrOvmQKEupCwwKjEoMCYGA1UEAwwfSW5z\ndGl0dXRlIG9mIFByb3ZlbmFuY2UgUm9vdCBDQYIUJqoJlpiSFg\u002B7W5IJLMrLttgR\nQp4wBQYDK2VwA0EA5FOht7YOsVRPp/FOKMQ\u002B3Mo9JxrvGR3ylKWAWNm6OUV7N3DB\nI9cD62wU5I0d0EKDBy0CX9DnoqUyxv5yguraAA==\n-----END CERTIFICATE-----\n",
    "-----BEGIN CERTIFICATE-----\nMIIBaTCCARugAwIBAgIUJqoJlpiSFg\u002B7W5IJLMrLttgRQp4wBQYDK2VwMCoxKDAm\nBgNVBAMMH0luc3RpdHV0ZSBvZiBQcm92ZW5hbmNlIFJvb3QgQ0EwHhcNMjUxMTAy\nMDMwNTEyWhcNMzUxMDMxMDMwNTEyWjAqMSgwJgYDVQQDDB9JbnN0aXR1dGUgb2Yg\nUHJvdmVuYW5jZSBSb290IENBMCowBQYDK2VwAyEAEWNZl\u002Br3IC7\u002BgBh90Yo1kWk1\npZCVzVuFdFT7qBBU8W2jUzBRMB0GA1UdDgQWBBQAZRTDswSVORu\u002BkUOKX6WvrOvm\nQDAfBgNVHSMEGDAWgBQAZRTDswSVORu\u002BkUOKX6WvrOvmQDAPBgNVHRMBAf8EBTAD\nAQH/MAUGAytlcANBAO6QeydOFNrN75qNyftggYudsxMyl4w9qWkSdZ6hlhrRcbSr\niG9Si0kbrIJOwYB/LTBU0RM4Rl\u002Bo9PM3Qp0mPwo=\n-----END CERTIFICATE-----\n"
  ],
  "key_id": "_A4EpsXvfBfVEGu5deK65yN4EjBJsUpgkdoS25zPEFk",
  "created_at": "2026-09-21T23:42:05Z"
}
-->