Skip to main content

Organization Webhooks

Organization webhooks deliver events for every home in an organization to an HTTPS endpoint you control. They are the right integration point if you manage a portfolio — a property manager, a PMS, or any partner platform — because you register the endpoint once against the organization instead of once per user.

This page covers the /organizations/{organization_id}/webhooks endpoints. If you are integrating on behalf of a single account rather than an organization, see Webhooks instead.

One property is worth internalising before the details, because most of them follow from it: Minut does not keep undelivered events. An event is delivered while its delivery attempts are still in flight, or it is gone. Beyond that window there is no buffer, no backlog and no replay — no API call can hand you an event after the fact, and nothing accumulates while your endpoint is unavailable. If you need a record you can go back to, that is what Fetching Events is for.

note

The two kinds of webhook are separate systems with separate payloads. An organization webhook delivers an event object keyed on type, home_id and organization_id; a user webhook delivers one keyed on an event id (see Webhooks), type and user_id. Registering one does not affect the other, and a home covered by both will produce a delivery to each.

Before you start​

Every request needs a valid access token — see Authentication for Partners.

The token must belong to a user who is an owner or an admin of the organization: reads (GET) require the webhook_read permission and writes (POST, DELETE) require webhook_write, and both are granted to those two roles only. Any other role gets 403 forbidden with error_key: invalid_access.

organization_id in the paths below is the organization whose homes you want events for. GET /organizations lists the organizations your token has access to.

Register a webhook​

POST /organizations/{organization_id}/webhooks with the URL you want events delivered to and the list of events you care about:

curl --request POST 'https://api.minut.com/v8/organizations/ORGANIZATION_ID/webhooks' \
--header 'Authorization: Bearer ACCESS_TOKEN' \
--header 'Content-Type: application/json' \
--data '{
"url": "https://example.com/minut/org-webhook",
"events": ["noise_detected", "noise_quieted", "battery_low"],
"token": "a-secret-you-choose"
}'
  • url — must be a publicly reachable HTTPS endpoint. A URL that isn't a valid web URI is rejected with a 400 carrying "context": "invalid url".
  • events — the event types to subscribe to, from the event catalogue below. Must be an array of known event names; anything else is rejected with a 400. Pass ["*"] to receive every declared event type — see The * wildcard.
  • token (optional) — an arbitrary string you choose. Minut sends it back on every delivery, both in the Authorization header and as hook_token in the body, so you can confirm a request really came from your webhook.

A 201 response contains the created webhook:

{
"hook_id": "6628f1a2b167b70039f80800",
"organization_id": "6612c4d5e6f7a8b901234567",
"url": "https://example.com/minut/org-webhook",
"requires_auth_token": true,
"events": ["noise_detected", "noise_quieted", "battery_low"],
"created_at": "2026-05-29T10:00:00.000Z",
"created_by": "5ffff21db167b70039f806f8",
"broken": false,
"hook_secret": "a3f1c8d2e9b047561234abcd5678ef901234abcd"
}

requires_auth_token tells you whether a token was set; the token itself is never returned. created_by is the id of the Minut user who created the webhook — not an organization-member id. Minut keeps the two distinct, so looking created_by up as a member id will not find the creator.

caution

hook_secret is the key you need to verify the HMAC signature on incoming deliveries, and this response is the only place you will ever see it. It is not returned when you list or fetch webhooks afterwards, and there is no endpoint to retrieve or rotate it. Store it somewhere safe as soon as you get it; if you lose it, your only option is to delete the webhook and create a new one, which gives you a new hook_id as well as a new secret.

One webhook per event per domain​

Minut rejects a second webhook that would send the same event to the same hostname. If you already have a webhook for noise_detected pointing at example.com, creating another noise_detected webhook for any URL on example.com fails with:

{
"error": "Conflict",
"message": "A webhook for noise_detected for domain example.com already exists",
"error_key": "generic_error"
}

