Outgoing Webhooks

Outgoing webhooks send event notifications to your URL when things happen in Monotree. Configure each endpoint and the events it should subscribe to in the Open API → Outgoing Webhooks tab, or programmatically via the Manage Outgoing Webhooks API.

Every delivery is signed. Before you trust a payload, check it against Signature verification.

Available events

EventFires when
monotree.post.createdA post is created.
monotree.comment.createdA comment is added to a post.
monotree.comment.updatedA comment on a post is edited.
monotree.comment.deletedA comment on a post is deleted.
monotree.announcement.publishedAn announcement is published.
monotree.formresponse.createdA form response is submitted.
monotree.formresponse.updatedA form response's status, deadline, or hidden flag changes, assignees are added or removed, or the response is deleted, restored, or permanently deleted — check the payload's deleted_at (always present, null while the response exists) to detect deletions. Does not fire on initial creation (use formresponse.created) or for comments (use formresponse.comment.created). Filterable by form. Whistleblower forms never dispatch.
monotree.formresponse.comment.createdA public comment is added to a form response. Filterable by form. Whistleblower forms never dispatch. Internal staff notes do not trigger webhooks.
monotree.formresponse.comment.deletedA public comment on a form response is deleted. Filterable by form. Whistleblower forms never dispatch.
monotree.formresponse.alarm_triggeredA form response matched a configured field alarm rule. One delivery per matched rule, idempotent per response + rule pair. Filterable by form. Whistleblower forms never dispatch.
monotree.user.registeredA user accepts an invite and registers. Not filterable.
monotree.onboarding.completedA user completes onboarding. Once per employee, only while onboarding is switched on. Not filterable.
monotree.achievement.awardedA user is given an achievement, automatically (completion or tenure trigger), by hand in the CMS or through POST /achievements/{id}/awards. Fires only for published achievements while the achievements feature is on; never for CMS re-evaluation backfills. Filterable by achievement.
monotree.announcement.confirmedAn employee confirms they have read a confirmable announcement. Once per employee per announcement — there is no un-confirm, so a confirmation is announced exactly once. Not filterable.
monotree.bot_reply.createdAn employee answers a bot: any message they send in their 1:1 chat with the bot, or a reply to a bot message in any chat. Not filterable — check payload.bot.id.
monotree.completion.createdAn employee completes a course, a journey or a handbook. One delivery per completion. Onboarding is not included (use monotree.onboarding.completed). Filterable by completion type.

Payload

Every delivery shares the same envelope — a unique id, the event name, the API version, the sending customer, a Unix timestamp, and an event-specific payload:

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "event": "monotree.post.created",
  "version": "v1",
  "customer": "acme",
  "timestamp": 1711270800,
  "payload": {
    "id": 123,
    "body": "Post content here",
    "wall_id": 1,
    "wall_name": "General",
    "author": { "id": 1, "name": "John Doe", "email": "john@example.com", "avatar_url": "https://images.monotree.com/avatars/john-doe.jpg" },
    "created_at": "2026-03-22T10:00:00+00:00",
    "updated_at": "2026-03-22T10:00:00+00:00"
  }
}

customer is the sending customer's Monotree identifier (for example acme), the same value on every event including the CMS test event, so one receiver can serve several Monotree customers and pick the right secret before verifying.

Form response payload

monotree.formresponse.created and monotree.formresponse.updated carry the full form response, including every answered field:

