Agency API
Cross-location rollups for the whole agency — the roster, per-client event/user/person stats, destination delivery health, event-contract coverage, and the agency's own API usage — on /client/v1/agency, with an agency key only.
Overview
Every other /client/v1 surface answers questions about one client. The
Agency API answers the agency-grain question — "how are all my clients
doing?" — in one call, without iterating a location list yourself:
| Route | One row per | Answers |
|---|---|---|
GET /agency/locations | location | The roster — every client linked to this agency |
GET /agency/stats | location | Event volume, unique users, unique sessions over a date window, plus the all-time live person count |
GET /agency/destinations/health | location × destination | Delivery success rate, failure counts, and the last error over a trailing window — sorted worst-first |
GET /agency/events | event | The newest raw events across every client, newest first, with the client on each row |
GET /agency/events/summary | event name | Every event name seen across clients in hours_back, with its total |
GET /agency/exceptions/summary | location | JavaScript exceptions this window and last, crash-free session rate, and new issues, most exceptions first |
GET /agency/last-event | location | Events and the latest event time over hours_back, every client listed, so a silent snippet shows up as a zero row |
GET /agency/links/uptime | location × short link | Each link checked in the last 24 hours: latest check, 24h uptime, and check counts over days_back, down first, with a check-weighted summary |
GET /agency/contracts/summary | location | Declared event contracts, whether the daily alert webhook is wired, whether an alert rule routes contract alerts, and when the last webhook alert fired |
GET /agency/contracts/status | location | Yesterday's contract result: status, violating and silent events, the worst violation |
GET /agency/usage | day × location × surface × route (or day × surface × route with group_by=route) | The agency's own API + MCP request telemetry — the same data Mythic meters on |
GET /agency/quota | quota in force | The plan, the features it includes, and month-to-date ingested events against the quota |
Alerts across every client (the feed, routing rules and Slack/webhook channels) live on their own surface: see Alerts.
The same rollups exist as MCP tools on the
Settings Server (get_agency_stats,
get_agency_destination_health, get_agency_last_event, list_agency_events, get_agency_event_types, get_agency_error_summary, get_agency_link_uptime, get_agency_contracts_summary,
get_agency_usage, get_agency_quota); this is their headless REST form.
Base URL
https://mythic-analytics.gulp.workers.dev/client/v1/agency
Authentication
Agency key (ak_), or an agency-wide scoped key (mcp_) holding
agency:read — and no location parameter — the agency itself is the scope.
Unlike the rest of /client/v1, a location secret key (sk_) is rejected with
403 agency_required, and a client-scoped mcp_ key with
403 agency_key_required: both belong to one location, and this surface reads
across all of them.
curl "https://mythic-analytics.gulp.workers.dev/client/v1/agency/stats" \
-H "Authorization: Bearer $AK"
This surface deliberately serves no CORS headers. ak_ keys are
server-side credentials — never call it from browser JavaScript.
The roster and location_ids
Tenancy is resolved server-side: the roster is every location whose
agency_id is the key's agency, and every query runs against exactly that
set. The pipes never decide tenancy.
?location_ids=a,b,c narrows a request to a subset — and the subset is
validated against the roster. Requesting a location that is not linked to
your agency is a hard 403 location_not_linked naming the foreign ids, never
a silent empty result, so a typo can't read as "no data".
The 100-location cap
Rollups are capped at 100 locations per request. An agency with more gets
422 too_many_locations:
{
"success": false,
"error": {
"code": "too_many_locations",
"message": "This agency has 240 locations; rollups are capped at 100 per request. Pass ?location_ids= with batches of up to 100."
}
}
Batch it: fetch the full roster from GET /agency/locations (which is never
capped), then call the rollup routes with ?location_ids= chunks of up to
100.
Zero-filled stats
GET /agency/stats zero-fills from the roster: a location that sent
nothing in the window still appears, as a row of zeros. A silent client is
a visible zero row, never an omission — which is exactly the client an
agency-wide sweep exists to catch.
{
"success": true,
"data": [
{
"location_id": "loc_abc123",
"name": "Acme Dental",
"active": true,
"total_events": 48210,
"unique_users": 6120,
"unique_sessions": 9344,
"people": 12873
},
{
"location_id": "loc_def456",
"name": "Quiet Client Co",
"active": true,
"total_events": 0,
"unique_users": 0,
"unique_sessions": 0,
"people": 0
}
]
}
total_events, unique_users, and unique_sessions cover the date window
(date_from defaults to 30 days ago; date_to is an inclusive day);
people is the all-time live person count (merge tombstones excluded).
Plan and event quota
GET /agency/quota reports the tier this account is on, the features it
includes, and how many events have been ingested this calendar month against
what was bought.
curl -H "Authorization: Bearer ak_..." \
"https://mythic-analytics.gulp.workers.dev/client/v1/agency/quota"
{
"success": true,
"data": {
"month_offset": 0,
"plan": {
"id": "growth",
"name": "Growth",
"features": ["autocapture", "error_tracking", "data_api", "session_replay", "heatmaps", "experiments", "contracts", "destinations"],
"unlimited": false,
"monthly_event_quota": 10000000
},
"buckets": [
{ "scope": "agency", "plan_id": "growth", "locations": 12, "metered": true, "used": 8412663, "quota": 10000000, "ratio": 0.841, "over": false, "threshold": 0.8 }
],
"locations": 12,
"soft_cap": true
}
}
The quota is a soft cap. Going over it is a billing signal, not a stop: ingestion continues, no events are dropped, and no endpoint starts failing. Analytics you lose at your busiest moment cannot be recovered later, so the cap alerts and bills rather than blocking.
Why buckets is a list
The tier normally lives on the agency and every location draws from one pooled
quota — that is the single scope: "agency" bucket above.
A location can be put on its own plan, and then it is metered separately:
it gets its own scope: "location" bucket against its own quota, and its
events leave the agency pool. Without that exclusion the same events would
count against two quotas at once. A location override replaces the agency
plan rather than adding to it, so a location on a smaller plan than its agency
really does get the smaller quota and the smaller feature set.
month_offset selects the period: 0 is the current calendar month (the
default), 1 the previous one, up to 12. Numbers are measured live at
request time, so a check right after a traffic spike reflects the spike.
Features and your plan
plan.features is the authoritative list of what this account can use. A
feature outside it is refused in three places, and the same 402
feature_not_in_plan comes back from all of them — the key is valid and
authorized, the tier is what is missing.
Turning one on. PUT /client/v1/settings/... refuses to enable a feature
the plan excludes. Turning a feature off and reading its current state work
on every tier, so an account that changes plan can always see and disable what
it no longer pays for.
Calling one. Whole API surfaces are sold by tier, and a request to one the plan excludes is refused before any query runs:
| Feature key | Surface |
|---|---|
data_api | /client/v1/data |
bigquery_export | /client/v1/data/export |
contracts | /client/v1/contracts |
destinations | /client/v1/destinations |
constraints | /client/v1/constraints |
correlation | /client/v1/correlation |
incrementality | /client/v1/incrementality |
airbyte | /client/v1/airbyte |
mcp | the MCP server |
Reads are refused as well as writes. A settings toggle is a switch you own, so turning it off stays open on every tier; an API surface is the product itself, and there is no equivalent of "off" to leave open.
Serving one to the browser. A feature outside the plan is switched off in the configuration served to the SDK, so a plan change reaches live pages on their next config fetch rather than waiting for someone to toggle it.
A plan change takes up to a minute to apply everywhere — entitlements are cached briefly on each edge node.
OAuth return origins
GET and PUT /client/v1/agency/oauth-return-origins read and replace the
origins a browser may return to after a GoHighLevel install
(POST /client/v1/locations/ghl/install) or a Google destination consent
(POST /client/v1/destinations/oauth/initiate). A return_url on another
origin is refused with return_url_not_allowed. The list starts empty.
curl -X PUT "https://mythic-analytics.gulp.workers.dev/client/v1/agency/oauth-return-origins" \
-H "Authorization: Bearer $AK" -H "Content-Type: application/json" \
-d '{"oauth_return_origins":["https://app.agency.com"]}'
PUT replaces the whole list. Entries are reduced to scheme://host[:port],
so register the origin of the app that starts the flow, not a page path. This
is the only write on this surface: a scoped mcp_ key needs agency:write.
Airbyte OAuth keeps its own list on PATCH /client/v1/airbyte/config.
Errors and limits
Errors are always { "success": false, "error": { "code", "message" } }.
| Status | Code | Meaning |
|---|---|---|
400 | invalid_group_by | GET /agency/usage: group_by is not location or route |
401 | unauthorized | Missing or invalid key |
403 | agency_required | The key is an sk_ — this surface takes agency keys only |
403 | location_not_linked | A location_ids entry is not linked to this agency (the message names it) |
404 | no_locations | No locations are linked to this agency |
422 | too_many_locations | More than 100 locations in scope — batch with location_ids |
429 | rate_limited | 60 requests/min per agency — retry after Retry-After |
402 | feature_not_in_plan | The feature is not included in this account's plan (returned by the surface you called, and by /client/v1/settings; never by this one) |
502 | stats_failed, health_failed, usage_failed, quota_failed, plan_failed | The upstream analytics query failed — retry |
GET /agency/locations is exempt from the cap (it is the batching tool)
and GET /agency/usage takes no location_ids at all — usage is already the
agency's own slice, filtered by day window (days_back, default 30, max 90).
It returns at most 1000 rows and sets truncated: true when the cap cut the
result. A busy agency passes 1000 location-grain rows within a week, so use
group_by=route to fit a month.