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). Usehttps: 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. Readevent_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_ididentifies 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.timestampis when the event was recorded, not when this delivery was sent. Use it to order events.is_testis 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 Deliveredinvitation_email_opened- Email Openedinvitation_email_clicked- Link Clickedinvitation_email_failed- Email Failedinvitation_email_spam- Marked as Spaminvitation_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 linkstarted- Interview Startedcompleted- 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 Evaluationinterview- Interview Evaluationholistic- 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
scoreandscore_percentarenull. - 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, noassessment.completedis sent (interview.evaluation.updatestill 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_typenames 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_addedorunknown. It isnullfor a Source your own organization created (a tracking link can use one), and when the application has no Source.source_detailis the free text kept with the Source: a short label such asCV upload, the title of one of your job posts, the label of a tracking link, or a note your team wrote. It isnullwhen 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
datacarries notimestampof its own: use the envelope's. (A Send Test Event payload does include adata.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_idvalues 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. A2xxacknowledges receipt and normally stops the automatic retries. Duplicates are still possible, so deduplicate byevent_id. If you could not save the event, answer an error so Flowmingo can retry within its retry limits. Any2xxanswer acknowledges the event. - Only the status counts. Any
2xxacknowledges the event, whatever your reply holds. If your server has sent a2xxstatus 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
2xxresets 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
tis the UNIX timestamp (seconds) at which this delivery attempt was signed. A retry is signed again, so itstis new.v1is the hexadecimal HMAC-SHA256 of the textt, 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 aswhsec_<hex>, but signatures are computed with<hex>alone, used as the text it is (do not decode it from hex). Signing with the fullwhsec_...string produces a different digest and every verification fails.
5.1 What to check
- Read the raw body before any JSON parser touches it. Re-serialising parsed JSON changes the bytes and the signature no longer matches.
- Compute the HMAC and compare it with
v1using a constant-time comparison. - Reject the request when
tis more than five minutes from your clock, in either direction. This stops someone replaying a captured request later. Keep your server clock correct (NTP). - Answer
400when verification fails. When it passes, store or queue the event, then answer2xx, 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)
}
import hashlib
import hmac
import os
import time
SECRET = os.environ["FLOWMINGO_WEBHOOK_SECRET"] # the hex part only, without the "whsec_" prefix
TOLERANCE_SECONDS = 5 * 60
def verify_signature(raw_body, header, now=None):
if not isinstance(header, str):
return False
parts = {}
for item in header.split(","):
key, _, value = item.strip().partition("=")
parts[key] = value
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not (t.isascii() and t.isdigit()) or len(t) > 12 or not v1:
return False
if abs((time.time() if now is None else now) - int(t)) > TOLERANCE_SECONDS:
return False
expected = hmac.new(SECRET.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected.encode(), v1.encode())
With Flask, read the raw bytes with request.get_data():
from flask import Flask, request
app = Flask(__name__)
@app.post("/flowmingo-webhook")
def flowmingo_webhook():
if not verify_signature(request.get_data(), request.headers.get("X-Webhook-Signature")):
return "", 400
event = request.get_json(silent=True)
if not isinstance(event, dict):
return "", 400
# A connectivity check has an endpoint_id and no event_id: there is nothing to keep.
if event.get("event_type") != "endpoint.verification":
try:
save_event(event) # store or queue it first, so that it cannot be lost
except Exception:
return "", 500 # Flowmingo will try again
return "", 200 # answered once the event is safe: do the slow work later, from your queue
def save_event(event):
# Replace this with a write to your database or queue. Keep it quick, and let it raise if the write fails.
# Skip an event_id you have already stored: the same event can arrive more than once.
print(event["event_type"], event["event_id"])
<?php
function verify_flowmingo_signature(string $rawBody, ?string $header, string $secret, int $tolerance = 300): bool
{
if ($header === null) {
return false;
}
$t = null;
$v1 = null;
foreach (explode(',', $header) as $item) {
$pair = explode('=', trim($item), 2);
if (count($pair) !== 2) {
continue;
}
if ($pair[0] === 't') {
$t = $pair[1];
}
if ($pair[0] === 'v1') {
$v1 = $pair[1];
}
}
if ($t === null || $v1 === null || !ctype_digit($t)) {
return false;
}
if (abs(time() - (int) $t) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
return hash_equals($expected, $v1);
}
$secret = getenv('FLOWMINGO_WEBHOOK_SECRET'); // the hex part only, without the "whsec_" prefix
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? null;
if (!verify_flowmingo_signature($rawBody, $header, (string) $secret)) {
http_response_code(400);
exit;
}
$event = json_decode($rawBody, true);
if (!is_array($event)) {
http_response_code(400);
exit;
}
// A connectivity check has an endpoint_id and no event_id: there is nothing to keep.
if (($event['event_type'] ?? '') !== 'endpoint.verification') {
try {
save_event($event); // store or queue it first, so that it cannot be lost
} catch (Throwable $e) {
http_response_code(500); // Flowmingo will try again
exit;
}
}
http_response_code(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.
function save_event(array $event): void
{
error_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 thetvalue 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/v1to see whether the event happened at all.Duplicate events: The same event can be delivered more than once. Skip an
event_idyou have already handled (section 4.1).Unexpected event types: Acknowledge them with
2xxand 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.