Skip to main content
Browse the docs

DocsIntegrations

Read API

Pull meeting costs, spend by team and your roster into your own dashboards, warehouse or finance tools with Hourari's read-only API.

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.

  1. Open Settings → Integrations and choose API keys, under Developer.
  2. Choose New key.
  3. Name it after what will use it, and tick only the access it needs: Read meetings, Read analytics or Read the roster.
  4. 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.amount is in the response's currency, to the cent. It is null only when cost.state is unpriced, meaning no cost has been worked out yet. Hourari never fills that gap with an estimate.
  • A priced amount of 0 is real. The meeting was all external guests, or everyone who counts was excluded from its cost.
  • cost.counts_toward_spend is false for 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.costed is how many people the cost covers: invited less external less excluded. Divide by this, not invited, for a per-person figure.
  • team_id: null means 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.

Last checked against the product on .