Inquerio AI API index Quick start

Mock vendor APIs on one database

One Go service. Ten business systems. Every platform is a different shape over the same SQLite rows, so the same person, company and invoice turn up in all of them. That is exactly what Inquerio AI has to join across in a demo. And the data keeps moving. New leads, deals, tickets and payments arrive while the service runs, so "what came in this week" answers differently every week.

Open this page from the running service (/docs) to see live numbers.
The data refreshes itself. A background ticker writes new records every TICK_INTERVAL, 45 seconds by default. New leads and prospects. Deals moving through the funnel. Tickets that open and get solved. Invoices that get paid. Every record carries the current timestamp, so nothing goes stale between demos and time-based questions always have a live answer. See Keeping data fresh, or post to /_simulate to skip the wait.
Every vendor endpoint needs an Authorization header. Any value works. Leave it out and you get that vendor's own 401 body. Set REQUIRE_AUTH=0 to drop the check.

Quick start

go run .
curl -H "Authorization: Bearer demo" localhost:8080/hubspot/crm/v3/objects/contacts?limit=5
VariableDefaultMeaning
PORT8080listen port
DB_PATHdb/demo.dbwhich demo database to serve. A missing file gets created and seeded
SEED_PROFILEtechniche to seed a new database with
TICK_INTERVAL45show often the ticker writes new activity. 0 freezes the dataset
REQUIRE_AUTH10 drops the Authorization check

Docker

The image is a static Go binary on Alpine. The documentation you are reading and the timezone data are compiled in. Only the databases live outside the binary.

docker compose up --build            # db/demo.db on :8080
docker compose --profile all up      # all three niches on :8080, :8081, :8082

./db is bind-mounted, so the container serves the committed databases and everything the ticker writes stays on the host. Point a single container at another niche with DB_PATH:

docker run --rm -p 8080:8080 -v "$PWD/db:/app/db" \
  -e DB_PATH=db/healthcare.db dummy-apis:local

Databases & profiles

The databases live in db/ and are committed to the repository. Hand someone a .db file and they get the same dataset back.

FileProfileTenantCustomers look like
db/demo.dbtechthis.nlenterprise & public sector accounts, platform projects
db/healthcare.dbhealthcareZorggroep Rivierduinhospitals, care groups, insurers; EPD/HL7 work
db/manufacturing.dbmanufacturingNedstaal Industriesindustrial firms; MES/WMS/PLC work
# serve an existing niche
DB_PATH=db/healthcare.db go run .

# build a fresh database for a new demo
SEED_PROFILE=manufacturing DB_PATH=db/acme-pitch.db go run .

A profile swaps vocabulary. Our own name and email domain. Customer names, industries and cities. Deal lines and invoice lines. Jira project keys, issue summaries and ticket subjects. The people, the departments, the API shapes and the cross-system ids stay identical. One demo script works against every profile.

Each database records its own profile in a meta table. SEED_PROFILE only counts when you create a new file, so pointing at a committed .db always loads the vocabulary it was built with. Add a niche in profiles.go.

How it works

One row in SQLite. Six vendor shapes. No per-platform copies of a person or a company, so nothing drifts apart.

people (SQLite) id=p008 Anouk Kowalski ext_jira, ext_hubspot, … Jira accountId "c526…", displayName HubSpot owner 70000007, string properties Zendesk agent 4400000007, role "agent" BambooHR employee 107, workEmail, payRate AFAS MDW-008, Functie, Uren_per_week Exact guid, AccountManagerFullName

The shared dataset