{
  "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "event": "monotree.formresponse.created",
  "version": "v1",
  "customer": "acme",
  "timestamp": 1716190200,
  "payload": {
    "id": 4521,
    "form_id": 12,
    "form_title": "Daily Fridge Check",
    "form_due_at": "2026-06-01",
    "deadline_at": null,
    "is_anonymous": false,
    "status": "todo",
    "respondent": {
      "id": 88,
      "name": "Jane Doe",
      "email": "jane@example.com",
      "type": "employee",
      "avatar_url": "https://images.monotree.com/avatars/jane-doe.jpg",
      "groups": [
        { "id": 3, "name": "Kitchen", "type": "department", "created_at": "2026-01-10T08:00:00+00:00" }
      ],
      "hired_at": "2025-09-01",
      "is_away": false,
      "last_active_on": "2026-05-20",
      "registered_at": "2025-09-01T09:12:00+00:00",
      "created_at": "2025-09-01T09:00:00+00:00"
    },
    "assignees": [
      {
        "id": 5,
        "name": "Sam Doe",
        "email": "sam@example.com",
        "type": "employee",
        "avatar_url": "https://images.monotree.com/avatars/sam-owner.jpg",
        "groups": [
          { "id": 3, "name": "Kitchen", "type": "department", "created_at": "2026-01-10T08:00:00+00:00" }
        ],
        "hired_at": "2024-02-01",
        "is_away": false,
        "last_active_on": "2026-05-20",
        "registered_at": "2024-02-01T09:00:00+00:00",
        "created_at": "2024-02-01T09:00:00+00:00"
      }
    ],
    "department": { "id": 3, "name": "Kitchen", "type": "department", "created_at": "2026-01-10T08:00:00+00:00" },
    "field_responses": [
      {
        "value": "9",
        "field": { "id": 41, "title": "Fridge temperature", "type": "number" }
      },
      {
        "value": [
          { "id": 7, "title": "Needs cleaning" },
          { "id": 9, "title": "Restock required" }
        ],
        "field": { "id": 42, "title": "Issues found", "type": "choices" }
      },
      {
        "value": [
          {
            "id": 88,
            "name": "Jane Doe",
            "email": "jane@example.com",
            "type": "employee",
            "avatar_url": "https://images.monotree.com/avatars/jane-doe.jpg",
            "groups": [],
            "hired_at": "2025-09-01",
            "is_away": false,
            "last_active_on": "2026-05-20",
            "registered_at": "2025-09-01T09:12:00+00:00",
            "created_at": "2025-09-01T09:00:00+00:00"
          }
        ],
        "field": { "id": 43, "title": "Responsible person", "type": "users" }
      }
    ],
    "media": [
      { "id": 55, "type": "image", "mime_type": "image/jpeg", "filename": "fridge.jpg", "size": 182034, "url": "https://…/fridge.jpg", "width": 1280, "height": 960, "created_at": "2026-05-20T07:30:00+00:00" }
    ],
    "created_at": "2026-05-20T07:30:00+00:00",
    "updated_at": "2026-05-20T07:30:00+00:00",
    "deleted_at": null
  }
}

Notes on the top-level fields:

  • respondent is null when the response is anonymous.
  • assignees lists the staff members currently assigned to handle the response — [] when unassigned.
  • form_due_at is the parent form's due date (YYYY-MM-DD) — null when the form has none. Changing a form's due date does not fire formresponse.updated; read it from the form endpoints when it matters.
  • deadline_at is the response's own deadline (YYYY-MM-DD), the date staff should have handled it by — null when none is set. Setting, changing or removing it fires formresponse.updated with the new value.
  • department is null when the respondent has no department.
  • status is null unless the form uses response statuses — otherwise one of todo, in_progress, done.
  • deleted_at is always present and null while the response exists. A formresponse.updated delivery with deleted_at set means the response was deleted — on a permanent delete the related rows are pruned first, so that delivery's field_responses and assignees arrays may be empty.
  • media lists the files uploaded with the submission — attached to the response as a whole, not to a specific field (see Forms). Signed urls for anonymous-response files expire ~5 minutes after dispatch; re-fetch GET /formresponses/{id} for a fresh link.

The field_responses array holds one entry per answered field. The shape of value depends on field.type:

Field typevalue
text, date, datetime, time, number, rating, booleanThe submitted value.
choicesArray of selected choices, each { id, title }.
users, department_usersArray of selected users — same shape as respondent.

Form response comment payload

monotree.formresponse.comment.created and monotree.formresponse.comment.deleted carry the public comment:

{
  "id": "1f3a2b6c-2d4e-4a1b-9c8d-7e6f5a4b3c2d",
  "event": "monotree.formresponse.comment.created",
  "version": "v1",
  "customer": "acme",
  "timestamp": 1716190800,
  "payload": {
    "id": 901,
    "body": "Could you re-check the freezer reading?",
    "form_response_id": 4521,
    "form_id": 12,
    "author": {
      "id": 5,
      "name": "Sam Doe",
      "email": "sam@example.com",
      "type": "employee",
      "avatar_url": "https://images.monotree.com/avatars/sam-owner.jpg",
      "groups": [],
      "hired_at": "2024-02-01",
      "is_away": false,
      "last_active_on": "2026-05-20",
      "registered_at": "2024-02-01T09:00:00+00:00",
      "created_at": "2024-02-01T09:00:00+00:00"
    },
    "media": [
      { "id": 77, "type": "image", "mime_type": "image/jpeg", "filename": "freezer.jpg", "url": "https://cdn.monotree.dk/…/freezer.jpg", "width": 1200, "height": 800, "created_at": "2026-05-20T08:00:00+00:00" }
    ],
    "created_at": "2026-05-20T08:00:00+00:00",
    "updated_at": "2026-05-20T08:00:00+00:00"
  }
}

