Get started
Webhooks
Receive the result of a long parse or scoring call on your own URL, signed with HMAC-SHA256.
Send webhook_url with POST /api/v4/resumes/parse or POST /api/v4/resumes/score and the call answers 202 at once with a request_id; the result is then posted to that URL, signed. With storage, a scoring call past 10 files or 50 resumes answers 202 even without a webhook: read its scores at results_url. Without storage, such a call needs webhook_url (400 invalid_request otherwise).
| Event | Sent when |
|---|---|
resume.parsed | A parse sent with webhook_url succeeded: data is the parse. |
resume.parse_failed | It failed: error says why. Nothing was billed. |
scores.completed | Every resume of a scoring call is evaluated or failed: data is the scoring result. |
scores.failed | A call without storage failed as a whole after the 202 (job file unreadable, credits run out while its files were parsed, analysis unavailable): error says why. Its evaluations were not billed; its resume files already parsed were, 1 credit each. |
- A
POSTwith a JSON body. Answer with any2xxwithin 10 seconds; other answers and timeouts are retried with backoff for 24 hours, each retry signed again. - Deduplicate on
event_id: it is the same on every retry. Match the event to its call withrequest_id, the one the202returned. - Without storage, the event is the only copy of the result: it is deleted once delivered, or after the last retry.
- The URL must be HTTPS on the standard port (443), without credentials, and reach a public address: any other URL is refused at once (
400 invalid_request,param: webhook_url).
Every delivery carries the HireLayer-Signature header. t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">, keyed with the account's webhook secret: the string shown in the dashboard (whsec_…), used as is, as UTF-8 bytes. Compare in constant time and reject a t more than 5 minutes old: every retry is signed again with a new t. For 24 hours after a rotation in the dashboard, the header carries one v1 per secret, such as t=1793054400,v1=5f2c…,v1=9ab0…; accept the call if one matches.
# Verify a HireLayer webhook before trusting it. body: the raw bytes of the request.
import hashlib
import hmac
import os
import time
SECRET = os.environ["HIRELAYER_WEBHOOK_SECRET"] # whsec_…, from the dashboard
def verify(signature_header: str, body: bytes) -> bool:
pairs = [part.split("=", 1) for part in signature_header.split(",")]
timestamp = next((value for key, value in pairs if key == "t"), None)
if timestamp is None or time.time() - int(timestamp) > 300:
return False
signed = timestamp.encode() + b"." + body
expected = hmac.new(SECRET.encode(), signed, hashlib.sha256).hexdigest()
return any(key == "v1" and hmac.compare_digest(value, expected) for key, value in pairs)
// Verify a HireLayer webhook before trusting it. body: the raw bytes of the request.
import { createHmac, timingSafeEqual } from 'node:crypto'
const SECRET = process.env.HIRELAYER_WEBHOOK_SECRET! // whsec_…, from the dashboard
export function verify(signatureHeader: string, body: Buffer) {
const pairs = signatureHeader.split(',').map((part) => part.split('='))
const timestamp = pairs.find(([key]) => key === 't')?.[1]
if (!timestamp || Date.now() / 1000 - Number(timestamp) > 300) return false
const expected = createHmac('sha256', SECRET)
.update(`${timestamp}.`)
.update(body)
.digest()
return pairs.some(([key, value]) => {
const candidate = Buffer.from(value ?? '', 'hex')
return key === 'v1' && candidate.length === expected.length && timingSafeEqual(candidate, expected)
})
}
The secret is shown, and rotated, in Dashboard → API keys. A rotation less than 24 hours after the previous one stops at once the secret that one replaced. Verify the raw body, before parsing it as JSON.
Posted once the parse ends, successful or not. Check the HireLayer-Signature header before trusting the body. Answer with any 2xx within 10 seconds; other answers and timeouts are retried with backoff for 24 hours, each retry signed again. Deduplicate on event_id.
| Field | Type | Description |
|---|---|---|
event_id | string | Unique per event, the same on every retry. |
event | string | One of: "resume.parsed", "resume.parse_failed". |
created_at | string (date-time) | |
request_id | string | The request_id of the 202. |
workspace | string | |
data | ParseResult | The answer of POST /resumes/parse. |
error | object | |
error.code | string | Stable codes: branch on them, never on message. method_not_allowed answers a method a path does not take (405, with an Allow header), outside any operation. One of: "invalid_request", "storage_required", "missing_api_key", "invalid_api_key", "insufficient_credits", "enterprise_plan_required", "not_found", "payload_too_large", "resume_unreadable", "job_unreadable", "rate_limited", "model_unavailable", "internal_error", "method_not_allowed". |
error.message | string | Readable explanation, for logs. |
error.param | string | The field or parameter at fault, when there is one, such as job.criteria or resumes[2].file. |
error.doc_url | string (URL) |
Posted once every resume of the call is evaluated or failed. Check the HireLayer-Signature header before trusting the body, and match it to its call with request_id. Answer with any 2xx within 10 seconds; other answers and timeouts are retried with backoff for 24 hours, each retry signed again. Deduplicate on event_id. Without storage this is the only copy of the result: it is deleted once delivered, or after the last retry.
| Field | Type | Description |
|---|---|---|
event_id | string | Unique per event, the same on every retry. |
event | string | One of: "scores.completed", "scores.failed". |
created_at | string (date-time) | |
request_id | string | The request_id of the 202. |
workspace | string | |
data | ScoreResult | The answer of POST /resumes/score. |
error | object | With scores.failed only: why the whole call failed after the 202 (model unavailable, credits run out while the files were parsed). Its evaluations were not billed; without storage, its resume files already parsed were, 1 credit each. |
error.code | string | Stable codes: branch on them, never on message. method_not_allowed answers a method a path does not take (405, with an Allow header), outside any operation. One of: "invalid_request", "storage_required", "missing_api_key", "invalid_api_key", "insufficient_credits", "enterprise_plan_required", "not_found", "payload_too_large", "resume_unreadable", "job_unreadable", "rate_limited", "model_unavailable", "internal_error", "method_not_allowed". |
error.message | string | Readable explanation, for logs. |
error.param | string | The field or parameter at fault, when there is one, such as job.criteria or resumes[2].file. |
error.doc_url | string (URL) |