TableRows (seed)Platform idsSurfaces as
people40jira, hubspot, zendesk, bamboohr, afas, exact, salesforce, nmbrs, loket, reworkJira user, HubSpot owner, Salesforce User, Zendesk agent, and an employee in BambooHR, AFAS, Nmbrs, Loket and Rework
companies16 to 18hubspot, afas, zendesk, exact, salesforceHubSpot company, Salesforce Account, AFAS debtor, Zendesk organization, Exact account
contacts60hubspot, zendesk, exact, salesforceHubSpot contact, Salesforce Contact, Zendesk end-user, Exact contact
deals45hubspot, salesforceHubSpot deal, Salesforce Opportunity
issues180noneJira issue
tickets140zendesk, salesforceZendesk ticket, Salesforce Case
absences90noneBambooHR time-off request, AFAS Profit_Absence, Nmbrs absence, Loket absence, Rework request split into one slot per day
invoices120exactAFAS Profit_SalesInvoices, Exact SalesInvoices + ReceivablesList
projects4noneJira project

Seeding is deterministic. seed.go runs from a fixed seed, so the same profile always produces the same people, the same ids and the same history. Historical timestamps lean towards recent dates, so "this week" questions return something sensible on a fresh database.

Cross-system joins

These are the answers a demo is really testing. No single system can give them.

JoinQuestion it answers
person = Jira user = Salesforce User = Zendesk agent = employee in BambooHR, AFAS, Nmbrs, Loket and Rework"How much leave did she take, and what is she working on?"
company = HubSpot company = Salesforce Account = AFAS debtor = Exact account = Zendesk organization"Which customers opened tickets last week and still have unpaid invoices?"
invoice = AFAS row = Exact SalesInvoices row (same number, same amount)"What did we invoice this month, and what is overdue?"
ticket, its requester in HubSpot, that contact's company in Exact and AFAS, its assignee in BambooHR"Who handled the most tickets for our biggest account?"
five HR systems on one leave period: BambooHR, AFAS, Nmbrs, Loket and Rework"Do our HR systems still agree on this employee's contract and time off?"

GET /_identity?q= returns the whole map for one person, including the endpoint to fetch them from each system.

Keeping data fresh

Generate a demo dataset once and it looks fake within a week. Every "recent" record is months old. "How many leads did we get this week" answers zero. So the service keeps writing.

ticker.go fires every TICK_INTERVAL and applies one to three weighted events. Everything it writes carries the current timestamp and lands in the database on disk. The activity builds up across restarts instead of resetting.

EventWeightShows up in
new lead (contact at an existing company)6HubSpot, Zendesk
new prospect (new company + first contact)2HubSpot, Zendesk, Exact, AFAS
contact promoted a lifecycle stage4HubSpot
deal opened3HubSpot
deal advanced (closedwon also writes an invoice)3HubSpot, AFAS, Exact
issue created / progressed3 / 4Jira
ticket created / progressed to solved5 / 5Zendesk
invoice paid2AFAS, Exact (drops off ReceivablesList)

Don't want to wait? curl -X POST localhost:8080/_simulate?events=25 generates the activity right away and returns what it did. Worth running just before a demo, so the last hour shows movement.

What stays stable

The ticker only adds new records and moves statuses. The 40 people, their platform ids, the seeded companies and the historical records never change. A demo script that names MDW-008 or INQ-42 keeps working. Seeded history also leans recent, so a fresh database answers "this month" sensibly before the ticker has done anything.

Need a frozen dataset for a recorded demo, a screenshot or a regression test? Run with TICK_INTERVAL=0.


Jira Cloud /jira

Jira Cloud REST API v3. Nested fields object, custom field ids, timestamps with a local offset and millisecond precision.

EndpointNotes
GET/jira/rest/api/3/myselfthe authenticated user
GET/jira/rest/api/3/searchjql, startAt, maxResults (≤100)
GET/jira/rest/api/3/issue/{key}e.g. INQ-42
GET/jira/rest/api/3/project/searchall projects
GET/jira/rest/api/3/users/searchquery matches name or email

JQL support. field = value clauses joined by AND, matched as case-insensitive substrings, over project, status, assignee, type, priority, labels and sprint. No OR. No comparison operators. No ORDER BY.