Notes:

  • Only public comments fire these events. Internal staff notes never dispatch.
  • media lists the comment's attachments (images, videos, documents); it is [] when the comment has none.
  • author is null when the comment was written by the respondent on an anonymous form — the respondent's identity is suppressed. Comments from managers/owners are always identified.

Form response alarm payload

monotree.formresponse.alarm_triggered carries two objects: the form response under payload.response, and the alarm that fired under payload.triggered_alarm. Neither has the shape of the other form response events. payload.response is not the object monotree.formresponse.created sends: it is the older webhook format, where every embedded object and list is wrapped in { "data": ... }, the respondent is user rather than respondent, and the whole form definition is embedded under form:

{
  "id": "3d5b7f90-1c2e-4a6b-8d0f-2e4c6a8b0d1f",
  "event": "monotree.formresponse.alarm_triggered",
  "version": "v1",
  "customer": "acme",
  "timestamp": 1716190201,
  "payload": {
    "response": {
      "id": 4521,
      "is_anonymous": false,
      "is_respondent_form_owner": false,
      "is_archived": false,
      "is_hidden": false,
      "respondent_comment_access": true,
      "created_at": "2026-05-20T07:30:00+00:00",
      "deadline_at": null,
      "status": { "id": "todo", "title": "To-do" },
      "form": {
        "data": {
          "id": 12,
          "title": "Daily Fridge Check",
          "description": "Check every fridge before opening.",
          "fields": { "data": [ { "id": 41, "title": "Fridge temperature", "type": "number", ... }, ... ] },
          ...
        }
      },
      "field_responses": {
        "data": [
          {
            "value": 9,
            "field": {
              "data": {
                "id": 41,
                "title": "Fridge temperature",
                "type": "number",
                "minimum": null,
                "maximum": null,
                "unit": "°C",
                "comment": null,
                "is_required": true,
                "placeholder": null,
                "created_at": "2026-01-10T08:00:00+00:00",
                "updated_at": "2026-01-10T08:00:00+00:00",
                "deleted_at": null,
                "meta": { "data": { "type": "FormField" } }
              }
            },
            "meta": { "data": { "type": "FormFieldResponse" } }
          }
        ]
      },
      "user": {
        "data": {
          "id": 88,
          "type": "default",
          "first_name": "Jane",
          "last_name": "Doe",
          "full_name": "Jane Doe",
          "email": "jane@example.com",
          "avatar_url": "https://images.monotree.com/avatars/jane-doe.jpg",
          "deleted_at": null,
          "is_away": false,
          "meta": { "data": { "type": "User" } }
        }
      },
      "selected_department": null,
      "meta": { "data": { "type": "FormResponse" } }
    },
    "triggered_alarm": {
      "id": 310,
      "form_field_alarm_id": 17,
      "form_field_response_id": 9921,
      "form_field_id": 41,
      "field_title": "Fridge temperature",
      "field_type": "number",
      "operator": "greater_than",
      "value": "8",
      "triggered_at": "2026-05-20T07:30:01+00:00",
      "alarm": {
        "data": {
          "id": 17,
          "form_field_id": 41,
          "name": "Fridge too warm",
          "operator": "greater_than",
          "value": "8",
          "notify_user_ids": [5],
          "notify_group_ids": [3],
          "notify_form_owners": true,
          "is_active": true,
          "position": 0,
          "created_at": "2026-01-10T08:05:00+00:00",
          "updated_at": "2026-01-10T08:05:00+00:00",
          "meta": { "data": { "type": "FormFieldAlarm" } }
        }
      },
      "meta": { "data": { "type": "FormResponseAlarm" } }
    }
  }
}

