> For a complete page index of the Captain API documentation, fetch https://docs.captain.dev/llms.txt?excludeSpec=true

# Records

> Structure records, then get, add, update and delete them. Every change reaches search on its own.

> **Note**
>
> Product Search is in beta with design partners. Endpoints roll out per tenant.

A record is one thing people search for: a product, an article, a listing or a contract. Each record is a JSON object with an id and the fields search should know about.

Records reach search on their own. Added and replaced records become searchable within a few seconds. Updates and deletes take effect within about a second. There's no build step to run.

Reading records takes a search key or a scoped key. Adding, updating and deleting records takes a write key, which Captain issues. Keep the write key on a server, and never ship it in a browser or an app.

## Structure a record

Here is a product record with the kinds of fields most indexes use:

```json
{
  "id": "sku-10482",
  "title": "Moab 3 Mid Waterproof Hiking Boot",
  "description": "Waterproof leather and mesh mid boot with a Vibram TC5+ outsole and a breathable membrane.",
  "brand": "Merrell",
  "category": "Hiking Boots",
  "color": ["brown", "tan"],
  "price": 139.0,
  "in_stock": true,
  "released": "2024-03-01",
  "attributes": {"waterproof": true, "weight_g": 1060},
  "store_location": {"lat": 39.7392, "lon": -104.9903}
}
```

Every record needs an id, in the field named at onboarding, which is usually `id`. When you send a record whose id the index already holds, the new record replaces the old one.

Captain agrees each field's type with you at onboarding. When a record has a field the schema doesn't name, Captain types it from its values. Dates, geo points and lists of objects are recognised, and other strings become text or keyword fields. [Index settings](/guides/product-search/settings) covers which fields are searched, faceted and sorted.

A nested object turns into dotted field names, so `attributes.weight_g` filters like any other field. A list gives a field several values, so `color = "tan"` matches the record above.

Values have to match their field's type. A record with a value that doesn't fit is rejected, and the reply names the record, the field and the problem.

### Field types

Each field has one of these types. The type decides what a value looks like and what search can do with it.

* **Text** is a string of words, such as a title or a description. Search matches the words in it, and titles count for more than descriptions. Text fields can't be filtered.
* **Keyword** is a string, or a list of strings, such as a brand or a category. Search can filter, facet and group on the exact value, and its words also match searches.
* **Enum** is one value from a set agreed at onboarding, such as a condition or a status. Search can filter and facet on it. A value outside the set is rejected.
* **Identifier** is a code, such as a SKU, a part number, a CUSIP, or a case or account number. A search for the exact code ranks that record first, and the field can be filtered exactly.
* **Number** is a number, or a number with a unit, such as `"5 lb"` or `"30 in"`. Search can filter, range, sort and facet on it. Values in other units of the same quantity are converted.
* **Date** is an ISO 8601 date or datetime, or unix seconds. Search can filter, range and sort on it. Dates are stored in UTC.
* **Geo** is a location, written as `{"lat": …, "lon": …}`, `[lat, lon]` or `"lat,lon"`. Search can filter by distance, as in `store_location WITHIN 25 km OF (39.74, -104.99)`.
* **Bool** is `true` or `false`. The strings `"yes"` and `"no"` also work. Search can filter on it.
* **URL** is a link. It comes back with the record.
* **Children** is a list of objects, such as the clauses of a contract or the line items of an order. Their text is searched with the parent record, `children.field` filters match when any child matches, and a child can be cited as the passage that answered a search.

[List Filters](/reference/product-search/indexes/list-filters) returns each field's type and the filter operators it accepts.

### Per-record access

On an index with per-record access control, each record carries an access list. The list names the users, groups or roles allowed to see the record. Its field is named at onboarding, and is `acl` by default.

```json
{"id": "case-714816", "title": "Indemnification cap dispute", "acl": ["group:litigation", "user:481"]}
```

A scoped key is minted for one end user. It returns only the records whose access list names that user, or one of that user's groups. What a record without an access list means, whether visible to nobody or to everyone, is agreed at onboarding.

A write key can change access lists, which includes making a record visible to everyone. Check how the integration sets access fields before going live.

## How changes reach search

Captain applies every change in the order it arrives. Each write gets a `seq` number that marks its place in that order. A new index starts at `seq` 1, including one created with the name of an index you deleted. [Get Update Status](/reference/product-search/indexes/get-update-status) reports the last `seq` that search has applied. When it reaches the `seq` of your write, the change is live.

Added and replaced records become searchable within a few seconds. Search keeps answering the whole time, from the records it already has.

Updates and deletes take effect within about a second. A user who loses access to a record stops seeing it as soon as that update is applied.

Suggestions and spelling correction learn new words, and forget removed ones, within a few minutes. Captain refreshes them in the background after changes settle, and search keeps serving while it does.

Writes always go to your tenant's primary region, and reads are served from the primary. A tenant can have other regions provisioned, but they don't serve reads until cross-region sync ships. This section will describe it then. Until then, `regions_applied` lists only the primary region and `regions_pending` is empty.

### When added records wait for a build

Get Update Status also carries `build`, where the automatic build of added records stands. Its `state` is `idle`, `waiting` (with `starts_in_s`), `building` or `failed`. A failed build is tried again on its own, three times in all, 30 seconds and then two minutes apart. While a try is left, `retry_in_s` says when the next one starts, and `message` says why the last one failed. After the third, the next write starts another.

A records update or delete answers with `job_id`, the write job it belongs to. It also lists the ids it skipped because the index doesn't have them, in `not_found` with their count in `not_found_count`. And it lists the ids that exist only in added records not built yet, in `waiting_for_build` with `waiting_for_build_count`. Those apply once that build is served.

A large first load can take a while to build. While the build runs, `build` in Get Update Status names its job in `job_id` and shows how far it has got in `progress`. [Get Job](/reference/product-search/indexes/get-job) with that id returns the same `progress`, and each step under `stages`. A write key reads the automatic builds of its own indexes. A build another key started answers `404` `JOB_NOT_FOUND`.

`step` is the step running now, named as in `stages`. It's `queued` before the first step and `done` after the last. `step_number` counts from 1 up to `steps`. `records_done` is how many records the running step has done, out of `records_total`. A step that doesn't count records reports every record done.

#### curl

```bash
curl -X GET "https://acme.captain.dev/v1/jobs/job_20260929231500_1a2b3c" \
  -H "Authorization: Bearer $CAPTAIN_WRITE_KEY"
```

#### Python