curl -H "Authorization: Bearer demo" \
  --get --data-urlencode 'jql=project = OPS AND status = Done' \
  localhost:8080/jira/rest/api/3/search
{
  "startAt": 0, "maxResults": 50, "total": 8,
  "issues": [{
    "id": "0031", "key": "OPS-7",
    "fields": {
      "summary": "Audit log misses question timestamps",
      "issuetype": { "name": "Bug", "subtask": false },
      "project":   { "id": 10002, "key": "OPS", "name": "Internal Operations" },
      "status":    { "name": "Done", "statusCategory": { "key": "done", "name": "Done" } },
      "priority":  { "name": "High" },
      "assignee":  { "accountId": "c526…", "displayName": "Anouk Kowalski",
                     "emailAddress": "anouk.kowalski@this.nl", "active": true },
      "labels": ["backend"],
      "customfield_10016": 5,
      "customfield_10020": [{ "name": "Sprint 34", "state": "active" }],
      "created": "2026-08-08T17:18:00.000+0200",
      "resolutiondate": "2026-08-21T11:02:00.000+0200"
    }
  }]
}

HubSpot CRM /hubspot

CRM API v3. Every property is a string, lists are newest-first with cursor-style after paging.

EndpointNotes
GET/hubspot/crm/v3/objects/{type}companies, contacts, deals; limit (≤100), after
GET/hubspot/crm/v3/objects/{type}/{id}by HubSpot id
GET/hubspot/crm/v3/ownerssales & marketing people
{
  "results": [{
    "id": "8000000061",
    "properties": {
      "firstname": "Nina", "lastname": "Nguyen",
      "email": "nina.nguyen@gemeenteutrecht.com",
      "jobtitle": "IT Manager", "company": "Gemeente Utrecht",
      "associatedcompanyid": "9000000007",
      "lifecyclestage": "salesqualifiedlead",
      "hs_analytics_source": "Organic search",
      "hubspot_owner_id": "70000031"
    },
    "createdAt": "2026-09-04T13:11:54.000Z",
    "updatedAt": "2026-09-04T13:11:54.000Z",
    "archived": false
  }],
  "paging": { "next": { "after": "10" } }
}

Salesforce /salesforce

REST API v60.0. Flat records with an attributes block, PascalCase fields, 18-character ids and errors as arrays.

EndpointNotes
GET/salesforce/services/dataavailable versions
GET/salesforce/services/data/v60.0/query?q=SOQL
GET…/v60.0/sobjectsthe queryable objects and their key prefixes
GET…/v60.0/sobjects/{type}describe plus recent items
GET…/v60.0/sobjects/{type}/{id}15 or 18-character id
GET…/v60.0/limitsAPI call budget
ObjectPrefixSame rows as
Account001HubSpot company, AFAS debtor, Exact account
Contact003HubSpot contact, Zendesk end-user
Opportunity006HubSpot deal, with the stage renamed to Salesforce's own
Case500Zendesk ticket
User005our own people

SOQL support. SELECT fields FROM object, an optional WHERE of Field = value clauses joined by AND, plus LIMIT and OFFSET. No relationship fields such as Account.Name. No aggregates. No subqueries.

curl -H "Authorization: Bearer demo" --get \\
  --data-urlencode "q=SELECT Id, Name, Industry FROM Account WHERE Industry = 'Chemicals' LIMIT 2" \\
  localhost:8080/salesforce/services/data/v60.0/query

{ "totalSize": 2, "done": true, "records": [{
    "attributes": { "type": "Account",
                    "url": "/services/data/v60.0/sobjects/Account/0013t00000hA4cEqeAAI" },
    "Id": "0013t00000hA4cEqeAAI", "Name": "AkzoNobel", "Industry": "Chemicals" }] }

Zendesk Support /zendesk

Support API v2. Offset pagination, .json suffix optional on every route.

EndpointNotes
GET/zendesk/api/v2/tickets.jsonstatus, page, per_page (≤100)
GET/zendesk/api/v2/tickets/{id}.jsonsingle ticket
GET/zendesk/api/v2/users.jsonrole=agent or role=end-user
GET/zendesk/api/v2/users/{id}.jsonagents are our people, end-users are CRM contacts
GET/zendesk/api/v2/organizations.jsonone per company
GET/zendesk/api/v2/organizations/{id}.jsonsingle organization
GET/zendesk/api/v2/search.jsonquery=type:ticket status:open invoice

