API reference Advanced
Read analytics and manage the resources listed below over JSON. Each operation names the permission it needs (listing your sites needs none). The MCP server, CLI and /api/openapi.json share the route catalogue. The Shopify backfill endpoint documented separately below is not yet in that catalogue.
Authentication
Create a key on your account page or on a site's Settings → API tab; it starts with dsk_ and is shown once. Send it as a Bearer header on every request:
curl -H "Authorization: Bearer dsk_YOUR_KEY" https://app.datastated.com/api/sitesA key cannot create a child key with broader permissions or site access than its own. A key sees the sites its owner belongs to. It can also be limited at creation to a list of sites; a key with that list can never address another site, even one its owner belongs to, and such a site answers as if it did not exist.
Permissions
Every key carries a list of permissions and every route names the one it needs. A route refuses with 403 and names the missing permission in missingScope. The grammar is family:action: a route asks for funnels:write, and a key may hold that, the family wildcard funnels:*, or * for everything. Keys made before permissions existed hold *. The key form offers these:
| Permission | Label | What it grants |
|---|---|---|
analytics:read | Read analytics | Traffic, revenue, breakdowns, visitors, claims and the live count. |
events:write | Record events | Send orders and goals from your own server. |
identify:write | Identify visitors | Attach a user id or hashed email to a visitor. |
sites:write | Create sites | Add a new site to your account. Deleting a site stays in the dashboard. |
team:* | Manage people | See who has access, invite someone, remove someone. |
keys:* | Manage API keys | List, create and revoke keys on your account. |
funnels:* | Manage funnels | See, create, change and delete funnels, and read their results. |
alerts:* | Manage alerts | See, create, switch and delete email alert rules. |
annotations:* | Manage chart notes | See, add and delete the notes pinned to chart days. |
segments:* | Manage segments | See, save and delete saved filters. |
settings:* | Manage settings | Exclusions, the primary goal, the weekly summary, the public page, integrations and sync. |
* | Everything | Every permission above, now and as new ones are added. |
Rate limit
Analytics date ranges use real yyyy-mm-dd dates, inclusive, with from on or before to, and span at most 1,830 days. Timeseries requests are limited to 2,000 points. Shorten the range or request a coarser grain when a request is rejected.
Authenticated API keys and sessions share a PostgreSQL-backed limit across replicas using the same database; restarting a web process does not reset that shared window. Anonymous requests and unrecognized credentials still pass through process-local limits. If authentication or the shared limiter cannot be checked, the request returns 503 with Retry-After, rather than falling back to a local-only allowance. Self-hosted instances must run the database migration that installs the limiter.
600 requests a minute per credential, in a sliding window. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (unix seconds when a slot next frees up). Past the limit the answer is 429 with a Retry-After header and the body {"status": "error", "code": "rate_limited", "error": "Too many requests. This key may make 600 requests a minute; try again in 12s.", "retryAfter": 12}. An MCP tool call counts once, not twice.
Two response shapes
Read each endpoint's scope before comparing it with a dashboard panel. The claims comparison is per platform; campaign rows carry platform-reported figures. In claims, verifiedOrders and verifiedRevenue require Stripe charge records or reconciled Shopify payments; unverifiedOrders and unverifiedRevenue hold self-reported orders and historical Shopify orders without payment evidence. Other analytics views count recorded orders, not just processor-confirmed ones.
Routes added with the catalogue answer an envelope: {"status": "ok", "data": ..., "meta"?, "pagination"?} on success (201 when a POST creates something; the action POSTs, such as enabling the public page or syncing an integration, answer 200), and {"status": "error", "code", "error", ...} on failure, where error is a sentence and code is stable: bad_request 400, unauthorized 401, payment_required 402 (the site is paused behind plan selection), forbidden 403, not_found 404 (no such site, or not yours, or your role is too low), conflict 409, rate_limited 429, internal 500. The routes that predate it answer their bare payload and {"error": "..."} bodies, unchanged so nothing built on them breaks. They are marked legacy in the table: GET /api/sites, /window, /claims, /live, /crawls (both verbs), /search, /metrics, /events, /identify, /sync and /billing. The 403 for a missing permission uses the envelope on old and new routes alike, and still carries error.
Lists page with ?limit= (1 to 500, default 50) and ?cursor=, the pagination.nextCursor of the previous page, null on the last. Routes marked filters take the dashboard's own grammar: f_channel=google-ads, f_country=US&f_country=DE, f_device.not=mobile, f_page.contains=pricing, f_page.matches=/docs/*, f_goal=signup&f_goal.prop.plan=pro. Dimensions: channel, utm_campaign, utm_term, utm_content, referrer, page, entry, goal, action, country, region, city, device, browser, os, visit. Operators: is (no suffix), not, contains, not_contains, starts_with, ends_with, matches. A filter picks visitors: everyone with at least one matching event, and all of their events count.
Money and imported history
Numeric analytics money is USD only, with no exchange-rate conversion. Order counts include other currencies. The window carries totals.money with reportingCurrency: "USD", conversion: "none", excludedEventCount and per-currency currencies totals. Missing currency is reported as UNKNOWN, not assumed to be USD. Read these fields before interpreting a zero.
The older /metrics summary is all-or-nothing: it returns 422 with code: "currency_unavailable" and currencyScope if any amount in the requested range is non-USD or has no known currency. It does not add those amounts into a dollar total. Its legacy /api/clients/{slug}/metrics alias follows the same rule. A site sync can succeed at importing data while returning reportStatus: "currency_unavailable"; raw data stays available even when a dollar report cannot be generated.
In claims, spend, claimed revenue, CPC and platform-reported ROAS are available for known USD ad records. Each platform and campaign carries money.comparable and per-currency totals; unknown, mixed or non-USD amounts leave combined dollar fields null, without conversion. Revenue gaps also require compatible USD processor-confirmed payments. Read the response-level money.comparisonsAvailable, each row's currency scope and nullable monetary fields. Older ad rows with unknown currency need a re-sync. Campaign rows remain platform reports, not matched payment records.
Unfiltered window totals, charts and weekly summaries share imported daily visitor/pageview history. When imports contribute, totals.trackedVisitors, totals.importedVisitors and totals.importedPageviews separate the counts. windowMeta.importedDayCount and windowMeta.visitorCountMode describe the scope. Imported visitors are daily counts, not deduplicated people across the range; imports add no revenue or journey identities. See import rules.
Catalogue endpoints
Generated from the route catalogue. Role is the site role the key's owner needs. Destructive rows are the ones the MCP server dry-runs unless told confirm: true, and the CLI asks about. Ranged reads take ?from= and ?to= as yyyy-mm-dd, UTC, defaulting to the trailing 30 days.
| Resource | Route | What it does | Permission | Notes |
|---|---|---|---|---|
| Sites | GET /api/sites | The sites this key may see. | any key | legacy |
POST /api/sites | Add a site to your account; you become its owner. | sites:write | ||
| Analytics | GET /api/sites/{site}/window | The full analytics window for one site, defaulting to the trailing 30 days. | analytics:read | viewer, legacy |
GET /api/sites/{site}/claims | Platform claims beside orders attributed to its paid channel. Campaign rows contain platform claims only. | analytics:read | viewer, legacy | |
GET /api/sites/{site}/live | How many visitors the site had in the last five minutes. | analytics:read | viewer, legacy | |
GET /api/sites/{site}/crawls | What search engines and AI assistants fetched from the site, from its own server's reports. | analytics:read | viewer, legacy | |
GET /api/sites/{site}/search | Google Search Console: top terms and landing pages, with the site's own orders beside each page. | analytics:read | viewer, legacy | |
GET /api/sites/{site}/metrics | The money-side PeriodSummary (spend, claimed and ledger revenue, MER) over a range. | analytics:read | viewer, legacy | |
| Server events | POST /api/sites/{site}/events | Record a goal, order or refund from your own server; immune to ad blockers. | events:write | owner, legacy |
| Crawler reports | POST /api/sites/{site}/crawls | Report raw fetches from your own server so crawler analytics can name who read the site. | events:write | owner, legacy |
| Identify | POST /api/sites/{site}/identify | Attach a user id, a hashed email and up to 10 properties to a visitor. | identify:write | owner, legacy |
| Sync | POST /api/sites/{site}/sync | Run the whole sync pipeline for a site: ad connections, Stripe, rollups, report. | settings:write | owner, legacy |
| Fee settings | GET /api/sites/{site}/billing | The site's fee configuration (fee percent and basis), or null. | settings:read | owner, legacy |
PUT /api/sites/{site}/billing | Set or clear the site's fee configuration. | settings:write | owner, legacy | |
| Breakdowns | GET /api/sites/{site}/breakdowns/{kind} | One dashboard panel as rows: sources, pages, countries, devices, goals and the rest. | analytics:read | viewer, filters, paged |
| Timeseries | GET /api/sites/{site}/timeseries | The range as hourly, daily or weekly buckets, with the previous period beside it. | analytics:read | viewer, filters |
| Visitors | GET /api/sites/{site}/visitors | The range's visitors, latest activity first. | analytics:read | viewer, filters, paged |
GET /api/sites/{site}/visitors/{id} | One visitor's whole journey, oldest event first. | analytics:read | viewer | |
| Segments | GET /api/sites/{site}/segments | List the site's saved segments (named dashboard filters). | segments:read | viewer |
POST /api/sites/{site}/segments | Save a segment. Saving a name that exists replaces its filter. | segments:write | owner | |
GET /api/sites/{site}/segments/{id} | One saved segment. | segments:read | viewer | |
PUT /api/sites/{site}/segments/{id} | Rename a segment and/or change its filter. | segments:write | owner | |
DELETE /api/sites/{site}/segments/{id} | Delete a saved segment. | segments:write | owner, destructive | |
| Funnels | GET /api/sites/{site}/funnels | List the site's funnels (their definitions, not their counts). | funnels:read | viewer |
POST /api/sites/{site}/funnels | Create a funnel from a name and 2 to 8 ordered steps. | funnels:write | owner | |
GET /api/sites/{site}/funnels/{id} | One funnel's definition. | funnels:read | viewer | |
PUT /api/sites/{site}/funnels/{id} | Rename a funnel and/or replace its steps. | funnels:write | owner | |
DELETE /api/sites/{site}/funnels/{id} | Delete a funnel. | funnels:write | owner, destructive | |
GET /api/sites/{site}/funnels/{id}/results | Count a funnel over a range, optionally under a dashboard filter. | funnels:read | viewer, filters | |
| Alerts | GET /api/sites/{site}/alerts | List the site's alert rules. | alerts:read | viewer |
POST /api/sites/{site}/alerts | Add an alert rule. Its cursor starts now, so history is never emailed. | alerts:write | owner | |
GET /api/sites/{site}/alerts/{id} | One alert rule. | alerts:read | viewer | |
PUT /api/sites/{site}/alerts/{id} | Switch a rule on or off and/or change who it emails. | alerts:write | owner | |
DELETE /api/sites/{site}/alerts/{id} | Delete an alert rule and its send ledger. | alerts:write | owner, destructive | |
| Chart notes | GET /api/sites/{site}/annotations | Chart markers (owner notes and synced commits) dated inside a range, oldest first. | annotations:read | viewer |
POST /api/sites/{site}/annotations | Add a note to one UTC day of the chart. | annotations:write | owner | |
DELETE /api/sites/{site}/annotations/{id} | Delete a note you wrote. Teammates' notes and commit markers answer 404. | annotations:write | owner, destructive | |
| Weekly summary | GET /api/sites/{site}/digest | The weekly summary email settings: switch, timezone, recipients, last send. | settings:read | owner |
PUT /api/sites/{site}/digest | Change the weekly summary: switch it on or off, set the timezone, set the recipients. | settings:write | owner | |
| Public page | GET /api/sites/{site}/public-dashboard | The public read-only dashboard: whether it is on, its link, and its two options. | settings:read | owner |
PUT /api/sites/{site}/public-dashboard | Set the public page's options: show revenue, allow search engines. | settings:write | owner | |
POST /api/sites/{site}/public-dashboard/enable | Switch the public page on. The first time mints its link. | settings:write | owner | |
POST /api/sites/{site}/public-dashboard/disable | Switch the public page off. The link is kept, so enabling again restores it. | settings:write | owner | |
POST /api/sites/{site}/public-dashboard/regenerate | Mint a new public link. The old link stops working immediately. | settings:write | owner, destructive | |
| Settings | GET /api/sites/{site}/settings | The site's settings: exclusions, primary goal, renewal revenue, proxy subdomain, verification. | settings:read | owner |
PUT /api/sites/{site}/settings | Change the site's settings. Only the fields sent change. | settings:write | owner | |
| Integrations | GET /api/sites/{site}/integrations | Whether each integration is connected, and when it last synced. | settings:read | viewer |
POST /api/sites/{site}/integrations/{kind}/disconnect | Disconnect an integration, exactly as the settings card's button does. | settings:write | owner, destructive | |
POST /api/sites/{site}/integrations/{kind}/sync | Pull from an integration now, the same pass the worker runs on its schedule. | settings:write | owner | |
| People | GET /api/sites/{site}/team | Who has access to the site, and who has been invited but not yet accepted. | team:read | owner |
POST /api/sites/{site}/team/invites | Invite someone by email as an owner or a viewer. | team:write | owner | |
DELETE /api/sites/{site}/team/invites/{inviteId} | Withdraw a pending invite before it is accepted. | team:write | owner, destructive | |
PUT /api/sites/{site}/team/{userId} | Change what a member can do. Refuses to demote the last owner. | team:write | owner | |
DELETE /api/sites/{site}/team/{userId} | Take a member's access away. Refuses to remove the last owner. | team:write | owner, destructive | |
| API keys | GET /api/keys | The API keys on your account. Never their values. | keys:read | |
POST /api/keys | Create a key within the caller’s permissions and site restrictions. | keys:write | ||
DELETE /api/keys/{id} | Revoke one of your keys. It stops working on its next request. | keys:write | destructive |
Shopify backfill
POST /api/shopify/{site}/backfill uses an owner key with settings:write. It reconciles historical orders against Shopify payment transactions. This separate endpoint returns an envelope with its cursor in data.after, not pagination.nextCursor. An empty JSON body requests the last 30 days; explicit UTC from and to allow a window of at most 60 days. Follow the request, permission and paging instructions. The generic integration sync is not this backfill, and there is no dedicated CLI or MCP command yet.
The older write bodies
POST /events records a goal, an order or a refund from your own server, idempotent by event_id; a visitor_id (from window.datastated.visitorId) makes it inherit that visitor's attribution. It answers 201 {"stored": true, "deduplicated": false}, or 200 with deduplicated: true on a repeat. A body carrying stripe_checkout_session_id and visitor_id instead joins that Checkout Session to the visitor and reads the settled amount from Stripe, never from the caller; it answers {"stored", "match", "order"}, described on the Stripe page.
Its fields: kind (goal, order or refund), name and up to 10 props for a goal, value and currency, visitor_id, event_id, order_id (the order an order or refund belongs to), email (only its SHA-256 is stored) and timestamp, ISO 8601, defaulting to now.
POST /identify attaches a user_id, a hashed email and up to 10 props to a visitor_id, with the rules on the goals page. POST /sync runs the site's whole sync pass now and lists what each source upserted.
OpenAPI and the playground
GET /api/openapi.json is an OpenAPI 3.1 document generated from the same catalogue on each request, public because it describes routes and never data. Each operation carries x-scope and x-role, plus x-destructive or x-legacy where they apply; tags are the resources above. Point a client generator at it, or open the API playground in the app at /api-playground: every endpoint, with a way to run the reads as yourself on your signed-in session. Writes are not run from there; they show the curl and CLI lines to run with a key.
Questions? Email us at hello@datastated.com.