```python
import json
import requests

BASE_URL = "https://acme.captain.dev"
API_KEY = "your_write_key"
JOB_ID = "job_20260929231500_1a2b3c"

headers = {
    "Authorization": f"Bearer {API_KEY}",
}

response = requests.get(
    f"{BASE_URL}/v1/jobs/{JOB_ID}",
    headers=headers,
    timeout=30.0,
)

print(json.dumps(response.json(), indent=2))
```

#### TypeScript

```typescript
const BASE_URL = "https://acme.captain.dev";
const API_KEY = "your_write_key";
const jobId = "job_20260929231500_1a2b3c";

const response = await fetch(`${BASE_URL}/v1/jobs/${jobId}`, {
  method: "GET",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
  },
});

const result = await response.json();
console.log(JSON.stringify(result, null, 2));
```

**`Example: (200 OK)`**

```json Example: (200 OK)
{
  "id": "job_20260929231500_1a2b3c",
  "type": "index.build",
  "index": "products",
  "status": "running",
  "created_at": 1759187700,
  "started_at": 1759187702,
  "finished_at": null,
  "stages": [
    {
      "name": "schema",
      "status": "done",
      "items": 250000,
      "seconds": 41
    },
    {
      "name": "dense",
      "status": "running",
      "items": 118000,
      "of": 250000
    },
    {
      "name": "splade",
      "status": "queued"
    },
    {
      "name": "upload",
      "status": "queued"
    },
    {
      "name": "tables",
      "status": "queued"
    },
    {
      "name": "verify",
      "status": "queued"
    },
    {
      "name": "publish",
      "status": "queued"
    },
    {
      "name": "replicate",
      "status": "queued"
    }
  ],
  "error": null,
  "result": null,
  "progress": {
    "step": "dense",
    "step_number": 2,
    "steps": 8,
    "records_done": 118000,
    "records_total": 250000
  }
}
```

### When your records will be searchable

While added records wait for a build or are being built, Get Update Status carries `eta`: an estimate of when they will be searchable. It is `null` when nothing is waiting.

Most adds to an index that already serves don't wait for a build. They match keyword searches within about a second and are fully searchable a few seconds later. While they're being made fully searchable, `eta` has `mode` `fast_lane`, `searchable_in_s` of `0` because keyword search finds them already, and two `stages`: `keyword`, which is `done`, and `meaning`, which is `running`. Once they're fully searchable, `eta` is `null`, even though Captain later rebuilds the index's ranking tables in the background.

A first build, a request over 1,000 records, clearing an index and a settings change still wait for a build, and `eta` estimates it as below.

Captain works the estimate out from the work ahead of your records and from how fast your own recent builds ran, stage by stage. It updates as the build moves, so ask again for a fresher answer.

| Field                   | Meaning                                                                                                                                                                                                                                                                               |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `searchable_in_s`       | Seconds from now until the records are searchable, as `low`, `point` and `high`. `point` is the likeliest time. `low` and `high` show how much your recent builds varied.                                                                                                             |
| `searchable_by`         | The same three times as Unix timestamps in seconds.                                                                                                                                                                                                                                   |
| `keyword_first`         | Present and `true` on a new index's first build. It goes live for keyword searches before it has added each record's meaning, so `searchable_in_s` estimates that moment.                                                                                                             |
| `fully_searchable_in_s` | With `keyword_first`: seconds until the first build has finished adding meaning too, as `low`, `point` and `high`.                                                                                                                                                                    |
| `all_regions_in_s`      | Reserved: seconds until every region serves the records, as `low`, `point` and `high`. It isn't reported yet.                                                                                                                                                                         |
| `state`                 | `waiting`: the build hasn't started, or your records wait for the build that's running now. `building`: the build that holds them is running.                                                                                                                                         |
| `mode`                  | `full`: the whole index is built again. `incremental`: only the changed records are written into the index that's serving. Small changes to an index that already serves take the incremental path. `fast_lane`: no build is needed, and the records are being made fully searchable. |
| `records`               | Records in the build.                                                                                                                                                                                                                                                                 |
| `to_encode`             | Records the build must process from scratch, as `low` and `high`. Records that haven't changed since the last build cost much less.                                                                                                                                                   |
| `stages`                | The same estimate step by step, in order: `name`, `status`, and `low_s`, `point_s` and `high_s` in seconds, plus `units` (records the step handles) and `basis`. A step that's done shows `0`.                                                                                        |
| `basis`                 | Where the speeds come from. `index`: this index's recent builds. `tenant`: other indexes' builds, when this one has none yet. `default`: no build has run on this tenant yet.                                                                                                         |
| `jobs_measured`         | How many recent builds the speeds come from.                                                                                                                                                                                                                                          |
| `shared_with_builds`    | Present when other indexes are building at the same time. Builds share capacity, so each one runs slower.                                                                                                                                                                             |
| `job_id`                | The build job, once it has started.                                                                                                                                                                                                                                                   |

The estimate is least accurate for the first build on a new tenant. No build has run there yet, so `basis` is `default` and the range is wide. It sharpens after the first build finishes. A build much larger than any before it is also less certain, so its range is wider.

```json
{
  "applied_seq": 41,
  "accepted_seq": 42,
  "build": { "automatic": true, "state": "building", "job_id": "job_20260929231500_1a2b3c" },
  "eta": {
    "state": "building",
    "mode": "full",
    "records": 20000,
    "to_encode": { "low": 10000, "high": 10000 },
    "searchable_in_s": { "low": 610, "point": 690, "high": 780 },
    "searchable_by": { "low": 1790723110, "point": 1790723190, "high": 1790723280 },
    "stages": [
      { "name": "prepare", "status": "done", "low_s": 0, "point_s": 0, "high_s": 0 },
      { "name": "dense", "status": "running", "low_s": 380, "point_s": 420, "high_s": 470 }
    ],
    "basis": "index",
    "jobs_measured": 4,
    "job_id": "job_20260929231500_1a2b3c"
  }
}
```

### When an index outgrows its tenant

There's no record limit. Every write is accepted and stored durably before its reply. When the records outgrow what the tenant has room for, Captain resizes the tenant and then builds. Search keeps answering from the last build meanwhile. Get Update Status shows `build.state` as `scaling` with `eta_s`, the seconds until the new records are searchable, about, and `message` says the same in words.

### Read your own writes

Every write that answers `202` also sends its `seq` in the `X-Captain-Seq` response header, the same number as the `seq` in the body.