Search support. type: selects ticket, user or organization. Any other key:value is an equality filter on that field. Bare words must all appear somewhere in the record. No ranges. No negation. No field:>value.

{
  "count": 140,
  "next_page": "https://this-nl.zendesk.com/api/v2/tickets.json?page=2&per_page=1",
  "previous_page": null,
  "tickets": [{
    "id": 1050, "external_id": "t0050",
    "subject": "Answer cites the wrong source system",
    "status": "solved", "priority": "low", "type": "problem",
    "via": { "channel": "email" },
    "requester_id": 4500000006, "assignee_id": 4400000013,
    "organization_id": 3600000014,
    "tags": ["customer"],
    "satisfaction_rating": { "id": 1050, "score": "good", "comment": "Sorted quickly, thanks." },
    "created_at": "2026-08-30T09:33:00.000Z",
    "updated_at": "2026-09-01T14:12:00.000Z"
  }]
}

BambooHR /bamboohr

API v1. Flat objects, tenant segment in the path (any value works).

EndpointNotes
GET/bamboohr/api/gateway.php/{company}/v1/employees/directoryall employees, summary fields
GET…/v1/employees/{id}full record: hire date, salary, contract, supervisor
GET…/v1/time_off/requestsstart, end, employeeId
GET…/v1/reports/company_headcountrollup by department. Not a real Bamboo route, but demos keep asking for it
{
  "id": "107", "displayName": "Anouk Kowalski",
  "jobTitle": "QA Engineer", "department": "Engineering",
  "workEmail": "anouk.kowalski@this.nl", "location": "Amsterdam",
  "division": "this.nl", "supervisor": "Jesse de Boer", "supervisorEId": "102",
  "hireDate": "2024-06-02", "dateOfBirth": "1983-09-07",
  "employmentHistoryStatus": "Fulltime", "status": "Active",
  "payRate": "4000.00 EUR", "payPer": "Month", "standardHoursPerWeek": 32
}

AFAS Profit /afas

Profit RESTServices GetConnectors. {"rows": […]} envelope, Dutch field names, skip/take paging.

EndpointNotes
GET/afas/ProfitRestServices/connectorslist the available connectors
GET/afas/ProfitRestServices/connectors/{connector}skip, take (≤1000), filterfieldids, filtervalues
ConnectorContents
Profit_Employeesemployees: Medewerker, Functie, Salaris_per_jaar, Leidinggevende
Profit_Absenceleave and sickness per employee
Profit_SalesInvoicessales invoices per debtor
Profit_Debtorscustomers with their account manager

Filtering is equality only. filterfieldids and filtervalues are zipped pairwise. AFAS' operatortypes are not implemented.

curl -H "Authorization: Bearer demo" \
  "localhost:8080/afas/ProfitRestServices/connectors/Profit_Employees?filterfieldids=Medewerker&filtervalues=MDW-008"

{ "skip": 0, "take": 100, "rows": [{
    "Medewerker": "MDW-008", "Naam": "Anouk Kowalski",
    "Email_werk": "anouk.kowalski@this.nl", "Functie": "QA Engineer",
    "Organisatorische_eenheid": "Engineering",
    "Datum_in_dienst": "2024-06-02T00:00:00",
    "Uren_per_week": 32, "Salaris_per_jaar": 48000,
    "Leidinggevende": "MDW-002", "Actief": true }] }

Nmbrs /nmbrs

Dutch payroll and HR. OAuth 2.0, JSON, PascalCase fields, a GUID Id next to a human EmployeeNumber.

