Skip to main content

Webhooks

Receive results as they happen, and verify they came from us.

Expert Hire developer workflow for Webhooks
Build against the same evidence trail the product uses.

Scoring is asynchronous. Register an endpoint and we push the result rather than you polling.

Registering

curl -X POST https://prep-api.experthire.cloud/v1/webhook-endpoints \
  -H "Authorization: Bearer $EH_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://api.yourcompany.com/hooks/experthire",
    "description": "Production consumer",
    "enabled_events": ["interview.completed", "resume.scored"]
  }'

Still a placeholder: $EH_SECRET_KEY. Add it under Your values above.

The response contains secret, which starts whsec_. It is shown once.

Register as many endpoints as you like, each with its own filter. An empty enabled_events receives everything.

Your URL must be HTTPS and publicly resolvable. We reject private and link-local addresses at registration and again at delivery, because DNS can be re-pointed in between.

Events

EventWhen
interview.createdInterview created
interview.startedCandidate joined
interview.endedRoom closed, scoring queued
interview.completedReport ready
interview.cancelledCancelled
interview.summary_availableSummary written
interview.transcript_availableTranscript uploaded
resume.scoredResume scoring finished

interview.completed is the one most integrations want.

Payload

{
  "id": "whevt_9f2c...",
  "type": "interview.completed",
  "created_at": 1770000600,
  "data": {
    "object": {
      "id": "1c9d6b3a-77aa-4e21-8b0e-6b2f9c4d1a55",
      "status": 4,
      "overall_score": 72
    }
  }
}

Verifying

Every delivery carries:

Eh-Event-Id: whevt_9f2c...
Eh-Signature: t=1770000600,v1=5257a8...,v1=8b1f0c...

The signature is HMAC-SHA256(secret, "{event_id}.{timestamp}.{raw_body}"), hex encoded.

There may be more than one v1. During a secret rotation both the old and new signatures are sent. Accept the delivery if any of them matches.

import crypto from 'node:crypto'

// express.json() throws the raw body away. Keep it, or every signature fails.
app.post('/hooks/experthire',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const header = req.get('Eh-Signature') ?? ''
    const parts = Object.fromEntries(
      header.split(',').map((p) => {
        const i = p.indexOf('=')
        return [p.slice(0, i), p.slice(i + 1)]
      })
    )

    const timestamp = Number(parts.t)
    // Reject anything older than five minutes so a captured delivery cannot be
    // replayed later.
    if (Math.abs(Date.now() / 1000 - timestamp) > 300) return res.sendStatus(400)

    const eventId = req.get('Eh-Event-Id')
    const expected = crypto
      .createHmac('sha256', process.env.EH_WEBHOOK_SECRET)
      .update(`${eventId}.${timestamp}.${req.body}`)
      .digest('hex')

    const signatures = header
      .split(',')
      .filter((p) => p.startsWith('v1='))
      .map((p) => p.slice(3))

    const ok = signatures.some(
      (sig) =>
        sig.length === expected.length &&
        crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
    )
    if (!ok) return res.sendStatus(401)

    res.sendStatus(200)
    process(JSON.parse(req.body))
  }
)

Three things that break signature verification, in the order people hit them:

  1. Parsing the body first. The signature covers the exact bytes we sent. Any JSON round trip changes them. Use the raw body.
  2. Comparing with ===. Use a constant-time compare.
  3. Assuming one signature. See rotation above.

Retries

A delivery is retried on any non-2xx or a timeout, at 30s, 2m, 10m, 1h and 6h. After five failures we stop.

Return 2xx as soon as you have stored the event, then process it. Doing the work before responding risks a timeout and a duplicate.

Deliveries are at least once, so make your handler idempotent. id on the envelope is stable across retries; use it to deduplicate.

Testing a receiver

Before real traffic depends on it, fire a probe at an endpoint:

curl -X POST https://prep-api.experthire.cloud/v1/webhook-endpoints/$ID/test \
  -H "Authorization: Bearer $EH_SECRET_KEY"

Still a placeholder: $EH_SECRET_KEY. Add it under Your values above.

You get a webhook.test event, signed exactly like a real one. It ignores the endpoint's enabled_events filter on purpose: you are proving the receiver answers and your signature check passes, not that routing works. It is attempted once rather than retried for six hours, so a failure shows up straight away in the delivery log below.

Seeing what was attempted

curl "https://prep-api.experthire.cloud/v1/webhook-endpoints/$ID/deliveries?limit=20" \
  -H "Authorization: Bearer $EH_SECRET_KEY"

Still a placeholder: $EH_SECRET_KEY. Add it under Your values above.

Every attempt, newest first, with the status code and body we saw and how long you took. This is the first place to look when an event "never arrived": usually it did, and your handler returned a 500.

Request headers are never returned. They carry the signature, and echoing it back would let anyone who can read the log forge a delivery. Response bodies are truncated.

Replaying a lost event

curl "https://prep-api.experthire.cloud/v1/events?status=failed" \
  -H "Authorization: Bearer $EH_SECRET_KEY"

curl -X POST https://prep-api.experthire.cloud/v1/events/$EVENT_ID/redeliver \
  -H "Authorization: Bearer $EH_SECRET_KEY"

Still a placeholder: $EH_SECRET_KEY, $EVENT_ID. Add them under Your values above.

If your endpoint was down through all five attempts, this puts the event back on the queue with a fresh budget. Only a failed event qualifies: replaying a delivered one would double-fire a handler that already acted, and one still retrying does not need your help. Anything else returns event_not_replayable.

payload on an event is the exact body that was signed and sent, so a signature mismatch can be debugged against the same bytes we hashed.

Events and deliveries are swept after 30 days. This is a recovery tool for an outage on your side, not an archive. If you need a durable record, store the events as you receive them.

Changing an endpoint

curl -X PATCH https://prep-api.experthire.cloud/v1/webhook-endpoints/$ID \
  -H "Authorization: Bearer $EH_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"enabled_events": ["interview.completed", "interview.summary_available"]}'

Still a placeholder: $EH_SECRET_KEY. Add it under Your values above.

enabled_events replaces the filter outright, so send the full set rather than a delta. An empty array means every event. Send {"disabled": true} to park an endpoint during an incident on your side without losing its secret or its history.

Rotating the secret

curl -X POST https://prep-api.experthire.cloud/v1/webhook-endpoints/$ID/rotate-secret \
  -H "Authorization: Bearer $EH_SECRET_KEY"

Still a placeholder: $EH_SECRET_KEY. Add it under Your values above.

The old secret keeps verifying for 24 hours and both signatures are sent, so you can deploy the new one without dropping anything.