Every answer about an index carries `X-Captain-Applied-Seq`: the last `seq` that search had applied when the request started. The results reflect at least that much. The header is missing on `401` and `403` answers, and for an index that doesn't exist.

Every answer about an index also carries `X-Captain-Index-Incarnation`, and [Get Update Status](/reference/product-search/indexes/get-update-status) returns the same value as `incarnation`. It's an opaque id that changes only when the index is deleted and created again, or restored from a backup. A re-created index starts again at `seq` 1, and a restored one continues from the backup's `seq`. So compare `seq` numbers only when the incarnation matches.

To make sure a read sees a write you just made, send that write's `seq` in the `X-Captain-Min-Seq` request header. It works on search, typeahead, browse, similar and Get Records. Captain answers from a copy of the index that has applied at least that `seq`, and never waits for one: when none has, you get the most current answer available. The `x-captain-min-seq-met` response header tells you which:

* `true`: the answer includes your write.
* `false`: it doesn't yet. Retry shortly.
* `unknown`: the answer carries no `X-Captain-Applied-Seq`, for example a `401`, a `403` or an index that doesn't exist.

The header must be a whole number. Anything else gets `400` `BAD_MIN_SEQ`. An empty value counts as no header.

```bash
# write, keep its seq, then search with it
SEQ=$(curl -s -D - -o /dev/null -X POST "https://acme.captain.dev/v1/indexes/products/records" \
  -H "Authorization: Bearer $CAPTAIN_WRITE_KEY" -H "Content-Type: application/json" \
  -d '[{"id": "sku-10482", "title": "Moab 3 Mid Waterproof Hiking Boot"}]' \
  | grep -i '^x-captain-seq:' | tr -dc '0-9')

curl -s -D - -X POST "https://acme.captain.dev/v1/indexes/products/search" \
  -H "Authorization: Bearer $CAPTAIN_SEARCH_KEY" -H "Content-Type: application/json" \
  -H "X-Captain-Min-Seq: $SEQ" \
  -d '{"query": "moab"}'
```

Every answer names the region that served it in the `X-Captain-Cell` response header, for example `use1` for us-east-1. Writes always go to your primary region, and today reads do too. If Captain retried the request in a second region, the value ends in `(second attempt)`. Browsers can read it, because it's listed in `Access-Control-Expose-Headers`.

#### curl

```bash
curl -X GET "https://acme.captain.dev/v1/indexes/products/updates" \
  -H "Authorization: Bearer $CAPTAIN_WRITE_KEY"
```

#### Python

```python
import json
import requests

BASE_URL = "https://acme.captain.dev"
API_KEY = "your_write_key"
INDEX = "products"

headers = {
    "Authorization": f"Bearer {API_KEY}",
}

response = requests.get(
    f"{BASE_URL}/v1/indexes/{INDEX}/updates",
    headers=headers,
    timeout=30.0,
)

print(json.dumps(response.json(), indent=2))
```

#### TypeScript

```typescript
const BASE_URL = "https://acme.captain.dev";
const API_KEY = "your_write_key";
const index = "products";

const response = await fetch(`${BASE_URL}/v1/indexes/${index}/updates`, {
  method: "GET",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
  },
});

const result = await response.json();
console.log(JSON.stringify(result, null, 2));
```

**`Example: (200 OK)`**

```json Example: (200 OK)
{
  "accepted_seq": 1844,
  "applied_seq": 1844,
  "pending": 0
}
```

## Get records

Get Records returns records by id. Repeat the `ids` parameter once for each record, up to 100 per request. Asking for a single id works the same way.

Ids that don't exist come back under `missing`. So do ids the key isn't allowed to see. The two cases look the same on purpose, so a scoped key can't learn that a hidden record exists.

#### curl

```bash
curl -G "https://acme.captain.dev/v1/indexes/products/records" \
  -H "Authorization: Bearer $CAPTAIN_SEARCH_KEY" \
  --data-urlencode "ids=sku-10482" \
  --data-urlencode "ids=sku-10517" \
  --data-urlencode "ids=sku-99999"
```

#### Python

```python
import json
import requests

BASE_URL = "https://acme.captain.dev"
API_KEY = "your_search_key"
INDEX = "products"

headers = {
    "Authorization": f"Bearer {API_KEY}",
}

response = requests.get(
    f"{BASE_URL}/v1/indexes/{INDEX}/records",
    headers=headers,
    params={"ids": ["sku-10482", "sku-10517", "sku-99999"]},
    timeout=30.0,
)

print(json.dumps(response.json(), indent=2))
```

#### TypeScript

```typescript
const BASE_URL = "https://acme.captain.dev";
const API_KEY = "your_search_key";
const index = "products";

const params = new URLSearchParams([["ids", "sku-10482"], ["ids", "sku-10517"], ["ids", "sku-99999"]]);

const response = await fetch(`${BASE_URL}/v1/indexes/${index}/records?${params}`, {
  method: "GET",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
  },
});

const result = await response.json();
console.log(JSON.stringify(result, null, 2));
```

**`Example: (200 OK)`**

```json Example: (200 OK)
{
  "records": [
    {
      "id": "sku-10482",
      "title": "Moab 3 Mid Waterproof Hiking Boot",
      "brand": "Merrell",
      "category": "Hiking Boots",
      "price": 139.0,
      "rating": 4.6,
      "image_url": "https://cdn.acme.example/img/sku-10482.jpg",
      "description": "Waterproof leather and mesh mid boot with a Vibram TC5+ outsole and a breathable membrane."
    },
    {
      "id": "sku-10517",
      "title": "X Ultra 4 Mid GTX Hiking Boot",
      "brand": "Salomon",
      "category": "Hiking Boots",
      "price": 165.0,
      "rating": 4.5,
      "image_url": "https://cdn.acme.example/img/sku-10517.jpg",
      "description": "GORE-TEX mid boot with a Contagrip outsole, built for rocky trails."
    }
  ],
  "missing": [
    "sku-99999"
  ]
}
```

## Add or replace records

Add Records takes a batch of records. A record with a new id is added, and a record with an id the index already holds replaces the old one. The records become searchable within a few seconds.

You can send the batch in three formats. A JSON array holds up to 1,000 records and 8 MiB, sent as `application/json`. NDJSON, with one record per line, holds up to 100,000 records and 64 MiB, sent as `application/x-ndjson`. CSV with a header row has the same limits as NDJSON, sent as `text/csv`. CSV cells are typed like JSON values, and an empty cell counts as a missing value.

#### curl

