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 No source column yet. Populated on /payments 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 and payments 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 is empty in the sandbox. The demo dataset does not generate payments, so a tdc_test_ key with payments:read gets an empty data array. This is a gap in the demo data, not an error. Production keys return real data.

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

updatedSince does not narrow results in production yet. See the caveat in Incremental Sync. Size your pipeline for a full daily extract.

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 filter set of the original request — reusing one with a different key or a changed filter 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

All list endpoints support:

Parameter Description
id Filter by one or more record IDs.
updatedSince Inclusive lower bound on updatedAt. ISO 8601 UTC timestamp. Not supported on banners or clinics.
limit Page size.
cursor Pagination cursor.

Repeated query parameters are supported for multi-value filters:

GET /v0beta/revenue-entries?clinicId=12&clinicId=18&revenueCategory=MedSpa

Repeated values for the same filter are OR'd together. Different filters are AND'd together.

For resources with business dates, use inclusive date filters:

GET /v0beta/appointments?startDate=2026-05-01&endDate=2026-05-31

This means:

date >= 2026-05-01 AND date <= 2026-05-31

Unknown or invalid query parameters return 400 invalid_request.

Values for string filters such as status, role or the category fields are not validated against a fixed list: an unmatched value returns an empty result set rather than an error. A typo therefore looks exactly like "no data". Check your value against Enumerated Values before concluding a filter returned nothing.

clinicId — and id on /clinics — are intersected with your key's access rather than rejected. Requesting a clinic 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 string Opaque. Format is not part of the contract.

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.

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.

Beta caveat — sandbox and production updatedAt differ today.

In the sandbox (tdc_test_ keys), updatedAt is derived from each record's business date, so updatedSince behaves like a real historical feed and you can develop and test an incremental sync against it.

In production, updatedAt currently advances on every nightly rebuild rather than on actual content change. A production updatedSince request will therefore return every record your key can read, every day — not just changed ones. Size your pipeline for a full daily extract, not for the sandbox's delta volume.

This is the change-detection work tracked as the /v1 launch gate. When it lands, production updatedAt becomes content-driven and the two converge. Until then, treat updatedSince in production as an optimization hint, not as a guarantee that the response is small.

updatedAt is never backdated — corrections always receive a new, later updatedAt — so checkpointing on the maximum value seen never misses changes. As an alternative, you can record each resource's latestSyncAt from /v0beta/sync-status before starting a sync pass and use it as the next updatedSince.

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.

Use startDate and endDate for business-date extracts, not as a replacement for incremental sync.

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 in filters.

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)

Adjustment, Consult, Dermatology, Energy Device, Gift Card, Injectable,
Membership, Non-Clinical, Other, Other Clinical, Retail, Surgery, Wellness

serviceCategory (appointments, revenue-entries)

Currently a single value, Botox. This field is sparsely populated and is expected to expand; do not build required logic on it during beta.

subCategory (appointments, revenue-entries)

A large open list (200+ values, e.g. Neurotoxin, Filler, Laser Hair Removal, Weight Loss) that grows as new source data is normalized. Discover the values present in your own data rather than hardcoding a list.

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

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. Employees and patients are organization-wide in v1.

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 five 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
id One or more employee IDs.
updatedSince Inclusive update timestamp lower bound.
active true or false.
role One or more roles. See Enumerated Values.

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

The v1 patient object does not include patient names or date of birth.

Filters:

Parameter Description
id One or more patient IDs.
updatedSince Inclusive update timestamp lower bound.

Response object:

Field Type Notes
id string Opaque. See Identifiers.
gender string Male, Female or Not Specified.
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",
  "gender": "Female",
  "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.

Single-record lookup:

GET /v0beta/patients/{id}

GET /appointments

Required scope: appointments:read

Filters:

Parameter Description
id One or more appointment IDs.
updatedSince Inclusive update timestamp lower bound.
startDate Inclusive date lower bound.
endDate Inclusive date upper bound.
clinicId One or more clinic IDs. Intersected with your key's access.
employeeId One or more employee IDs.
patientId One or more patient IDs.
status One or more statuses. See Enumerated Values.
parentCategory One or more parent categories.
subCategory One or more subcategories.
serviceCategory One or more service categories.
revenueCategory One or more revenue categories.

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.
subCategory string Open list.
serviceCategory string Sparsely populated.
revenueCategory string Free-form.
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": "Injectable",
  "subCategory": "Neurotoxin",
  "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 — startDate/endDate filters apply to date.

Single-record lookup:

GET /v0beta/appointments/{id}

GET /revenue-entries

Required scope: revenue_entries:read

Filters:

Parameter Description
id One or more revenue entry IDs.
updatedSince Inclusive update timestamp lower bound.
startDate Inclusive date lower bound.
endDate Inclusive date upper bound.
clinicId One or more clinic IDs. Intersected with your key's access.
employeeId One or more employee IDs.
patientId One or more patient IDs.
invoiceId One or more invoice IDs.
parentCategory One or more parent categories.
subCategory One or more subcategories.
serviceCategory One or more service categories.
revenueCategory One or more revenue categories.

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.
subCategory string Open list.
serviceCategory string Sparsely populated.
revenueCategory string Free-form.
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": "Injectable",
  "subCategory": "Neurotoxin",
  "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
id One or more payment IDs.
updatedSince Inclusive update timestamp lower bound.
startDate Inclusive paymentDate lower bound.
endDate Inclusive paymentDate upper bound.
clinicId One or more clinic IDs. Intersected with your key's access.
patientId One or more patient IDs.
status One or more payment statuses.
paymentMethod One or more payment methods.
paymentCategory One or more payment categories.

Response object:

Field Type Notes
id string Opaque. See Identifiers.
status string Free-form, org-specific.
total money
paymentDate date What startDate/endDate filter on.
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
}

startDate/endDate filter on paymentDate, not effectiveDate.

Single-record lookup:

GET /v0beta/payments/{id}

Batch Lookup By ID

Every list endpoint supports repeated id query parameters:

GET /v0beta/appointments?id=16-90114&id=16-90115

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.

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 Invalid query parameter, malformed value, or unsupported filter.
400 invalid_cursor Cursor is invalid, expired, issued to another key, or does not match the request filters.
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 or narrow the filters.

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 filters, 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.