# Webhooks

> Receive the result of a long parse or scoring call on your own URL, signed with HMAC-SHA256.

Source: https://hirelayer.co/api-docs/webhooks · Markdown: https://hirelayer.co/api-docs/webhooks.md

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

## Delivery

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

## Verify the signature

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.

**Python**

```python
# 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)

```

**TypeScript**

```typescript
// 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](https://hirelayer.co/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.

## Parse events

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`](https://hirelayer.co/api-docs/parse-resume.md). |
| `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)` |  |

## Scoring events

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`](https://hirelayer.co/api-docs/score.md). |
| `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)` |  |

## Next

- [HireLayer Resume Scorer](https://hirelayer.co/api-docs/score.md): HireLayer candidate matching and ranking API: score one or many resumes against a job, criterion by criterion, with evidence, a verdict and an optional precise ranking.
- [Errors and retries](https://hirelayer.co/api-docs/errors.md): The error format of the HireLayer API, every error code, and when to retry a call.