```bash
curl -X POST "https://acme.captain.dev/v1/indexes/products/records" \
  -H "Authorization: Bearer $CAPTAIN_WRITE_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '[
  {
    "id": "sku-20001",
    "title": "Speedcross 6 Trail Running Shoe",
    "brand": "Salomon",
    "category": "Trail Running Shoes",
    "price": 139.0,
    "color": "black"
  },
  {
    "id": "sku-10482",
    "title": "Moab 3 Mid Waterproof Hiking Boot",
    "brand": "Merrell",
    "category": "Hiking Boots",
    "price": 129.0,
    "color": "brown"
  }
]'
```

#### Python

```python
import json
import requests
import uuid

BASE_URL = "https://acme.captain.dev"
API_KEY = "your_write_key"
INDEX = "products"

headers = {
    "Authorization": f"Bearer {API_KEY}",
    "Content-Type": "application/json",
    "Idempotency-Key": str(uuid.uuid4()),
}

response = requests.post(
    f"{BASE_URL}/v1/indexes/{INDEX}/records",
    headers=headers,
    json=[
        {
            "id": "sku-20001",
            "title": "Speedcross 6 Trail Running Shoe",
            "brand": "Salomon",
            "category": "Trail Running Shoes",
            "price": 139.0,
            "color": "black",
        },
        {
            "id": "sku-10482",
            "title": "Moab 3 Mid Waterproof Hiking Boot",
            "brand": "Merrell",
            "category": "Hiking Boots",
            "price": 129.0,
            "color": "brown",
        },
    ],
    timeout=30.0,
)

print(json.dumps(response.json(), indent=2))
```

#### TypeScript

```typescript
const BASE_URL = "https://acme.captain.dev";
const API_KEY = "your_write_key";
const index = "products";

const response = await fetch(`${BASE_URL}/v1/indexes/${index}/records`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify([
    {
      id: "sku-20001",
      title: "Speedcross 6 Trail Running Shoe",
      brand: "Salomon",
      category: "Trail Running Shoes",
      price: 139.0,
      color: "black",
    },
    {
      id: "sku-10482",
      title: "Moab 3 Mid Waterproof Hiking Boot",
      brand: "Merrell",
      category: "Hiking Boots",
      price: 129.0,
      color: "brown",
    },
  ]),
});

const result = await response.json();
console.log(JSON.stringify(result, null, 2));
```

**`Example: (202 Accepted)`**

```json Example: (202 Accepted)
{
  "index": "products",
  "accepted": 2,
  "new": 1,
  "replaced": 1,
  "rejected": [],
  "rejected_count": 0,
  "duplicates": 0,
  "warnings": [],
  "record_count": 12841,
  "max_records": null,
  "seq": 1842,
  "accepted_at": 1759095298.114,
  "regions_applied": [
    "us-east-1"
  ],
  "regions_pending": [
    "us-west-1",
    "us-east-2"
  ]
}
```

The reply says what happened. `accepted` is the number of records taken, split into `new` and `replaced`. `record_count` is the number of records the index now holds. `max_records` is `null`: an index has no record limit, and Captain grows the tenant to fit what you add. The `seq` marks this change's place in order.

By default, one bad record rejects the whole request, and the reply names every bad record. To add the good records anyway, send `on_error=skip`. The bad ones are then listed under `rejected`. Each bad record is named by its line or array position, with a code: `BAD_JSON`, `BAD_ROW`, `MISSING_ID` or `NOT_AN_OBJECT`.

When the same id appears twice in one request, the last copy wins and the reply adds a `DUPLICATE_ID` warning. Send `on_duplicate=fail` to reject the request instead.

For a large file sent in parts, set `line_offset` to the first line number of each part. Errors then report line numbers from the whole file.

## Update fields

Update Records changes some fields of existing records and leaves the rest alone. Send each record's id with only the fields to change, and set a field to `null` to clear it. A request can update up to 1,000 records. The changes take effect within about a second.

#### curl

```bash
curl -X PATCH "https://acme.captain.dev/v1/indexes/products/records" \
  -H "Authorization: Bearer $CAPTAIN_WRITE_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
  "records": [
    {
      "id": "sku-10482",
      "price": 119.0,
      "in_stock": false
    }
  ]
}'
```

#### Python

```python
import json
import requests
import uuid

BASE_URL = "https://acme.captain.dev"
API_KEY = "your_write_key"
INDEX = "products"

headers = {
    "Authorization": f"Bearer {API_KEY}",
    "Content-Type": "application/json",
    "Idempotency-Key": str(uuid.uuid4()),
}

response = requests.patch(
    f"{BASE_URL}/v1/indexes/{INDEX}/records",
    headers=headers,
    json={
        "records": [{"id": "sku-10482", "price": 119.0, "in_stock": False}],
    },
    timeout=30.0,
)

print(json.dumps(response.json(), indent=2))
```

#### TypeScript

```typescript
const BASE_URL = "https://acme.captain.dev";
const API_KEY = "your_write_key";
const index = "products";

const response = await fetch(`${BASE_URL}/v1/indexes/${index}/records`, {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    records: [{ id: "sku-10482", price: 119.0, in_stock: false }],
  }),
});

const result = await response.json();
console.log(JSON.stringify(result, null, 2));
```

**`Example: (202 Accepted)`**

```json Example: (202 Accepted)
{
  "index": "products",
  "seq": 1843,
  "accepted_at": 1759095310.482,
  "updated": 1,
  "deleted": 0,
  "served": true,
  "live_updates": "single_region",
  "regions_applied": [
    "us-east-1"
  ],
  "regions_pending": [
    "us-west-1",
    "us-east-2"
  ],
  "replicates_on_next_build": true,
  "stale_until_build": [],
  "stale_features_until_build": []
}
```

Updates to keyword, enum, identifier, number, date, geo and bool fields, and to access lists, take effect within about a second. An update that names a text field, such as a title or a description, is merged into the stored record, and the whole record is added again, as Add Records does. The fields you didn't send are kept. Such records are listed in `readded`, and become searchable within a few seconds, like an added record. A request that names a text field re-adds every record it updates.

An id the index doesn't have is skipped and listed in `not_found`, and the rest of the request still applies.

The reply counts the records `updated` and `deleted`, and gives the change's `seq`. `stale_features_until_build` lists any features, such as `typeahead` or `spelling`, that still show the old values. They catch up within a few minutes, when Captain refreshes them in the background.

## Delete records

Delete Record removes one record by its id. The record leaves search results, browse totals and Get Records within about a second.

#### curl

