Developers

Keep an index in step with PostgreSQL.

A sync job polls a table, or a SELECT you write, for rows past a saved cursor and indexes them. It only runs SELECT statements against your database.

How change tracking works

There are no triggers and no replication slots. A worker asks your database a question on a timer: which rows have a cursor value beyond the last one I saw?

  1. PostgreSQLpublic.articlesor a custom SELECThost, port, ssl_moderows past the cursor
  2. Sync jobcursor_column updated_atid_column idpoll_interval 30sbatch_size 1000batches of documents
  3. Indexarticlescolumns indexed, typesinferred from OIDsqueries
  4. SearchPOST /v1/articles/_searchmatch, term, bool, range
resync_schedule "24h"
  1. Shadow indexarticles.resync-7c1e90aball rows re-syncedwhen synced
  2. Validatedoc count drop over 20%or sync errors: abortif it passes
  3. Swapshadow replaces liveold index discarded
  1. Each started job has its own worker, which polls every poll_interval.
  2. The poll is SELECT … WHERE cursor_column > last_cursor ORDER BY cursor_column LIMIT batch_size. A custom query is wrapped as a subquery and filtered the same way. The first poll has no cursor and reads from the start.
  3. Each row becomes a document. The id column becomes _id; the cursor column is used for tracking and is not added to the document; NULL values are skipped; every other column is indexed with a type inferred from its PostgreSQL type.
  4. The documents are sent to the search engine in one bulk request, and the cursor and the running total are saved after the batch succeeds.
  5. If the batch was full, the worker polls again at once to catch up. Otherwise it waits for the next interval.
  6. After 10 consecutive errors the worker stops itself and the job’s status becomes error. When the service restarts, jobs that were running are started again from their saved cursor.

What the cursor can and cannot see

  • Inserts and updates are picked up when the row’s cursor value moves beyond the saved one. An updated_at column that is set on every change works; an id column sees new rows only. The row is re-indexed under the same id, so the old version is replaced.
  • Deletes are not seen by polling, because a deleted row leaves nothing to read. A full resync removes them from the index.
  • Choose a column that only increases. A cursor that can move backward will miss changes.

Connect a data source

A data source is a PostgreSQL connection. Bolt Search connects to the host and port you give it, so the database must be reachable from the hosted service. Add one in the console under Data Sources, or with the API.

FieldRequired / defaultNotes
nameyesA label for the connection.
hostyesThe database host. It must be reachable from the hosted service.
portno, 5432The PostgreSQL port.
databaseyesThe database to read from.
usernameyesUse a user that can only read the tables you sync.
passwordyesWrite-only: the API never returns it.
ssl_modeno, disableSet require or stricter for any database that is not on a private network.
max_connsno, 5Connections the job may hold open.

Request

curl -X POST https://api.boltsearch.app/v1/_sync/datasources \
  -H 'Authorization: ApiKey bsk_YOUR_API_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "production-db",
    "host": "db.example.com",
    "port": 5432,
    "database": "myapp",
    "username": "readonly",
    "password": "••••••••",
    "ssl_mode": "require"
  }'

Response

HTTP 201
{
  "id": "a1b2c3d4-…",
  "name": "production-db",
  "host": "db.example.com",
  "port": 5432,
  "database": "myapp",
  "username": "readonly",
  "ssl_mode": "require",
  "max_conns": 5
}
  • Test checks the connection with a five-second ping: POST /v1/_sync/datasources/{id}/_test.
  • You can list, read, update and delete data sources. A data source that a sync job uses cannot be deleted.
  • The password is never returned by the API or shown in the console again.

Create a sync job

A job says what to read, how to identify and track rows, and which index receives them.

ParameterRequired / defaultWhat it does
nameyesA label for the job.
data_source_idyesThe data source to read from.
source_tableone of twoA table name. Required unless custom_query is set.
custom_queryone of twoA SELECT, for joins and transforms. See the rules below.
target_indexyesThe logical index name. It is created if it does not exist. Must not contain __.
cursor_columnyesA column that only increases, such as updated_at or an auto-increment id.
id_columnyesA unique column. Its value becomes the document _id.
poll_intervalno, 30sHow often to check for changes: a duration such as 30s, 1m or 5m.
batch_sizeno, 1000Rows per poll. A full batch is followed at once by another poll.
resync_scheduleno, offInterval between scheduled blue-green resyncs, as a duration such as 24h or 168h.

Request

curl -X POST https://api.boltsearch.app/v1/_sync/jobs \
  -H 'Authorization: ApiKey bsk_YOUR_API_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "sync-articles",
    "data_source_id": "a1b2c3d4-…",
    "source_table": "articles",
    "cursor_column": "updated_at",
    "id_column": "id",
    "target_index": "articles",
    "poll_interval": "30s",
    "batch_size": 1000
  }'

Response

HTTP 201
{
  "id": "…",
  "name": "sync-articles",
  "status": "stopped",
  …
}

Custom queries

Use custom_query instead of a table for joins and computed columns. Every column in the result is indexed.

curl -X POST https://api.boltsearch.app/v1/_sync/jobs \
  -H 'Authorization: ApiKey bsk_YOUR_API_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "sync-enriched-articles",
    "data_source_id": "a1b2c3d4-…",
    "custom_query": "SELECT a.id, a.title, a.body, c.name AS category FROM articles a JOIN categories c ON a.category_id = c.id",
    "cursor_column": "id",
    "id_column": "id",
    "target_index": "articles",
    "poll_interval": "1m",
    "batch_size": 500
  }'
  • It must start with SELECT.
  • It must not contain semicolons, -- or /* */ comments, $$ dollar-quoting, or the keywords DROP, ALTER, CREATE, TRUNCATE, INSERT, UPDATE, DELETE, GRANT or REVOKE followed by a space.
  • The cursor and id columns must be in the result.