Notes on payload.response:

  • The response's own fields sit at the top: id, the flags, created_at and deadline_at (YYYY-MM-DD or null). There is no form_id; read it from form.data.id.
  • status is an object, { id, title }, with a translated title. On a form without response statuses, id is the string "null".
  • user is the respondent, with full_name instead of name, and null when the response is anonymous.
  • selected_department is { "data": { id, name, type } } when the form asks the respondent to pick a department, otherwise null.
  • form.data holds the form's id, type, title, description, instructions, respondent_identity and deleted_at, and embeds its fields (with their choices), its creator as user, its owner_users, owner_groups and groups. Each list is wrapped in { "data": [ ... ] }.
  • field_responses.data holds one entry per answered field. value is the submitted value, typed by the field: a number for number and rating fields, true or false for boolean fields, an ISO 8601 string for dates and datetimes, and a string for text and time fields. For choices it is a list of { id, title, created_at, updated_at }, and for users and department_users a list of users shaped like user.data. The field is under field.data.
  • Every embedded object carries a meta object naming its type. It holds nothing else you need.
  • There is no assignees, media or deleted_at. Fetch GET /formresponses/{id} with payload.response.id when you need them, or when you would rather work with the shape the other events use.

Notes on payload.triggered_alarm:

  • id is the triggered alarm itself: one per response and rule, never reused. form_field_alarm_id is the rule and form_field_response_id the answer that matched it.
  • operator is one of equals, not_equals, contains, not_contains, greater_than, less_than, before, after (text, boolean, number, rating, date and time fields), includes_choice, excludes_choice (choice fields), includes_user and excludes_user (user fields).
  • value is meant to be shown to people, and what it holds depends on the operator:
    • includes_choice and includes_user: the titles of the choices, or the full names of the users, in the response that matched the rule, joined with , (for example "Needs cleaning, Restock required"). Not IDs.
    • excludes_choice and excludes_user: the titles or names of all the rule's choices or users, joined with , . None of them is in the response; that is why the alarm fired.
    • Every other operator: the rule's threshold as stored, as a string ("8" for a number rule, a date for a date rule). The answer that crossed it is in response.field_responses.
  • alarm.data is the rule as configured: its name, operator and raw value (for choice and user rules that is the stored list of IDs, as a string), who it notifies, and whether it is active.

Achievement award payload

monotree.achievement.awarded carries the award — the same object as GET /achievements/{id}/awards with both achievement and user embedded:

{
  "id": "9b2f0c1e-6a3d-4e8f-b1c2-3d4e5f6a7b8c",
  "event": "monotree.achievement.awarded",
  "version": "v1",
  "customer": "acme",
  "timestamp": 1754212361,
  "payload": {
    "id": 4711,
    "awarded_at": "2026-08-01T00:00:00+02:00",
    "is_manually_added": true,
    "department": { "id": 41, "name": "Copenhagen", "type": "department" },
    "assigned_by": { "id": 5, "name": "Sam Doe", "email": "sam@example.com", ... },
    "achievement": { "id": 12, "title": "Employee of the month", "emoji": "👑", "type": "employee_of_the_month", "visibility": "public", "awarded_users_count": 37, ... },
    "user": { "id": 501, "name": "Jane Doe", "email": "jane@example.com", "groups": [ ... ], ... },
    "created_at": "2026-08-03T09:12:41+00:00",
    "updated_at": "2026-08-03T09:12:41+00:00"
  }
}

