Flowmingo Logo

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 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

  1. Sign in at https://team.flowmingo.ai, open the avatar menu → Account Settings.
  2. In the Settings dialog's left sidebar, under INTEGRATIONS, click Webhooks.
  3. 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_type and 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.update data: { interview_set_id, interview_name, candidate_id, candidate_email, status, reason, timestamp }.
  • interview.status.update data: { interview_set_id, interview_name, candidate_id, candidate_email, status, timestamp }.
  • interview.evaluation.update data: { interview_set_id, interview_name, candidate_id, candidate_email, evaluation_type, evaluation_score, submission_url, timestamp } (evaluation_score is out of 10; the submission id is the last path segment of submission_url).
  • application.created data: { application_id, com_project_id, com_job_post_id, com_stage_id, tal_candidate_id }.
  • candidate.stage_changed data: { 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 / reason on invitation.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 of whsec_abc… means the key is the string abc….)
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.