# Guide to Outhire Webhooks: Features and Best Practices

[Outhire](https://outhire.ai) 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](https://github.com/hookdeck/webhook-samples/blob/main/providers/outhire/latest/webhook.ping.json), the only complete example in Outhire's docs:

```json
{
  "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

1. In Outhire, open **Settings > Webhooks** (`/settings/webhooks`). You need an admin account.
2. Click **Add webhook** and enter the URL. A URL can be registered once per company, so edit the existing webhook to change its events.
3. Select `candidate.applied`, `candidate.stage_changed` or both.
4. 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-timestamp` and `webhook-signature` are reserved.
5. Save. Outhire generates the signing secret; copy it into `OUTHIRE_WEBHOOK_SECRET`, including the `whsec_` 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](/docs/cli) forwards it to your machine:

```bash
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_request_body}`, 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:

```javascript
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:

```javascript
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](/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 `failed` deliveries after an outage, and reconcile candidates through the API.

**How Hookdeck Can Help:** Hookdeck accepts each event on Outhire's first attempt and [retries](/docs/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 `event` in your code, or use different URLs.

**How Hookdeck Can Help:** A single Hookdeck source can split events to different [destinations](/docs/destinations) with [filters](/docs/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](/webhooks/guides/implement-webhook-idempotency) 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](https://hookdeck.com) brings retries, routing and payload history to Outhire's candidate events. [Try Hookdeck at no cost](https://dashboard.hookdeck.com/signup).