Skip to content

Webhooks

Real-time HTTPS notifications when application status changes or job visibility changes. HMAC-signed for authenticity verification.

Event types

EventFires when
application.status_changedApplication reaches a terminal state (accepted or rejected)
job.openedA job becomes visible to your key (new job, access granted, un-archived)
job.closedA job stops being visible (closed, access revoked, marked internal-only)

TIP

application.status_changed fires only on terminal transitions. Intermediate progress (received → in_progress) does NOT fire a webhook. Use GET /applications/:id or GET /applications for intermediate status polling.

Payload envelope

All events share the same outer shape:

json
{
  "id": "evt_2026052214301198abc",
  "type": "application.status_changed",
  "createdAt": "2026-05-22T14:30:11Z",
  "apiVersion": "v1",
  "data": { }
}
FieldDescription
idGlobally unique event identifier. Stable across retries — use it to deduplicate
typeOne of the three event types above
createdAtWhen the event occurred (not when it was delivered)
apiVersionAlways v1
dataEvent-specific payload (see below)

Event payloads

application.status_changed

json
{
  "applicationId": "ej_app_789",
  "sourceApplicationId": "your-internal-id-123",
  "jobId": "job_12345",
  "oldStatus": "in_progress",
  "newStatus": "accepted",
  "currentStage": "Hired",
  "occurredAt": "2026-05-29T11:42:00Z"
}

job.opened

json
{
  "jobId": "job_12345",
  "companyId": "co_67",
  "title": "Janitor",
  "openedAt": "2026-05-20T14:00:00Z"
}

job.closed

json
{
  "jobId": "job_12345",
  "companyId": "co_67",
  "reason": "closed_by_customer",
  "closedAt": "2026-06-14T16:20:00Z"
}

reason values: closed_by_customer | access_revoked | internal_sourcing_only

Signature verification

Every webhook request includes two headers:

X-EmployJoy-Signature: t=1716393611,v1=d7b4ed92ded8c3629bad3c1ef456e80e0e7dd4681675693b1684575562da6a12
X-EmployJoy-Timestamp: 1716393611

Verification algorithm

  1. Extract t (timestamp) and v1 (signature) from the X-EmployJoy-Signature header
  2. Construct the signing input: ${t}.${raw_request_body} (literal dot separator, raw bytes)
  3. Compute HMAC-SHA256 of the signing input using your signing secret as the key
  4. Compare the computed hex digest with v1 using constant-time comparison
  5. Verify the timestamp is within 5 minutes of your server's current time (replay protection)

WARNING

The signing input uses the raw request body bytes — not parsed/re-serialized JSON. Do not JSON.parse then JSON.stringify the body before verification. Use the raw bytes your HTTP framework received.

Test vector

Use this to validate your verification implementation:

Secret:    whsec_test_abcdef1234567890
Body:      {"id":"evt_test","type":"application.status_changed","data":{}}
Timestamp: 1716393611
Expected:  d7b4ed92ded8c3629bad3c1ef456e80e0e7dd4681675693b1684575562da6a12

Node.js verification example

javascript
const crypto = require('crypto');

function verifyWebhookSignature(req, signingSecret) {
  const signatureHeader = req.headers['x-employjoy-signature'];
  if (!signatureHeader) return false;

  // Parse t= and v1= from header
  const parts = {};
  for (const segment of signatureHeader.split(',')) {
    const [key, value] = segment.split('=', 2);
    parts[key.trim()] = value;
  }
  const timestamp = parts['t'];
  const signature = parts['v1'];
  if (!timestamp || !signature) return false;

  // Replay window: reject if older than 5 minutes
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (age > 300) return false;

  // Compute expected signature from raw body
  const signingInput = `${timestamp}.${req.rawBody}`;
  const expected = crypto
    .createHmac('sha256', signingSecret)
    .update(signingInput)
    .digest('hex');

  // Constant-time comparison
  const expectedBuf = Buffer.from(expected, 'hex');
  const actualBuf = Buffer.from(signature, 'hex');
  if (expectedBuf.length !== actualBuf.length) return false;
  return crypto.timingSafeEqual(expectedBuf, actualBuf);
}

// Express example:
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  req.rawBody = req.body.toString('utf8');
  if (!verifyWebhookSignature(req, process.env.EMPLOYJOY_SIGNING_SECRET)) {
    return res.status(401).send('Invalid signature');
  }
  const event = JSON.parse(req.rawBody);
  // Process event...
  res.status(200).json({ received: true });
});

Python verification example

python
import hmac
import hashlib
import time

def verify_webhook_signature(body: bytes, signature_header: str, signing_secret: str) -> bool:
    """Verify an EmployJoy webhook signature.

    Args:
        body: Raw request body bytes (not decoded/re-encoded)
        signature_header: Value of X-EmployJoy-Signature header
        signing_secret: Your signing secret (starts with wsec_)
    """
    # Parse t= and v1= from header
    parts = {}
    for segment in signature_header.split(','):
        key, _, value = segment.partition('=')
        parts[key.strip()] = value

    timestamp = parts.get('t')
    signature = parts.get('v1')
    if not timestamp or not signature:
        return False

    # Replay window: reject if older than 5 minutes
    age = abs(int(time.time()) - int(timestamp))
    if age > 300:
        return False

    # Compute expected signature from raw body
    signing_input = f"{timestamp}.".encode('utf-8') + body
    expected = hmac.new(
        signing_secret.encode('utf-8'),
        signing_input,
        hashlib.sha256,
    ).hexdigest()

    # Constant-time comparison
    return hmac.compare_digest(expected, signature)


# Flask example:
@app.route('/webhook', methods=['POST'])
def handle_webhook():
    body = request.get_data()  # raw bytes
    sig_header = request.headers.get('X-EmployJoy-Signature', '')

    if not verify_webhook_signature(body, sig_header, os.environ['EMPLOYJOY_SIGNING_SECRET']):
        return 'Invalid signature', 401

    event = json.loads(body)
    # Process event...
    return {'received': True}, 200

Retry schedule

If your endpoint returns a non-2xx response, we retry with exponential backoff:

AttemptDelay from previous
1Immediate
2~1 minute
3~5 minutes
4~30 minutes
5~2 hours
6~6 hours
7~12 hours
8~24 hours
9~24 hours
10~24 hours

After 10 attempts (~3 days), the delivery is moved to dead-letter and an EmployJoy operator is alerted.

Delays include ±25% jitter to avoid thundering-herd on recovery.

Auto-pause

Your webhook endpoint is automatically paused when:

  • 50 consecutive failures, OR
  • 24 hours since the last successful delivery

When paused, new events are still recorded but not delivered. Contact partnerships@employjoy.ai to re-enable after fixing your endpoint.

410 Gone

If your endpoint returns HTTP 410 Gone, we immediately pause delivery and alert an operator. Use this to signal that your endpoint has been permanently decommissioned.

Deduplication

The id field in every webhook payload is stable across retries. If you receive the same id twice, it's a retry — safe to skip if you've already processed it.

Best practices

  1. Respond 2xx quickly — acknowledge receipt, then process asynchronously. We timeout at 30 seconds.
  2. Verify the signature first — before parsing the body or taking any action.
  3. Use the id field for deduplication — retries reuse the same event ID.
  4. Don't rely solely on webhooks — they're best-effort hints. Poll GET /applications as the source of truth.
  5. Return 410 if you decommission your endpoint — we'll stop retrying immediately.

Partner API v1