Looker Studio or Power BI
A dashboard that combines your ZeroChats leads with your ad spend and works out the real cost per client.
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.
Go to Business setup. In the Integrations card, click the Integrations button.
Find the Statistics API tile and click Create key.
Click Generate key. The key appears on screen, starts with zcst_ and is unique to your business.
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.
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 base URL is:
https://app.zerochats.com/api/statistics/v1Every call is a GET and the key travels in the Authorization header:
curl -H "Authorization: Bearer zcst_your_key" \ https://app.zerochats.com/api/statistics/v1/overviewAdd an HTTP Request node:
GET, URL https://app.zerochats.com/api/statistics/v1/overview.Authorization, value Bearer zcst_your_key.Save the credential once and reuse it in every node that calls the API.
const response = await fetch( 'https://app.zerochats.com/api/statistics/v1/overview', { headers: { Authorization: `Bearer ${process.env.ZEROCHATS_STATS_KEY}` } });
const { success, data, meta } = await response.json();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.
| Endpoint | Takes dates | What it returns |
|---|---|---|
/me | No | The business the key belongs to, and its time zone. |
/overview | Yes | Lifetime totals and the funnel by stage, split into inbound and outbound. |
/leads/daily | Yes | One row per day: leads received and how far they got. |
/leads/by-weekday | No | Lifetime distribution of leads across the days of the week. |
/leads/by-hour | No | Lifetime distribution of leads across the hours of the day. |
/conversions/goals | Yes | Leads and clients attributed to each sales link, per day. |
/conversions/tags | Yes | Leads converted per tag and day. |
/conversions/contents | Yes | Leads and clients attributed to each content piece, per day. |
/performance | Yes | Performance per publication, ad or origin, with a link back to the post. |
/welcome-messages | Yes | Welcome 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.
/overview{ "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 app | state |
|---|---|
| Open | OPEN_CONVERSATION |
| Discovering | IN_PROGRESS |
| Qualifying | QUALIFYING |
| Proposal | LEAD |
| Booked | BOOKED |
| Paid | CLIENT |
| Postponed | POSTPONED |
| Unqualified | NO_QUALIFY |
| Lost | LOST |
/leads/daily[{ "date": "2026-07-01", "total": 34, "qualified": 6, "booked": 2, "paid": 1 }]| Field | What it is |
|---|---|
date | The day, as YYYY-MM-DD. |
total | Leads received that day. |
qualified | Of those, the ones now sitting in Proposal. |
booked | Of those, the ones now sitting in Booked. |
paid | Of 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.
/leads/by-weekday and /leads/by-hour[{ "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/contentsAll 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.
/performanceOne 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 } }]| Field | What it is |
|---|---|
kind | media (a publication or an ad), personalized (an origin you defined), inbound, outbound. |
id | Identifier of the row. On media rows it is the Instagram publication’s. |
name | The name of the personalized origin. Empty on every other row. |
mediaId | Publication identifier. null when the row is not a publication. |
mediaType | STORY, FEED, REELS or AD. null when the row is not a publication. |
url | Public link to the post on Instagram. null when we don’t have it. |
leads | Leads that engaged with that publication or origin. |
answered | Of those, the ones that wrote back. |
qualified | Of those, the ones that reached Proposal, Booked or Paid. |
booked | The ones that booked. |
paid | The ones that paid. |
rates | Those 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.
/welcome-messages[{ "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:
| Parameter | Format | What it is |
|---|---|---|
startDay | YYYY-MM-DD | First day, included. |
endDay | YYYY-MM-DD | Last day, included. |
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:
meta.timezone travels in every response.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:
| Header | What it is |
|---|---|
X-RateLimit-Limit | Requests allowed per minute. |
X-RateLimit-Remaining | How many you have left this minute. |
X-RateLimit-Reset | When the budget renews, in Unix seconds. |
Retry-After | Seconds 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 status | error.code | What happened |
|---|---|---|
| 401 | unauthorized | The header is missing, or the key is mistyped, revoked or no longer exists. |
| 400 | invalid_request | Bad date format, only one of the two days, reversed order, or a range longer than 366 days. |
| 429 | rate_limited | You went over 60 requests in the current minute. |
| 500 | internal_error | Something 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.
Work out the range. Last Monday to last Sunday, as YYYY-MM-DD. For example startDay=2026-08-17 and endDay=2026-08-23.
Call /performance with that range.
curl -H "Authorization: Bearer zcst_your_key" \ "https://app.zerochats.com/api/statistics/v1/performance?startDay=2026-08-17&endDay=2026-08-23"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.
Sort by paid — or by rates.paid if you care more about efficiency than volume — and keep the top five.
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 August1. 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/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.
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.id as your key, not name. Tag, content and sales-link names can be changed from the app; identifiers can’t.401 with a key I just copiedCheck 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.
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 haveThat’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.
/performance rows have url set to nullIt 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.