Overview
Webhooks send an HTTPS POST to a URL you choose when something happens in your workspace, so your own systems can react without polling the read API. Every request is signed, so you can check it came from Hourari.
Webhooks are part of the Business and Enterprise plans, and only the workspace owner can set them up.
Add an endpoint
- Open Settings → Integrations and choose Webhooks, under Developer.
- Choose Add endpoint.
- Give it a name, enter its Endpoint URL, and tick the Events it should receive. The URL must use
https://and a domain name. An IP address is refused. - Copy the signing secret. It is shown only once; you need it to verify requests.
To change which events an endpoint receives, open the … menu on its row and choose Edit events, tick or untick them, and choose Save. Its URL and signing secret stay the same, so your receiving code does not need to change.
Open the … menu on the endpoint and choose Send test to send it a webhook.test delivery. The endpoint's row shows when it last received something, and Recent deliveries lists each attempt.
Events
| Event | Sent when |
|---|---|
meeting.threshold_exceeded |
An upcoming meeting's cost reaches your Warn the organizer above amount. |
approval.requested |
Someone asks for an exception to a cost limit. |
budget.threshold_crossed |
A personal or team monthly budget reaches one of its alert thresholds, or is on course to go over. |
series.ended |
Hourari ends a recurring meeting because nobody kept it at its review. |
series.reopened |
Someone removes the end date Hourari set on a recurring meeting, so it carries on. |
meeting.declined |
An organizer cancels an upcoming meeting, or the rest of a series, through Hourari. |
meeting.seat_declined |
Someone declines an upcoming meeting on their calendar through Hourari, and it still goes ahead. |
sync.failed |
A calendar sync fails, or some of its events could not be synced. |
webhook.test |
You choose Send test on the endpoint. It cannot be subscribed to. |
meeting.threshold_exceeded
The threshold is Warn the organizer above, in Settings → Meeting rules → Cost thresholds. A meeting is sent when its cost is worked out at or above that amount and it was below it before: when it is first synced, or when a change to its attendees, length or rates pushes it over.
- Once per crossing. A meeting that stays over the threshold is not sent again on every sync. If its cost drops below and later rises over again, it is sent again.
- Upcoming and in-progress meetings only. A meeting that has already ended is not sent, so connecting a calendar does not replay weeks of history.
- Declined meetings are not sent. They count as savings, not spend.
- Changing the threshold sends nothing by itself. It applies to the next cost worked out.
{
"meeting": {
"id": "3f1c9a52-7d4e-4b8a-9c21-6e0f5a7b8d13",
"title": "Quarterly planning",
"date": "2026-10-14T15:00:00+00:00",
"duration_minutes": 120,
"source": "google",
"organizer_email": "dana@example.com",
"team_id": null,
"recurring": false,
"attendees": { "invited": 12, "external": 1, "excluded": 0, "costed": 11 }
},
"cost": { "amount": 1870.0, "threshold": 500.0, "currency": "USD" }
}
The fields match the read API's meetings: date is the start time, attendees.costed is how many people the cost covers, and team_id: null means the meeting has no team. Amounts are in your company's currency.
approval.requested
Sent once for each request, whether it was filed while scheduling a meeting or from a Slack alert. Decisions are not sent.
{
"request": {
"id": "2d37f15e-8777-4e0c-83eb-10caa2a0e6dc",
"status": "pending",
"created_at": "2026-10-12T14:03:51+00:00",
"title": "Quarterly planning",
"meeting_id": null,
"duration_minutes": 120,
"attendee_count": 12,
"occurrences_per_year": 4,
"series_occurrences": null,
"justification": "Board prep needs the whole leadership team."
},
"requester": { "email": "dana@example.com", "full_name": "Dana Reyes" },
"cost": { "per_meeting": 2400.0, "annual": 9600.0, "threshold": 1000.0, "currency": "USD" }
}
meeting_id is null when the request was made before the meeting existed. occurrences_per_year is 1 for a one-off meeting. series_occurrences is set for a series that ends within a year, which is judged on its total rather than a year's cost.
budget.threshold_crossed
Sent once for each alert a budget sends: at most once per threshold per budget per month, whether or not that alert also reached Slack or email. The thresholds are the ones turned on for that budget (50%, 80% and 100%). If spend jumps past two at once, each is sent.
budget.typeispersonalfor someone's own monthly budget, which measures the whole workspace's spend, orteamfor a team's budget, which measures that team's meetings.team_idandteam_nameare set for a team budget;holder_emailandholder_namefor a personal one. The other pair isnull.alert.kindisthresholdwhen spend reachedalert.percentof the budget, orpacefor the early warning that the month is on course to go over.percentisnullfor a pace alert.alert.monthis the budget month, in the workspace's time zone.- The spend itself is not included; open Hourari for the figure the alert was based on.
{
"budget": {
"type": "team",
"team_id": "7a2e4c91-5b3d-4f08-9e16-2c8d0a4b6f53",
"team_name": "Sales",
"holder_email": null,
"holder_name": null,
"amount": 8000.0,
"currency": "USD"
},
"alert": { "kind": "threshold", "percent": 80, "month": "2026-10" }
}
series.ended
Sent when a recurring meeting's review is not answered and Hourari ends the series in the calendar. It is sent after the calendar has been changed. A series ended by hand in Google Calendar or Outlook is not sent.
{
"series": {
"id": "c41d7e28-93a5-4b1f-8e60-5f2a9d3c7b14",
"series_key": "gcal:4kq9vl2m8c1s6t0r3n5b7x",
"title": "Weekly pipeline review",
"organizer_email": "dana@example.com",
"status": "ended",
"end_date": "2026-11-02",
"ended_at": "2026-10-19T09:00:12+00:00",
"next_review_at": "2026-10-12T09:00:00+00:00"
}
}
id and series_key stay the same for the life of the series, so you can match a later series.reopened to it. end_date is the last day the meeting runs.
series.reopened
Sent when a series Hourari ended is back in use: usually because someone removed the end date Hourari set, which puts it back under review. The fields are the same as series.ended. status is the series' new review status (usually active), next_review_at is when it will next be reviewed, and end_date and ended_at still show the end that was undone.
{
"series": {
"id": "c41d7e28-93a5-4b1f-8e60-5f2a9d3c7b14",
"series_key": "gcal:4kq9vl2m8c1s6t0r3n5b7x",
"title": "Weekly pipeline review",
"organizer_email": "dana@example.com",
"status": "active",
"end_date": "2026-11-02",
"ended_at": "2026-10-19T09:00:12+00:00",
"next_review_at": "2027-01-17T09:05:40+00:00"
}
}
meeting.declined
Sent when an organizer cancels a meeting through Hourari, so it is not happening for anyone. One event is sent for each upcoming occurrence that is cancelled: cancelling the next occurrence of a series sends one, and cancelling the rest of a series sends one for each meeting still to come that Hourari has synced (up to the end of next month). decline.kind is cancelled, decline.origin is dashboard and decline.reason is null.
Until 20 October 2026 this event was sent when a meeting was marked as skipped in the dashboard, with decline.reason set to the reason chosen and no kind or origin. A skip is now a private note that tells nobody and still counts toward spend, so it is not sent.
One person declining is sent as
meeting.seat_declinedinstead. When someone declines on their own calendar, from the dashboard, a Slack alert or Microsoft Teams, the meeting still goes ahead for everyone else. Only their time comes off its cost.Once per decline. If a decline is undone and the meeting is declined again, it is sent again.
Excluding a meeting from a Slack alert is not sent. It removes the meeting from cost tracking but tells nobody, so the meeting still happens.
Meetings that have already ended are not sent.
The note sent to the organizer, if any, is not included.
{
"meeting": {
"id": "3f1c9a52-7d4e-4b8a-9c21-6e0f5a7b8d13",
"title": "Quarterly planning",
"date": "2026-10-14T15:00:00+00:00",
"duration_minutes": 120,
"source": "google",
"organizer_email": "dana@example.com",
"team_id": null,
"recurring": false,
"attendees": { "invited": 12, "external": 1, "excluded": 0, "costed": 11 }
},
"decline": { "kind": "cancelled", "reason": null, "origin": "dashboard", "declined_at": "2026-10-13T10:41:07+00:00" },
"cost": { "amount": 1870.0, "currency": "USD" }
}
meeting has the same fields as in meeting.threshold_exceeded. cost.amount is what the meeting was costed at, and is null if it had not been costed yet.
meeting.seat_declined
Sent when someone declines an upcoming or in-progress meeting on their own calendar through Hourari: from the dashboard, a Slack alert or Microsoft Teams. The meeting still goes ahead for everyone else, and their time comes off its cost. decline.origin is dashboard, slack or teams. decline.reason is the reason chosen in the dashboard, or Declined from Slack or Declined from Microsoft Teams, and can be null.
- Once per decline. If the same person accepts again and later declines again, it is sent again.
- Who declined is not included, and nor is what their time cost: that figure comes from their pay.
meeting.attendeesis the meeting as it was when they declined, so it still counts them; the meeting's cost drops by their seat straight after. - Meetings that have already ended are not sent.
- The note sent to the organizer, if any, is not included.
{
"meeting": {
"id": "3f1c9a52-7d4e-4b8a-9c21-6e0f5a7b8d13",
"title": "Quarterly planning",
"date": "2026-10-14T15:00:00+00:00",
"duration_minutes": 120,
"source": "google",
"organizer_email": "dana@example.com",
"team_id": null,
"recurring": false,
"attendees": { "invited": 12, "external": 1, "excluded": 0, "costed": 11 }
},
"decline": { "reason": "Declined from Slack", "origin": "slack", "declined_at": "2026-10-13T10:41:07+00:00" }
}
sync.failed
Sent once for each calendar sync that fails, or that finishes with some events it could not sync. It is not sent for each event.
- Brief outages are not sent. If Google or Microsoft drops a request or asks us to slow down, the sync is retried within minutes. If the same calendar fails that way three times in 24 hours, it is sent once, with
retrying: true. Hourari keeps retrying either way. - A calendar that needs reconnecting is not sent. Its owner is asked to reconnect in Hourari.
stageisconnectionwhen the whole sync failed, oreventswhen it finished but some events could not be synced. When the whole sync failed, the event counts cover only what it got through before it stopped, and are often 0.- Which events failed is not included.
{
"sync": {
"connection_id": "e8b3f1a6-2d47-4c95-b0e3-91a7c5d2f864",
"source": "google",
"stage": "events",
"error": "3 event(s) failed to sync",
"retrying": false,
"failed_at": "2026-10-19T08:15:33+00:00",
"events_seen": 40,
"events_synced": 37,
"events_failed": 3
},
"user": { "email": "dana@example.com", "full_name": "Dana Reyes" }
}
user is the person whose calendar it is.
webhook.test
{ "message": "This is a test delivery from Hourari.", "sent_at": "2026-10-12T14:03:51+00:00" }
What a request looks like
Every delivery is a POST with a JSON body. The event's fields above arrive in data:
{
"id": "9b7e2c41-0f3a-4d6b-8e15-2a4c6d8f0b13",
"type": "meeting.threshold_exceeded",
"created_at": "2026-10-12T14:05:02.118Z",
"company_id": "5c8a1e37-2b9d-4f60-a4c2-7e1d3b5f9a08",
"data": { "meeting": { "id": "3f1c9a52-7d4e-4b8a-9c21-6e0f5a7b8d13" } }
}
id identifies the delivery and stays the same on every retry. created_at is when this attempt was sent.
The request carries these headers:
| Header | Value |
|---|---|
X-Hourari-Delivery |
The delivery's id. |
X-Hourari-Event |
The event type. |
X-Hourari-Timestamp |
When this attempt was signed, in Unix seconds. |
X-Hourari-Signature |
sha256= followed by the signature in hex. |
User-Agent |
Starts with Hourari-Webhooks/1. Our hosting provider may add more after it. |
Verify the signature
The signature is an HMAC-SHA256 of the timestamp, a full stop, and the raw request body, keyed with your signing secret. To check a request:
- Read the raw body exactly as received, before any JSON parsing, which can change it.
- Compute the HMAC-SHA256 of
<X-Hourari-Timestamp>.<raw body>with your signing secret, in hex. - Compare it with the part of
X-Hourari-Signatureaftersha256=, using a constant-time comparison. - Reject the request if the timestamp is more than five minutes from your clock, so a captured request cannot be replayed later.
In Node.js:
import { createHmac, timingSafeEqual } from 'node:crypto';
function isFromHourari(rawBody, headers, secret) {
const timestamp = headers['x-hourari-timestamp'];
const signature = (headers['x-hourari-signature'] ?? '').replace(/^sha256=/, '');
if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
return signature.length === expected.length && timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
Respond, retries and failures
Answer with any 2xx status within 10 seconds. Do slow work after you respond.
- Anything else is a failure and is retried: another status, a timeout, or a redirect. Redirects are not followed. A delivery is tried up to six times, at growing intervals over about two hours, and then given up. If you answer
429or503with aRetry-Afterheader, the next attempt waits that long, up to an hour. - Deliveries arrive at least once. A request you accepted can still be sent again if your answer did not reach us. Use
id, orX-Hourari-Delivery, to ignore one you have already handled. - Order is not guaranteed. Use the timestamps in the event rather than the order requests arrive.
- Expect a few minutes' delay. Deliveries are sent every five minutes.
- An endpoint that keeps failing is turned off. After 20 failed attempts in a row it is disabled and stops receiving events. The workspace owner gets one email saying so, and the endpoint's row shows the last error it returned. Fix it, then switch it back on in Webhooks: it can then fail three more times before it is turned off again, and the first success clears the count. Deliveries that queued while it was off are retried first, so if it is still broken it is turned off again within minutes.
Removing an endpoint, or unsubscribing it from an event, also stops anything already waiting to be sent to it.