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?
- PostgreSQLpublic.articlesor a custom SELECThost, port, ssl_moderows past the cursor
- Sync jobcursor_column updated_atid_column idpoll_interval 30sbatch_size 1000batches of documents
- Indexarticlescolumns indexed, typesinferred from OIDsqueries
- SearchPOST /v1/articles/_searchmatch, term, bool, range
- Shadow indexarticles.resync-7c1e90aball rows re-syncedwhen synced
- Validatedoc count drop over 20%or sync errors: abortif it passes
- Swapshadow replaces liveold index discarded
- Each started job has its own worker, which polls every
poll_interval. - 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. - 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. - 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.
- If the batch was full, the worker polls again at once to catch up. Otherwise it waits for the next interval.
- 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_atcolumn 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.
| Field | Required / default | Notes |
|---|---|---|
name | yes | A label for the connection. |
host | yes | The database host. It must be reachable from the hosted service. |
port | no, 5432 | The PostgreSQL port. |
database | yes | The database to read from. |
username | yes | Use a user that can only read the tables you sync. |
password | yes | Write-only: the API never returns it. |
ssl_mode | no, disable | Set require or stricter for any database that is not on a private network. |
max_conns | no, 5 | Connections 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.
| Parameter | Required / default | What it does |
|---|---|---|
name | yes | A label for the job. |
data_source_id | yes | The data source to read from. |
source_table | one of two | A table name. Required unless custom_query is set. |
custom_query | one of two | A SELECT, for joins and transforms. See the rules below. |
target_index | yes | The logical index name. It is created if it does not exist. Must not contain __. |
cursor_column | yes | A column that only increases, such as updated_at or an auto-increment id. |
id_column | yes | A unique column. Its value becomes the document _id. |
poll_interval | no, 30s | How often to check for changes: a duration such as 30s, 1m or 5m. |
batch_size | no, 1000 | Rows per poll. A full batch is followed at once by another poll. |
resync_schedule | no, off | Interval 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.keywordwith 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 type | Index field |
|---|---|
| smallint, integer, bigint | integer |
| real, double precision, numeric | float |
| boolean | boolean |
| timestamp, timestamptz, date | date |
| text, varchar, uuid, json, jsonb, bytea and every other type | text (json and jsonb are stored as a JSON string) |
Run and monitor a job
| Action | Endpoint | Effect |
|---|---|---|
| Start | POST /v1/_sync/jobs/{id}/_start | Begins polling. |
| Stop | POST /v1/_sync/jobs/{id}/_stop | Stops the worker; the cursor is kept. |
| Reset | POST /v1/_sync/jobs/{id}/_reset | Clears the cursor. The job must be stopped (otherwise 409). |
| Full resync | POST /v1/_sync/jobs/{id}/_resync | Rebuilds the index from the beginning in a shadow index while the live index keeps serving, then swaps it in. |
| State | GET /v1/_sync/jobs/{id}/_state | Cursor, documents synced and error counts. |
| Logs | GET /v1/_sync/jobs/{id}/_logs | Live log of a running job as a server-sent event stream. |
| Resync history | GET /v1/_sync/jobs/{id}/_resyncs | Up 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
}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.
| Status | Meaning |
|---|---|
running | The worker is polling. |
stopped | No worker. The cursor and counters are kept; the next start continues from them. |
error | The 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_schedulefor a scheduled resync, or use the Full resync button orPOST /v1/_sync/jobs/{id}/_resyncfor 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 examplearticles.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.
- Shadow indexarticles.resync-7c1e90abevery row is syncedlive index keeps servingwhen synced
- Validatedocument count fell over 20%or any error: abortand delete the shadowif it passes
- 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
| Endpoint | Purpose | Role |
|---|---|---|
POST /v1/_sync/datasources | Create a data source | editor |
GET /v1/_sync/datasources | List data sources | viewer |
PUT DELETE /v1/_sync/datasources/{id} | Update or delete one | editor |
POST /v1/_sync/datasources/{id}/_test | Test the connection | editor |
POST /v1/_sync/jobs | Create a sync job | editor |
GET /v1/_sync/jobs | List sync jobs | viewer |
PUT DELETE /v1/_sync/jobs/{id} | Update or delete one | editor |
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.
| Event | Sent when |
|---|---|
sync_job.started | A job starts |
sync_job.stopped | A job is stopped |
sync_job.failed | A job stops because of errors |
sync_job.resync.succeeded | A full resync finishes |
sync_job.resync.failed | A full resync fails |
- Each request carries
X-Bolt-Signature: t=, where the value is the hex HMAC-SHA256 of,v1= "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.