A wildcard subscription conflicts with every event on that hostname, so ["*"] and a named subscription cannot coexist for the same domain. Use one webhook with several events rather than several webhooks with one event each.

List webhooks​

GET /organizations/{organization_id}/webhooks returns every webhook registered for the organization:

curl --request GET 'https://api.minut.com/v8/organizations/ORGANIZATION_ID/webhooks' \
--header 'Authorization: Bearer ACCESS_TOKEN'
{
"webhooks": [
{
"hook_id": "6628f1a2b167b70039f80800",
"organization_id": "6612c4d5e6f7a8b901234567",
"url": "https://example.com/minut/org-webhook",
"requires_auth_token": true,
"events": ["noise_detected", "noise_quieted", "battery_low"],
"created_at": "2026-05-29T10:00:00.000Z",
"created_by": "5ffff21db167b70039f806f8",
"broken": false
}
]
}

Get a single webhook​

GET /organizations/{organization_id}/webhooks/{hook_id} returns one webhook, in the same shape as the entries above. An id that isn't a valid object id is rejected with 400, and one that doesn't belong to this organization with 404.

curl --request GET 'https://api.minut.com/v8/organizations/ORGANIZATION_ID/webhooks/HOOK_ID' \
--header 'Authorization: Bearer ACCESS_TOKEN'

Delete a webhook​

DELETE /organizations/{organization_id}/webhooks/{hook_id} stops deliveries:

curl --request DELETE 'https://api.minut.com/v8/organizations/ORGANIZATION_ID/webhooks/HOOK_ID' \
--header 'Authorization: Bearer ACCESS_TOKEN'

The response has an empty body; treat any 2xx as success. Deleting an already deleted or unknown webhook returns 404.

Send a test event​

POST /organizations/{organization_id}/webhooks/{hook_id}/ping asks Minut to deliver a ping to the webhook's URL, so you can exercise your receiver without waiting for a real event:

curl --request POST 'https://api.minut.com/v8/organizations/ORGANIZATION_ID/webhooks/HOOK_ID/ping' \
--header 'Authorization: Bearer ACCESS_TOKEN'

The API responds 201 with an empty JSON object once the ping has been attempted. The ping targets the webhook named in the path and is sent with the same headers and the same hook_id / hook_token / hook_digest envelope as a real delivery.

A ping is a test delivery and nothing more. It bypasses the retry path described in Delivery and failure handling, so it never touches the failure counter or last_error. It does not re-enable a webhook that has stopped delivering, and it does not tell you events are being delivered again.

A 201 does not mean your endpoint answered

The 201 means only that Minut found the webhook and attempted the ping. The outcome of the attempt is not reflected in the response: a connection failure, a timeout, or a non-2xx from your endpoint all still return 201, and the failure is recorded only in Minut's own logs.

So treat the 201 as "the request was accepted", never as proof of delivery. Confirm the ping actually arrived by checking your own endpoint's logs.

Pinging a broken webhook is refused with a 409, and nothing is sent to your endpoint:

{
"error": "Conflict",
"message": "This webhook is broken and is not receiving events. Send {\"broken\": false} to PATCH /organizations/{organization_id}/webhooks/{hook_id} to clear it, then ping again.",
"error_key": "webhook_broken"
}

Refusing is deliberate: answering 201 for a webhook that is delivering nothing would suggest the webhook is working, and because the success body is empty there would be nothing to tell you otherwise. So the order is: fix your endpoint, clear broken, then ping — and check your own logs to confirm the ping arrived.

One consequence worth knowing rather than discovering: you cannot use a ping to exercise the Minut-to-your-endpoint path before accepting live traffic again, since the ping is refused until you clear the flag. You can of course call your own endpoint directly, and if it turns out to still be failing, the webhook simply breaks again once an event exhausts its attempts.

caution

A ping's event object is not shaped like a real event. It carries only type, organization_id and created_at — there is no home_id, no device_id and no metadata, because a ping does not belong to any home:

