Skip to content

Statistics API — pull your numbers into the tools you already use

The Statistics API lets you read your ZeroChats numbers from outside the platform. You generate a key, drop it into the tool you already use, and that tool queries your metrics directly: the same leads, the same conversions and the same per-publication performance you see in the Statistics section.

It answers a very concrete problem: your ZeroChats data lives in ZeroChats and the rest of your data — ad spend, sales, invoicing — lives somewhere else. With the API you put them in the same dashboard instead of exporting a CSV every Monday.

Looker Studio or Power BI

A dashboard that combines your ZeroChats leads with your ad spend and works out the real cost per client.

Google Sheets

A sheet that updates itself with this month’s leads and the conversions per sales link.

n8n, Make or Zapier

A flow that reads last week every Monday and posts the summary to Slack or emails it to you.

Your own backend

Any service that can make an HTTP request with one header can consume it.

The API is read-only. It creates no leads, sends no messages and changes nothing inside ZeroChats.

  1. Go to Business setup. In the Integrations card, click the Integrations button.

  2. Find the Statistics API tile and click Create key.

  3. Click Generate key. The key appears on screen, starts with zcst_ and is unique to your business.

  4. Copy it right there. That’s the only time it is shown in full. After that you only see a shortened version, something like zcst_ab12…7yz9 — enough to recognise it, not enough to use it.

  5. Store it in your tool and make a first call to check it answers.

Once created, the tile tells you whether the key has ever been used and when the last call was. That’s the quick way to confirm the integration on the other side is actually wired up: if it says no calls yet, your tool never managed to connect.

From that same dialog you can Regenerate the key (creates a new one and invalidates the previous one instantly) or Revoke it (deletes it; from then on every call gets an authorization error).

Read this whole section before pasting the key anywhere.

  • The key only ever reads your business. The business isn’t part of the request: it is derived from the key itself. A key cannot query another account’s data even if whoever holds it tries on purpose, simply because there is no parameter to ask with.
  • It’s for server-to-server use. Don’t put it in a web page, a mobile app or the JavaScript of your landing page. Anyone who opens that page can read the key in the source and, with it, all of your metrics.
  • Store it where you store passwords. Your n8n credential store, your server’s environment variables, GoHighLevel custom values. Not in a shared document or a workflow export that later circulates by email.
  • When in doubt, regenerate. If the key has ended up in a log, a screenshot or a chat, regenerate it. It’s free and instant; the only cost is updating it in your tool.
  • One key per business. Generating a new one replaces the previous one, so if several integrations point at the same key, they all stop working at once when you regenerate. Update them all.

The base URL is:

https://app.zerochats.com/api/statistics/v1

Every call is a GET and the key travels in the Authorization header:

Ventana de terminal
curl -H "Authorization: Bearer zcst_your_key" \
https://app.zerochats.com/api/statistics/v1/overview

If your tool won’t let you set the Authorization header, we also accept the key in x-api-key with the same value, without the Bearer prefix.

Every response has the same shape. When the call succeeds:

{
"success": true,
"data": {},
"meta": {
"startDay": "2026-07-01",
"endDay": "2026-07-30",
"timezone": "Europe/Madrid"
}
}

data changes per endpoint. meta always tells you which period and which time zone produced those numbers, so you never need to remember what you asked for in order to read what you received.

When something fails:

{
"success": false,
"error": { "code": "invalid_request", "message": "startDay: must be a date in YYYY-MM-DD format" }
}

Branch on error.code, which is stable. The message is written for you to read while you build the integration and may be reworded.

EndpointTakes datesWhat it returns
/meNoThe business the key belongs to, and its time zone.
/overviewYesLifetime totals and the funnel by stage, split into inbound and outbound.
/leads/dailyYesOne row per day: leads received and how far they got.
/leads/by-weekdayNoLifetime distribution of leads across the days of the week.
/leads/by-hourNoLifetime distribution of leads across the hours of the day.
/conversions/goalsYesLeads and clients attributed to each sales link, per day.
/conversions/tagsYesLeads converted per tag and day.
/conversions/contentsYesLeads and clients attributed to each content piece, per day.
/performanceYesPerformance per publication, ad or origin, with a link back to the post.
/welcome-messagesYesWelcome messages sent, answered and the response rate, per day.

The three endpoints that don’t take dates (/me, /leads/by-weekday and /leads/by-hour) cover the whole history of the business. Sending them startDay and endDay is not an error: they simply ignore them, and you can tell because meta comes back without dates.

{
"lifetime": { "totalLeads": 1840, "positiveLeads": 213 },
"funnel": {
"inbound": [
{ "state": "OPEN_CONVERSATION", "count": 48 },
{ "state": "LEAD", "count": 21 },
{ "state": "CLIENT", "count": 7 }
],
"outbound": [{ "state": "IN_PROGRESS", "count": 12 }]
}
}

