Flowmingo pushes a signed HTTPS POST to your endpoint whenever something happens with your interviews — an invitation email is delivered, a candidate finishes an interview, an evaluation is scored. This keeps your ATS/HRIS in sync without polling.
Webhook endpoints are created and managed in the Flowmingo UI (there is no webhook-management API). Pipeline state — applications, stages, submission scores — is read via the API (see the API Integration guide Verify the signature). Poll those read endpoints if you need to track pipeline changes programmatically.
1. Create a webhook endpoint
- Sign in at
https://team.flowmingo.ai, open the avatar menu → Account Settings. - In the Settings dialog's left sidebar, under INTEGRATIONS, click Webhooks.
- Click Create Endpoint and provide:
- Name — a label.
- URL — a public HTTPS endpoint you control (e.g.
https://your-app.com/hooks/flowmingo). Private, localhost, and cloud-metadata URLs are rejected. - Events — which event types this endpoint receives (see Supported events). "Subscribe to all events" is the simplest start.
- Description (optional).
On save you get a signing secret (whsec_…, shown once). Store it — you need it to verify signatures
(Verify the signature).
Managing an endpoint. Click Edit on the endpoint to open its config, where you can Update it, Regenerate Secret (rotate — update your verifier the moment you regenerate; see Retries), Send Test Event, or Delete. (The card's ⋯ menu only offers Edit and Delete — Regenerate Secret and Send Test Event live inside the Edit view.) Send Test fires a sample payload for a chosen event type so you can validate delivery and your signature check without waiting for real activity.
2. Supported events
| Event | Fires when | Sub-event identified by |
|---|---|---|
invitation.status.update |
the invitation email changes state | data.status: invitation_email_delivered, …_opened, …_clicked, …_failed, …_spam |
interview.status.update |
a candidate starts/finishes an interview | data.status: initiated, started, completed |
interview.evaluation.update |
an evaluation is scored | data.evaluation_type: interview |
application.created |
a candidate is added to a pipeline via the API (Retries add) | — |
candidate.stage_changed |
a candidate is moved to another stage via the API (Retries move-stage) | — |
The first three appear in the event picker; subscribe to a parent event to receive all of its sub-events,
or pick specific sub-events. The selected sub-event is identified inside the payload by data.status
(invitation / interview) or data.evaluation_type (evaluation).
The two pipeline events (application.created, candidate.stage_changed) fire on the Retries pipeline
write operations. To receive them, subscribe the endpoint to All Events — they are delivered and
signed exactly like the others, but are not individually listed in the event picker yet and are not
available in Send Test Event (verify them by performing a real Retries add / move-stage). Pipeline-event
delivery is queued and can lag the API call by ~30–60 seconds, so allow time before concluding they
didn't fire.
Subscribing to All Events also means you receive any event types added in the future. Make your handler forward-compatible: switch on
event_typeand ignore unknown values rather than erroring.
3. Payload structure
Every delivery is wrapped 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…" },
"is_test": true
}
is_test is true only on Send Test Event deliveries (absent on real events). The envelope
timestamp is ISO-8601; note that some events carry an inner data.timestamp in epoch
milliseconds — parse the two differently.
invitation.status.updatedata:{ interview_set_id, interview_name, candidate_id, candidate_email, status, reason, timestamp }.interview.status.updatedata:{ interview_set_id, interview_name, candidate_id, candidate_email, status, timestamp }.interview.evaluation.updatedata:{ interview_set_id, interview_name, candidate_id, candidate_email, evaluation_type, evaluation_score, submission_url, timestamp }(evaluation_scoreis out of 10; the submission id is the last path segment ofsubmission_url).application.createddata:{ application_id, com_project_id, com_job_post_id, com_stage_id, tal_candidate_id }.candidate.stage_changeddata:{ application_id, com_stage_id }.
Send Test Event payloads are illustrative samples and may omit optional fields that a real delivery includes (e.g.
status/reasononinvitation.status.update). Use them to validate transport and signature verification; treat a real delivery as the field-complete contract.
4. Verify the signature
Every delivery includes a signature header:
X-Webhook-Signature: t=1700000000,v1=ef360046da0e38b757d03e1cb18452719e31706acecff90a3eb1391dae5bd385
t— UNIX timestamp (seconds).v1— HMAC-SHA256 of the string"<t>.<raw request body>", hex-encoded.- The HMAC key is your signing secret with the
whsec_prefix removed, used verbatim as a UTF-8 string. Do not hex-decode it — even though the secret looks like hex, it is used as the literal string key. (A secret ofwhsec_abc…means the key is the stringabc….)
import crypto from 'crypto'
// Strip the whsec_ prefix; the remaining string IS the HMAC key (used as UTF-8, not hex-decoded).
const SECRET = process.env.FLOWMINGO_WEBHOOK_SECRET!.replace(/^whsec_/, '')
function verify(rawBody: Buffer, header: string | undefined): boolean {
if (!header) return false
// Parse "t=...,v1=..." tolerantly: split each pair on the FIRST '=', and trim keys/values so a space
// after the comma ("t=..., v1=...") still works.
const parts: Record<string, string> = {}
for (const pair of header.split(',')) {
const i = pair.indexOf('=')
if (i === -1) continue
parts[pair.slice(0, i).trim()] = pair.slice(i + 1).trim()
}
const t = parts['t'], v1 = parts['v1']
// Validate v1 is exactly 64 hex chars BEFORE comparing — timingSafeEqual throws if the buffers differ
// in length, so a malformed signature must be rejected here, not crash the handler.
if (!t || !/^[a-f0-9]{64}$/i.test(v1 ?? '')) return false
const expected = crypto.createHmac('sha256', SECRET).update(`${t}.${rawBody.toString('utf8')}`).digest('hex')
return crypto.timingSafeEqual(Buffer.from(v1, 'hex'), Buffer.from(expected, 'hex'))
}
Use the raw request body bytes (not re-serialized JSON — re-serializing changes the bytes and breaks
the signature). Reject if verification fails or t is outside your allowed clock-skew window — a
300-second (5-minute) window is a reasonable default (reject if abs(now_seconds - t) > 300). Return
2xx to acknowledge.
Test vector (check your implementation against these exact values):
secret (after stripping whsec_): a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90
t: 1700000000
raw body: {"event_type":"invitation.status.update","event_id":"demo"}
signed string ("<t>.<body>"): 1700000000.{"event_type":"invitation.status.update","event_id":"demo"}
expected v1: ef360046da0e38b757d03e1cb18452719e31706acecff90a3eb1391dae5bd385
5. Retries
If your endpoint returns non-2xx or is unreachable, Flowmingo retries up to 4 attempts total with
~5 minutes between attempts. Keep your handler idempotent (dedupe on event_id) and acknowledge with
a 2xx as soon as you've stored the event.
Deliveries are asynchronous and not strictly ordered — retries in particular can make a later event
arrive before an earlier one. Don't depend on arrival order; dedupe on event_id and use the payload's
own timestamps to sequence events.
Rotating the secret (Regenerate Secret) takes effect immediately — there is no old/new overlap window — so update your verifier with the new secret at the same moment you regenerate.
6. Troubleshooting
- Every signature fails — strip the
whsec_prefix, use the secret as a UTF-8 string key (don't hex-decode), and sign the raw body as"<t>.<body>"(timestamp, dot, body). Validate against the Verify the signature test vector first. - No events arriving — confirm the URL is public HTTPS and reachable, the endpoint is active, and it's subscribed to the right events. Use Send Test Event to check delivery without waiting for real activity.
- Duplicate events — expected on retries; dedupe on
event_id.