{
"hook_id": "6628f1a2b167b70039f80800",
"hook_token": "a-secret-you-choose",
"event": {
"type": "webhook_ping",
"organization_id": "6612c4d5e6f7a8b901234567",
"created_at": "2026-05-29T10:01:12.412Z"
},
"hook_digest": "9f2c1b7e...hex"
}

Use the ping to exercise your Authorization header check and your HMAC verification, which operate on the same envelope as a real delivery — but don't use it as a sample of a real event payload. Write your parsing code against the shapes in What Minut sends below, and branch on type so a webhook_ping is ignored rather than mis-parsed.

What Minut sends to your endpoint​

For each event, Minut makes an HTTP POST to your url with these headers:

HeaderValue
Content-Typeapplication/json; charset=utf-8
User-Agentminut-webhook-service
Authorizationthe raw token you set when registering, sent verbatim without a Bearer prefix (absent if you didn't set one)

and a JSON body with four top-level fields:

FieldDescription
hook_idThe id of the webhook this delivery belongs to.
hook_tokenThe token you registered, or absent if you didn't set one.
eventThe event itself — see below.
hook_digestAn HMAC-SHA256 signature over event. See Verifying deliveries.

The event object has these fields:

FieldDescription
webhook_event_idA unique id for the event, identical on every delivery attempt of the same event — use it to deduplicate, see Deduplicating. Specific to webhook delivery: it is not the event_id returned by the home events endpoints and cannot be used to fetch the event from the API.
typeThe event name, e.g. noise_detected. This is the field you branch on.
home_idThe home the event occurred in.
organization_idThe organization that owns the webhook.
created_atWhen the event occurred, as a UTC ISO-8601 timestamp. Event time, not delivery time.
device_idThe device that produced the event. Only present on transient events (currently pir_motion).
metadataEvent-specific detail. Present on events recorded in the home event log; absent on transient events.

A home event​

Most events come from the home event log and carry metadata. A noise disturbance looks like this:

{
"hook_id": "6628f1a2b167b70039f80800",
"hook_token": "a-secret-you-choose",
"event": {
"webhook_event_id": "0f9a4c1e-8b2d-4e7a-9c31-5d6e7f801234",
"type": "noise_detected",
"home_id": "5ffff22ab167b70039f806fe",
"organization_id": "6612c4d5e6f7a8b901234567",
"created_at": "2026-05-29T10:05:00.708Z",
"metadata": {
"homeName": "Seaside Cottage",
"homegroupId": "6612c4d5e6f7a8b901234599",
"homegroupName": "Coastal portfolio",
"homegroupType": "building",
"timezone": "Europe/Stockholm",
"incidentId": "6628f0aab167b70039f807ff",
"noiseDetected": {
"soundLevel": 82,
"soundThreshold": 75,
"disturbanceMonitoringLevel": 1,
"deviceId": "5ffff25ab167b70039f80700",
"deviceName": "Living room",
"durationMinutes": 5,
"nextNotificationAt": "2026-05-29T10:20:00.000Z"
}
}
},
"hook_digest": "3a7bdb1f...hex"
}

A device-scoped event such as battery_low or risk_of_mould carries the device inside metadata instead:

{
"hook_id": "6628f1a2b167b70039f80800",
"hook_token": "a-secret-you-choose",
"event": {
"webhook_event_id": "3c7b1d92-4a6f-4b18-8e05-1f2a3b4c5d6e",
"type": "battery_low",
"home_id": "5ffff22ab167b70039f806fe",
"organization_id": "6612c4d5e6f7a8b901234567",
"created_at": "2026-05-29T10:35:46.000Z",
"metadata": {
"homeName": "Seaside Cottage",
"timezone": "Europe/Stockholm",
"deviceId": "5ffff25ab167b70039f80700",
"deviceName": "Living room",
"hardwareVersion": 100
}
},
"hook_digest": "c41f9d02...hex"
}
note

metadata is passed through from Minut's internal home event log, so its keys are camelCase while the rest of the payload is snake_case, and its contents differ per event type. The examples above are illustrative of a single delivery, not a contract: keys can be added, and absent values are omitted rather than sent as null. Treat metadata as event-type-specific and essentially opaque — read only the keys you need, tolerate ones you don't recognise, and don't require any particular key to be present. The stable part of the payload is the event envelope.

A transient event​

pir_motion is not written to the home event log, so it is delivered without metadata but with the device_id of the sensor that saw the motion:

{
"hook_id": "6628f1a2b167b70039f80800",
"hook_token": "a-secret-you-choose",
"event": {
"webhook_event_id": "a51e6f30-7d94-4c2b-b8a7-9e0d1c2b3a45",
"type": "pir_motion",
"home_id": "5ffff22ab167b70039f806fe",
"organization_id": "6612c4d5e6f7a8b901234567",
"device_id": "5ffff25ab167b70039f80700",
"created_at": "2026-05-29T10:41:03.000Z"
},
"hook_digest": "7e0c53a8...hex"
}

Motion is frequent. Subscribe to pir_motion only if you actually need raw motion, and expect a high delivery rate on busy homes.

Verifying deliveries​

Your endpoint is public, so verify that a delivery actually came from Minut before acting on it. There are two independent checks:

  1. The token. Compare the Authorization header (and/or hook_token in the body) against the token you registered. If it doesn't match, reject the request. The header holds the raw token with no Bearer prefix.
  2. The HMAC signature. hook_digest is an HMAC-SHA256, hex-encoded, computed over the JSON serialization of the event object — not the whole body — using your hook_secret as the key.
import { createHmac, timingSafeEqual } from 'crypto'

function isFromMinut(body, hookSecret) {
const expected = createHmac('sha256', hookSecret)
.update(JSON.stringify(body.event))
.digest('hex')

const a = Buffer.from(expected)
const b = Buffer.from(body.hook_digest ?? '')
return a.length === b.length && timingSafeEqual(a, b)
}

In any language, the steps are: take the event value, serialize it to exactly the same bytes Minut signed, compute HMAC-SHA256(bytes, hook_secret), hex-encode, and compare against hook_digest in constant time.

Non-ASCII content

"Exactly the same bytes" is the part that bites. Minut signs the output of JavaScript's JSON.stringify, which emits non-ASCII characters as raw UTF-8 with no \uXXXX escaping, no added whitespace, and keys in insertion order. Many other serializers differ — Python's json.dumps escapes non-ASCII by default (ensure_ascii=True) and can be asked to sort keys, and some libraries re-order or re-indent. Re-serializing a parsed event with any of those settings produces a different digest and the check fails.

This shows up as verification that works for every home until one is named Café du Nord or カフェ, because metadata.homeName and metadata.deviceName come straight from user input. To avoid it:

  • In JavaScript, re-serializing the parsed event with JSON.stringify reproduces the signed bytes, so the snippet above is safe as written.
  • In other languages, match JSON.stringify's output: UTF-8, no ASCII escaping, no added whitespace, keys left in the order they arrived. In Python that means json.dumps(event, ensure_ascii=False, separators=(",", ":")) with sort_keys left off.
  • If your serializer can't be made to agree, read the raw request body as bytes before parsing and compute the HMAC over the raw event sub-slice instead of over a re-serialized object.
  • Test with a home name containing non-ASCII characters before you go live. This was a real integration bug (BE-87) and it only reproduces with non-ASCII content.

Delivery and failure handling​

  • Respond quickly with a 2xx. Minut applies a 10 second timeout to the request, so do your own processing asynchronously — acknowledge first, work afterwards.
  • A delivery counts as failed if it times out, if the connection can't be established, or if the response status — after any redirects, which are followed — is anything other than a 2xx. There is no status-code classification: a 401 or a 404 from your endpoint is treated exactly like a 503.
  • Deliveries are fired independently per webhook, and dispatch never blocks the event that triggered it. A failure on your side has no effect on the rest of the platform, and no effect on your other webhooks.

Delivery is at-least-once: each event gets up to 5 attempts — the initial delivery plus 4 retries. The nominal delays before attempts 2 to 5 are currently 1, 5, 15 and 60 minutes — 81 minutes in total — and each delay is jittered independently by up to ±20%, so in the normal case the span between the first failure and Minut giving up is roughly 65 to 97 minutes. Use that for expectations and monitoring, and treat the ladder as approximate and subject to change.

Retries are queued durably rather than held in memory, so they survive a backend restart or a deploy on our side. That cuts both ways. A retry whose delay elapses while our job processor is unavailable is not dropped — it runs when processing resumes, however much later that is, because a queued retry never expires and its age is not checked before it is sent. There is therefore no hard upper bound on how late a delivery can arrive, and the 65 to 97 minute span above is the normal case rather than a ceiling. Because there is no ceiling, protecting yourself from duplicates has to rest on idempotency rather than on a timer — see Deduplicating.

A pending retry re-reads the webhook before it runs, so deleting a webhook also stops any attempts still queued for it.

Treat webhooks as a fast path rather than a ledger: if you need certainty that you have every event, reconcile periodically against Fetching Events.

Deduplicating​

Because a delivery can be retried, your endpoint must expect the same event more than once and make reprocessing harmless. webhook_event_id is generated once per event and is identical on every attempt, so make your handler idempotent on (hook_id, webhook_event_id). This is the one thing retries require you to implement. One event that matches two of your webhooks is delivered to both with the same webhook_event_id, which is why hook_id belongs in the key.

Idempotency, not a timer, is what protects you. If you keep deduplication state with a TTL, treat that retention as a cost-versus-risk trade-off you choose rather than as a guarantee: retries have no hard upper bound on how late they can arrive, so longer retention narrows the window in which a late retry is no longer recognised as a duplicate, but nothing closes it entirely.

Build that in even if you never see a retry while testing. A retry is not only what happens when your endpoint is down: if your endpoint processes an event but the response is lost on the way back — a timeout, a dropped connection, a proxy that gives up — Minut records the attempt as failed and delivers that same event again.

hook_digest is byte-identical across all attempts too — it is an HMAC over the serialized event, and the event is redelivered unchanged — so verification code needs no special handling for a retry.

For the same reason, created_at is never restamped: it is the time the event occurred, not the time it was delivered. By a fifth attempt it is normally over an hour old, and a delayed retry can make it arbitrarily older than that.

Arrival order is not event order. A retried event can reach you after events that occurred later, so ordering by arrival will silently give you the wrong sequence — which matters if you drive a state machine or "latest wins" logic from deliveries. Order by created_at instead, and treat any delivery as potentially stale, with no fixed bound on by how much.

Broken webhooks​

If all 5 attempts fail, the webhook is flagged broken. Minut then stops delivering to it and does not keep retrying in the background. Three fields on GET /organizations/{organization_id}/webhooks and GET /organizations/{organization_id}/webhooks/{hook_id} let you see this:

FieldDescription
brokenBoolean. false means healthy. The API reference marks the field optional, but responses always include it, so you never need to handle it being absent. While it is true, matching events are discarded as they occur rather than queued, so no backlog builds up waiting to be released.
broken_atISO-8601 timestamp, present only when the webhook is broken.
last_errorPresent once a delivery has failed since the last success: reason (string), status_code (integer, absent when your endpoint never responded — timeout, DNS failure, connection refused), failed_at (ISO-8601) and attempts (how many attempts that event had used when it last failed).

Poll those fields, or check them when deliveries go quiet. There is no notification when a webhook breaks.

To start receiving events again, fix your endpoint and then clear the flag by setting broken to false on the webhook.

There is no queue behind the flag, and clearing it releases nothing. While a webhook is broken, matching events are discarded at the moment they occur — they are not held, buffered or stored anywhere, so there is nothing to drain. Clearing broken turns delivery back on; it does not go back and get what you missed. Concretely, the call does not resend the event whose delivery attempts were exhausted, does not deliver anything that occurred while the webhook was broken, and does not trigger any delivery at all. The next event to occur is the first one you receive.

curl --request PATCH 'https://api.minut.com/v8/organizations/ORGANIZATION_ID/webhooks/HOOK_ID' \
--header 'Authorization: Bearer ACCESS_TOKEN' \
--header 'Content-Type: application/json' \
--data '{ "broken": false }'

broken is a field you patch rather than an action you call because it isn't really Minut's state to own — it is our inference about a server you operate. You know things we can't, namely that you have shipped a fix, so clearing it is you telling us something we have no way to observe.

That is also why false is the only value accepted. {"broken": true} is rejected with a 400: Minut sets broken when every delivery attempt for an event fails, and that is an observation about your endpoint, not a value you can assign. Omitting the field is a 400 too — broken is required.

The request needs the same webhook_write permission as the other writes and responds 200 with the full webhook object, so you can confirm broken is back to false. It also clears broken_at and last_error and resets the failure count. It is idempotent — patching a webhook that isn't broken succeeds and changes nothing. A malformed hook_id gives 400; one that is unknown, deleted or belongs to another organization gives 404.

Mind the gap

Because nothing is replayed, a broken webhook leaves a permanent hole in what you receive: every event between broken_at and the moment you clear the flag is gone, and no webhook call can recover it. This is the one failure mode worth building for.

If you need the events from that window, backfill them from the API — see Fetching Events.

Note that a backfill cannot be deduplicated against your webhook deliveries: the two surfaces share no common identifier, so there is nothing that links a delivered event to the same event fetched from the API.

Event catalogue​

These are the event names an organization webhook can subscribe to:

CategoryEvents
Noisenoise_detected, noise_quieted, noise_sms_sent, noise_email_sent, noise_auto_call, noise_flash_and_sound, sound_level_dropped_normal
Occupancycrowd_detected, pir_motion, motion_alarm_on, motion_alarm_off, motion_alarm_critical_event
Motion automationmotion_automation_guest_checkin, motion_automation_guest_no_show
Climatetemperature_high, temperature_low, temperature_risen_normal, temperature_dropped_normal, humidity_high, humidity_low, humidity_risen_normal, humidity_dropped_normal, almost_freezing, risk_of_mould, risk_of_mould_cleared
Smokingsmoking_detected
Alarmsalarm_heard
Devicedevice_online, device_offline, device_power_off, battery_low, battery_empty, tamper_mounted, tamper_removed, short_button_press
Guestsguest_connect_sms_sent

This table is the authoritative list of subscribable event names, and it is also the full set that ["*"] delivers. A name outside it is rejected at registration with a 400 carrying "context": "invalid event: <name>", so a typo or a stale name fails loudly at POST time rather than silently never firing.

Minut declares new event names over time and this page is updated when it does, so don't hard-code the set as permanent: branch on type and ignore any type you don't recognise rather than failing on it.

For what each event means and what triggers it, see the Events Reference.

The * wildcard​

Instead of naming events, you can subscribe to ["*"] to have the webhook receive events of every type rather than a chosen set. The wildcard must be the only entry in the array — combining it with named events is rejected with 400 wildcard events can only be used with a single event.

* delivers only the event types declared in the event catalogue above. That catalogue is the bound on what a wildcard subscription delivers, not merely a menu of names you can subscribe to individually — an event Minut produces internally but has not declared is not delivered to a * subscriber.

Prefer naming the events you need. A wildcard subscription means your endpoint receives event types you have no handler for, at a volume you didn't plan for (it includes pir_motion). Minut also declares new event types over time, and once a type is declared it is added to the catalogue and * subscribers start receiving it with no change on your side — so still branch on type and ignore any type you don't recognise.

Worked example​

The full loop: register a webhook, ping it to exercise your receiver, then receive and verify a real event.

1. Register the webhook and keep the hook_secret from the response.

curl --request POST 'https://api.minut.com/v8/organizations/ORGANIZATION_ID/webhooks' \
--header 'Authorization: Bearer ACCESS_TOKEN' \
--header 'Content-Type: application/json' \
--data '{
"url": "https://example.com/minut/org-webhook",
"events": ["noise_detected", "noise_quieted"],
"token": "a-secret-you-choose"
}'
{
"hook_id": "6628f1a2b167b70039f80800",
"organization_id": "6612c4d5e6f7a8b901234567",
"url": "https://example.com/minut/org-webhook",
"requires_auth_token": true,
"events": ["noise_detected", "noise_quieted"],
"created_at": "2026-05-29T10:00:00.000Z",
"created_by": "5ffff21db167b70039f806f8",
"broken": false,
"hook_secret": "a3f1c8d2e9b047561234abcd5678ef901234abcd"
}

