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.
/docs) to see live numbers.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.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
| Variable | Default | Meaning |
|---|---|---|
PORT | 8080 | listen port |
DB_PATH | db/demo.db | which demo database to serve. A missing file gets created and seeded |
SEED_PROFILE | tech | niche to seed a new database with |
TICK_INTERVAL | 45s | how often the ticker writes new activity. 0 freezes the dataset |
REQUIRE_AUTH | 1 | 0 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.
| File | Profile | Tenant | Customers look like |
|---|---|---|---|
db/demo.db | tech | this.nl | enterprise & public sector accounts, platform projects |
db/healthcare.db | healthcare | Zorggroep Rivierduin | hospitals, care groups, insurers; EPD/HL7 work |
db/manufacturing.db | manufacturing | Nedstaal Industries | industrial 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.
- One database.
db.goholds the schema. Every table is vendor-neutral. - One file per platform.
api_jira.go,api_hubspot.goand the rest are pure shapers. They read rows and emit that vendor's JSON. Each registers itself ininit(), so adding a namespace touches no other file. - Platform ids are columns.
ext_jira,ext_hubspot,ext_zendesk,ext_bamboohr,ext_afas,ext_exact, each in the vendor's real id format. Demo questions say "look up MDW-008". - A ticker keeps it moving.
ticker.gowrites new records with the current timestamp so time-based questions have live answers.
The shared dataset
| Table | Rows (seed) | Platform ids | Surfaces as |
|---|---|---|---|
people | 40 | jira, hubspot, zendesk, bamboohr, afas, exact, salesforce, nmbrs, loket, rework | Jira user, HubSpot owner, Salesforce User, Zendesk agent, and an employee in BambooHR, AFAS, Nmbrs, Loket and Rework |
companies | 16 to 18 | hubspot, afas, zendesk, exact, salesforce | HubSpot company, Salesforce Account, AFAS debtor, Zendesk organization, Exact account |
contacts | 60 | hubspot, zendesk, exact, salesforce | HubSpot contact, Salesforce Contact, Zendesk end-user, Exact contact |
deals | 45 | hubspot, salesforce | HubSpot deal, Salesforce Opportunity |
issues | 180 | none | Jira issue |
tickets | 140 | zendesk, salesforce | Zendesk ticket, Salesforce Case |
absences | 90 | none | BambooHR time-off request, AFAS Profit_Absence, Nmbrs absence, Loket absence, Rework request split into one slot per day |
invoices | 120 | exact | AFAS Profit_SalesInvoices, Exact SalesInvoices + ReceivablesList |
projects | 4 | none | Jira 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.
| Join | Question 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.
| Event | Weight | Shows up in |
|---|---|---|
| new lead (contact at an existing company) | 6 | HubSpot, Zendesk |
| new prospect (new company + first contact) | 2 | HubSpot, Zendesk, Exact, AFAS |
| contact promoted a lifecycle stage | 4 | HubSpot |
| deal opened | 3 | HubSpot |
deal advanced (closedwon also writes an invoice) | 3 | HubSpot, AFAS, Exact |
| issue created / progressed | 3 / 4 | Jira |
| ticket created / progressed to solved | 5 / 5 | Zendesk |
| invoice paid | 2 | AFAS, 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.
| Endpoint | Notes | |
|---|---|---|
| GET | /jira/rest/api/3/myself | the authenticated user |
| GET | /jira/rest/api/3/search | jql, startAt, maxResults (≤100) |
| GET | /jira/rest/api/3/issue/{key} | e.g. INQ-42 |
| GET | /jira/rest/api/3/project/search | all projects |
| GET | /jira/rest/api/3/users/search | query 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.
| Endpoint | Notes | |
|---|---|---|
| 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/owners | sales & 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.
| Endpoint | Notes | |
|---|---|---|
| GET | /salesforce/services/data | available versions |
| GET | /salesforce/services/data/v60.0/query?q= | SOQL |
| GET | …/v60.0/sobjects | the 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/limits | API call budget |
| Object | Prefix | Same rows as |
|---|---|---|
Account | 001 | HubSpot company, AFAS debtor, Exact account |
Contact | 003 | HubSpot contact, Zendesk end-user |
Opportunity | 006 | HubSpot deal, with the stage renamed to Salesforce's own |
Case | 500 | Zendesk ticket |
User | 005 | our 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.
| Endpoint | Notes | |
|---|---|---|
| GET | /zendesk/api/v2/tickets.json | status, page, per_page (≤100) |
| GET | /zendesk/api/v2/tickets/{id}.json | single ticket |
| GET | /zendesk/api/v2/users.json | role=agent or role=end-user |
| GET | /zendesk/api/v2/users/{id}.json | agents are our people, end-users are CRM contacts |
| GET | /zendesk/api/v2/organizations.json | one per company |
| GET | /zendesk/api/v2/organizations/{id}.json | single organization |
| GET | /zendesk/api/v2/search.json | query=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).
| Endpoint | Notes | |
|---|---|---|
| GET | /bamboohr/api/gateway.php/{company}/v1/employees/directory | all employees, summary fields |
| GET | …/v1/employees/{id} | full record: hire date, salary, contract, supervisor |
| GET | …/v1/time_off/requests | start, end, employeeId |
| GET | …/v1/reports/company_headcount | rollup 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.
| Endpoint | Notes | |
|---|---|---|
| GET | /afas/ProfitRestServices/connectors | list the available connectors |
| GET | /afas/ProfitRestServices/connectors/{connector} | skip, take (≤1000), filterfieldids, filtervalues |
| Connector | Contents |
|---|---|
Profit_Employees | employees: Medewerker, Functie, Salaris_per_jaar, Leidinggevende |
Profit_Absence | leave and sickness per employee |
Profit_SalesInvoices | sales invoices per debtor |
Profit_Debtors | customers 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.
| Endpoint | Notes | |
|---|---|---|
| GET | /nmbrs/api/v1/companies | the payroll company |
| GET | /nmbrs/api/v1/employees | active, includeDeactivated, departmentId, employeeId, page, pageSize |
| GET | /nmbrs/api/v1/employees/{id} | adds function, contract type and manager |
| GET | /nmbrs/api/v1/employees/{id}/salaries | gross monthly, yearly and hourly |
| GET | /nmbrs/api/v1/employees/{id}/absences | the same leave BambooHR and AFAS report |
/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.
| Endpoint | Notes | |
|---|---|---|
| GET | /loket/v2/providers/employers | the employer |
| GET | /loket/v2/providers/employers/{id} | single employer |
| GET | /loket/v2/providers/employers/{id}/employees | index and size, 25 per page by default |
| GET | /loket/v2/providers/employees/{id} | adds the contract block |
| GET | /loket/v2/providers/employees/{id}/employments | contract history |
| GET | /loket/v2/providers/employees/{id}/absences | leave with Dutch type keys |
/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.
| Endpoint | Notes | |
|---|---|---|
| GET | /rework/v2/{companyId}/leave/users | email, reference, not_yet_archived_on, page, per_page |
| GET | …/leave/users/{id} | single user with schedule and reviewer |
| GET | …/leave/offices | one per work location |
| GET | …/leave/request_types | labels in nl, en and de, with the balance bucket |
| GET | …/leave/requests | status, user_id, reviewer_id, from_date, to_date, request_type_id |
| GET | …/leave/slots | the same leave per day instead of per request |
| GET | /rework/v2/{companyId}/employments | outside 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.
| Endpoint | Notes | |
|---|---|---|
| GET | /exact/api/v1/current/Me | current user and division |
| GET | /exact/api/v1/{division}/system/Divisions | the administration |
| GET | /exact/api/v1/{division}/crm/Accounts | customers, with account manager |
| GET | /exact/api/v1/{division}/crm/Contacts | contact persons per account |
| GET | /exact/api/v1/{division}/salesinvoice/SalesInvoices | status 20 = open, 50 = processed |
| GET | /exact/api/v1/{division}/read/financial/ReceivablesList | outstanding 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.
| Endpoint | What it does | |
|---|---|---|
| GET | /_apis | mounted 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 | /_stats | row counts and what the ticker produced in the last 7 days |
| POST | /_simulate?events=25 | generate 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:
| Skill | Ask 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:
- 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.
- Reuse the canonical tables. If the platform overlaps something that already exists,
add an
ext_<platform>column and fill it inseed.go. Only add a table for a genuinely new entity, and reference existing rows by canonical id. - Write
api_<platform>.gowith aregister(...)call ininit(). That is the only wiring. Then add one assertion tomain_test.goproving 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.