Guide to Fountain Webhooks: Features and Best Practices
Fountain is applicant-tracking and onboarding software built for companies that hire hourly workers in volume: delivery drivers, warehouse staff, retail and care workers. Candidates move through a funnel of stages, background checks run through partners such as Checkr and Onfido, and approved hires become workers in Fountain's Post-Hire module. Fountain Hire webhooks tell your HRIS, payroll or scheduling system when any of that changes, so a new hire can be provisioned the moment they reach the right stage.
This guide covers the two payload shapes Fountain sends, how to tell them apart without an event name, how the signature is keyed to your API token, and the tight retry and auto-disable limits.
What are Fountain webhooks?
In Fountain Hire, each trigger is its own webhook setting with its own payload URL: one for stage transitions, one for background-check status, and one for each Post-Hire event. When the trigger fires, Fountain sends a JSON POST to that URL.
Screening triggers send the applicant, as returned by Fountain's API v2, wrapped in an applicant key. Post-Hire triggers send the worker as a bare object. Neither carries an event name, and there's no type header, so your receiver works out what happened from the body or from the URL it was sent to.
Each request is signed. X-OBIQ-SIGNATURE-V2 holds a hex HMAC-SHA256 of the body, and the key is your account's Fountain Hire private API token rather than a webhook-specific secret.
Fountain webhook features
| Feature | Details |
|---|---|
| Configuration | Company Settings -> Webhooks, one payload URL per trigger setting |
| Signature | hex HMAC-SHA256 |
| Signature header | x-obiq-signature-v2 |
| Signed content | The raw request body (default mode) |
| Key | The Fountain Hire private API token |
| Body | JSON: { "applicant": { ... } } or a flat worker object |
| Event routing | None on the wire. Use body shape or the receiving URL |
| Retries | 2 retries after a failed request |
| Auto-disable | More than 10 failures in 24 hours disables the webhook |
| Source IPs | 15 published addresses |
Events and payloads
| Event | Payload shape | Triggered When | Common Use Cases |
|---|---|---|---|
applicant | { "applicant": { ... } } | An applicant changes stage, or a background check changes status | Sync candidates to an HRIS, trigger onboarding at an "Approved" stage, track Checkr/Onfido results |
worker | Flat worker object | A worker is activated or deactivated, or documents are uploaded or approved | Provision or revoke access, collect approved documents |
applicant and worker are labels the linked skill and its examples use for the two payload shapes; they are not strings sent by Fountain.
Telling the deliveries apart
Check for an applicant object first: that's a screening delivery, and applicant.stage.title or the entries in applicant.background_ say what changed. If the body instead has a string id and an is_active field, it's a Post-Hire worker, and is_active, approved_at and data_collection_checks show whether it was an activation, a deactivation or a document change. Anything else should be acknowledged and logged rather than rejected.
Because every trigger has its own URL setting, you can also give each one a distinct path (for example /webhooks/fountain/stage and /webhooks/fountain/worker-deactivated) and route on the path. That's the only way to tell, for example, a stage transition from a background-check update with certainty.
Published payload example
This is the screening sample from the webhook samples library. It shows an applicant who has reached the "Approved" stage of a funnel; the custom answers under data vary with how each funnel is configured.
{
"applicant": {
"email": "example@example.com",
"name": "Logan Paucek",
"first_name": "Logan",
"last_name": "Paucek",
"phone_number": "72 71 98",
"data": {
"city": "Berkeley",
"ssn": "00-22-4566",
"which_bart_stations_are_close_to_you": [
"North Berkeley",
"Downtown Berkeley"
],
"which_shifts_are_you_available_to_work": [
{
"Monday": "Both"
},
{
"Tuesday": "Both"
},
{
"Wednesday": "Both"
},
{
"Thursday": "Both"
},
{
"Friday": "Both"
},
{
"Saturday": "First"
},
{
"Sunday": "None"
}
]
},
"created_at": "2017-03-09T10:23:27.596Z",
"receive_automated_emails": true,
"labels": [],
"lessonly": {
"lesson_results": [],
"course_results": []
},
"id": "c780d083-6e73-4810-85a9-17e72d5b7a93",
"background_checks": [],
"funnel": {
"title": "Fountain applicants",
"id": "05e515a8-fe15-4f11-a1a7-98e0aabb0cd6"
},
"stage": {
"title": "Approved",
"id": "e2921e5b-ae20-4c56-9dc8-bb6209e021b7"
},
"document_signatures": [],
"score_cards_results": []
}
}
Setting up Fountain webhooks
Prerequisites
- A Fountain Hire account with access to Company Settings and the API settings
- A public HTTPS endpoint (use
npx hookdeck-cli listen 3000 fountain --path /webhooks/fountainfor local development)
Get Your Signing Secret
Paste your Fountain Hire private API token. Only the default body signing is supported: accounts switched to applicant ID signing fail verification.
- In Fountain Hire, open the API settings and copy your private API token.
- Store it as
WEBHOOK_SECRET, exactly as shown (use its UTF-8 bytes; don't decode it).
Because the key is the API token, rotating the token for any reason (a leaked credential, a staff change) also changes the webhook key. Update WEBHOOK_SECRET, and the secret on any Hookdeck source, in the same change.
Register Your Endpoint
- In Fountain Hire, go to Company Settings -> Webhooks.
- For each trigger you need (stage transitions, background-check status, the Post-Hire worker and document events), enter your payload URL.
- Save, then move a test applicant to another stage, or activate a test worker, to send a delivery.
Default Body Signing Only
Fountain's Admin Tools can switch an account so that it signs the applicant ID instead of the body. Fountain doesn't publish the exact bytes signed in that mode, and neither this guide's code nor Hookdeck's Fountain source supports it. If signatures stop matching on an account that used to verify, ask Fountain whether the signing mode was changed, and have it set back to body signing.
Test a delivery
Start a local receiver and forward requests with the Hookdeck CLI:
npx hookdeck-cli listen 3000 fountain --path /webhooks/fountain
Paste the CLI's public URL into one trigger setting, then move a test applicant through a stage. The first delivery is the quickest way to see which custom fields your funnel adds to applicant.data.
Securing Fountain webhooks
Compute HMAC-SHA256 over the raw body with the private API token's UTF-8 bytes, encode the result as hex, and compare it with X-OBIQ-SIGNATURE-V2 in constant time. See Fountain's webhook reference.
const crypto = require('node:crypto');
function verifySignature(rawBody, headers, secret) {
if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
const signature = headers['x-obiq-signature-v2'];
if (typeof signature !== 'string') return false;
const key = Buffer.from(secret, 'utf8');
const message = rawBody;
const candidates = [signature];
const expected = crypto.createHmac('sha256', key).update(message).digest();
return candidates.some((candidate) => {
if (!/^[0-9a-fA-F]{64}$/.test(candidate)) return false;
const received = Buffer.from(candidate, 'hex');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
The handler below separates the two payload shapes. With no delivery id, it builds a dedup key from what changed: the applicant and stage for screening events, the worker and its active state for Post-Hire events.
const express = require('express');
function createFountainApp({ alreadyHandled, onApplicant, onWorker }) {
const app = express();
app.post('/webhooks/fountain', express.raw({ type: '*/*' }), async (req, res) => {
const secret = process.env.WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500);
if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);
let body;
try {
body = JSON.parse(req.body.toString('utf8'));
} catch {
return res.sendStatus(400);
}
if (!body || typeof body !== 'object' || Array.isArray(body)) return res.sendStatus(400);
const isApplicant = body.applicant !== null && typeof body.applicant === 'object';
const isWorker = typeof body.id === 'string' && 'is_active' in body;
if (!isApplicant && !isWorker) return res.sendStatus(400);
try {
if (isApplicant) {
const { id, stage, background_checks } = body.applicant;
const dedupKey = `applicant:${id}:${stage && stage.id}:${JSON.stringify(background_checks || [])}`;
if (!(await alreadyHandled(dedupKey))) await onApplicant(body.applicant);
} else {
const dedupKey = `worker:${body.id}:${body.is_active}:${body.approved_at}`;
if (!(await alreadyHandled(dedupKey))) await onWorker(body);
}
} catch {
return res.sendStatus(503);
}
return res.sendStatus(200);
});
return app;
}
Only the body is signed, so a captured request stays valid indefinitely. The dedup keys above are what stop a replayed or retried delivery from repeating an action.
On a Fountain source, Hookdeck checks X-OBIQ-SIGNATURE-V2 against the raw body using your API token. Fountain always gets a 200 back; a mismatch is logged as failed verification and the request goes no further. There's no timestamp to check, so no tolerance applies, and applicant-ID signing isn't supported.
Make Fountain webhooks production-ready. Hookdeck Event Gateway verifies every Fountain delivery, accepts it before Fountain's two retries run out, and lets you replay hiring events into your HR systems after an outage.
Fountain webhook limitations and pain points
Only two retries, then auto-disable
The Problem: A short outage loses new-hire events, and a longer one switches the webhook off entirely.
Why It Happens: Fountain retries a failed request only 2 times. A webhook that fails more than 10 times in 24 hours is disabled until someone fixes it and re-enables it in Company Settings.
Workarounds:
- Monitor for the disabled state and re-enable promptly.
- Reconcile applicants and workers through the Fountain API after any incident.
How Hookdeck Can Help: Hookdeck answers Fountain straight away, so your downtime doesn't count towards the failure limit. Deliveries wait in Hookdeck and can be retried or replayed when your service is back.
No event name or delivery id
The Problem: Your receiver can't say for sure which trigger fired, or whether it has seen a delivery before.
Why It Happens: Triggers are named only as settings in the Fountain UI. The body is an applicant or a worker, with no type field, and there's no delivery id.
Workarounds:
- Give each trigger its own path and route on it.
- Build idempotency keys from the applicant or worker id plus the state that changed.
How Hookdeck Can Help: Separate Hookdeck sources or connection filters on body fields (applicant.stage.title, is_active) can route each kind of delivery to its own destination.
The key is your API token
The Problem: Rotating the API token breaks webhook verification without warning.
Why It Happens: Fountain has no webhook-specific secret. The private API token is the HMAC key.
Workarounds:
- Treat token rotation as a webhook change and update every verifier at the same time.
- Restrict who can view and rotate the API token.
How Hookdeck Can Help: If the receiver is behind Hookdeck, only the Hookdeck source needs the token, and failed verifications show up in the request log straight after a rotation.
Applicant-ID signing mode
The Problem: All signatures suddenly fail on an account that has been switched to the alternate mode.
Why It Happens: Fountain can sign the applicant ID instead of the body, and the docs suggest trying it on a mismatch. The exact signed bytes are undocumented.
Workarounds:
- Keep the account on the default body signing.
- Ask Fountain support to confirm the mode if verification starts failing.
How Hookdeck Can Help: Hookdeck's Fountain source supports body signing only, and its request log makes a mode change visible as a sudden run of failed verifications.
Sensitive applicant data in the body
The Problem: Webhook bodies end up in logs and queues with personal data in them.
Why It Happens: The applicant object includes custom funnel answers under data, which can hold anything a funnel asks for, as the sample's ssn field shows.
Workarounds:
- Keep raw bodies out of application logs, and set retention on any store that holds them.
- Fetch only the fields you need and discard the rest after processing.
How Hookdeck Can Help: Hookdeck transformations can strip fields such as applicant.data before the event reaches downstream services.
Best practices
Allowlist Fountain's addresses
Fountain publishes 15 egress IPs: 3.211.39.199, 54.165.241.13, 107.22.35.198, 13.204.39.37, 15.207.32.74, 15.207.81.155, 63.178.253.186, 3.64.195.44, 63.178.78.162, 3.149.211.76, 3.149.140.89, 3.20.30.46, 34.198.47.214, 100.25.142.181 and 54.147.66.78. Use them as an extra check alongside the signature, not instead of it.
Process after acknowledging
Fountain recommends asynchronous processing and doesn't state a timeout. Store the verified body, return 200, and provision accounts or call payroll afterwards. Our asynchronous processing guide covers the queueing side.
Key on the state change
Without a delivery id, combine the applicant id with the stage id, or the worker id with is_active, and store that key alongside the action you took. The idempotency guide explains how to keep the key and the change in one transaction.
Expect funnel-specific fields
applicant.data reflects each funnel's questions. Validate the fields you depend on and tolerate missing or extra ones.
Conclusion
Fountain's signing is simple, but it's tied to your API token, and nothing in a delivery names the event. Routing by URL, keying on the state that changed and staying within two retries and the 10-failure limit are the parts that need design work.
Hookdeck Event Gateway takes Fountain's deliveries on the first attempt and keeps them replayable, so an outage on your side no longer risks a disabled webhook.
Get started with Hookdeck for free and handle Fountain webhooks reliably in minutes.