The Data Co API (v0beta)

The API is designed for server-to-server integrations. Do not call it directly from browser or mobile frontend code.

This is a beta release. The /v0beta path is not a stable contract and may change without a version bump. Several documented fields are not populated yet, and record identifiers will change format before /v1. Read Beta Limitations before you design your schema. Access is currently limited to tdc_test_ keys on the demo dataset.

Base URLs

Production: https://api.thedataco.com/v0beta

Authentication

Send your API key in the Authorization header:

Authorization: Bearer tdc_live_...

Example:

curl https://api.thedataco.com/v0beta/appointments \
  -H "Authorization: Bearer tdc_live_..."

API keys are scoped to one organization and one environment. Test keys start with tdc_test_; production keys start with tdc_live_.

Keep API keys in a secret manager or backend environment variable. Do not commit them to source control or expose them in frontend code.

Quickstart

Check your key and permissions:

curl https://api.thedataco.com/v0beta/access \
  -H "Authorization: Bearer tdc_live_..."

Fetch the first page of revenue entries:

curl "https://api.thedataco.com/v0beta/revenue-entries?limit=500" \
  -H "Authorization: Bearer tdc_live_..."

Continue paging with pagination.nextCursor:

curl "https://api.thedataco.com/v0beta/revenue-entries?limit=500&cursor=..." \
  -H "Authorization: Bearer tdc_live_..."

Beta Limitations

These are the differences between the beta and the intended /v1 contract. Each one is called out again on the field it affects.

Fields that are always null today

Field Resources Why
createdAt banners, clinics, employees, patients, appointments, revenue-entries, campaigns No source column yet. Populated on /payments and /leads only.
updatedAt banners, clinics No source column yet. Populated on all other resources.
deletedAt all resources Deletes are physical today; there is no tombstone to report.

Treat these as "not yet available", not as "this record has no value". Do not add a NOT NULL constraint downstream and do not infer anything from the null.

Deletions are not reported. Because deletedAt is always null, a record removed at the source simply stops appearing in responses. There is no signal you can act on. If you need deletions today, periodically re-sync a date window and reconcile by absence. The sync recipe below documents the deletedAt flow so your integration is ready when it lands, but the branch will not fire during beta.

Record identifiers will change. id values on employees, patients, appointments, revenue-entries, payments, campaigns and leads are opaque strings whose format differs between the sandbox and production, and changes again at /v1. See Identifiers. Store them as variable-length text, never parse them, and be prepared to re-key at /v1.

banners and clinics do not accept updatedSince. They have no updatedAt yet, so the parameter is rejected with 400 invalid_request. Sync these two in full on every pass; they are small.

payments, campaigns and leads are empty in the sandbox. The demo dataset does not generate them, so a tdc_test_ key holding those scopes gets an empty data array. This is a gap in the demo data, not an error. Production keys return real data.

campaigns and leads are reported per banner, not per clinic. Marketing spend and lead capture are not attributed to an individual clinic in the source systems, so these two resources carry a bannerId and no clinicId. See Permissions for how that interacts with a restricted key.

/sync-status reports null for banners and clinics, and reports the same timestamp for all seven gold-backed resources — they are rebuilt by one pipeline run. See Data Freshness.

updatedSince uses the linked Silver sync job's start time. Gold rebuilds do not change it, so each request returns records supplied by source syncs at or after the requested time.

Response Format

List endpoints return:

{
  "data": [],
  "pagination": {
    "nextCursor": null,
    "hasMore": false,
    "limit": 500
  }
}

Single-record endpoints return:

{
  "data": {}
}

Every response includes:

X-Request-Id: req_...

Include this request ID when contacting support about an API request.

Pagination

All list endpoints use cursor pagination.

Query parameters:

Parameter Description
limit Number of records to return. Default 500, maximum 5000.
cursor Opaque cursor from the previous response.

Ordering:

When pagination.hasMore is true, request the next page with pagination.nextCursor. When hasMore is false, the result set is complete.

Do not parse or modify cursors. A cursor is bound to the API key that issued it, the resource, and the exact query of the original request — reusing one with a different key, or after changing updatedSince, returns 400 invalid_cursor. You may change limit mid-pagination.

Cursors are valid for at least 24 hours. If a cursor returns 400 invalid_cursor, restart the sync from your last updatedSince checkpoint.

Filtering

This API is a replication feed, not a query interface. It exists to move whole resources into your warehouse, where you can query them with your own SQL. It does not accept filters for narrowing a result set by date, category, status or foreign key.

The complete query vocabulary of a list endpoint is:

Parameter Description
updatedSince Inclusive lower bound on the linked Silver sync job's start time, returned as updatedAt. ISO 8601 UTC timestamp. Not supported on banners or clinics.
limit Page size.
cursor Pagination cursor.

Two exceptions, both on the reference resources that have no updatedAt to page by — see Batch Lookup By ID:

Endpoint Parameter Description
/banners, /clinics id One or more integer ids, repeated. Max 100 per request.
/clinics bannerId One or more integer banner ids, repeated.

Repeated values for the same parameter are OR'd together; different parameters are AND'd together.

Any other query parameter returns 400 invalid_request with the offending name in error.details.field, including every filter this API accepted before 2026-08-05 — startDate, endDate, clinicId, employeeId, patientId, invoiceId, campaignId, contactId, status, role, active, channel, paymentMethod, paymentCategory, parentCategory, subCategory, serviceCategory, revenueCategory, firstAttribution, lastAttribution, isExistingPatient, and id on the resources not listed above. Nothing is silently ignored: a request that would once have been narrowed now fails rather than returning more rows than you asked for.

To fetch one record, use its /{resource}/{id} endpoint. To work with a subset, sync the resource and filter it downstream.

Every remaining parameter is strictly typed, so a malformed value is always a 400 — no filter value can quietly match nothing.

id and bannerId on the two reference resources are intersected with your key's access rather than rejected. Requesting a clinic or banner your key cannot read returns 200 with an empty data array, not 403.

Identifiers

Resource id type Format
banners, clinics integer Stable numeric identifier, e.g. 12.
employees, patients, appointments, revenue-entries, payments, leads string Opaque. Format is not part of the contract.
campaigns string Opaque. Identifies one campaign on one day — see below.

The opaque string ids are generated differently per environment today:

Both formats change at /v1, when ids become the source system's own identifier.

Design for this:

invoiceId on revenue entries is an opaque grouping field with the same caveats. There is no /invoices endpoint in v1.

A campaign's id identifies one campaign on one day, because /campaigns returns one row per campaign per day. Two rows for the same campaign on different dates have different id values. The field that is stable across a campaign's days is campaignId, which is the grouping field for that resource — the same kind of opaque grouping field as invoiceId. Use id as your primary key and campaignId when you want to roll a campaign up across dates.

Incremental Sync

Use updatedSince for ongoing syncs. updatedSince is inclusive.

Recommended initial sync:

  1. Request each resource without updatedSince.
  2. Page until pagination.hasMore is false.
  3. Upsert records by id.
  4. Store the maximum updatedAt seen per resource.

Recommended ongoing sync:

  1. Request each resource with updatedSince=<last checkpoint>.
  2. Page until complete.
  3. Upsert records by id.
  4. If deletedAt is not null, treat the record as deleted or inactive downstream. Beta: deletedAt is always null, so this branch never fires yet. Implement it now so you are ready when tombstones land.
  5. Store the new maximum updatedAt seen.

banners and clinics have no updatedAt and reject updatedSince; re-fetch them in full on each pass.

Because updatedSince is inclusive, records can appear again across sync runs. Upsert by id and make your sync idempotent.

updatedAt is the linked Silver sync job's start time. Gold rebuilds do not change it, so checkpointing on the maximum value seen returns records from the next source sync onward. Do not use /v0beta/sync-status as an updatedSince checkpoint: it reports Gold job completion time.