EndpointNotes
GET/nmbrs/api/v1/companiesthe payroll company
GET/nmbrs/api/v1/employeesactive, includeDeactivated, departmentId, employeeId, page, pageSize
GET/nmbrs/api/v1/employees/{id}adds function, contract type and manager
GET/nmbrs/api/v1/employees/{id}/salariesgross monthly, yearly and hourly
GET/nmbrs/api/v1/employees/{id}/absencesthe same leave BambooHR and AFAS report
Partly reconstructed. The employee field set and the filter names come from the Nmbrs REST reference. The /api/v1 prefix, the data envelope and the pagination block could not be read, because the reference renders client-side. Check those against a sandbox before pointing a real connector at this namespace.
{ "data": [{
    "Id": "fcf70d12-b748-4b87-ac5e-7aa0d95dcbd1",
    "EmployeeNumber": 5008, "FullName": "Anouk Rossi",
    "Email": "anouk.rossi@this.nl", "DateOfBirth": "1983-09-07",
    "Department": { "Code": "Engineering", "Description": "Engineering" },
    "EmploymentStartDate": "2024-06-02", "EmploymentEndDate": null, "IsActive": true }],
  "pagination": { "currentPage": 1, "pageSize": 100, "totalItems": 37, "totalPages": 1 } }

Loket.nl /loket

Dutch payroll and HR. OAuth 2.0, camelCase fields grouped into nested objects, GUID ids, and the Dutch name split into initials, tussenvoegsel and last name.

EndpointNotes
GET/loket/v2/providers/employersthe employer
GET/loket/v2/providers/employers/{id}single employer
GET/loket/v2/providers/employers/{id}/employeesindex and size, 25 per page by default
GET/loket/v2/providers/employees/{id}adds the contract block
GET/loket/v2/providers/employees/{id}/employmentscontract history
GET/loket/v2/providers/employees/{id}/absencesleave with Dutch type keys
Partly reconstructed. The /v2/providers/employers base path and the 401 body are verified live against api.loket.nl. The collection envelope, the paging block and the exact field grouping could not be read, because the documentation site was down. Check those against a sandbox before pointing a real connector at this namespace.
{ "_embedded": [{
    "id": "7b20cd95-880a-4422-adf9-2fc2c63e9ad2", "employeeNumber": 5008,
    "personalDetails": { "firstName": "Lotte", "initials": "L.", "prefix": "van",
                         "lastName": "Dijk", "formattedName": "Lotte van Dijk" },
    "employmentDetails": { "dateOfEmployment": "2021-09-16", "isActive": true } }],
  "_metadata": { "paging": { "index": 0, "size": 25, "returned": 25, "totalItems": 40 } } }

Rework /rework

Dutch leave and time tracking. Paths carry the company id and an application scope, lists come back as a bare array, and paging lives in the headers.

EndpointNotes
GET/rework/v2/{companyId}/leave/usersemail, reference, not_yet_archived_on, page, per_page
GET…/leave/users/{id}single user with schedule and reviewer
GET…/leave/officesone per work location
GET…/leave/request_typeslabels in nl, en and de, with the balance bucket
GET…/leave/requestsstatus, user_id, reviewer_id, from_date, to_date, request_type_id
GET…/leave/slotsthe same leave per day instead of per request
GET/rework/v2/{companyId}/employmentsoutside the leave scope, as in the real API

Paging is in the headers. per_page defaults to 30 and caps at 100, and every list sets X-Total, X-Page, X-Per-Page and a Link header with rel="next", "last", "first" and "prev". Statuses are pending, ok, notok and canceled. Hours are strings.

curl -i -H "Authorization: Bearer demo" \\
  "localhost:8080/rework/v2/4821/leave/requests?status=ok&per_page=1"

X-Total: 6
Link: <https://api.rework.nl/v2/4821/leave/requests?page=2&per_page=1>; rel="next"

[{ "id": 13, "title": "Study leave", "status": "ok",
   "first_date": "2026-11-01T00:00:00+01:00",
   "last_date": "2026-11-09T00:00:00+01:00",
   "total_hours": "64.0",
   "request_type": { "id": 5, "name": "Studieverlof", "mode": "absence", "bucket": null },
   "slots": [{ "id": 1301, "hours": "8.0", "all_day": true,
               "date": "2026-11-01T00:00:00+01:00" }],
   "user":     { "id": 10, "name": "Iris Kok", "reference": "MDW-010",
                 "email": "iris.kok@this.nl" },
   "reviewer": { "id": 12, "name": "Noor Lopez", "reference": "MDW-012",
                 "email": "noor.lopez@this.nl" } }]