```bash
curl -X DELETE "https://acme.captain.dev/v1/indexes/products/records/sku-10482" \
  -H "Authorization: Bearer $CAPTAIN_WRITE_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
```

#### Python

```python
import json
import requests
import uuid

BASE_URL = "https://acme.captain.dev"
API_KEY = "your_write_key"
INDEX = "products"
RECORD_ID = "sku-10482"

headers = {
    "Authorization": f"Bearer {API_KEY}",
    "Idempotency-Key": str(uuid.uuid4()),
}

response = requests.delete(
    f"{BASE_URL}/v1/indexes/{INDEX}/records/{RECORD_ID}",
    headers=headers,
    timeout=30.0,
)

print(json.dumps(response.json(), indent=2))
```

#### TypeScript

```typescript
const BASE_URL = "https://acme.captain.dev";
const API_KEY = "your_write_key";
const index = "products";
const recordId = "sku-10482";

const response = await fetch(`${BASE_URL}/v1/indexes/${index}/records/${recordId}`, {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});

const result = await response.json();
console.log(JSON.stringify(result, null, 2));
```

**`Example: (202 Accepted)`**

```json Example: (202 Accepted)
{
  "index": "products",
  "seq": 1844,
  "accepted_at": 1759095402.117,
  "updated": 0,
  "deleted": 1,
  "served": true,
  "live_updates": "single_region",
  "regions_applied": [
    "us-east-1"
  ],
  "regions_pending": [
    "us-west-1",
    "us-east-2"
  ],
  "replicates_on_next_build": true
}
```

To delete several records at once, send Update Records with a `delete` list of up to 1,000 ids.

#### curl

```bash
curl -X PATCH "https://acme.captain.dev/v1/indexes/products/records" \
  -H "Authorization: Bearer $CAPTAIN_WRITE_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
  "delete": [
    "sku-10482",
    "sku-10517"
  ]
}'
```

#### Python

```python
import json
import requests
import uuid

BASE_URL = "https://acme.captain.dev"
API_KEY = "your_write_key"
INDEX = "products"

headers = {
    "Authorization": f"Bearer {API_KEY}",
    "Content-Type": "application/json",
    "Idempotency-Key": str(uuid.uuid4()),
}

response = requests.patch(
    f"{BASE_URL}/v1/indexes/{INDEX}/records",
    headers=headers,
    json={
        "delete": ["sku-10482", "sku-10517"],
    },
    timeout=30.0,
)

print(json.dumps(response.json(), indent=2))
```

#### TypeScript

```typescript
const BASE_URL = "https://acme.captain.dev";
const API_KEY = "your_write_key";
const index = "products";

const response = await fetch(`${BASE_URL}/v1/indexes/${index}/records`, {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    delete: ["sku-10482", "sku-10517"],
  }),
});

const result = await response.json();
console.log(JSON.stringify(result, null, 2));
```

**`Example: (202 Accepted)`**

```json Example: (202 Accepted)
{
  "index": "products",
  "seq": 1845,
  "accepted_at": 1759095455.903,
  "updated": 0,
  "deleted": 2,
  "served": true,
  "live_updates": "single_region",
  "regions_applied": [
    "us-east-1"
  ],
  "regions_pending": [
    "us-west-1",
    "us-east-2"
  ],
  "replicates_on_next_build": true
}
```

A deleted record's words leave suggestions and spelling within a few minutes, along with the background refresh. Deleted records never appear in results or in Get Records in the meantime.

An id the index doesn't have is not an error. Delete Record answers `202` with `deleted: 0` and the id in `not_found`, and Update Records lists such ids in `not_found` too, for updates and deletes alike. So a retried delete is always safe.

### Delete records by filter

Delete Records By Filter removes every record a [filter](/guides/product-search/search) matches, in the same language search's `filter` takes. Use it to drop discontinued products or a whole brand without listing their ids. Records added but not yet searchable are matched too.

#### curl

```bash
curl -X POST "https://acme.captain.dev/v1/indexes/products/records/delete" \
  -H "Authorization: Bearer $CAPTAIN_WRITE_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
  "filter": "brand = \"Acme\" AND in_stock = false"
}'
```

#### Python

```python
import json
import requests
import uuid

BASE_URL = "https://acme.captain.dev"
API_KEY = "your_write_key"
INDEX = "products"

headers = {
    "Authorization": f"Bearer {API_KEY}",
    "Content-Type": "application/json",
    "Idempotency-Key": str(uuid.uuid4()),
}

response = requests.post(
    f"{BASE_URL}/v1/indexes/{INDEX}/records/delete",
    headers=headers,
    json={"filter": 'brand = "Acme" AND in_stock = false'},
    timeout=30.0,
)

print(json.dumps(response.json(), indent=2))
```

#### TypeScript

```typescript
const BASE_URL = "https://acme.captain.dev";
const API_KEY = "your_write_key";
const index = "products";

const response = await fetch(`${BASE_URL}/v1/indexes/${index}/records/delete`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({ filter: 'brand = "Acme" AND in_stock = false' }),
});

const result = await response.json();
console.log(JSON.stringify(result, null, 2));
```

**`Example: (202 Accepted)`**

```json Example: (202 Accepted)
{
  "index": "products",
  "job_id": "job_20261002184501_1a2b3c",
  "seq": 1852,
  "matched": 214,
  "deleted": 214,
  "staged_records": 12626,
  "accepted_at": 1759430701.118
}
```

The records leave search within about a second, like any delete. The reply's `job_id` tracks it, and Get Update Status shows it applied once `applied_seq` reaches `seq`.

Send `"dry_run": true` to count the matches without deleting anything. The reply then has `matched` and `deleted: 0`.

Repeating the call is safe. The same filter then matches nothing, deletes nothing and answers `200` with `matched: 0`. A filter that names an unknown field, or uses an operator the field doesn't take, answers `400 FILTER_INVALID` and deletes nothing.

### Empty an index

Delete All Records removes every record and keeps the index, with its fields, settings, synonyms and rules. Searches stop returning the records within about a second, including while the index's first build is still running.

#### curl

```bash
curl -X DELETE "https://acme.captain.dev/v1/indexes/products/records" \
  -H "Authorization: Bearer $CAPTAIN_WRITE_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
```

#### Python

```python
import json
import requests
import uuid

BASE_URL = "https://acme.captain.dev"
API_KEY = "your_write_key"
INDEX = "products"

headers = {
    "Authorization": f"Bearer {API_KEY}",
    "Idempotency-Key": str(uuid.uuid4()),
}

response = requests.delete(
    f"{BASE_URL}/v1/indexes/{INDEX}/records",
    headers=headers,
    timeout=30.0,
)

print(json.dumps(response.json(), indent=2))
```

