Creating a Custom Source
Push data from any system into Guru as searchable, citable objects using the custom source API, from creating the source through running a sync.
A custom source lets you push data from any system into Guru as searchable, citable objects. You define the shape of your data once, then run a sync each time you want to refresh it: open the sync, push your records, and close it. This guide walks through the full lifecycle using nothing but the API, with a NetSuite customer sync as the running example.
Reach for a custom source when Guru has no native connector for your system, or when you want full control over how records are structured, searched, and displayed. If you only need to push a handful of one-off records, the pattern is the same; you just run a single sync.
Endpoints
| Endpoint | Purpose |
|---|---|
POST /api/v1/sources | Create the source and define its object types, fields, facets, and display templates. |
GET /api/v1/sources/{sourceId} | Fetch a single source by id to read its current sync state. |
GET /api/v1/sources | List every source you can access. |
PUT /api/v1/sources/{sourceId}/types/{objectTypeId}/status | Open a sync (START / START_INITIAL) and later close it (COMPLETE / COMPLETE_INCREMENTAL). |
PUT /api/v1/sources/{sourceId}/objecttypes/{objectTypeId}/objects/{externalId}/content | Push a single object's content during an open sync. |
DELETE /api/v1/sources/{sourceId} | Delete the source and everything in it. |
All requests use basic auth with your Guru user and API token, and the API root https://api.getguru.com/api/v1/.
How a sync works
A sync is a bracketed operation on one object type:
- Open the sync with a status call. Guru returns a
syncNumber. - Push each object's content, tagging every push with that
syncNumber. - Close the sync with a second status call.
The syncNumber is how Guru knows which objects belong to this run. On a full sync, closing with COMPLETE reconciles the source: any object that was not pushed with the current syncNumber is removed. This is the key behavior to understand before you write records, because a sync that opens, pushes nothing, and completes will empty the object type.
Step 1: Create the source
Creating a source defines its structure. The example below creates a CUSTOM source with one object type, Customer. Each field has a contentSelector (a JSON path into the payload you will push later) and a dataType. The fields field, typed JSON, holds an arbitrary object of the record's data, which keeps the schema flexible: you can add or remove keys inside the pushed fields object without changing the source definition.
trackStatus: true is required if you intend to run the open/push/close sync lifecycle described here. Without it, the status endpoints reject the sync.
Request
curl -X POST https://api.getguru.com/api/v1/sources \
-u $GURU_USER:$GURU_TOKEN \
-H "Content-Type: application/json" \
-d '{
"definition": {
"type": "CUSTOM"
},
"config": {
"type": "CUSTOM",
"name": "Acme NetSuite - Customers",
"trackStatus": true,
"specification": {
"application": {
"objectTypes": [
{
"name": "Customer",
"sourceDataType": "STRUCTURED",
"fields": [
{"name": "title", "contentSelector": "/title", "dataType": "TEXT"},
{"name": "url", "contentSelector": "/url", "dataType": "TEXT"},
{"name": "fields", "contentSelector": "/fields", "dataType": "JSON"},
{"name": "created_at", "contentSelector": "/created_at", "dataType": "DATE_TIME"},
{"name": "modified_at", "contentSelector": "/modified_at", "dataType": "DATE_TIME"}
],
"facets": [
{"type": "FIELD", "name": "Created Date", "field": {"name": "created_at"}, "allowMultipleValues": false, "targetType": "CREATED_DATE"},
{"type": "FIELD", "name": "Modified Date", "field": {"name": "modified_at"}, "allowMultipleValues": false, "targetType": "MODIFIED_DATE"}
],
"templates": {
"titleTemplate": "${f_title}",
"externalUrlTemplate": "${f_url}",
"searchTemplate": "<#if f_fields??><#list f_fields as propName, propValue>${propName}: ${propValue?string}\n</#list></#if>"
}
}
]
}
}
}
}'Response
The response echoes the created source. Capture two ids from it: the source's id, and the object type's id under sourceObjectTypes. You need both for every later call.
{
"id": "1ceb7083-1049-43ee-a4fa-c75c8d901521",
"name": "Acme NetSuite - Customers",
"config": {"type": "CUSTOM", "name": "Acme NetSuite - Customers", "trackStatus": true},
"sourceObjectTypes": [
{
"name": "Customer",
"id": "643fed7f-4982-4852-b0a1-b8f9a427fdf0",
"sourceDataType": "STRUCTURED",
"fields": [ ... ]
}
]
}Body fields
The request nests a few levels deep. The top level sets the source type and name; config.specification.application.objectTypes is where you define the structure.
| Field | Description |
|---|---|
type | Required. The source type. Use CUSTOM for a source you push to over the API. |
config.type | Required. Matches the source type (CUSTOM). |
config.name | Required. The source name shown in Guru. |
config.trackStatus | Set to true to enable the open/push/close sync lifecycle. Required for the sync flow in this guide, and it can only be set at creation time. |
config.specification.application.objectTypes | Required. The list of object types this source contains. Each object type is a distinct kind of record (for example Customer, or Ticket and Comment in a ticketing source). |
Each entry in objectTypes has:
| Field | Description |
|---|---|
name | Required. The object type's name, shown in Guru. |
sourceDataType | Use STRUCTURED for object types with defined fields. |
fields | Required. The fields that make up this object type. See below. |
facets | Optional. Fields exposed as filters, including Knowledge Agent filters. A facet must reference a field that exists in fields. |
templates | Optional. How the object's title, click-through URL, and searchable body are rendered. See below. |
Each entry in fields has:
| Field | Description |
|---|---|
name | Required. The field's name. Referenced from templates as ${f_<name>}. |
contentSelector | Required. A JSON path into the content payload you push in Step 4. This is the link between the source definition and the pushed object (see below). |
dataType | Required. The field's type, one of TEXT, HTML, JSON, DATE_TIME, LONG, DOUBLE, BOOLEAN, MARKDOWN. JSON lets a single field hold a nested object. |
Each entry in facets has:
| Field | Description |
|---|---|
type | FIELD to facet on a field's value. |
name | The facet's display name in Guru's filters. |
field.name | The name of the field to facet on. It must exist in fields. |
targetType | What the facet represents: CREATED_DATE, MODIFIED_DATE, or CUSTOM for an arbitrary field. |
allowMultipleValues | Whether a single object can have more than one value for this facet. |
The templates object controls display and search:
| Field | Description |
|---|---|
titleTemplate | The object's display title, as a template over the fields (for example ${f_title}). |
externalUrlTemplate | The click-through link shown with citations. Point this at the record in its source system, not at an unrelated URL. |
searchTemplate | The searchable body Guru indexes. It is a FreeMarker template, so you can iterate a JSON field to label every key. |
How fields map to the objects you push
The contentSelector on each field is the contract between the source definition and the content you push in Step 4. When you push an object, Guru reads each field's value from the path its contentSelector names. If the pushed body has nothing at that path, the field is empty.
The definition above and this push body line up like this:
Field contentSelector | Value in the pushed body |
|---|---|
/title | "title": "Westmen Chemicals Ltd" |
/url | "url": "https://..." |
/created_at | "created_at": "2026-10-01T18:06:41.000Z" |
/modified_at | "modified_at": "2026-09-27T15:32:50.000Z" |
/fields | "fields": { "Company Name": "...", "Email": "...", ... } |
Because the fields field is typed JSON and its selector is /fields, Guru stores the whole nested object. The searchTemplate then iterates it, so you can add or remove keys inside fields without changing the source definition. Keep the paths in the definition and the keys in the pushed body in sync: they are the same contract, described once at creation and fulfilled on every push.
The searchTemplate above iterates the fields object and emits one Key: Value line per entry, which turns an arbitrary bag of fields into readable, labeled search text without listing each field by hand.
📘 Guru custom sources do not support an XML data type. Push content as JSON.
Step 2: Store the source and object type ids
Creating a source is a one-time setup step, not something you repeat on every sync. When you create the source, store the returned source id and object type id in your own system. Every later call in this guide is keyed by those two ids, so persist them once and reuse them on every run.
Do not rely on the source name to look the source up later. Names are not guaranteed unique, so a lookup by name can resolve to the wrong source. The id you get back at creation is the stable, reliable handle.
To read a source's current state on a later run, fetch it by the id you stored with GET /api/v1/sources/{sourceId}. The response includes each object type's currentSyncNumber and lastSyncedDate, which you use to choose the open action (Step 3) and to drive incremental syncs (see the incremental section).
curl "https://api.getguru.com/api/v1/sources/$SOURCE_ID" \
-u $GURU_USER:$GURU_TOKENGET /api/v1/sources (no id) lists every source you can access, which is useful for discovery or debugging but is not needed once you have stored the id.
Step 3: Open the sync
Open a sync on the object type with a status call. The response returns a syncNumber; hold onto it for every push and for the close call.
Choose the statusAction by whether this object type has synced before. You can read currentSyncNumber from GET /api/v1/sources/{sourceId}:
START_INITIALfor the first-ever sync of an object type, whencurrentSyncNumberis absent.STARTfor every subsequent sync.
Request
curl -X PUT "https://api.getguru.com/api/v1/sources/$SOURCE_ID/types/$OBJECT_TYPE_ID/status" \
-u $GURU_USER:$GURU_TOKEN \
-H "Content-Type: application/json" \
-d '{
"statusAction": "START",
"maxSyncTimeInMinutes": 60,
"dependentObjectTypeIds": []
}'Request fields
| Field | Description |
|---|---|
statusAction | START or START_INITIAL to open. Do not send a syncNumber here; Guru assigns and returns it. |
maxSyncTimeInMinutes | How long Guru holds the sync open before timing out. |
dependentObjectTypeIds | Other object types synced together with this one. Use [] when the type has no dependencies. |
The response includes the assigned syncNumber. Read it and use it in Step 3 and Step 4.
Step 4: Push objects
With the sync open, push each object's content. The externalId in the path is your system's unique id for the record; it is what makes each object distinct in Guru, so it must vary per record. The syncNumber query parameter ties the push to the open sync.
The body is the JSON your contentSelector paths point into. The fields object here matches the JSON field defined in Step 1.
Request
curl -X PUT "https://api.getguru.com/api/v1/sources/$SOURCE_ID/objecttypes/$OBJECT_TYPE_ID/objects/4570/content?syncNumber=$SYNC_NUMBER" \
-u $GURU_USER:$GURU_TOKEN \
-H "Content-Type: application/json" \
-d '{
"title": "Westmen Chemicals Ltd",
"url": "https://example.netsuite.com/app/common/entity/custjob.nl?id=4570",
"created_at": "2026-10-01T18:06:41.000Z",
"modified_at": "2026-09-27T15:32:50.000Z",
"fields": {
"Company Name": "Westmen Chemicals Ltd",
"Email": "[email protected]",
"Phone": "+448450389258",
"Category": "Manufacturing",
"Subsidiary": "United Kingdom",
"Status": "Active"
}
}'Repeat this call once per record, each with its own externalId and body, all using the same syncNumber.
❗️ Every record must use its own unique externalId. If several records share one externalId, each push overwrites the previous one and you end up with a single object instead of many.
Step 5: Close the sync
Close the sync with a second status call. Pass the same syncNumber you opened with, in the body.
COMPLETEruns a full reconciliation: objects not pushed with thissyncNumberare removed. Use this when the sync pushed the complete set of records.COMPLETE_INCREMENTALapplies only the changes you pushed and leaves other objects in place. Use this when the sync pushed only the records that changed since the last run.
Request
curl -X PUT "https://api.getguru.com/api/v1/sources/$SOURCE_ID/types/$OBJECT_TYPE_ID/status" \
-u $GURU_USER:$GURU_TOKEN \
-H "Content-Type: application/json" \
-d '{
"statusAction": "COMPLETE",
"syncNumber": '"$SYNC_NUMBER"'
}'Once the sync completes, Guru indexes the objects. There is a short delay before they are searchable and answerable.
Incremental syncs
Full syncs are simplest: push every record each run and close with COMPLETE. For large or slow-changing data sets, an incremental sync pushes only what changed and avoids re-sending everything.
To run one, push only the records modified since the last sync, then close with COMPLETE_INCREMENTAL instead of COMPLETE. Because COMPLETE_INCREMENTAL does not reconcile, records you did not push stay in Guru untouched. The lastSyncedDate on the object type (from GET /api/v1/sources/{sourceId}) gives you the watermark to filter your source system by.
Deleting a source
To tear a source down completely, delete it. This removes the source and all of its objects.
curl -X DELETE "https://api.getguru.com/api/v1/sources/$SOURCE_ID" \
-u $GURU_USER:$GURU_TOKENUpdated 5 days ago