Exact Online /exact

REST API v1, OData JSON verbose: {"d": {"results": […], "__next": …}}, GUID ids, /Date(milliseconds)/ timestamps and a __metadata block per record. The division segment accepts any number.

EndpointNotes
GET/exact/api/v1/current/Mecurrent user and division
GET/exact/api/v1/{division}/system/Divisionsthe administration
GET/exact/api/v1/{division}/crm/Accountscustomers, with account manager
GET/exact/api/v1/{division}/crm/Contactscontact persons per account
GET/exact/api/v1/{division}/salesinvoice/SalesInvoicesstatus 20 = open, 50 = processed
GET/exact/api/v1/{division}/read/financial/ReceivablesListoutstanding invoices with DaysOverdue

OData support. $select, $top, $skip and $filter with Field eq 'value', eq guid'…' or eq 20, joined by and. No gt or lt. No substringof. No $orderby.

curl -H "Authorization: Bearer demo" \
  "localhost:8080/exact/api/v1/3120001/salesinvoice/SalesInvoices?\$select=InvoiceNumber,OrderedByName,AmountDC&\$top=2"

{ "d": { "results": [{
    "__metadata": {
      "uri": "https://start.exactonline.nl/api/v1/3120001/salesinvoice/SalesInvoices(guid'55e1…')",
      "type": "Exact.Web.Api.Models.SalesInvoice" },
    "InvoiceNumber": 202600078, "OrderedByName": "Achmea", "AmountDC": 19360 }],
  "__next": "…?$skip=2&$top=2" } }

Helper endpoints

These belong to no vendor. They drive and inspect the demo. No auth needed.

EndpointWhat it does
GET/_apismounted namespaces, active database and profile, as JSON
GET/_identity?q=one human, every platform id, and the endpoint to fetch them from each system
GET/_statsrow counts and what the ticker produced in the last 7 days
POST/_simulate?events=25generate activity now instead of waiting for the ticker
GET/this page. /docs redirects here
curl "localhost:8080/_identity?q=MDW-008"

{ "query": "MDW-008", "matches": [{
    "person_id": "p008", "name": "Anouk Kowalski", "department": "Engineering",
    "ids": { "jira": "c526…", "hubspot": "70000007", "zendesk": "4400000007",
             "bamboohr": "107", "afas": "MDW-008", "exact": "2ae3064f-…" },
    "endpoints": { "bamboohr": "/bamboohr/api/gateway.php/this-nl/v1/employees/107", … } }] }

Adding an API

Ask Claude Code. The repo ships three skills in .claude/skills/ that walk the whole procedure:

SkillAsk it with
add-api"add a Salesforce API", "mock ServiceNow", "add purchase orders"
add-profile"add a retail niche", "build a dataset for this prospect"
refresh-demo-data"regenerate the databases", "the demo data is stale"

By hand, adding a namespace is three steps:

  1. Research the real API first. Base path, envelope, pagination params, field naming, id format and the 401/404 bodies. A namespace with the wrong envelope is worse than no namespace at all.
  2. Reuse the canonical tables. If the platform overlaps something that already exists, add an ext_<platform> column and fill it in seed.go. Only add a table for a genuinely new entity, and reference existing rows by canonical id.
  3. Write api_<platform>.go with a register(...) call in init(). That is the only wiring. Then add one assertion to main_test.go proving the new platform agrees with an existing one about the same person or company, plus a row in the table above.

If the platform has records that should keep appearing, add an event to ticker.go and give it a weight. And rebuild the committed databases in db/ whenever the schema, the seed or a profile changes. An old .db file opens fine and quietly serves data that no longer matches the code.