Guide to Outhire Webhooks: Features and Best Practices
Outhire is an AI recruiting platform: candidates apply through careers pages and forms, and recruiters and Outhire's AI agent, Joel, move them through a hiring pipeline. Its outbound webhooks push that candidate activity into the rest of your stack, so a new application can create a record in your ATS and a stage change can update your HRIS or start onboarding.
What are Outhire webhooks?
Outhire webhooks are company-level endpoints that admins manage in Settings > Webhooks. Each endpoint subscribes to one or both candidate events, has its own whsec_ signing secret, and can send up to ten custom headers of your choice. Deliveries are signed to the Standard Webhooks specification.
Every webhook also has a delivery log showing the status code, request and response headers and bodies, delivery status (sending, success or failed), timestamp and attempt count.
Outhire webhook features
| Feature | Details |
|---|---|
| Configuration | Settings > Webhooks, admins only, one webhook per URL |
| Events | candidate.applied, candidate.stage_changed, plus a test ping |
| Custom headers | Up to 10 per webhook |
| Signature | Standard Webhooks HMAC-SHA256 |
| Signature header | webhook-signature |
| Secret | Per webhook, whsec_..., viewable later |
| Body | JSON |
| Event routing | Body field event |
| Response window | 2xx within 10 seconds |
| Retries | Not documented |
| Source IPs | Not published |
Events and payloads
| Event | Triggered when | Common use cases |
|---|---|---|
candidate.applied | A candidate submits a self-serve application (careers page or application form; not candidates created through the API or other intake) | Create an ATS record, notify the hiring team |
candidate.stage_changed | A candidate moves between pipeline stages, from the UI, Joel (Outhire's AI agent) or the public API. Not sent if the stage is unchanged | Sync stage to your ATS or HRIS, trigger onboarding steps |
webhook.ping | An admin clicks Send test ping. Not subscribable | Check connectivity and signature verification |
The trigger rules matter. candidate.applied covers self-serve applications only, so candidates you create through the API or other intake routes don't produce one. candidate.stage_changed fires whoever moves the candidate, a recruiter, Joel or an API call, but not when the stage doesn't actually change.
This is the webhook.ping sample from the webhook samples library, the only complete example in Outhire's docs:
{
"event": "webhook.ping",
"occurred_at": "2026-03-11T00:00:00.000Z",
"webhook": {
"id": 123,
"url": "https://example.com/webhooks/outhire"
}
}
The candidate events' examples include placeholders, such as a resume_url of https://..., so log a real delivery before relying on their fields. There is no event ID in the body.
Setting up Outhire webhooks
- In Outhire, open Settings > Webhooks (
/settings/webhooks). You need an admin account. - Click Add webhook and enter the URL. A URL can be registered once per company, so edit the existing webhook to change its events.
- Select
candidate.applied,candidate.stage_changedor both. - Optionally add custom headers, for example an extra token or routing metadata. Names can be up to 100 characters and values up to 2000, with no case-insensitive duplicates;
content-type,webhook-id,webhook-timestampandwebhook-signatureare reserved. - Save. Outhire generates the signing secret; copy it into
OUTHIRE_WEBHOOK_SECRET, including thewhsec_prefix. You can view it again from the webhook's settings.
To route Outhire through Hookdeck, create a source with the Outhire type, add the secret, and register the source URL. For local development, the Hookdeck CLI forwards it to your machine:
npx hookdeck-cli listen 3000 outhire --path /webhooks/outhire
Then click Send test ping to send a signed webhook.ping and check your verification.
Securing Outhire webhooks
Outhire's docs give the base string {webhook-id}.{webhook-timestamp}.{raw_, signed with HMAC-SHA256 using the secret with whsec_ stripped and the rest base64-decoded. webhook-signature can contain several space-separated entries, and the function below accepts any v1, match:
const crypto = require('node:crypto');
function decodeBase64(value) {
if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
const bytes = Buffer.from(value, 'base64');
return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
? bytes : null;
}
function verifySignature(rawBody, headers, secret) {
if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
const prefix = headers['webhook-id'] !== undefined ? 'webhook' : 'svix';
const id = headers[`${prefix}-id`];
const timestamp = headers[`${prefix}-timestamp`];
const signature = headers[`${prefix}-signature`];
if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
!/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
const key = decodeBase64(secret.replace(/^whsec_/, ''));
if (!key) return false;
const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
const expected = crypto.createHmac('sha256', key).update(message).digest();
return candidates.some((candidate) => {
const received = decodeBase64(candidate);
if (!received) return false;
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
Outhire also tells you to check that webhook-timestamp is within five minutes of now. That works for a direct receiver with an accurate clock. Hookdeck applies no tolerance and keeps the original timestamp when it retries or replays, so behind Hookdeck leave the check out, as above.
The receiver stores the event and answers inside Outhire's ten seconds:
const express = require('express');
// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
try {
const value = JSON.parse(rawBody.toString('utf8'));
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
} catch {
return null;
}
}
// candidateEvents.store(deliveryId, event) saves the event and resolves once it is durable.
function createOuthireReceiver(candidateEvents) {
const app = express();
app.post('/webhooks/outhire', express.raw({ type: '*/*' }), async (req, res) => {
const secret = process.env.OUTHIRE_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500);
if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);
const event = parseJsonObject(req.body);
if (!event) return res.sendStatus(400); // signed, but not a JSON object
if (event.event === 'webhook.ping') return res.sendStatus(200);
try {
await candidateEvents.store(req.headers['webhook-id'], event);
} catch {
return res.sendStatus(503);
}
return res.sendStatus(200);
});
return app;
}
When Outhire delivers to a Hookdeck source, a bad signature still gets 200 back. Hookdeck records it as failed verification and doesn't forward it.
Make Outhire webhooks production-ready. Hookdeck Event Gateway verifies each candidate event and can send applications to your ATS and stage changes to your HRIS from one Outhire webhook.
Outhire webhook limitations and pain points
Retry behaviour isn't documented
The Problem: You don't know whether a candidate event missed during an outage will be sent again.
Why It Happens: Delivery logs show an attempt count, but Outhire doesn't publish a retry schedule, a give-up point or rules for disabling a webhook. It does say an event already delivered successfully isn't resent.
Workarounds:
- Check the delivery log for
faileddeliveries after an outage, and reconcile candidates through the API.
How Hookdeck Can Help: Hookdeck accepts each event on Outhire's first attempt and retries your service on a schedule you can see.
API-created candidates don't trigger candidate.applied
The Problem: Candidates imported or created by your own integration never appear as applications.
Why It Happens: candidate.applied only fires for self-serve applications.
Workarounds:
- Record candidates you create through the API at the time you create them, rather than waiting for a webhook.
How Hookdeck Can Help: Hookdeck's log shows exactly which events arrived, which helps confirm what Outhire does and doesn't send.
One webhook per URL
The Problem: You can't register the same URL twice with different event sets.
Why It Happens: Outhire allows each URL once per company.
Workarounds:
- Use one webhook with both events and route on
eventin your code, or use different URLs.
How Hookdeck Can Help: A single Hookdeck source can split events to different destinations with filters on event.
Candidate payloads are only partly published
The Problem: You can't write a full parser for the candidate events from the docs.
Why It Happens: Their examples include placeholders, and only webhook.ping is complete.
Workarounds:
- Capture real deliveries from a test pipeline before building on nested fields.
How Hookdeck Can Help: Hookdeck stores every payload, so you can inspect real candidate events while you build.
Best practices
Deduplicate on webhook-id
There's no event ID in the body, and Outhire asks receivers to be idempotent. Store the header value; the idempotency guide covers the patterns.
Keep candidate data out of logs
Candidate events include personal details and résumé links. Log IDs and event names only.
Treat custom headers as extras
Custom headers can carry an extra token or routing metadata such as an environment tag. Keep verifying the signature even when you add a token header.
Keep your clock in sync
If you verify directly with Outhire's five-minute check, run NTP on the receiver, or timestamps will fail.
Conclusion
Outhire sends two candidate events with Standard Webhooks signing, a signed test ping and a detailed delivery log. The retry policy and parts of the candidate payload aren't documented, so capture a real delivery before building on it.
Hookdeck Event Gateway brings retries, routing and payload history to Outhire's candidate events. Try Hookdeck at no cost.