2. Stand up the receiver. It verifies both the token and the signature, acknowledges immediately, and only then does any work:

import express from 'express'
import { createHmac, timingSafeEqual } from 'crypto'

const HOOK_TOKEN = 'a-secret-you-choose'
const HOOK_SECRET = 'a3f1c8d2e9b047561234abcd5678ef901234abcd'

const app = express()
app.use(express.json())

function signatureMatches(body) {
const expected = createHmac('sha256', HOOK_SECRET)
.update(JSON.stringify(body.event))
.digest('hex')

const a = Buffer.from(expected)
const b = Buffer.from(body.hook_digest ?? '')
return a.length === b.length && timingSafeEqual(a, b)
}

app.post('/minut/org-webhook', (req, res) => {
if (req.get('Authorization') !== HOOK_TOKEN) {
return res.sendStatus(401)
}
if (!signatureMatches(req.body)) {
return res.sendStatus(401)
}

res.sendStatus(200)

if (req.body.event.type === 'webhook_ping') {
return
}

const { webhook_event_id, type, home_id, created_at, metadata } = req.body.event
if (alreadyProcessed(req.body.hook_id, webhook_event_id)) {
return
}
void handleEvent({ eventId: webhook_event_id, type, homeId: home_id, occurredAt: created_at, metadata })
})