#### TypeScript

```typescript
const BASE_URL = "https://acme.captain.dev";
const API_KEY = "your_write_key";
const index = "products";

const response = await fetch(`${BASE_URL}/v1/indexes/${index}/records`, {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});

const result = await response.json();
console.log(JSON.stringify(result, null, 2));
```

**`Example: (202 Accepted)`**

```json Example: (202 Accepted)
{
  "index": "products",
  "job_id": "job_20261002184733_4d5e6f",
  "seq": 1853,
  "matched": 12626,
  "deleted": 12626,
  "staged_records": 0,
  "accepted_at": 1759430853.402
}
```

To swap in a fresh catalog, empty the index, then add the new records. Searches return nothing in between, so for a switch with no gap build the new catalog in a second index and point your app at it once it is ready.

## Check an index

Get Index reports how many records the index holds and whether any changes are still being applied.

#### curl

```bash
curl -X GET "https://acme.captain.dev/v1/indexes/products" \
  -H "Authorization: Bearer $CAPTAIN_WRITE_KEY"
```

#### Python

```python
import json
import requests

BASE_URL = "https://acme.captain.dev"
API_KEY = "your_write_key"
INDEX = "products"

headers = {
    "Authorization": f"Bearer {API_KEY}",
}

response = requests.get(
    f"{BASE_URL}/v1/indexes/{INDEX}",
    headers=headers,
    timeout=30.0,
)

print(json.dumps(response.json(), indent=2))
```

#### TypeScript

```typescript
const BASE_URL = "https://acme.captain.dev";
const API_KEY = "your_write_key";
const index = "products";

const response = await fetch(`${BASE_URL}/v1/indexes/${index}`, {
  method: "GET",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
  },
});

const result = await response.json();
console.log(JSON.stringify(result, null, 2));
```

**`Example: (200 OK)`**

```json Example: (200 OK)
{
  "name": "products",
  "status": "ready",
  "staged_documents": 12840,
  "staged_records": 12840,
  "record_count": 12840,
  "build_id": "b20260928211503",
  "last_job": "job_20260928211502_4f9c1a",
  "created_at": 1759093140,
  "staged_changes_since_build": false,
  "pending_changes": false,
  "object_search": true,
  "last_build_at": 1759094251,
  "serving": {
    "build": "b20260928211503",
    "documents": 12840,
    "records": 12840
  }
}
```

The `status` field is one of four values. `empty` means the index has no records yet. `pending` means changes were accepted and are still being applied. `building` means Captain is refreshing suggestions and spelling in the background, while search serves as usual. `ready` means every change is applied.

## Manage indexes

A write key manages the indexes it can write: it lists them, deletes them, and reads and cancels their jobs. A key for every index manages them all.

### List indexes

List Indexes returns each index the key can write, with the same fields Get Index returns.

#### curl

```bash
curl -X GET "https://acme.captain.dev/v1/indexes" \
  -H "Authorization: Bearer $CAPTAIN_WRITE_KEY"
```

#### Python

```python
import json
import requests

BASE_URL = "https://acme.captain.dev"
API_KEY = "your_write_key"
INDEX = "products"

headers = {
    "Authorization": f"Bearer {API_KEY}",
}

response = requests.get(
    f"{BASE_URL}/v1/indexes",
    headers=headers,
    timeout=30.0,
)

print(json.dumps(response.json(), indent=2))
```

#### TypeScript

```typescript
const BASE_URL = "https://acme.captain.dev";
const API_KEY = "your_write_key";
const index = "products";

const response = await fetch(`${BASE_URL}/v1/indexes`, {
  method: "GET",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
  },
});

const result = await response.json();
console.log(JSON.stringify(result, null, 2));
```

**`Example: (200 OK)`**

```json Example: (200 OK)
{
  "indexes": [
    {
      "name": "products",
      "status": "ready",
      "record_count": 12840,
      "pending_changes": false,
      "serving": {"build": "b20260928211503", "records": 12840}
    },
    {
      "name": "products-staging",
      "status": "empty",
      "record_count": 0,
      "pending_changes": false,
      "serving": null
    }
  ]
}
```

### Delete an index

Delete Index removes the index, its records, settings, synonyms and rules. It stops answering searches at once.

#### curl

```bash
curl -X DELETE "https://acme.captain.dev/v1/indexes/products" \
  -H "Authorization: Bearer $CAPTAIN_WRITE_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
```

#### Python

```python
import json
import requests
import uuid

BASE_URL = "https://acme.captain.dev"
API_KEY = "your_write_key"
INDEX = "products"

headers = {
    "Authorization": f"Bearer {API_KEY}",
    "Idempotency-Key": str(uuid.uuid4()),
}

response = requests.delete(
    f"{BASE_URL}/v1/indexes/{INDEX}",
    headers=headers,
    timeout=30.0,
)

print(json.dumps(response.json(), indent=2))
```

#### TypeScript

```typescript
const BASE_URL = "https://acme.captain.dev";
const API_KEY = "your_write_key";
const index = "products";

const response = await fetch(`${BASE_URL}/v1/indexes/${index}`, {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
  },
});

const result = await response.json();
console.log(JSON.stringify(result, null, 2));
```

**`Example: (202 Accepted)`**

```json Example: (202 Accepted)
{
  "deleted": "products-staging",
  "keys_narrowed": ["key_6c1f0e2a9b3d4c57"],
  "keys_revoked": ["key_2c7e90a1b4d35f18"],
  "job": {"id": "job_20261002185012_7a8b9c", "type": "index.delete", "status": "queued"}
}
```

Deleting an index also changes the keys that named it:

* A key limited to this index alone is revoked. Its id is in `keys_revoked`. It could reach nothing else, and revoking it means an index created later with the same name is never reachable with it.
* A key that also covers other indexes loses only this one. Its id is in `keys_narrowed`, and it keeps working on the rest.
* A key for every index is never changed.

So deleting a staging index never touches the keys your production index uses, unless a key was limited to the staging index alone. Captain can issue a new key if you need one.

An index that was searchable answers `202` with a job that removes its stored data. An index that never had searchable records answers `200`. A queued or running refresh of the index is cancelled.

### Switch to a new index with an alias

