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

EndpointPurpose
POST /api/v1/sourcesCreate 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/sourcesList every source you can access.
PUT /api/v1/sources/{sourceId}/types/{objectTypeId}/statusOpen a sync (START / START_INITIAL) and later close it (COMPLETE / COMPLETE_INCREMENTAL).
PUT /api/v1/sources/{sourceId}/objecttypes/{objectTypeId}/objects/{externalId}/contentPush 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:

  1. Open the sync with a status call. Guru returns a syncNumber.
  2. Push each object's content, tagging every push with that syncNumber.
  3. 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.

FieldDescription
typeRequired. The source type. Use CUSTOM for a source you push to over the API.
config.typeRequired. Matches the source type (CUSTOM).
config.nameRequired. The source name shown in Guru.
config.trackStatusSet 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.objectTypesRequired. 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:

FieldDescription
nameRequired. The object type's name, shown in Guru.
sourceDataTypeUse STRUCTURED for object types with defined fields.
fieldsRequired. The fields that make up this object type. See below.
facetsOptional. Fields exposed as filters, including Knowledge Agent filters. A facet must reference a field that exists in fields.
templatesOptional. How the object's title, click-through URL, and searchable body are rendered. See below.

Each entry in fields has:

FieldDescription
nameRequired. The field's name. Referenced from templates as ${f_<name>}.
contentSelectorRequired. 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).
dataTypeRequired. 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:

FieldDescription
typeFIELD to facet on a field's value.
nameThe facet's display name in Guru's filters.
field.nameThe name of the field to facet on. It must exist in fields.
targetTypeWhat the facet represents: CREATED_DATE, MODIFIED_DATE, or CUSTOM for an arbitrary field.
allowMultipleValuesWhether a single object can have more than one value for this facet.

The templates object controls display and search:

FieldDescription
titleTemplateThe object's display title, as a template over the fields (for example ${f_title}).
externalUrlTemplateThe click-through link shown with citations. Point this at the record in its source system, not at an unrelated URL.
searchTemplateThe 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 contentSelectorValue 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_TOKEN

GET /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_INITIAL for the first-ever sync of an object type, when currentSyncNumber is absent.
  • START for 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

FieldDescription
statusActionSTART or START_INITIAL to open. Do not send a syncNumber here; Guru assigns and returns it.
maxSyncTimeInMinutesHow long Guru holds the sync open before timing out.
dependentObjectTypeIdsOther 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.

  • COMPLETE runs a full reconciliation: objects not pushed with this syncNumber are removed. Use this when the sync pushed the complete set of records.
  • COMPLETE_INCREMENTAL applies 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_TOKEN