Notes:

  • Fires for automatic awards (completion and tenure triggers) and for awards handed out in the CMS or through the API (assigned_by is then the company's own user, type: customer), only while the achievement is published and the achievements feature is enabled. CMS re-evaluation backfills never dispatch.
  • Filterable by achievement — pick one or more achievements when creating the webhook to receive only their awards.
  • awarded_at is the awarded day as midnight in the customer server's timezone, with its offset — read the date in that offset. created_at and updated_at are the instants the award was recorded and last changed.
  • Personal achievements dispatch too; check achievement.visibility before showing the award to colleagues.

Announcement confirmation payload

monotree.announcement.confirmed carries the confirmation, with the announcement and the employee embedded. The announcement is the same object GET /announcements/{id} returns, keyed by the confirmation's type:

{
  "id": "1c7a4f2e-88b0-4f3a-9c11-2de5b6a7c901",
  "event": "monotree.announcement.confirmed",
  "version": "v1",
  "customer": "acme",
  "timestamp": 1758441161,
  "payload": {
    "id": 8842,
    "type": "announcement",
    "created_at": "2026-09-21T09:12:41+02:00",
    "user": { "id": 501, "name": "Jane Doe", "email": "jane@example.com", "groups": [ ... ], ... },
    "announcement": { "id": 456, "title": "Fire drill briefing", "is_confirmable": true, "confirmations_count": 12, ... }
  }
}

Notes:

  • Only announcements marked confirmable in the CMS can be confirmed, and only they fire this event. Company events can be made confirmable too, but those confirmations neither fire an event nor appear in the API yet.
  • type is always announcement here, and names the key the confirmed item sits under.
  • Confirming is once per employee per announcement, and there is no way to un-confirm, so payload.id (the confirmation) is a stable idempotency key that is never reused.
  • created_at is the moment the employee confirmed, in the customer server's timezone with its offset.
  • Confirmations made before the endpoint existed are not replayed, and a delivery that fails three times is not redelivered — catch up with GET /confirmations?since=.

Bot reply payload

monotree.bot_reply.created carries the employee's message, the employee, the bot, and the bot message being answered. Use it to act on answers to messages your integration sent, for example an employee accepting a shift offered by a direct message:

{
  "id": "4e1d2c3b-5a69-4f78-8e9d-0a1b2c3d4e5f",
  "event": "monotree.bot_reply.created",
  "version": "v1",
  "customer": "acme",
  "timestamp": 1759316561,
  "payload": {
    "id": 5530,
    "room_id": 240,
    "text": "Yes, I can take it",
    "user": { "id": 501, "name": "Jane Doe", "email": "jane@example.com", "groups": [ ... ], ... },
    "bot": { "id": 873, "name": "Shift Bot", "avatar_url": "https://images.monotree.com/avatars/shift-bot.png", "created_at": "2026-06-01T09:00:00+02:00" },
    "reply_to": {
      "id": 5512,
      "room_id": 240,
      "text": "Can you cover Tuesday 14:00–22:00?",
      "bot_user_id": 873,
      "pinned_at": null,
      "pinned_until": null,
      "created_at": "2026-10-01T09:00:00+02:00"
    },
    "created_at": "2026-10-01T09:12:41+02:00"
  }
}

Notes:

  • Two kinds of message fire the event: any message an employee sends in their 1:1 chat with a bot (the chat direct messages arrive in), and a reply to a bot message in any chat, such as a group chat an incoming webhook posts to as its bot. A plain message in a group chat, without replying to the bot, does not fire it.
  • reply_to is the bot message being answered: the one the employee replied to, or, when they just wrote in the 1:1 chat, the bot's most recent message there before theirs. It is null when the bot has not written in the chat yet. When the bot has sent several messages and the employee did not reply to a specific one, reply_to is the latest, which may not be the one they meant.
  • user is the same object GET /users/{id} returns, bot the same as GET /bots/{id}, and reply_to the message shape returned by POST /users/{id}/messages.
  • Only employees fire the event. Messages from bots, including the bot's own, and chat events such as members joining never do.
  • text is null when the employee sent only a photo or file. Attachments are not included in the payload.
  • Edited and deleted messages do not fire the event again.
  • Not filterable: an endpoint subscribed to the event receives answers to every bot. Use payload.bot.id to pick out your own.

Completion payload

monotree.completion.created carries the completion, with the employee and the completed item embedded. The item is the same object its own endpoint returns (GET /courses/{id}, GET /journeys, GET /handbooks/{id}), keyed by the completion's type:

{
  "id": "2a6f8c1d-3b4e-4d5f-9a70-1b2c3d4e5f60",
  "event": "monotree.completion.created",
  "version": "v1",
  "customer": "acme",
  "timestamp": 1759655561,
  "payload": {
    "id": 90412,
    "type": "course",
    "completed_at": "2026-10-05T11:12:41+02:00",
    "user": { "id": 501, "name": "Jane Doe", "email": "jane@example.com", "groups": [ ... ], ... },
    "course": { "id": 34, "title": "Food safety basics", "is_published": true, "chapters_count": 6, "groups": [ ... ], ... }
  }
}

Notes:

  • type is course, journey or handbook, and names the key the completed item sits under: a journey completion has "type": "journey" and a journey object, a handbook completion "type": "handbook" and a handbook object.
  • The event fires the moment the employee finishes: they read the last chapter or pass the last quiz, complete the last step of a journey, or a manager marks or approves the course as completed. A completion that Monotree records later without notifying anyone, for example when it catches up on content finished earlier, is not sent.
  • Completions that Monotree imports or fills in on a customer's behalf, such as training records migrated from another system, are never sent. Read those from the academy statistics.
  • A journey can complete for many employees at the same moment without any of them doing anything: when it is published, when an item is added to or removed from it, or when group membership changes. Each of those completions is sent, so expect a burst of deliveries after such a change.
  • Filterable by type: pick one or more of courses, journeys and handbooks when creating the webhook, or pass the type tokens under filters in the management API. Without a filter the endpoint receives all three.
  • One completion is one delivery per endpoint. Finishing the last course of a journey completes both, so it sends two deliveries: one with type: course and one with type: journey.
  • Only journeys an employee works through themselves are sent. Onboarding has its own event, monotree.onboarding.completed, and is never sent as a journey here. A journey in a chain that Monotree skips for an employee who has no access to it is not sent either.
  • payload.id is the completion and is never reused, so it is a stable idempotency key. When a completion is cleared or reset (an expired certificate, a manager clearing it) and the employee completes the item again, that is a new completion with a new id and a new delivery.
  • completed_at is the moment of completion, in the customer server's timezone with its offset.
  • If an achievement is triggered by the same course, journey or handbook, monotree.achievement.awarded still fires as before. An endpoint subscribed to both events receives both, so subscribe to the one you act on.
  • Completions from before the endpoint existed are not replayed. Read current state from GET /users/{id}/journeys and the academy statistics.

User payloads

monotree.user.registered and monotree.onboarding.completed carry the user, the same object GET /users/{id} returns. monotree.onboarding.completed adds onboarding_completed_at:

{
  "id": "7b3e9d14-2f6a-4c8e-9a51-0c4d8e2f7a93",
  "event": "monotree.onboarding.completed",
  "version": "v1",
  "customer": "acme",
  "timestamp": 1758542591,
  "payload": {
    "id": 501,
    "name": "Jane Doe",
    "first_name": "Jane",
    "last_name": "Doe",
    "title": null,
    "email": "jane@example.com",
    "type": "default",
    "avatar_url": "https://images.monotree.com/avatars/jane-doe.jpg",
    "is_manager": false,
    "groups": [
      { "id": 41, "name": "Copenhagen", "type": "department", "is_manager": false, "created_at": "2026-01-15T10:00:00+00:00" }
    ],
    "hired_at": "2026-09-01",
    "date_of_birth": null,
    "is_away": false,
    "last_active_on": "2026-09-22T12:03:11.000000Z",
    "registered_at": "2026-09-01T08:30:00+02:00",
    "created_at": "2026-08-28T10:00:00+02:00",
    "onboarding_completed_at": "2026-09-22T14:03:11+02:00"
  }
}

Notes:

  • monotree.user.registered has the same payload without onboarding_completed_at. registered_at is the moment the user registered.
  • onboarding_completed_at is the moment the employee completed onboarding, in the customer server's timezone with its offset. The envelope timestamp is when the delivery was sent.
  • Onboarding completes once per employee, so monotree.onboarding.completed fires at most once per user. It does not fire while the onboarding feature is switched off.

Signature verification

Every delivery — retries included — is signed afresh: the Timestamp header is the time of that attempt and the signature covers it, while the timestamp inside the body keeps the original dispatch time. Verify against the header. Every delivery includes two headers:

HeaderValue
TimestampUnix timestamp at the time of delivery.
SignatureHMAC-SHA256(timestamp + "." + raw_json_body, your_webhook_secret) as a hex string.

Verify both:

  1. Recompute the HMAC using your secret and compare it to the Signature header in constant time.
  2. Reject requests whose Timestamp is more than 5 minutes old to prevent replay attacks.
const crypto = require('crypto');

function verify(req, secret) {
  const ts = req.headers['timestamp'];
  const sig = req.headers['signature'];
  const body = req.rawBody; // the raw, unparsed JSON string
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${ts}.${body}`)
    .digest('hex');
  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return false;
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  return true;
}

Authentication

Each webhook can also be configured with one of: Bearer token, Basic Auth, custom API key header, or arbitrary custom headers — useful when your receiver enforces its own auth on top of the signature.

Retries

A failed attempt (a non-2xx response other than 404 or 410, or no response within 3 seconds) is retried 1 minute and again 10 minutes after the first attempt; every attempt is signed with a fresh Timestamp. After the third failure the delivery is marked failed and is not redelivered automatically. Delivery logs, attempt by attempt, are visible in the Outgoing Webhooks tab.

A 404 Not Found or 410 Gone response is treated as a receiver that is gone: the delivery ends with that first attempt and is not retried. If your receiver is only temporarily unavailable (a deploy, maintenance, a route change), answer with a 5xx such as 503 Service Unavailable so the retries apply.