lifetime is historical and does not depend on the range you ask for: totalLeads is every lead you have and positiveLeads the ones sitting in Proposal, Booked or Paid.

funnel does cover the range, and it separates inbound leads (they wrote to you) from outbound ones (you started the conversation). They travel apart on purpose: they are two different acquisition motions, and blending them hides which one converts.

Each stage arrives under its English name, exactly like in the Custom Webhook and for the same reason: the names are identical across every account whatever language you work in, so a filter built on them doesn’t break.

Stage in the appstate
OpenOPEN_CONVERSATION
DiscoveringIN_PROGRESS
QualifyingQUALIFYING
ProposalLEAD
BookedBOOKED
PaidCLIENT
PostponedPOSTPONED
UnqualifiedNO_QUALIFY
LostLOST
[{ "date": "2026-07-01", "total": 34, "qualified": 6, "booked": 2, "paid": 1 }]
FieldWhat it is
dateThe day, as YYYY-MM-DD.
totalLeads received that day.
qualifiedOf those, the ones now sitting in Proposal.
bookedOf those, the ones now sitting in Booked.
paidOf those, the ones now sitting in Paid.

Days with no activity come back too, filled with zeros, so you can plot the series as-is without patching gaps.

[{ "dayOfWeek": 0, "day": "MONDAY", "total": 240, "qualified": 31, "booked": 14 }]
[{ "hour": 9, "total": 88, "qualified": 12, "booked": 5 }]

dayOfWeek runs from 0 (Monday) to 6 (Sunday) and day gives you the name so you never have to remember the convention. hour runs from 0 to 23. Both always come back complete: all seven days and all twenty-four hours, even the empty ones.

Here qualified means leads in Proposal and booked groups Booked and Paid together. Don’t try to reconcile these two endpoints row by row against /leads/daily: they answer a different question — what day and time people write to you — not how a cohort converted.

/conversions/goals, /conversions/tags and /conversions/contents

Section titled “/conversions/goals, /conversions/tags and /conversions/contents”

All three return the same shape, one row per entity and day:

[
{
"date": "2026-07-14",
"id": "9f1c8a4e-2b77-4d0a-9d1c-3f2b6a5c7e10",
"name": "Assessment call",
"leads": 4,
"clients": 1
}
]

name comes already resolved — the name of the sales link, the tag or the content piece — so you don’t need a second call or your own lookup table.

In /conversions/tags, clients equals leads: a tag is either assigned or it isn’t, so there is no lead/client split to report. For sales links and content pieces they are two different numbers.

One row per publication, ad or lead origin in the period:

[
{
"kind": "media",
"id": "17900000000000000",
"name": null,
"mediaId": "17900000000000000",
"mediaType": "REELS",
"url": "https://www.instagram.com/reel/CxYzAbCdEfG/",
"leads": 120,
"answered": 86,
"qualified": 24,
"booked": 9,
"paid": 4,
"rates": { "answered": 71.67, "qualified": 20, "booked": 7.5, "paid": 3.33 }
}
]
FieldWhat it is
kindmedia (a publication or an ad), personalized (an origin you defined), inbound, outbound.
idIdentifier of the row. On media rows it is the Instagram publication’s.
nameThe name of the personalized origin. Empty on every other row.
mediaIdPublication identifier. null when the row is not a publication.
mediaTypeSTORY, FEED, REELS or AD. null when the row is not a publication.
urlPublic link to the post on Instagram. null when we don’t have it.
leadsLeads that engaged with that publication or origin.
answeredOf those, the ones that wrote back.
qualifiedOf those, the ones that reached Proposal, Booked or Paid.
bookedThe ones that booked.
paidThe ones that paid.
ratesThose same four numbers as a percentage of leads, to two decimals.

url is what turns a report into something actionable: every row of your dashboard can link to the real post, so whoever reads it goes from the number to the content in one click. It’s a normal Instagram link — stable, public and it doesn’t expire.

The counters aren’t mutually exclusive either: answered includes the qualified ones, and qualified includes those who booked and those who paid. They read as a funnel, widest to narrowest.

[{ "date": "2026-07-01", "sent": 40, "responded": 11, "responseRate": 27.5 }]

responseRate is the percentage of sent to two decimals, and it is 0 on days when you sent nothing.

Endpoints marked “Takes dates” accept two parameters:

ParameterFormatWhat it is
startDayYYYY-MM-DDFirst day, included.
endDayYYYY-MM-DDLast day, included.
Ventana de terminal
curl -H "Authorization: Bearer zcst_your_key" \
"https://app.zerochats.com/api/statistics/v1/leads/daily?startDay=2026-07-01&endDay=2026-07-31"