An alias is a name that points at one index. Search through the alias, then rebuild into a new index and switch the alias to it in one call. Every request through the alias reaches the new index once the call answers, and your app's config doesn't change. Your tenant's other regions get the switch with the rest of your writes, usually within a few seconds.

```bash
# 1. Copy the live index's fields, settings, synonyms and rules, without its records
curl -X POST "https://acme.captain.dev/v1/indexes/products-v1/copy" \
  -H "Authorization: Bearer $CAPTAIN_WRITE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"to": "products-v2", "records": false}'

# 2. Add the new catalog to products-v2 and wait until Get Update Status shows it applied

# 3. Point the alias at the new index
curl -X PUT "https://acme.captain.dev/v1/aliases/products" \
  -H "Authorization: Bearer $CAPTAIN_WRITE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"index": "products-v2"}'
```

**`Example: (200 OK)`**

```json Example: (200 OK)
{"alias": "products", "index": "products-v2", "previous": "products-v1"}
```

Every endpoint under `/v1/indexes/{index}`, the Algolia routes and multiple searches accept the alias in place of an index name. A key that names the alias keeps working after a switch, and reaches the index only through the alias.

An alias and an index can't share a name. Deleting an index that an alias points to answers `409 INDEX_HAS_ALIAS`, so an app searching through the alias never starts getting `404`s. Point the alias elsewhere or delete it first. `DELETE /v1/indexes/{alias}` with a key that may delete answers `409 ALIAS_NOT_INDEX` (any other key gets the same `401` or `403` as for a name that doesn't exist), and Delete Alias (`DELETE /v1/aliases/{alias}`) removes only the alias. List Aliases (`GET /v1/aliases`) returns every alias and its index.

Copy Index without `"records": false` copies the records too, which then become searchable on their own. Copying creates an index, so it takes a write key for every index.

### Read usage

Get Usage (`GET /v1/usage`) reports, per index and in total, how many records each index holds and serves, how many records and bytes were added, and how many searches and events it got. `days` sets the window: that many whole UTC days ending today, from `start_date` to `end_date`, 30 by default and at most 90. Today counts up to the moment of the call. Usage answers within about 5 seconds. On a very large search log, days not yet counted are left out, and the index and the reply say `partial: true`. Counting continues after the reply, so a later call is whole. Today's numbers can lag by up to 5 minutes. A write key sees the indexes it writes.

**`Example: (200 OK)`**

```json Example: (200 OK)
{
  "days": 30,
  "start_date": "2026-09-03",
  "end_date": "2026-10-02",
  "indexes": [
    {"index": "products", "records": 12840, "searchable_records": 12840, "accepted_records": 25920,
     "accepted_bytes": 41873022, "searches": 18211, "search_requests": 64120, "events": 2307, "partial": false}
  ],
  "total": {"indexes": 1, "records": 12840, "searchable_records": 12840, "accepted_records": 25920,
            "accepted_bytes": 41873022, "searches": 18211, "search_requests": 64120, "events": 2307},
  "partial": false
}
```

`searches` counts a search once however many keystrokes led to it, and `search_requests` counts every request. `accepted_records` counts every record added since the index was created, replacements included.

### List jobs

List Jobs returns the jobs the key can read, newest first: the jobs it started, and the automatic builds of its indexes. `status` is `running` (the default), `queued`, `succeeded`, `failed` or `all`. `index` limits the list to one index, and `limit` sets how many come back (50 by default, at most 500).

#### curl

```bash
curl -X GET "https://acme.captain.dev/v1/jobs?status=all&index=products" \
  -H "Authorization: Bearer $CAPTAIN_WRITE_KEY"
```

#### Python

```python
import json
import requests

BASE_URL = "https://acme.captain.dev"
API_KEY = "your_write_key"
INDEX = "products"

headers = {
    "Authorization": f"Bearer {API_KEY}",
}

response = requests.get(
    f"{BASE_URL}/v1/jobs?status=all&index=products",
    headers=headers,
    timeout=30.0,
)

print(json.dumps(response.json(), indent=2))
```

#### TypeScript

```typescript
const BASE_URL = "https://acme.captain.dev";
const API_KEY = "your_write_key";
const index = "products";

const response = await fetch(`${BASE_URL}/v1/jobs?status=all&index=products`, {
  method: "GET",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
  },
});

const result = await response.json();
console.log(JSON.stringify(result, null, 2));
```

**`Example: (200 OK)`**

```json Example: (200 OK)
{
  "jobs": [
    {
      "id": "job_20261002184501_1a2b3c",
      "type": "index.write",
      "index": "products",
      "status": "succeeded",
      "created_at": 1759430701,
      "started_at": null,
      "finished_at": 1759430702
    }
  ]
}
```

### Cancel a job

Cancel Job stops a refresh that has not started yet, one with status `queued`. A write key cancels the jobs it started.

#### curl

```bash
curl -X POST "https://acme.captain.dev/v1/jobs/job_20261002193012_9f8e7d/cancel" \
  -H "Authorization: Bearer $CAPTAIN_WRITE_KEY"
```

#### Python

```python
import json
import requests

BASE_URL = "https://acme.captain.dev"
API_KEY = "your_write_key"
JOB_ID = "job_20261002193012_9f8e7d"

headers = {
    "Authorization": f"Bearer {API_KEY}",
}

response = requests.post(
    f"{BASE_URL}/v1/jobs/{JOB_ID}/cancel",
    headers=headers,
    timeout=30.0,
)

print(json.dumps(response.json(), indent=2))
```

#### TypeScript

```typescript
const BASE_URL = "https://acme.captain.dev";
const API_KEY = "your_write_key";
const jobId = "job_20261002193012_9f8e7d";

const response = await fetch(`${BASE_URL}/v1/jobs/${jobId}/cancel`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
  },
});

const result = await response.json();
console.log(JSON.stringify(result, null, 2));
```

**`Example: (200 OK)`**

```json Example: (200 OK)
{
  "id": "job_20261002193012_9f8e7d",
  "type": "index.build",
  "index": "products",
  "status": "failed",
  "cancelled": true,
  "created_at": 1759433412,
  "started_at": null,
  "finished_at": 1759433415,
  "error": "cancelled by key key_2c7e90a1b4d35f18 before it started"
}
```

The job ends with status `failed` and `cancelled: true`, and the index keeps serving what it served before. The next records you add start the next refresh. A running refresh, a finished job, or a write job answers `409 JOB_NOT_CANCELLABLE`. A write job's records are already accepted, so undo them with another write instead.

## Import from Algolia