Target index

  • Give the logical name only, such as articles; the project prefix is handled for you.
  • If the index does not exist when the job starts, it is created with every column indexed. Create it yourself first if you want different mappings, for example to make a column a keyword.
  • Text columns also get a companion field named column.keyword with the exact value, for exact-match queries.
  • Several jobs may write to the same index; each job writes to exactly one.

How column types map

PostgreSQL typeIndex field
smallint, integer, bigintinteger
real, double precision, numericfloat
booleanboolean
timestamp, timestamptz, datedate
text, varchar, uuid, json, jsonb, bytea and every other typetext (json and jsonb are stored as a JSON string)

Run and monitor a job

ActionEndpointEffect
StartPOST /v1/_sync/jobs/{id}/_startBegins polling.
StopPOST /v1/_sync/jobs/{id}/_stopStops the worker; the cursor is kept.
ResetPOST /v1/_sync/jobs/{id}/_resetClears the cursor. The job must be stopped (otherwise 409).
Full resyncPOST /v1/_sync/jobs/{id}/_resyncRebuilds the index from the beginning in a shadow index while the live index keeps serving, then swaps it in.
StateGET /v1/_sync/jobs/{id}/_stateCursor, documents synced and error counts.
LogsGET /v1/_sync/jobs/{id}/_logsLive log of a running job as a server-sent event stream.
Resync historyGET /v1/_sync/jobs/{id}/_resyncsUp to 50 records, newest first.

Request

curl https://api.boltsearch.app/v1/_sync/jobs/{id}/_state \
  -H 'Authorization: ApiKey bsk_YOUR_API_KEY'

State

HTTP 200
{
  "last_cursor_value": "2026-02-21T10:05:00Z",
  "total_docs_synced": 4523,
  "last_sync_at": "2026-02-21T10:05:30Z",
  "consecutive_errors": 0
}
Sync articlesRunning

public.articles → articles

Total synced
12,500
Last cursor
2026-01-01T00:00:00Z
Errors
0

07:00:01 [info] polled public.articles, batch of 1000 rows

07:00:01 [info] bulk indexed 1000 documents into articles

07:00:02 [info] full batch returned, polling again

07:00:03 [info] cursor saved

A sample job on the console’s Monitoring page.

StatusMeaning
runningThe worker is polling.
stoppedNo worker. The cursor and counters are kept; the next start continues from them.
errorThe worker stopped itself after 10 consecutive errors. Fix the cause, then start it again.

Starting, stopping, resetting and resyncing are also buttons on the Sync Jobs page, shown according to the job’s status. Sync endpoints need the editor role; reading state, logs and history needs viewer.

Full resync

A full resync is the way to remove rows that were deleted at the source. The live index keeps serving searches while it runs, and it only changes when the new index is complete.

Start it
Set resync_schedule for a scheduled resync, or use the Full resync button or POST /v1/_sync/jobs/{id}/_resync for a manual one. Both follow the same steps.
Shadow index
The worker syncs every row, from the beginning, into a shadow index. Its name is the index name, then .resync-, then the first 8 characters of the job id, for example articles.resync-7c1e90ab.
Check
When it is synced, the document count is compared with the live index. If the count fell by more than 20% or any error occurred, the resync is aborted: the shadow index is deleted and the live index and the saved cursor stay as they were.
Swap
Otherwise the shadow index replaces the live index in one step and the old index is discarded. If the server restarts during a resync, the history row is marked failed and the shadow index is deleted at startup.
Scheduled (resync_schedule "24h") or manual (the Full resync button)
  1. Shadow indexarticles.resync-7c1e90abevery row is syncedlive index keeps servingwhen synced
  2. Validatedocument count fell over 20%or any error: abortand delete the shadowif it passes
  3. Swapshadow replaces liveold index discarded

Every resync is recorded in the resync history with a trigger type (scheduled or manual), an outcome (in_progress, success, aborted or failed) and the document counts before and after.

Sync API summary

EndpointPurposeRole
POST /v1/_sync/datasourcesCreate a data sourceeditor
GET /v1/_sync/datasourcesList data sourcesviewer
PUT DELETE /v1/_sync/datasources/{id}Update or delete oneeditor
POST /v1/_sync/datasources/{id}/_testTest the connectioneditor
POST /v1/_sync/jobsCreate a sync jobeditor
GET /v1/_sync/jobsList sync jobsviewer
PUT DELETE /v1/_sync/jobs/{id}Update or delete oneeditor

Sync endpoints are not metered. Data sources and jobs belong to the project of the key; another project’s ids answer 404.

Webhooks

A webhook posts a sync-job event to a URL you choose. Create one with POST /v1/_sync/webhooks.

EventSent when
sync_job.startedA job starts
sync_job.stoppedA job is stopped
sync_job.failedA job stops because of errors
sync_job.resync.succeededA full resync finishes
sync_job.resync.failedA full resync fails
  • Each request carries X-Bolt-Signature: t=,v1=, where the value is the hex HMAC-SHA256 of "t.body" (the timestamp, a dot, then the raw body) with the webhook secret as the key.
  • A delivery that does not get a 2xx response is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, then marked failed.

Connect a database and start a sync job.

Get startedSee pricing