There are four rules:

  • Both days are calendar days in your business’s time zone, not timestamps. If your business is in Madrid, July 1st runs from 00:00 to 23:59 Madrid time even if you fire the query from Mexico. That’s why meta.timezone travels in every response.
  • Send both or send neither. Sending just one is an error rather than a guess at the other.
  • If you send neither, you get the last 30 days, today included.
  • The maximum is 366 days. A longer range is an error; split the query into chunks.

An example of the first two rules together: asking for startDay=2026-07-01&endDay=2026-07-31 gives you the whole of July, all 31 days, first and last row included. Asking for startDay=2026-07-31&endDay=2026-07-01 is an error, because the start has to come before the end. And asking for only startDay=2026-07-01 is an error too, with the message startDay and endDay must be provided together.

You can make 60 requests per minute. That’s plenty for a dashboard refreshing hourly or a daily report; you’ll only hit it if you build a loop that walks many days one at a time.

Every response tells you where you stand:

HeaderWhat it is
X-RateLimit-LimitRequests allowed per minute.
X-RateLimit-RemainingHow many you have left this minute.
X-RateLimit-ResetWhen the budget renews, in Unix seconds.
Retry-AfterSeconds you must wait. Only when you go over.

When you go over you get a 429 with error.code set to rate_limited. The right way to handle it is to wait the number of seconds in Retry-After and retry: by the time you come back you’ll have the full budget. Retrying sooner only burns attempts.

If you need many days, ask for one wide range in a single call rather than a day per request: the daily endpoints already return the full series.

HTTP statuserror.codeWhat happened
401unauthorizedThe header is missing, or the key is mistyped, revoked or no longer exists.
400invalid_requestBad date format, only one of the two days, reversed order, or a range longer than 366 days.
429rate_limitedYou went over 60 requests in the current minute.
500internal_errorSomething failed on our side. Retry with a wait and, if it persists, open a ticket.

A 401 after everything had been working usually means one of two things: someone regenerated the key from the app, or revoked it. Open the dialog and check the creation date.

A complete, real case: every Monday you want to know which publications brought you clients last week, with a link to each post so you can review them.

  1. Work out the range. Last Monday to last Sunday, as YYYY-MM-DD. For example startDay=2026-08-17 and endDay=2026-08-23.

  2. Call /performance with that range.

    Ventana de terminal
    curl -H "Authorization: Bearer zcst_your_key" \
    "https://app.zerochats.com/api/statistics/v1/performance?startDay=2026-08-17&endDay=2026-08-23"
  3. Keep the kind: "media" rows. Those are the publications and ads; inbound and outbound are the catch-all buckets and won’t help in this report.

  4. Sort by paid — or by rates.paid if you care more about efficiency than volume — and keep the top five.

  5. Build the message using url. Every line carries the link to the post, so whoever reads it can open it without hunting for it.

    Top content, 17–23 August
    1. REELS — 120 leads · 4 clients (3.33%) → https://www.instagram.com/reel/CxYzAbCdEfG/
    2. FEED — 86 leads · 3 clients (3.49%) → https://www.instagram.com/p/CxAbCdEfGhI/
  6. Publish it wherever you’ll actually read it: a Slack channel, an email, or a new row in a spreadsheet.

If you want business context in that same report, also call /leads/daily with the same range for the week’s total volume, and /conversions/goals to see which sales link closed those clients.

  • Store the time zone from meta alongside the data if you keep it. A number with no record of which calendar it was measured in can’t be compared to another one later.
  • Use id as your key, not name. Tag, content and sales-link names can be changed from the app; identifiers can’t.
  • Keep the raw response as well as your processed table, at least while you’re building the integration. When a number doesn’t add up, having the original JSON saves you half a day.
  • Ask for ranges, not single days. One 30-day call is faster and cheaper than thirty one-day calls.
  • Refresh sensibly. Business dashboards get read once a day; refreshing every minute gives you no new information and pushes you towards the rate limit.

Check that you copied the whole key, zcst_ prefix included, and that no space or line break slipped in when pasting. Check the header too: it should be Authorization: Bearer <key> with the Bearer prefix, or x-api-key: <key> without it — but not a mix of the two.

The numbers don’t match what I see in Statistics

Section titled “The numbers don’t match what I see in Statistics”

It’s almost always the range. Check you’re asking for the same days — remember both ends are included — and look at meta.timezone: if your tool works out dates in its own time zone and your business is in another, you’re comparing slightly different periods.

/leads/by-weekday and /leads/by-hour also apply a deliberately different criterion, explained above: booked there includes the leads that have already paid.

/performance returns more leads than I have

Section titled “/performance returns more leads than I have”

That’s expected and explained above: a lead who engaged several publications counts in all of them. For the business total, use /overview or /leads/daily.

Some /performance rows have url set to null

Section titled “Some /performance rows have url set to null”

It means we don’t have the public link for that publication — usually old or deleted content. Show the row anyway, using mediaType as its label, just without a link.