An existing Algolia index can come into Captain through Import From Algolia. Send either an export of the Algolia index, with its records, settings, synonyms and rules, or read access to it. The import runs at once, with your write key.

> **Warning**
>
> Importing into an index that already has records deletes all of them first, then adds the Algolia records in their place. To keep an index's records, import into a new index instead, and switch your app to it when it's ready.

To give read access, send the Algolia application id, a key with read permissions only, and the index name. Captain refuses a key that can write to Algolia before it reads anything, with `400 ALGOLIA_KEY_CAN_WRITE`. Algolia keys are never stored.

Send `"object_search": true`. The index then gets typed fields from the Algolia settings, the same kind of index Create Index makes, and [Index Settings](/guides/product-search/settings) applies to it. Without it, the import uses an older mapping with no field settings.

#### curl

```bash
curl -X POST "https://acme.captain.dev/v1/indexes/products/import/algolia" \
  -H "Authorization: Bearer $CAPTAIN_WRITE_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
  "app_id": "YOUR_ALGOLIA_APP_ID",
  "api_key": "YOUR_ALGOLIA_READ_KEY",
  "index_name": "prod_products",
  "object_search": true
}'
```

#### Python

```python
import json
import requests
import uuid

BASE_URL = "https://acme.captain.dev"
API_KEY = "your_write_key"
INDEX = "products"

headers = {
    "Authorization": f"Bearer {API_KEY}",
    "Content-Type": "application/json",
    "Idempotency-Key": str(uuid.uuid4()),
}

response = requests.post(
    f"{BASE_URL}/v1/indexes/{INDEX}/import/algolia",
    headers=headers,
    json={
        "app_id": "YOUR_ALGOLIA_APP_ID",
        "api_key": "YOUR_ALGOLIA_READ_KEY",
        "index_name": "prod_products",
        "object_search": True,
    },
    timeout=30.0,
)

print(json.dumps(response.json(), indent=2))
```

#### TypeScript

```typescript
const BASE_URL = "https://acme.captain.dev";
const API_KEY = "your_write_key";
const index = "products";

const response = await fetch(`${BASE_URL}/v1/indexes/${index}/import/algolia`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    app_id: "YOUR_ALGOLIA_APP_ID",
    api_key: "YOUR_ALGOLIA_READ_KEY",
    index_name: "prod_products",
    object_search: true,
  }),
});

const result = await response.json();
console.log(JSON.stringify(result, null, 2));
```

**`Example: (202 Accepted)`**

```json Example: (202 Accepted)
{
  "job": {
    "id": "job_20260928213040_9b27e1",
    "type": "index.import",
    "index": "products",
    "status": "importing",
    "created_at": 1759095040,
    "started_at": 1759095040,
    "finished_at": null,
    "stages": [],
    "error": null,
    "result": null
  }
}
```

Reading from Algolia answers `202` with an import job: Captain reads the index, adds the records with their settings, synonyms and rules, and starts the build (`result.build_job`). Send `"build": false` to skip the build. Get Job reports its progress, and `result.counts` how many settings, synonyms and rules were mapped, approximated or not mapped.

An export in the body is imported at once and answers `200`. Its `report` lists each setting, synonym and rule as `mapped`, `approximated` or `not_mapped`, with the reason. Add `"build": true` to start the build too. Send `"dry_run": true` with an export to see that report without storing anything.

An index the import creates is keyed by `objectID`. Importing into a new index needs a write key for every index. A second import into the same index while one runs answers `409 IMPORT_RUNNING`. [Index Settings](/guides/product-search/settings#settings-from-algolia) lists how each Algolia setting, synonym and rule carries over.

#### curl

```bash
curl -X GET "https://acme.captain.dev/v1/jobs/job_20260928213040_9b27e1" \
  -H "Authorization: Bearer $CAPTAIN_WRITE_KEY"
```

#### Python

```python
import json
import requests

BASE_URL = "https://acme.captain.dev"
API_KEY = "your_write_key"
JOB_ID = "job_20260928213040_9b27e1"

headers = {
    "Authorization": f"Bearer {API_KEY}",
}

response = requests.get(
    f"{BASE_URL}/v1/jobs/{JOB_ID}",
    headers=headers,
    timeout=30.0,
)

print(json.dumps(response.json(), indent=2))
```

#### TypeScript

```typescript
const BASE_URL = "https://acme.captain.dev";
const API_KEY = "your_write_key";
const jobId = "job_20260928213040_9b27e1";

const response = await fetch(`${BASE_URL}/v1/jobs/${jobId}`, {
  method: "GET",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
  },
});

const result = await response.json();
console.log(JSON.stringify(result, null, 2));
```

**`Example: (200 OK)`**

```json Example: (200 OK)
{
  "id": "job_20260928213040_9b27e1",
  "type": "index.import",
  "index": "products",
  "status": "succeeded",
  "created_at": 1759095040,
  "started_at": 1759095040,
  "finished_at": 1759095161,
  "stages": [
    {
      "name": "pull",
      "status": "done",
      "records": 12840,
      "synonyms": 46,
      "rules": 6,
      "notes": []
    },
    {
      "name": "map",
      "status": "done",
      "fields": 14,
      "facets": [
        "brand",
        "category",
        "color"
      ],
      "counts": {
        "settings": {
          "mapped": 9,
          "approximated": 2,
          "not_mapped": 1
        },
        "synonyms": {
          "mapped": 46,
          "approximated": 0,
          "not_mapped": 0
        },
        "rules": {
          "mapped": 5,
          "approximated": 0,
          "not_mapped": 1
        },
        "records": 12840
      }
    },
    {
      "name": "add",
      "status": "done",
      "documents": 12840,
      "synonyms_saved": 46,
      "rules_saved": 5
    }
  ],
  "error": null,
  "result": {
    "documents": 12840,
    "records": 12840,
    "counts": {
      "settings": {
        "mapped": 9,
        "approximated": 2,
        "not_mapped": 1
      },
      "synonyms": {
        "mapped": 46,
        "approximated": 0,
        "not_mapped": 0
      },
      "rules": {
        "mapped": 5,
        "approximated": 0,
        "not_mapped": 1
      },
      "records": 12840
    },
    "build_job": "job_20260928213301_4c8d2e"
  }
}
```

## Retries

Send an `Idempotency-Key` header with every write. If a request fails in transit and you retry it with the same key within 24 hours, Captain returns the first reply instead of applying the change twice. That reply carries the header `Idempotent-Replayed: true`. Reusing a key for a different request answers `422`.