References between resources (for example an appointment's patientId) can occasionally point to a record you have not synced yet. Tolerate the missing reference; it will resolve on the next sync.

There is no date-window extract: updatedSince and the cursor are the only ways to page a resource. If you need a historical slice — one month of appointments, one clinic's revenue — sync the resource and select the slice in your own warehouse.

Data Freshness

Use /sync-status to check the latest sync time for resources your key can read:

curl https://api.thedataco.com/v0beta/sync-status \
  -H "Authorization: Bearer tdc_live_..."

Example response:

{
  "data": [
    {
      "resource": "appointments",
      "latestSyncAt": "2026-05-12T06:15:00Z"
    },
    {
      "resource": "revenue-entries",
      "latestSyncAt": "2026-05-12T06:15:00Z"
    }
  ]
}

Notes:

Data is available after source syncs and normalization complete. Freshness varies by connected source and resource.

Data Types

Type Format
Date YYYY-MM-DD
Timestamp ISO 8601 UTC, for example 2026-05-12T14:30:00Z
Decimal and money JSON string, for example "1250.00"
Integer JSON number
Boolean JSON boolean
Empty optional value null

All monetary values are in USD.

Enumerated Values

These fields draw from fixed value sets. Values are case-sensitive, and the sets below are what you can expect to see in the data — the API does not accept them as filters. Use them to size columns and build enums downstream.

gender (patients)

Male, Female, Not Specified

status (appointments)

scheduled, confirmed, cancelled, rescheduled, no_show, completed, in_progress, unknown

role (employees)

Unassigned, Aesthetician, Aesthetician - All Devices, Consultant, Dermatologist,
Doctor, Hybrid Injector, Injector, Laser Technician, Nurse, Nurse Injector,
Support Staff, Surgeon, Therapist, Wellness

parentCategory (appointments, revenue-entries)

Surgical Procedures, Products, Injectables, Energy and Device Treatments,
Body Contouring (Non-Surgical), Clinical Aesthetics, Wellness and Hormones,
Hair Restoration, Consults & Follow Ups, Medical and General, Admin, Fees

subCategory and serviceCategory (appointments, revenue-entries)

These fields use a fixed, parent-specific taxonomy. A subcategory and service category must be used with the parent category shown below. Botox is one valid service category; it is not the only value.

parentCategory subCategory serviceCategory values
Surgical Procedures Face Facelift, Neck Lift, Lip Lift, Cheek Augmentation, Cheek Reduction, Buccal Fat Removal, Blepharoplasty, Rhinoplasty, Brow Lift, Otoplasty, Chin Augmentation, Scar Revision, Earlobe Repair, Fat Grafting, Other
Surgical Procedures Breast Breast Augmentation, Breast Lift (Mastopexy), Breast Reduction, Implant Removal/Exchange, Scar Revision, Fat Grafting, Nipple Repair, Other
Surgical Procedures Body Arm Lift, Butt Lift, Liposuction, Mommy Makeover, Thigh Lift, Tummy Tuck, Labiaplasty, Scar Revision, Fat Grafting, Other
Surgical Procedures Hair FUE, FUT, Neograft, Artas, Other
Surgical Procedures Reconstructive Breast Reconstruction, Skin Cancer Reconstruction, Trauma Repair, Other
Surgical Procedures Gender-Affirming Top Surgery, Facial Feminization, Facial Masculinization, Body Contouring (Gender-Affirming), Tracheal Shave, Other
Surgical Procedures Other Scar Revision, Other
Surgical Procedures Adjustments Adjustments
Products Surgical Implants Breast Implants, Allografts, Other
Products Surgical Garments Compression Garments, Surgical Bras, Abdominal Binders, Other
Products Other Surgical Supplies Puregraft, Other
Products Skincare Sunscreen, Cleanser, Moisturizer, Serum, Eye Cream, Mask, Other
Products Prescription Latisse, Upneeq, Hydroquinone, Other
Products Other Supplements, Other
Injectables Neurotoxins Botox, Dysport, Xeomin, Jeuveau, Daxxify, Other
Injectables Fillers Juvederm, Restylane, RHA, Revanesse, Belotero, Evolysse, PhalloFILL, Other
Injectables Biostimulators Sculptra, Radiesse, Bellafill, Renuva, alloClae, ariessence, Other
Injectables Regenerative PRP, PRF, Exosomes, EZGEL, Other
Injectables Threads PDO Threads, InstaLift, Other
Injectables Dissolvers Hyaluronidase, Other
Injectables Skin Quality Skinvive, Skinboosters, Mesotherapy, Other
Injectables Vein Therapy Sclerotherapy, Asclera, Other
Injectables Other Other
Energy and Device Treatments Laser Hair Removal Laser Hair Removal, Electrolysis, Other
Energy and Device Treatments Laser Resurfacing (Ablative) CO2, Erbium, UltraClear, Other
Energy and Device Treatments Laser Resurfacing (Non-Ablative) Halo, Fraxel, Moxi, Clear + Brilliant, Icon 1540, ResurFX, AgeJET, Other
Energy and Device Treatments Skin Resurfacing Microneedling, AquaGold, Dermaplaning, Microdermabrasion, HydraFacial, DiamondGlow, Other
Energy and Device Treatments Pigment and Vascular IPL, BBL, Vbeam, Nd:YAG, Other
Energy and Device Treatments Light Therapy Red Light, PDT, Blue Light, Other
Energy and Device Treatments RF Microneedling Morpheus8, Sylfirm X, Potenza, Vivace, Genius, Exion, Other
Energy and Device Treatments Skin Tightening Aerolase, Ultherapy, Sofwave, Thermage, Exilis, Pelleve, ThermiSmooth, SkinTyte, FaceTite, AccuTite, Other
Energy and Device Treatments Sweat Reduction MiraDry, Other
Energy and Device Treatments Acne Therapy AviClear, Other
Energy and Device Treatments Tattoo Removal PicoSure, Picoway, Q-Switch, Other
Energy and Device Treatments Other Other
Body Contouring (Non-Surgical) Fat Reduction CoolSculpting, SculpSure, Vanquish, Kybella, Other
Body Contouring (Non-Surgical) Muscle Stimulation Emsculpt, CoolTone, TruSculpt Flex, Other
Body Contouring (Non-Surgical) Cellulite Reduction BodyTite, Venus Legacy, Emtone, Qwo, Other
Body Contouring (Non-Surgical) Other Other
Clinical Aesthetics Facials Oxygen, Acne Facial, Extractions, Other
Clinical Aesthetics Chemical Peels PCA, VI, BioRePeel, TCA, Glycolic, Jessner, Other
Clinical Aesthetics Hair Removal (Non-Laser) Waxing, Sugaring, Threading, Other
Clinical Aesthetics Lashes, Brows, and Permanent Makeup Microblading, Lip Blushing, Lamination, Tints, Permanent Makeup, Other
Clinical Aesthetics Other Piercing, Spray Tanning, Other
Wellness and Hormones Medical Weight Loss Semaglutide, Tirzepatide, Phentermine, Other
Wellness and Hormones Hormone Therapy BioTE Pellets, Testosterone, HRT Management, Other
Wellness and Hormones IV and Vitamin Therapy Myers Cocktail, NAD+, B12/MIC Shots, Other
Wellness and Hormones Sexual Wellness O-Shot, P-Shot, Emsella, Empower, MonaLisa Touch, ThermiVa, Diva, Other
Wellness and Hormones Spa Chiropractic, Acupuncture, Massage, Scrubs, Other
Wellness and Hormones Other Peptides, Other
Hair Restoration Restoration PRP, PRF, KeraLase, TED, Other
Hair Restoration Other Other
Consults & Follow Ups Consults Aesthetic Consult, Surgical Consult, Other
Consults & Follow Ups Follow Ups Aesthetic Follow Up, Surgical Follow Up, Other
Medical and General Dermatology Mole Removal, Skin Tags, Cyst Excision, Kenalog, Other
Medical and General Add-Ons Numbing, Pronox, Nitrous Oxide, Other
Medical and General Diagnostics Biopsies, Pathology, Labs, Genetic Testing, Other
Medical and General Conditions Acne Consults, Rosacea, Eczema, Hyperhidrosis Consults, Other
Medical and General Other Other
Admin Admin Fees Booking Deposits, Cancellation Fees, No Show Fees, Other
Admin Non-Revenue Gift Card, Package, Membership, Tips, Donations, Other
Admin Misc Shipping, Taxes, Training, Events, Other
Admin Other Other
Fees Anaesthesia Anaesthesia, Other
Fees OR Fees OR Fees, Facility Fees, Other

The API does not accept these values as filters. Values are case-sensitive. The taxonomy can grow in a later API version; clients should keep an unknown value path when reading these fields.

channel (leads)

Derived by normalizing the lead's attribution and source fields into this set:

3rd Party, Email, Entity Med, Events, Google, Meta, Organic Search, Other,
Phone, Referral, Returning Patient, Social Media, Walk-in, Website

Other is the fallback when nothing matches. This set is derived rather than stored, so it can grow as new sources are normalized — do not treat it as frozen.

channel (campaigns)

Free-form, and not the same value set as the leads channel. It arrives from each advertising source's own ingestion (for example Meta), so it varies by organization and by which platforms are connected. Discover the values present in your own data.

firstAttribution and lastAttribution (leads) are the raw, un-normalized attribution strings from the source system — that is the point of them; channel is the normalized view of the same information. Expect a long tail of values.

revenueCategory on appointments and revenue entries is derived and is normally MedSpa or Surgery. On campaigns and leads, revenueCategory, status/paymentMethod/paymentCategory (payments), and region (clinics) are free-form strings sourced per organization. Discover them from your own data.

Permissions

API keys have read scopes. You can only access endpoints included in your key's scopes.

Available v1 scopes:

Scope Endpoint
banners:read /banners
clinics:read /clinics
employees:read /employees
patients:read /patients
appointments:read /appointments
revenue_entries:read /revenue-entries
payments:read /payments
campaigns:read /campaigns
leads:read /leads

Keys may also be restricted to specific clinics or banners. If your key is restricted, responses only include records within your allowed access. Restrictions resolve to a set of allowed clinics: clinics granted directly, plus all clinics in granted banners (including clinics added to those banners later). Appointments, revenue entries, and payments are limited to allowed clinics; you can also see any banner that contains an allowed clinic. Campaigns and leads are banner-level records, so they follow that same banner rule: you see those attached to any banner containing at least one allowed clinic. Employees and patients are organization-wide in v1.

Because a banner is visible when it contains at least one allowed clinic, a key restricted to a single clinic still sees the full banner-level campaign and lead records for that clinic's banner. Those records are not subdivided by clinic in the source data, so they cannot be narrowed further.

Restricted keys additionally receive null for the patient fields lifetimeRevenue and firstRevenueDate, which aggregate revenue across all clinics.

Endpoints

GET /access

Returns the current API key context. Requires no scope.

Field Type Notes
keyId string ak_-prefixed key identifier. Not the secret.
name string Display name of the key.
environment string live or test.
organization.id string Opaque organization identifier.
organization.name string
scopes string[] Granted read scopes.
clinicIds integer[] Clinic restrictions. Empty with bannerIds means unrestricted.
bannerIds integer[] Banner restrictions.
rateLimit.requestsPerMinute integer
rateLimit.requestsPerDay integer
expiresAt timestamp null if the key does not expire.

Example response:

{
  "data": {
    "keyId": "ak_123",
    "name": "Customer warehouse sync",
    "environment": "live",
    "organization": {
      "id": "example-clinics",
      "name": "Example Clinics"
    },
    "scopes": [
      "appointments:read",
      "revenue_entries:read"
    ],
    "clinicIds": [12, 18],
    "bannerIds": [],
    "rateLimit": {
      "requestsPerMinute": 120,
      "requestsPerDay": 50000
    },
    "expiresAt": null
  }
}

GET /sync-status

Returns sync status for resources your key can read. See Data Freshness.

Field Type Notes
resource string URL-style resource name, e.g. revenue-entries.
latestSyncAt timestamp Beta: null for banners and clinics. Identical across the seven gold-backed resources.

GET /banners

Required scope: banners:read

Filters:

Parameter Description
id One or more banner IDs (integers).

updatedSince is not supported and returns 400 invalid_request.

Response object:

Field Type Notes
id integer
name string
createdAt timestamp Beta: always null.
updatedAt timestamp Beta: always null.
deletedAt timestamp Beta: always null.
{
  "id": 3,
  "name": "Austin MedSpa Group",
  "createdAt": null,
  "updatedAt": null,
  "deletedAt": null
}

Single-record lookup:

GET /v0beta/banners/{id}

GET /clinics

Required scope: clinics:read

Filters:

Parameter Description
id One or more clinic IDs (integers). Intersected with your key's access — an id you cannot read is omitted, not rejected.
bannerId One or more banner IDs.

updatedSince is not supported and returns 400 invalid_request.

Response object:

Field Type Notes
id integer
name string
bannerId integer null if the clinic has no banner.
address.line1 string Street address.
address.city string
address.state string
address.postalCode string
address.country string
region string Free-form, org-specific.
currency string
timezone string IANA name, e.g. America/Chicago.
createdAt timestamp Beta: always null.
updatedAt timestamp Beta: always null.
deletedAt timestamp Beta: always null.
{
  "id": 12,
  "name": "Main Street Clinic",
  "bannerId": 3,
  "address": {
    "line1": "123 Main Street",
    "city": "Austin",
    "state": "TX",
    "postalCode": "78701",
    "country": "US"
  },
  "region": "Southwest",
  "currency": "USD",
  "timezone": "America/Chicago",
  "createdAt": null,
  "updatedAt": null,
  "deletedAt": null
}

Single-record lookup:

GET /v0beta/clinics/{id}

GET /employees

Required scope: employees:read

Filters:

Parameter Description
updatedSince Inclusive update timestamp lower bound. The only filter — see Filtering.

Response object:

Field Type Notes
id string Opaque. See Identifiers.
fullName string
active boolean
role string null if unassigned. Enumerated.
firstActivityDate date
createdAt timestamp Beta: always null.
updatedAt timestamp
deletedAt timestamp Beta: always null.
{
  "id": "16-4821",
  "fullName": "Jane Smith",
  "active": true,
  "role": "Nurse Injector",
  "firstActivityDate": "2024-03-12",
  "createdAt": null,
  "updatedAt": "2026-05-12T14:30:00Z",
  "deletedAt": null
}

Single-record lookup:

GET /v0beta/employees/{id}

GET /patients

Required scope: patients:read

Filters:

Parameter Description
updatedSince Inclusive update timestamp lower bound. The only filter — see Filtering.

Response object:

Field Type Notes
id string Opaque. See Identifiers.
firstName string
lastName string
fullName string firstName and lastName joined by a space.
gender string Male, Female or Not Specified.
dateOfBirth date null when the source EMR has none on file.
email string null when unavailable.
homePhone string null when unavailable — frequently so.
mobilePhone string null when unavailable. Not normalized to a common format; passed through as the source EMR recorded it.
firstActivityDate date
firstRevenueDate date null for clinic- or banner-restricted keys.
lifetimeRevenue money null for clinic- or banner-restricted keys.
createdAt timestamp Beta: always null.
updatedAt timestamp
deletedAt timestamp Beta: always null.
{
  "id": "16-7734",
  "firstName": "Dana",
  "lastName": "Whitfield",
  "fullName": "Dana Whitfield",
  "gender": "Female",
  "dateOfBirth": "1985-07-02",
  "email": "dana.whitfield@example.com",
  "homePhone": null,
  "mobilePhone": "5551234567",
  "firstActivityDate": "2024-03-12",
  "firstRevenueDate": "2024-03-20",
  "lifetimeRevenue": "1250.00",
  "createdAt": null,
  "updatedAt": "2026-05-12T14:30:00Z",
  "deletedAt": null
}

Patients are organization-wide even for restricted keys, but firstRevenueDate and lifetimeRevenue are null for them because those values aggregate revenue across clinics the key cannot read. The remaining fields are returned in full to every key.

Single-record lookup:

GET /v0beta/patients/{id}

GET /appointments

Required scope: appointments:read

Filters:

Parameter Description
updatedSince Inclusive update timestamp lower bound. The only filter — see Filtering.

Response object:

Field Type Notes
id string Opaque. See Identifiers.
date date Clinic-local business date.
clinicId integer
employeeId string null if unassigned.
patientId string null if unassigned.
startTime timestamp UTC.
endTime timestamp UTC.
durationHours decimal Hours, e.g. "1.00".
status string Enumerated.
parentCategory string Enumerated; see the parent-specific taxonomy above.
subCategory string Enumerated; valid values depend on parentCategory.
serviceCategory string Enumerated; valid values depend on parentCategory and subCategory.
revenueCategory string Derived category, normally MedSpa or Surgery.
createdAt timestamp Beta: always null. Intended to carry the booking time.
updatedAt timestamp
deletedAt timestamp Beta: always null.
{
  "id": "16-90114",
  "date": "2026-05-12",
  "clinicId": 12,
  "employeeId": "16-4821",
  "patientId": "16-7734",
  "startTime": "2026-05-12T14:00:00Z",
  "endTime": "2026-05-12T15:00:00Z",
  "durationHours": "1.00",
  "status": "completed",
  "parentCategory": "Injectables",
  "subCategory": "Neurotoxins",
  "serviceCategory": "Botox",
  "revenueCategory": "MedSpa",
  "createdAt": null,
  "updatedAt": "2026-05-12T14:30:00Z",
  "deletedAt": null
}

date is the clinic-local business date of the appointment; startTime and endTime are UTC timestamps. Near midnight they can appear to disagree — treat date as the business date of record.

Single-record lookup:

GET /v0beta/appointments/{id}

GET /revenue-entries

Required scope: revenue_entries:read

Filters:

Parameter Description
updatedSince Inclusive update timestamp lower bound. The only filter — see Filtering.

Response object:

Field Type Notes
id string Opaque. See Identifiers.
date date Business date of the revenue.
clinicId integer
employeeId string null if unassigned.
patientId string null if unassigned.
invoiceId string Opaque grouping field. No /invoices endpoint in v1.
revenue money
total money
quantity decimal
discount money
tax money
parentCategory string Enumerated; see the parent-specific taxonomy above.
subCategory string Enumerated; valid values depend on parentCategory.
serviceCategory string Enumerated; valid values depend on parentCategory and subCategory.
revenueCategory string Derived category, normally MedSpa or Surgery.
createdAt timestamp Beta: always null.
updatedAt timestamp
deletedAt timestamp Beta: always null.
{
  "id": "16-552310",
  "date": "2026-05-12",
  "clinicId": 12,
  "employeeId": "16-4821",
  "patientId": "16-7734",
  "invoiceId": "16-INV-88213",
  "revenue": "950.00",
  "total": "1000.00",
  "quantity": "1.00",
  "discount": "50.00",
  "tax": "0.00",
  "parentCategory": "Injectables",
  "subCategory": "Neurotoxins",
  "serviceCategory": "Botox",
  "revenueCategory": "MedSpa",
  "createdAt": null,
  "updatedAt": "2026-05-12T14:30:00Z",
  "deletedAt": null
}

Single-record lookup:

GET /v0beta/revenue-entries/{id}

GET /payments

Required scope: payments:read

Beta: the demo dataset generates no payments, so this endpoint returns an empty data array for tdc_test_ keys.

Filters:

Parameter Description
updatedSince Inclusive update timestamp lower bound. The only filter — see Filtering.

Response object:

Field Type Notes
id string Opaque. See Identifiers.
status string Free-form, org-specific.
total money
paymentDate date The business date of the payment.
effectiveDate date
paymentMethod string Free-form, org-specific.
paymentCategory string Free-form, org-specific.
clinicId integer
patientId string
createdAt timestamp Populated. The only resource with a real createdAt today.
updatedAt timestamp
deletedAt timestamp Beta: always null.
{
  "id": "16-771204",
  "status": "paid",
  "total": "1000.00",
  "paymentDate": "2026-05-12",
  "effectiveDate": "2026-05-12",
  "paymentMethod": "Credit Card",
  "paymentCategory": "Patient Payment",
  "clinicId": 12,
  "patientId": "16-7734",
  "createdAt": "2026-05-12T14:05:00Z",
  "updatedAt": "2026-05-12T14:30:00Z",
  "deletedAt": null
}

paymentDate is the business date of the payment; effectiveDate is the accounting date, and the two can differ.

Single-record lookup:

GET /v0beta/payments/{id}

GET /campaigns

Required scope: campaigns:read

Beta: the demo dataset generates no campaigns, so this endpoint returns an empty data array for tdc_test_ keys.

Returns one row per campaign per day. impressions, clicks and spend are that day's totals, not campaign-to-date totals — sum them over a date range to get a campaign total. id identifies the campaign-day; campaignId is stable across a campaign's days. See Identifiers.

Campaigns are attached to a banner, not to a clinic, so they carry a bannerId and no clinicId. See Permissions.

Filters:

Parameter Description
updatedSince Inclusive update timestamp lower bound. The only filter — see Filtering.

Response object:

Field Type Notes
id string Opaque. Identifies one campaign on one day.
channel string Free-form, org-specific.
campaignId string Opaque grouping field, stable across dates.
campaignName string
date date The day these metrics cover.
startTime timestamp Campaign flight start. Often null. Not the day the metrics cover — that is date.
endTime timestamp Campaign flight end. Often null.
impressions integer That day's total.
clicks integer That day's total.
spend money That day's total.
bannerId integer Campaigns are banner-scoped; there is no clinicId.
revenueCategory string Free-form. Derived from the campaign name.
createdAt timestamp Beta: always null.
updatedAt timestamp
deletedAt timestamp Beta: always null.
{
  "id": "16-98431:2026-05-12",
  "channel": "Meta",
  "campaignId": "16-98431",
  "campaignName": "Spring Botox Promo",
  "date": "2026-05-12",
  "startTime": "2026-04-01T07:00:00Z",
  "endTime": "2026-06-30T07:00:00Z",
  "impressions": 18420,
  "clicks": 322,
  "spend": "415.77",
  "bannerId": 33,
  "revenueCategory": "MedSpa",
  "createdAt": null,
  "updatedAt": "2026-05-13T06:15:00Z",
  "deletedAt": null
}

Single-record lookup:

GET /v0beta/campaigns/{id}

The {id} is the campaign-day id exactly as returned. To assemble a whole campaign, sync the resource and group its rows by campaignId.

GET /leads

Required scope: leads:read

Beta: the demo dataset generates no leads, so this endpoint returns an empty data array for tdc_test_ keys.

One row per lead. Leads are attached to a banner, not to a clinic, so they carry a bannerId and no clinicId. See Permissions.

Filters:

Parameter Description
updatedSince Inclusive update timestamp lower bound. The only filter — see Filtering.

Response object:

Field Type Notes
id string Opaque. See Identifiers.
contactId string CRM-side identity. null if the lead has none.
patientId string null until the lead is matched to a patient.
employeeId string Owning employee. null if unassigned.
bannerId integer Leads are banner-scoped; there is no clinicId.
channel string Normalized. See Enumerated Values.
campaignId string Best-effort attribution; joins to campaignId on /campaigns when the source captured a real campaign id. null if unattributed. See the note below the example.
firstAttribution string Raw first-touch source string.
lastAttribution string Raw last-touch source string.
revenueCategory string Free-form.
isExistingPatient boolean Whether the person was already a patient when the lead was created. null where it could not be determined.
firstCallDate timestamp First logged call with this lead. null if never called.
createdAt timestamp Populated. Lead capture time.
updatedAt timestamp
deletedAt timestamp Beta: always null.
{
  "id": "16-c8841",
  "contactId": "16-crm-55102",
  "patientId": "16-7734",
  "employeeId": "16-4821",
  "bannerId": 33,
  "channel": "Meta",
  "campaignId": "16-98431",
  "firstAttribution": "Facebook Lead Ad",
  "lastAttribution": "Facebook Lead Ad",
  "revenueCategory": "MedSpa",
  "isExistingPatient": false,
  "firstCallDate": "2026-05-12T16:42:00Z",
  "createdAt": "2026-05-12T14:05:00Z",
  "updatedAt": "2026-05-13T06:15:00Z",
  "deletedAt": null
}

Leads have no business-date column: createdAt is the capture timestamp, and it is the field to date a lead by downstream.

campaignId is a best-effort attribution field — verify it against your own data before you build on it. Where a lead arrived with real ad-platform attribution, it holds that platform's campaign id and lines up with campaignId on /campaigns, which is what makes a cost-per-lead rollup possible: group /campaigns by campaignId over a date range for spend, count /leads with the same campaignId for volume.

But it is populated per source, from whatever that source records, and the sources do not agree. Some supply a genuine campaign id (for example a Google Ads gad_campaignid captured off the landing-page URL); at least one CRM path currently fills it from a different UTM field entirely, which will not match any campaign. It is also null for any unattributed lead — organic, walk-in, phone, and referral leads normally have none.

Treat a match as a match and a non-match as unknown, not as zero. Check the join rate on a sample of your own leads before reporting cost-per-lead from it. This is source-data quality, not an API behavior, and it is expected to improve rather than change shape.

Single-record lookup:

GET /v0beta/leads/{id}

Batch Lookup By ID

/banners and /clinics accept repeated id query parameters:

GET /v0beta/clinics?id=12&id=18

Maximum IDs per request: 100. Exceeding it returns 400 invalid_request.

The response uses the normal list envelope. Nonexistent or inaccessible IDs are omitted from data.

These two resources keep batch lookup because they have no updatedAt and therefore no incremental sync: id is the only way to address a subset of them. They are also small enough to re-fetch in full, which is the recommended approach — pull them on every sync pass and resolve clinicId / bannerId references locally.

No other endpoint accepts id. To fetch a single record from the other resources, call GET /v0beta/{resource}/{id}, one record per request. For anything wider than that, sync the resource — see Filtering.

Error Responses

Errors use this format:

{
  "error": {
    "code": "invalid_request",
    "message": "limit must be between 1 and 5000",
    "requestId": "req_abc123",
    "details": {
      "field": "limit"
    }
  }
}

details is omitted when there is nothing to add. requestId matches the X-Request-Id response header.

Common status codes:

Status Code Meaning
400 invalid_request Unknown query parameter (including any removed filter), or a malformed value.
400 invalid_cursor Cursor is invalid, expired, issued to another key, or does not match the request's updatedSince.
401 unauthenticated Missing or invalid API key.
403 forbidden API key does not have the required scope.
404 not_found Record was not found or is outside the key's allowed access.
429 rate_limited Rate limit exceeded.
500 internal_error Unexpected server error.
503 service_unavailable Service temporarily unavailable.
504 request_timeout Request exceeded the maximum processing time.

All invalid_cursor responses are identical regardless of the underlying cause; the remedy is always to drop the cursor and restart from your last checkpoint.

Requests have a maximum processing time of 30 seconds. If a request times out, reduce limit.

Rate Limits

Default limits:

Limit Default
Requests per minute 120
Requests per day 50,000

Higher limits may be available by contract. Your key's actual limits are in GET /access.

Every response carries the current per-minute window state:

X-RateLimit-Limit: 120
X-RateLimit-Remaining: 93
X-RateLimit-Reset: 1778600000

X-RateLimit-Reset is the epoch-seconds time at which the current minute window ends. These three headers always describe the minute window — the daily limit is not represented in them.

Exceeding either limit returns 429 rate_limited with a Retry-After header giving the seconds until the binding window ends. Back off for that long rather than retrying immediately.

Security

API keys provide access to scoped operational data for your organization.

Recommended practices:

Changelog Policy

V1 may add new fields, enum values, optional query parameters, and endpoints without a version change. New endpoints require explicit scopes before your key can access them.

Breaking changes will use a new major version path.

During beta, /v0beta may change without a version bump — see Beta Limitations.

Beta changes

2026-08-06 — new fields on /patients. The patient object now includes firstName, lastName, fullName, dateOfBirth, email, homePhone and mobilePhone. Additive and covered by patients:read; existing fields are unchanged, so a client that ignores unknown fields needs no change.

2026-08-05 — query filters removed. List endpoints now accept only updatedSince, limit and cursor; /banners and /clinics additionally keep id and bannerId. Every other filter — date ranges, category, status, and foreign-key filters, and batch id lookup outside those two resources — returns 400 invalid_request. The API is a replication feed: sync each resource and query your own copy. Single records remain available at /v0beta/{resource}/{id}. Syncs that only page with updatedSince, limit and cursor are unaffected, including cursors issued before the change.