Integrations

Webhook Integration Guide

Subscribe HTTPS endpoints to Flowmingo events, validate signatures, and stream candidate updates directly into your systems.

Need help integrating?

Stuck on a step or need technical support? Our team is happy to help — reach out any time and we'll get you connected.

Webhook Integration

Flowmingo emits notifications whenever candidates move through invitation, interview, assessment, application or review workflows. This section explains how to create webhook endpoints, secure them with HMAC signatures, and understand the payloads you will receive so downstream systems (ATS, HRIS, Slack bots, etc.) stay in sync. If your tool cannot receive webhooks, read the same events by polling the events feed instead. To connect Flowmingo to Zapier, Make or n8n without code, follow the recipes in the API guide.

1. Create a webhook endpoint

Log in to your Flowmingo workspace, click your email at the bottom-left of the sidebar, then Settings, and open Integrations -> Webhooks. Click Create Endpoint and provide:

  • Name: A label to identify this endpoint later.
  • Endpoint URL: A public address that Flowmingo will POST events to (for example: https://your-app.com/api/flowmingo-webhook). Use https: the payloads of some events contain candidate email addresses. See section 4.5 for which addresses are refused and what your address must do.
  • Description (optional).
  • Event types: Subscribe to All Events (recommended) or Subscribe to Specific Events and pick the ones this endpoint should receive (see section 2).

On save, Flowmingo shows the Webhook Endpoint Created! window with the signing secret, which looks like whsec_ followed by 64 hexadecimal characters. It is shown once: store it securely. You need this value to verify X-Webhook-Signature.

Managing endpoints:

  • Edit / Delete: Return to Settings -> Integrations -> Webhooks, open an endpoint with Edit, change it and click Save Changes, or delete it.
  • On/off switch: The switch beside an endpoint's name turns delivery on and off. An endpoint that Flowmingo switched off after repeated failures (section 4.3) is turned back on here.
  • Regenerate Secret: Replaces the signing secret immediately. There is no period in which both secrets work, and a delivery that was already being prepared can still carry a signature made with the old one. See section 5.4 for what to do around it.
  • Send Test Event: Sends a sample event to the endpoint, so you can check your receiver without waiting for a real one. Test deliveries carry "is_test": true.
  • Recent Deliveries (and View all): The outcome of recent deliveries to that endpoint.

2. Supported events

Event Sent when Sub-event (field it comes from)
invitation.status.update An invitation email is delivered, opened, clicked, fails or is marked as spam. status
invitation.lifecycle.update An invitation is created, sent or reminded. status
interview.status.update A candidate opens, starts or completes an AI interview. status
interview.evaluation.update A new CV, interview or holistic evaluation result is stored. evaluation_type
assessment.completed A candidate completes a skill test, technical test, showcase or questionnaire, or the result of a recording evaluation or a CV evaluation is stored. assessment_type
application.created A candidate arrives on a hiring project. none
candidate.stage_changed An application moves to another stage. none
application.rejected An application is rejected through the API, or removed from a hiring project. none
submission.review.update A note, a rating or a shortlist mark is added to a result. action

Subscribing to a parent event (for example invitation.status.update) receives all sub-events of that type. You can also subscribe to specific sub-events only (for example invitation_email_failed).

Which endpoints receive which events. An endpoint set to Subscribe to All Events receives every event type above, and every type added later. An endpoint with a list of events receives only those. The Select specific events list offers invitation.status.update, interview.status.update, interview.evaluation.update and assessment.completed; the other five events are delivered to endpoints subscribed to All Events. The list offers assessment.completed as one choice that covers all six assessment types; read assessment_type in the payload to tell them apart.

Ignore event types you do not know, and answer 2xx. A receiver written for a few events can start receiving new ones. If it answers an error to an event type it does not recognise, every attempt to deliver such an event counts as a failure. An event is attempted up to 6 times, and 15 failed attempts in a row switch the endpoint off (section 4.3), so a few unrecognised events are enough. Read event_type, handle the ones you need, and acknowledge the rest.

3. Payload structure

Flowmingo wraps every event in a consistent envelope:

{
  "schema_version": "1.0.0",
  "event_type": "invitation.status.update",
  "event_id": "3a74fa26-4b66-4534-ba76-073548c95239",
  "timestamp": "2026-03-04T01:00:00.000Z",
  "organization_id": 123,
  "data": {
    "...event specific fields..."
  },
  "is_test": true
}
  • event_id identifies the event. It is the same on every delivery attempt of that event, and it is the id the events feed uses for it too.
  • timestamp is when the event was recorded, not when this delivery was sent. Use it to order events.
  • is_test is only included for test deliveries.

A request whose event_type is endpoint.verification is a connectivity check that Flowmingo can send to an address, not an event: it carries an endpoint_id and a verification_token and has no data. If you receive one, answer 2xx.

Invitation status payload

Event name: invitation.status.update

{
  "interview_set_id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
  "candidate_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "candidate_email": "alex@example.com",
  "status": "invitation_email_delivered",
  "reason": "delivered"
}

reason is always present and is never null: it mirrors the provider signal (delivered, opened, clicked) or names the failure (invalid_email, unsubscribed, bounce, dropped). interview_name appears only on failures we raise ourselves (invalid_email, unsubscribed); it is absent on the provider-reported delivered / opened / clicked events.

Possible status values:

  • invitation_email_delivered - Email Delivered
  • invitation_email_opened - Email Opened
  • invitation_email_clicked - Link Clicked
  • invitation_email_failed - Email Failed
  • invitation_email_spam - Marked as Spam
  • invitation_email_accepted - reserved; nothing sends it today

Invitation lifecycle payload

Event name: invitation.lifecycle.update

{
  "interview_set_id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
  "interview_name": "Sales Assessment",
  "candidate_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "candidate_email": "alex@example.com",
  "status": "invitation_sent"
}

status is invitation_created (the invitation exists), invitation_sent (the invitation email went out) or invitation_reminded (a reminder went out). This event reports Flowmingo's own steps, while invitation.status.update reports what the email provider saw afterwards. It is not sent for practice invitations, or for a Matched Talent invitee who has not started yet.

Interview status payload

Event name: interview.status.update

{
  "interview_set_id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
  "interview_name": "Sales Assessment",
  "candidate_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "candidate_email": "alex@example.com",
  "status": "started"
}

Possible status values:

  • initiated - Candidate opened the interview link
  • started - Interview Started
  • completed - Interview Completed

Interview evaluation payload

Event name: interview.evaluation.update

{
  "interview_set_id": "string",
  "interview_name": "string",
  "candidate_id": "string",
  "candidate_email": "string",
  "evaluation_type": "cv" | "interview" | "holistic",
  "evaluation_score": "number | null",
  "score_status": "scored" | "cannot_evaluate" | "not_scored",
  "submission_id": "string",
  "submission_url": "string"
}

Possible evaluation_type values:

  • cv - CV Evaluation
  • interview - Interview Evaluation
  • holistic - CV + Interview Evaluation

evaluation_score and score_status. The score is a decimal out of 10 (for example 8.4). It is null whenever there is no usable number, and score_status says why:

  • scored: a real score.
  • cannot_evaluate: the AI ran and could not rate the attempt (too little to go on). This is not a zero, and it is not a failure on your side.
  • not_scored: no score exists yet.

When it arrives. For evaluation_type interview and holistic this event is held back while the candidate's improvement window is open, and re-sent by a job that runs every ten minutes once the window closes, so it can land some minutes after the interview finished. A cv evaluation is not held.

submission_id is the id to pass to GET /company/integration/hiring/submissions/{id}/detail/v1 for the full evaluation: the written summary, strengths, gaps, recommendations and per-criterion scores. See Read results through the API.

Assessment completed payload

Event name: assessment.completed

One event for every assessment that has no status and result event of its own. It is sent for:

assessment_type Assessment
skill_test Skill test
technical_test Technical test
showcase Showcase
recording Recording evaluation
questionnaire Questionnaire
cv_evaluation CV evaluation

For a technical test this event means the attempt has ended, not that its score is ready. The event carries no score for a technical test: score and score_percent are always null. Read the score through the API (see "A technical test event never carries a score" below).

AI interviews and role-plays do not send this event. They send only interview.status.update (with the status completed) and interview.evaluation.update (with the score), as they always have.

A CV evaluation and a recording evaluation send two events for one result, on purpose. Each sends interview.evaluation.update (as before; its evaluation_type is interview) and assessment.completed, both with the same submission_id. Let your receiver act on one of them: on assessment.completed if you want one event shape for all six assessment types, or on interview.evaluation.update if it already handles that event. An endpoint subscribed to both, or to All Events, receives both events for every such result; use submission_id to recognise the pair.

A skill test:

{
  "assessment_type": "skill_test",
  "set_type": 3,
  "interview_set_id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
  "submission_id": "59065d06-a013-44c2-97d2-88e9d6b3c858",
  "application_id": "2d1c0f6a-8b3e-4f57-9a62-7c41d5e0b8f3",
  "invitation_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "status": "completed",
  "score": 8.7,
  "score_percent": 87,
  "report_url": "https://team.flowmingo.ai/company/ai-interview-candidates?set_id=3f2504e0-4f89-41d3-9a0c-0305e82c3301&submissionId=59065d06-a013-44c2-97d2-88e9d6b3c858",
  "completed_at": "2026-10-20T09:15:28.000Z",
  "is_practice": false
}

A technical test: set_type is 5 and the score is always null:

{
  "assessment_type": "technical_test",
  "set_type": 5,
  "interview_set_id": "8d6f1c3a-52e7-4b0e-9d14-6a3b7c2f9e10",
  "submission_id": "e4b2a7d9-1c35-4f68-8a02-b9d3f6c1e547",
  "application_id": "b7c2e9a4-3d18-4a65-b0f2-5e91c7d3a608",
  "invitation_id": "0c9a5e71-6f24-4d83-9b1a-d2e48f7c3b95",
  "status": "completed",
  "score": null,
  "score_percent": null,
  "report_url": "https://team.flowmingo.ai/company/ai-interview-candidates?set_id=8d6f1c3a-52e7-4b0e-9d14-6a3b7c2f9e10&submissionId=e4b2a7d9-1c35-4f68-8a02-b9d3f6c1e547",
  "completed_at": "2026-10-20T09:42:11.000Z",
  "is_practice": false
}
Field Meaning
assessment_type One of the six names above.
set_type The same kind as a number: 2 CV evaluation, 3 skill test, 4 showcase, 5 technical test, 8 recording evaluation, 10 questionnaire.
interview_set_id The assessment.
submission_id The result. This is the id to pass to GET /company/integration/hiring/submissions/{id}/detail/v1 for the full result. For a technical test it is also how you read the score, which the event does not carry.
application_id The candidate's application in the hiring project of the invitation (for a Showcase started from a link, the project of that link), or null. Always null for a CV evaluation and a recording evaluation.
invitation_id The invitation, or null. Always null for a CV evaluation and a recording evaluation.
status completed today. It says the attempt has ended; for a technical test it does not say the score is ready (see below). Ignore a value you do not know.
score The score from 0 to 10 on the same scale as overall_score in the API, or null when the event carries none: always for a technical test, a showcase and a questionnaire, and when the AI could not rate a CV evaluation or a recording evaluation (see below).
score_percent The score from 0 to 100. Only a skill test carries it; it is null for every other assessment, a technical test included (see below).
report_url A link to the result in the Flowmingo dashboard. The reader has to be signed in to your workspace. null when there is none.
completed_at When the attempt ended (ISO 8601). For a CV evaluation and a recording evaluation, when the result was recorded.
is_practice Always false: a practice, trial or demo run never sends this event.

The event carries ids, not personal data. There is no candidate name or email. To get them, call GET /company/integration/hiring/submissions/{submission_id}/detail/v1 with a key that has read_hiring and read_candidates (see the API guide).

What score and score_percent hold, by assessment:

assessment_type score score_percent
skill_test The percentage divided by 10. The percentage.
technical_test null: the event carries no score (see below). null
cv_evaluation, recording The AI's rating, 0-10. null when the AI could not rate it. null
showcase null: the event carries no score. A recruiter's rating of a Showcase is sent as submission.review.update. null
questionnaire null: the event carries no score and no pass or fail result. null

A technical test event never carries a score. score and score_percent are null for every technical test, whether or not any code is still being graded. The event tells you that the attempt has ended: the candidate submitted it, ended it early, or the time ran out. It does not tell you that the score is ready. Flowmingo runs the code a candidate submits against its test cases in a sandbox, and that can still be running when the attempt ends, so the score can still change after the event. The event is sent once, and no second event follows when the score changes.

Read the score with GET /company/integration/hiring/submissions/{submission_id}/v1 or GET /company/integration/hiring/submissions/{submission_id}/detail/v1 (a key with read_hiring is enough for the score; see the API guide, section 5.5). A read made right after the event can show a provisional number, and nothing in the response, and no later event, says when the number has stopped changing. So do not store the first number you read as the result: read it when you need to use or show it, or read it again later and keep the latest value. The API gives a technical test no score_percent; multiply overall_score by 10 if you want a percentage (the stored rating is rounded to two decimals, so the percentage is an approximation, to the nearest tenth of a point). Do not use score: null to detect that grading is under way, because it is null for every technical test. A technical test event created before 20 Oct 2026 can carry a score under the earlier rule, and it keeps that score if it is read from the events feed or sent again: ignore it and read the result from the API.

When it is sent. Once for each result: the event takes its event_id from the result, so a second path to the same completion does not announce it again. It is never sent for a practice run, a trial run or a demo run. Results that were completed before this event existed are not sent. By assessment:

  • Skill test: when the candidate submits, answers the last question, or runs out of time while still in the test. An attempt that Flowmingo closes afterwards because the candidate had walked away is not announced.
  • Technical test: for every way an attempt ends: the candidate submits it, ends it early, or the time runs out. The event is a completion signal only: its score and score_percent are null.
  • Showcase: when the Showcase is completed. The event is the same for every submission: it carries no take count and nothing else about how the candidate recorded it.
  • Questionnaire: when the candidate submits their answers.
  • CV evaluation and recording evaluation: when the evaluation result is stored, together with interview.evaluation.update. If the submission has been archived or deleted by then, no assessment.completed is sent (interview.evaluation.update still is).

Application created payload

Event name: application.created

{
  "application_id": "2d1c0f6a-8b3e-4f57-9a62-7c41d5e0b8f3",
  "com_project_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
  "com_job_post_id": "c0ffee00-dead-4bee-8f00-0123456789ab",
  "com_stage_id": "5b8d1e92-3a7c-4c60-b2f4-91e6a0d7c358",
  "tal_candidate_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
}

The five ids are always present (null when the application has none). While your organization has candidate sources switched on (the recruiter's Source and Attributed to screens), two more keys are added; without them the payload is the five ids and nothing else:

  • source_type names the candidate's Source only when it is one of Flowmingo's 13 built-in Sources: flowmingo, job_link, invited, matched_talents, ai_interview_link, roleplay_link, showcase_link, skill_test_link, technical_test_link, chat_screening_link, manually_added, api_added or unknown. It is null for a Source your own organization created (a tracking link can use one), and when the application has no Source.
  • source_detail is the free text kept with the Source: a short label such as CV upload, the title of one of your job posts, the label of a tracking link, or a note your team wrote. It is null when there is none. It can name a person.

It is sent when a candidate arrives on a hiring project: by applying on one of your job pages, by starting an AI interview, a role-play or a Showcase from a link that files them on the project, from an invitation that creates an application, when a recruiter adds candidates one by one or in bulk, and when the API adds one (POST /hiring/applications/v1). It is sent once for each application: the event takes its event_id from the application, so a second path to the same application does not announce it again. It is not sent for a Matched Talent invitee who has not started yet, and a way of reaching a project that is not listed here does not send it.

The event goes to your organization's endpoints. A job post that has no organization of its own is announced under the organization of its interview set, and failing that of its hiring project.

Candidate stage changed payload

Event name: candidate.stage_changed

{
  "application_id": "2d1c0f6a-8b3e-4f57-9a62-7c41d5e0b8f3",
  "com_project_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
  "com_job_post_id": "c0ffee00-dead-4bee-8f00-0123456789ab",
  "com_stage_id": "5b8d1e92-3a7c-4c60-b2f4-91e6a0d7c358",
  "com_stage_from_id": "9e7a4d31-6b02-4f85-8c19-3a5d70e2b6c4",
  "tal_candidate_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
}

com_stage_id is the stage the application moved to and com_stage_from_id the stage it came from (null or absent on older events). It is sent for a move in the dashboard and for move-stage and reject through the API. Moving a card into a Rejected stage in the dashboard sends this event and not application.rejected.

Application rejected payload

Event name: application.rejected

{
  "application_id": "2d1c0f6a-8b3e-4f57-9a62-7c41d5e0b8f3",
  "com_project_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
  "com_job_post_id": "c0ffee00-dead-4bee-8f00-0123456789ab",
  "com_stage_id": "9e7a4d31-6b02-4f85-8c19-3a5d70e2b6c4",
  "tal_candidate_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
}

Sent when an application is rejected through the API (com_stage_id is then the Rejected stage it moved into) and when a recruiter removes it from a hiring project (com_stage_id is the stage it was in).

Submission review payload

Event name: submission.review.update

{
  "submission_id": "59065d06-a013-44c2-97d2-88e9d6b3c858",
  "interview_set_id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
  "candidate_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "action": "rated",
  "rating": 8,
  "activity_id": "7241c51b-23a7-4072-b345-5484cd0b54af"
}

action is note_added, rated or shortlisted. rating appears for a rating and shortlisted (a boolean) for a shortlist change. The text of a note is never included. The event is sent for reviews made in the dashboard and through the API.

Note: payloads gain fields over time. Read the keys your integration needs and ignore the rest; a new key is not a breaking change. On real deliveries data carries no timestamp of its own: use the envelope's. (A Send Test Event payload does include a data.timestamp; do not build a parser that needs it.)

4. Delivery

4.1 What to expect

  • Limited retries, and repeats are possible. Flowmingo tries an event up to 6 times (section 4.2), so delivery is not guaranteed, and the same event can also be delivered more than once, for example when your endpoint did the work but answered too slowly. Keep the event_id values you have handled and skip repeats, and recover anything you missed from the events feed (section 4.4).
  • Not in order. An event that had to be retried arrives after events that happened later. Order by the envelope's timestamp.
  • Answer fast, once the event is safe. Deliveries time out at 10 seconds, and a timeout counts as a failure. Verify the signature, store or queue the event, then answer 2xx, and do the slow processing afterwards. A 2xx acknowledges receipt and normally stops the automatic retries. Duplicates are still possible, so deduplicate by event_id. If you could not save the event, answer an error so Flowmingo can retry within its retry limits. Any 2xx answer acknowledges the event.
  • Only the status counts. Any 2xx acknowledges the event, whatever your reply holds. If your server has sent a 2xx status and the rest of the reply then stalls or is cut off, the event still counts as delivered. Flowmingo reads at most the first 16 KB of a reply.
  • About thirty days of history. Events and their delivery logs are kept for about 30 days.

4.2 Retries

A delivery attempt fails when your endpoint answers with anything other than 2xx (a redirect included), does not answer within 10 seconds, or cannot be reached. Flowmingo then tries again with exponential backoff: up to 6 attempts in total. The first retry comes about 2 minutes after the first failure, and each wait after that doubles (about 4, 8, 16 and 32 minutes), each with up to 30 seconds added. An event is therefore given up on roughly an hour after it first failed. After the 6th attempt the event is left in an error state for that endpoint and is not retried again. Every failed attempt, retries included, counts towards the switch-off in section 4.3.

4.3 An endpoint that keeps failing is switched off

After 15 failed delivery attempts in a row Flowmingo switches the endpoint off and stops sending to it, so a dead address does not collect failures forever. Every failed attempt counts, the retries of one event included: an event that fails all 6 of its attempts adds 6 to the count, so a few events that never get through are enough. Any single 2xx resets the count to zero.

The person who created the endpoint is emailed when this happens; the email says the endpoint was switched off after repeated failed deliveries and that events which happen while it is off are not sent to it. Those events are not sent to it later, and an event waiting for a retry is dropped if the endpoint is still switched off when the retry falls due. Fix the problem, then turn the endpoint back on with its switch in Settings -> Integrations -> Webhooks. Turning an endpoint from off to on restarts the count at zero, so one failed attempt does not switch it off again straight away. Saving changes to an endpoint that is already on leaves the count as it is. If deliveries stop arriving, check that switch before assuming no events occurred.

4.4 Catching up after an outage

An event that was given up on, or that happened while the endpoint was off, is not replayed to you. Read what you missed from the events feed, GET /company/integration/events/v1, which keeps about 30 days of events and uses the same event_id values (see the API guide).

4.5 Which addresses are refused

Webhook addresses must be public and must start with http or https (use https). When you save an endpoint, Flowmingo refuses localhost, names that end in .local or .internal, and addresses in private, loopback, link-local or cloud-metadata ranges. Any public port is allowed.

What your endpoint must do. Use a public HTTPS address with reliable DNS. Flowmingo looks the name up again for every delivery, and the attempt fails if the name does not resolve or if any address it leads to is private. The endpoint must accept a POST and answer with a 2xx within the 10 seconds of section 4.1; a failed attempt is retried (section 4.2). If Flowmingo cannot reach your server, Recent Deliveries on the endpoint shows each attempt and the reason it failed.

Credentials written into the address (https://user:password@your-app.com/) are not sent. Check the signature (section 5) to know a request is from Flowmingo.

Redirects are treated as failures, not followed. Point the endpoint at its final URL: an address that answers with a redirect (adding www., or http to https) never receives a delivery, and the attempt counts as failed.

5. Signature verification

Every delivery includes an X-Webhook-Signature header:

X-Webhook-Signature: t=1760951730,v1=c5968e38a9d69572df9af234b4369cc208a3107386724dbacddb392b1b2799c0
  • t is the UNIX timestamp (seconds) at which this delivery attempt was signed. A retry is signed again, so its t is new.
  • v1 is the hexadecimal HMAC-SHA256 of the text t, a full stop and the raw request body: <t>.<body>.
  • The signing key is the hex part only: strip the whsec_ prefix. Flowmingo displays the secret as whsec_<hex>, but signatures are computed with <hex> alone, used as the text it is (do not decode it from hex). Signing with the full whsec_... string produces a different digest and every verification fails.

5.1 What to check

  1. Read the raw body before any JSON parser touches it. Re-serialising parsed JSON changes the bytes and the signature no longer matches.
  2. Compute the HMAC and compare it with v1 using a constant-time comparison.
  3. Reject the request when t is more than five minutes from your clock, in either direction. This stops someone replaying a captured request later. Keep your server clock correct (NTP).
  4. Answer 400 when verification fails. When it passes, store or queue the event, then answer 2xx, before you do any slow work (section 4.1).

5.2 Verifier code

These verifiers do all four checks, and handle a malformed header without crashing.

import crypto from 'node:crypto'

const SECRET = process.env.FLOWMINGO_WEBHOOK_SECRET // the hex part only, without the "whsec_" prefix
const TOLERANCE_SECONDS = 5 * 60

export function verifySignature(rawBody, header, nowSeconds = Date.now() / 1000) {
  if (typeof header !== 'string') return false
  const t = /(?:^|,)\s*t=(\d+)/.exec(header)?.[1]
  const v1 = /(?:^|,)\s*v1=([0-9a-fA-F]+)/.exec(header)?.[1]
  if (!t || !v1) return false
  if (Math.abs(nowSeconds - Number(t)) > TOLERANCE_SECONDS) return false

  const expected = crypto.createHmac('sha256', SECRET).update(`${t}.`).update(rawBody).digest()
  const received = Buffer.from(v1, 'hex')
  return received.length === expected.length && crypto.timingSafeEqual(received, expected)
}

With Express, keep the body raw for this route:

import express from 'express'

const app = express()

app.post('/flowmingo-webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  if (!verifySignature(req.body, req.get('X-Webhook-Signature'))) return res.sendStatus(400)

  let event
  try {
    event = JSON.parse(req.body.toString('utf8'))
  } catch {
    return res.sendStatus(400)
  }

  // A connectivity check has an endpoint_id and no event_id: there is nothing to keep.
  if (event.event_type !== 'endpoint.verification') {
    try {
      await saveEvent(event) // store or queue it first, so that it cannot be lost
    } catch {
      return res.sendStatus(500) // Flowmingo will try again
    }
  }
  res.sendStatus(200) // answered once the event is safe: do the slow work later, from your queue
})

// Replace this with a write to your database or queue. Keep it quick, and let it throw if the write fails.
// Skip an event_id you have already stored: the same event can arrive more than once.
async function saveEvent(event) {
  console.log(event.event_type, event.event_id)
}

5.3 A test vector

Use this to check your verifier. It uses the verifier's time check, so pass 1760951730 as the current time when you test, or the signature will be rejected as too old.

secret (hex part): 3b1f0c9d5e7a42c8a6d1f8e2b4c7095e1d3a6f8b0c2e4a6d8f1b3c5e7a9d0f21
t:                 1760951730
raw body:          {"event_id":"cca11cbd-00fd-4e16-ba60-75625ee9d196","event_type":"assessment.completed"}
signed text:       1760951730.{"event_id":"cca11cbd-00fd-4e16-ba60-75625ee9d196","event_type":"assessment.completed"}
v1:                c5968e38a9d69572df9af234b4369cc208a3107386724dbacddb392b1b2799c0

5.4 Rotating the secret

Regenerate Secret replaces the secret at once. There is no overlap, and a delivery that was already being prepared can still be signed with the previous secret. Update your verifier to the new secret as soon as Flowmingo shows it. Events that fail verification in the meantime are answered with an error and retried (retries run for about an hour, section 4.2), but do not assume that retries will recover every event: every failed attempt counts towards the switch-off of section 4.3, and an event given up on is not sent again. After a rotation, check that the endpoint is still switched on, and read what you missed from the events feed (section 4.4).

6. Webhook Issues

  • Signature verification failures: Use the raw request body (not parsed JSON), the hex secret without whsec_, and the t value from the same header. If you regenerated the secret, your verifier still has the old one. Test with the vector in section 5.3.

  • Missing events: Check that the endpoint URL is public and correct, that its switch is on (section 4.3), and that it is subscribed to the event types you expect (section 2). Recent Deliveries on the endpoint shows what Flowmingo tried and what your server answered. Read GET /company/integration/events/v1 to see whether the event happened at all.

  • Duplicate events: The same event can be delivered more than once. Skip an event_id you have already handled (section 4.1).

  • Unexpected event types: Acknowledge them with 2xx and ignore them (section 2).

  • Retry behavior: see Retries in section 4.2: up to 6 attempts with exponential backoff, then the endpoint is switched off after 15 failed attempts in a row. Answer with any 2xx to acknowledge receipt.

  • Respond within 10 seconds. Deliveries time out at 10s, and a timeout counts as a failure. Store or queue the event, answer 2xx, and do your processing afterwards; an endpoint that works synchronously and answers in 12s will fail every delivery and eventually be switched off.

  • Redirects are treated as failures, not followed. Point the endpoint at its final URL: an address that 301s to a canonical host (adding www., or http to https) never receives a delivery.