app.listen(3000)

3. Ping it to exercise the receiver end to end:

curl --request POST 'https://api.minut.com/v8/organizations/ORGANIZATION_ID/webhooks/6628f1a2b167b70039f80800/ping' \
--header 'Authorization: Bearer ACCESS_TOKEN'

Your endpoint receives a webhook_ping. The 201 from the curl above only means Minut attempted the delivery, so check your receiver's logs to confirm it arrived.

A ping that verifies confirms your HMAC implementation on ASCII content — the envelope and the signing are the same as a real delivery. It does not exercise the non-ASCII path: a ping's event carries only type, organization_id and created_at, and none of the user-supplied metadata that causes the serialization mismatch described in Verifying deliveries. A passing ping is therefore exactly the false confidence behind BE-87 — before you go live, test against an event from a home whose name contains non-ASCII characters.

4. Receive the real thing. When noise monitoring next raises a disturbance in any home in the organization, your endpoint gets the noise_detected payload shown in A home event, and handleEvent runs with the home id, the timestamp and the sound levels from metadata.noiseDetected.

Good to know​

  • Use a fresh, hard-to-guess token per webhook, keep hook_secret server-side, and never log either.
  • Subscribe to the events you need rather than ["*"], and branch on type so unknown types are ignored rather than fatal.
  • Acknowledge with a 2xx before doing any work. The 10 second timeout applies to your response, not to your processing.
  • Make your handler idempotent on (hook_id, webhook_event_id). Retries have no hard upper bound on how late they can arrive, so deduplication state with a TTL narrows the duplicate window but never closes it — reprocessing has to be harmless regardless.
  • Verify with a home name containing non-ASCII characters before you go live.
  • Reconcile against Fetching Events if you need to be certain you have every event.