Records

App 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:

{
"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.

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 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.

{"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.

Captain applies every change in the order it arrives. Each write gets a seq number that marks its place in that order. 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.

When a tenant serves search from more than one region, a change applies first in the region that received it. The reply lists where the change is live in regions_applied, and where it’s still on its way in regions_pending. Every region catches up automatically.

curl -X GET "https://acme.captain.dev/v1/indexes/products/updates" \
-H "Authorization: Bearer $CAPTAIN_WRITE_KEY"
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 -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"
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 -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"
}
]'
Example: (202 Accepted)
{
"index": "products",
"accepted": 2,
"new": 1,
"replaced": 1,
"rejected": [],
"rejected_count": 0,
"duplicates": 0,
"warnings": [],
"record_count": 12841,
"max_records": 20000,
"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, and max_records is the most it can hold. 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 -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
}
]
}'
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 work on every field type except text. To change a title or a description, send the whole record again through Add Records. An update to a text field answers 422 FIELD_NEEDS_REBUILD.

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 -X DELETE "https://acme.captain.dev/v1/indexes/products/records/sku-10482" \
-H "Authorization: Bearer $CAPTAIN_WRITE_KEY" \
-H "Idempotency-Key: $(uuidgen)"
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 -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"
]
}'
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.

Check an index

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

curl -X GET "https://acme.captain.dev/v1/indexes/products" \
-H "Authorization: Bearer $CAPTAIN_WRITE_KEY"
Example: (200 OK)
{
"name": "products",
"status": "ready",
"build_id": "b20260928211503",
"pending_changes": false,
"serving": {
"build": "b20260928211503",
"records": 12840
},
"record_count": 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.

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.

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. Algolia keys are never stored.

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"
}'
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
}
}

The reply is an import job. Captain stores the import and reviews it with you, and then loads the records into the index. The job ends with the status landed_for_review once the import is stored. Get Job reports its progress.

curl -X GET "https://acme.captain.dev/v1/jobs/job_20260928213040_9b27e1" \
-H "Authorization: Bearer $CAPTAIN_WRITE_KEY"
Example: (200 OK)
{
"id": "job_20260928213040_9b27e1",
"type": "index.import",
"index": "products",
"status": "landed_for_review",
"created_at": 1759095040,
"started_at": 1759095040,
"finished_at": 1759095161,
"stages": [
{
"name": "pull",
"status": "done",
"items": 12840,
"of": 12840,
"seconds": 118
}
],
"error": null,
"result": {
"counts": {
"records": 12840,
"synonyms": 46,
"rules": 6
},
"next": "a Captain engineer reviews the export, then the records load into the index"
}
}

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.

© 2026 Captain