API Reference
The Data Co APIv0betaBeta
The Data Co API lets you sync your organization's operational data into your own backend systems, data warehouse, or analytics tools.
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
/v0betapath 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 totdc_test_keys on the demo dataset.
Base URLs
Production: https://api.thedataco.com/v0betaAuthentication
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:
- Most resources are returned in ascending
updatedAt, thenidorder. bannersandclinicsare ordered byidalone, because they have noupdatedAtyet.
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:
- Sandbox (
tdc_test_keys): UUIDs, e.g."9f8c2b41-5d3e-4a17-9c62-8e0b7a1d4f35". - Production (
tdc_live_keys): composite<integration>-<source id>strings, e.g."16-4821".
Both formats change at /v1, when ids become the source system's own identifier.
Design for this:
- Store ids as variable-length text (
TEXT/VARCHARwith no tight limit). Do not size a column from the sandbox's 36-character UUIDs. - Never parse an id or derive meaning from its parts.
- Never sort on an id expecting chronological order.
- Expect to re-key your tables once at
/v1. Keep your own surrogate key.
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:
- Request each resource without
updatedSince. - Page until
pagination.hasMoreisfalse. - Upsert records by
id. - Store the maximum
updatedAtseen per resource.
Recommended ongoing sync:
- Request each resource with
updatedSince=<last checkpoint>. - Page until complete.
- Upsert records by
id. - If
deletedAtis notnull, treat the record as deleted or inactive downstream. Beta:deletedAtis alwaysnull, so this branch never fires yet. Implement it now so you are ready when tombstones land. - Store the new maximum
updatedAtseen.
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:
- Only resources your key holds a read scope for are listed. A key with no read scopes gets
403 forbidden. employees,patients,appointments,revenue-entries,payments,campaignsandleadsare rebuilt by a single pipeline run, so they always report the samelatestSyncAt. Do not read a per-resource difference into it.- Beta:
bannersandclinicsreportlatestSyncAt: null— they are maintained manually and have no sync timestamp.
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 Specifiedstatus (appointments)
scheduled, confirmed, cancelled, rescheduled, no_show, completed, in_progress, unknownrole (employees)
Unassigned, Aesthetician, Aesthetician - All Devices, Consultant, Dermatologist,
Doctor, Hybrid Injector, Injector, Laser Technician, Nurse, Nurse Injector,
Support Staff, Surgeon, Therapist, WellnessparentCategory (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, FeessubCategory 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, WebsiteOther 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=18Maximum 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: 1778600000X-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:
- Store keys in a secret manager or backend environment variable.
- Do not expose keys in browser, mobile, or desktop client code.
- Do not commit keys to source control.
- Use the least-privileged scopes needed for the integration.
- Use staging keys and demo data while developing.
- Rotate keys periodically or when team access changes.
- Contact The Data Co immediately if a key is exposed or compromised.
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.