Skip to content

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).

EventSent when
resume.parsedA parse sent with webhook_url succeeded: data is the parse.
resume.parse_failedIt failed: error says why. Nothing was billed.
scores.completedEvery resume of a scoring call is evaluated or failed: data is the scoring result.
scores.failedA 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 POST with a JSON 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: it is the same on every retry. Match the event to its call with request_id, the one the 202 returned.
  • 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)

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.

FieldTypeDescription
event_idstringUnique per event, the same on every retry.
eventstringOne of: "resume.parsed", "resume.parse_failed".
created_atstring (date-time)
request_idstringThe request_id of the 202.
workspacestring
dataParseResultThe answer of POST /resumes/parse.
errorobject
error.codestringStable 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.messagestringReadable explanation, for logs.
error.paramstringThe field or parameter at fault, when there is one, such as job.criteria or resumes[2].file.
error.doc_urlstring (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.

FieldTypeDescription
event_idstringUnique per event, the same on every retry.
eventstringOne of: "scores.completed", "scores.failed".
created_atstring (date-time)
request_idstringThe request_id of the 202.
workspacestring
dataScoreResultThe answer of POST /resumes/score.
errorobjectWith 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.codestringStable 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.messagestringReadable explanation, for logs.
error.paramstringThe field or parameter at fault, when there is one, such as job.criteria or resumes[2].file.
error.doc_urlstring (URL)