Overview
The read API gives your own systems the same meeting cost data you see in Hourari: every meeting with its cost, spend by month or quarter and team, and your roster. It is read-only: nothing you send can change your workspace.
To be told when something happens rather than asking, use webhooks.
The API is part of the Enterprise plan. Every request goes to this base URL:
https://www.hourari.com/api
Three routes, all GET:
| Route | Returns | Scope it needs |
|---|---|---|
/v1/meetings |
Meetings, each with its cost and attendee counts | meetings:read |
/v1/analytics/summary |
Spend totals by period and by team | analytics:read |
/v1/users |
Your roster, with role and cost tier names | users:read |
Create an API key
Only the workspace owner can create keys.
- Open Settings → Integrations and choose API keys, under Developer.
- Choose New key.
- Name it after what will use it, and tick only the access it needs: Read meetings, Read analytics or Read the roster.
- Copy the key straight away. It is shown once, and Hourari keeps only a fingerprint of it, so nobody, including us, can show it to you again. If you lose it, revoke it and create another.
Keys look like hri_<prefix>_<secret>. Give each system its own key with the narrowest access that does the job: a loader that only needs costs has no reason to hold the key that lists your colleagues.
The API is for server-to-server use. It sends no CORS headers, so a browser cannot call it. Never put a key in frontend code.
Authentication
Send the key as a bearer token on every request:
curl https://www.hourari.com/api/v1/meetings \
-H "Authorization: Bearer $HOURARI_API_KEY"
A missing, malformed, unknown, revoked or expired key all get the same 401 unauthorized. The API does not say which, so a leaked prefix tells nobody anything.
Your workspace is told instead. When a refused call names one of your keys, the activity log (Settings → Activity log) records API key rejected against that key, with whether it was revoked, expired or sent with the wrong secret. That is how a server still running an old key shows up. At most five are recorded per key per hour.
Two-factor applies to signed-in sessions. An API key signs in with its own credential and can't ask for a code, so keep keys as carefully as a password: anyone holding one reads what its scopes allow, whether or not your workspace requires two-factor.
Allowed networks
If your workspace has an IP allowlist (Settings → Access & data), API keys follow it too: a request from outside your ranges gets 403 forbidden_network, even with a valid key. Add the address your servers call from to the list, or the calls will be refused. Without a list, keys work from anywhere.
Rate limits
Each key has its own hourly limit. The workspace owner chooses standard or high from the key's row in API keys. unlimited is set by Hourari: contact us to have it turned on for a key.
| Limit | Requests per rolling hour |
|---|---|
standard |
600 |
high |
6,000 |
unlimited |
No limit |
Every successful response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. Over the limit, you get 429 rate_limited with a Retry-After header giving the seconds to wait.
List meetings
GET /v1/meetings returns meetings newest first.
| Parameter | Default | Meaning |
|---|---|---|
start |
90 days before end |
First day covered, YYYY-MM-DD, UTC. |
end |
Today | Last day covered, inclusive, YYYY-MM-DD, UTC. |
team_id |
All teams | One team's meetings, or untagged for meetings with no team. |
limit |
50 | Meetings per page, 1 to 200. |
cursor |
None | page.next_cursor from the previous page. |
One request covers at most 366 days. A wider range, or a limit outside 1–200, is refused with 400 invalid_parameter rather than quietly narrowed.
curl "https://www.hourari.com/api/v1/meetings?start=2026-09-01&end=2026-09-30&limit=1" \
-H "Authorization: Bearer $HOURARI_API_KEY"
{
"data": [
{
"id": "3f1c9a52-7d4e-4b8a-9c21-6e0f5a7b8d13",
"title": "Weekly product sync",
"date": "2026-09-29T15:00:00Z",
"duration_minutes": 60,
"source": "google",
"organizer_email": "dana@example.com",
"team_id": null,
"attendees": { "invited": 6, "external": 1, "excluded": 0, "costed": 5 },
"cost": { "amount": 412.5, "state": "priced", "counts_toward_spend": true },
"declined": false,
"shortened": false,
"recurring": true,
"value_score": null,
"updated_at": "2026-09-29T16:02:11Z"
}
],
"currency": "USD",
"window": { "start": "2026-09-01", "end": "2026-09-30" },
"page": { "limit": 1, "has_more": true, "next_cursor": "eyJ0IjoiMjAyNi0wOS0yOVQxNTowMDowMFoifQ" }
}
Reading the fields:
cost.amountis in the response'scurrency, to the cent. It isnullonly whencost.stateisunpriced, meaning no cost has been worked out yet. Hourari never fills that gap with an estimate.- A
pricedamount of0is real. The meeting was all external guests, or everyone who counts was excluded from its cost. cost.counts_toward_spendisfalsefor a meeting declined through Hourari, whose value counts as a saving instead. Skip these when you add up spend, or you will count them twice.attendees.costedis how many people the cost covers:invitedlessexternallessexcluded. Divide by this, notinvited, for a per-person figure.team_id: nullmeans the meeting has no team. That is a real group, often the largest, and not missing data.
Spend summary
GET /v1/analytics/summary returns spend totals for the whole range, for each month or quarter, and for each team.
| Parameter | Default | Meaning |
|---|---|---|
start |
90 days before end |
First day covered, YYYY-MM-DD, UTC. |
end |
Today | Last day covered, inclusive, YYYY-MM-DD, UTC. |
granularity |
month |
month or quarter. |
team_id |
All teams | One team, or untagged. |
One request covers at most 1,100 days.
{
"window": { "start": "2026-07-01", "end": "2026-09-30", "granularity": "month" },
"currency": "USD",
"totals": { "meetings": 1284, "minutes": 61920, "cost": 98412.75 },
"periods": [
{ "period_start": "2026-07-01", "label": "2026-07", "meetings": 402, "minutes": 19380, "cost": 30851.25 }
],
"teams": [
{ "team_id": null, "team_name": null, "cost_center_code": null, "meetings": 611, "minutes": 29460, "cost": 46820.5 }
],
"basis": { "excludes_declined": true, "excludes_unpriced": true, "estimates_missing_costs": false }
}
Periods run oldest first; teams run most expensive first. cost_center_code is the ERP code you mapped to the team, or null if none is mapped.
The summary leaves out declined meetings and meetings not yet priced, and basis says so. Its meetings count is therefore not your total number of meetings. These are the same figures as the ERP export and the warehouse sync, so a total here matches the file your finance team downloaded.
List users
GET /v1/users returns your roster, oldest first, so pages stay stable as people are added. It takes limit and cursor, as meetings does.
{
"data": [
{
"id": "8b2d4f60-1a3e-4c7b-b5d9-2f6e8a0c4d17",
"email": "dana@example.com",
"full_name": "Dana Reyes",
"job_title": "Product manager",
"department": "Product",
"workspace_role": "member",
"active": true,
"placeholder": false,
"cost_tier": { "id": "c41e7a09-5b2f-4d8e-a6c3-9f1b0e2d7a54", "name": "Band 3" },
"role": { "id": "e7a3c512-9d4b-4f6e-8a1c-3b5d7f9e0c28", "name": "Product manager" },
"created_at": "2026-08-14T10:22:05Z",
"updated_at": "2026-09-30T08:41:37Z"
}
],
"page": { "limit": 50, "has_more": false, "next_cursor": null }
}
No pay figure is returned, on this route or any other. cost_tier and role give names only. Salaries and per-person rates stay owner-only inside Hourari, and no key can read them.
placeholder: true marks a roster entry for someone who has never signed in. In most workspaces that is most rows, so do not treat every row as an account. active: false marks someone who has been paused.
Pagination
/v1/meetings and /v1/users return pages. While page.has_more is true, pass page.next_cursor back as cursor with the same other parameters. The last page has next_cursor: null.
Pages are stable while your calendars sync: a meeting added mid-walk does not shift later pages or repeat rows. A cursor the API did not issue is refused with 400 invalid_cursor, never treated as a first page.
Errors
Every error has the same body:
{ "error": { "code": "forbidden_scope", "message": "This API key does not carry the 'meetings:read' scope." } }
Write your code against code, which does not change. message is for your logs and may be reworded.
| Status | code |
What happened |
|---|---|---|
| 400 | invalid_parameter |
A parameter is malformed or out of range. |
| 400 | invalid_cursor |
The cursor did not come from this API. |
| 401 | unauthorized |
The key is missing, malformed, unknown, revoked or expired. |
| 403 | forbidden_scope |
The key does not have the scope this route needs. |
| 403 | plan_required |
The workspace is not on the Enterprise plan. The key itself is still valid. |
| 403 | forbidden_network |
The request came from outside your workspace's IP allowlist. The key itself is still valid. |
| 404 | not_found |
No such route. Include the version: /v1/meetings, not /meetings. |
| 405 | method_not_allowed |
The API is read-only; use GET. |
| 429 | rate_limited |
The key is over its hourly limit. Wait for Retry-After seconds. |
| 500 | internal |
Something failed unexpectedly. Retry. |
| 503 | unavailable |
A service the API depends on failed. Nothing was changed; retry. |