> ## Documentation Index
> Fetch the complete documentation index at: https://docs.parser-app.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Get every finished job pushed to your system, signed, retried until you take it.

Register an endpoint once, and every job event is `POST`ed to it.

```bash theme={null}
curl -sS -X POST "$API/v1/webhooks" -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
     -d '{"url": "https://crm.example.com/hooks/parser", "topics": ["job.completed", "job.review_required", "job.failed"]}'
```

The answer holds the endpoint's signing `secret` (`whsec_...`), shown only now. `topics` may also include
`mailbox.disconnected`, `job.corrected` (a reviewer [corrected](/guides/review-and-corrections) a finished job,
or reverted a correction) and `email.received`. An empty list receives every topic except the opt-in
`job.corrected` and `email.received`, which an endpoint receives only when it lists them. The CRM
[integrations](/guides/integrations#corrections) can take `job.corrected` too.

## The event

```json theme={null}
{"id": "<event id>", "tenant_id": "acme", "topic": "job.completed", "resource_type": "job", "resource_id": "<job id>",
 "created_at": "2026-10-14T20:30:00Z",
 "payload": {"job_id": "...", "status": "completed", "result_path": "/v1/jobs/...", "review_reasons": [],
             "document_count": 3, "statements": 2, "statements_accepted": 2, "positions": 2,
             "current_withhold_pct": 21.8, "merchant_fingerprint": "mfp1:...", "related_jobs": [], "cost_usd": "0.0093",
             "intake": {"source": "upload", "received_at": "2026-10-14T20:29:12Z", "broker_email": "deals@iso.example",
                        "end_user_id": "user-381", "email": null, "files": [...], ...}}}
```

The payload never carries the merchant's transactions. Fetch `result_path` with your API key for the full result.
`intake` is the job's [intake](/guides/platform-integration#open-a-deal-for-every-job-failed-ones-included): who
sent the packet, by which email, for which of your end users, with which files. It is in every job event,
`job.failed` included, so a job that failed before anything was read still names its broker and its email.

| Header | Value |
| - | - |
| `X-Parser-Event-Id` | The event's id, the same on every retry. Deduplicate on it. |
| `X-Parser-Topic` | The topic, e.g. `job.completed`. |
| `X-Parser-Signature` | `t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" with the endpoint secret>` |

## email.received

An endpoint that lists `email.received` gets one event for every inbound email, the moment it is taken: jobs,
merges, ignored and refused emails alike, so a CRM can track each one. A redelivery of the same message sends no
second event.

```json theme={null}
{"id": "<event id>", "tenant_id": "acme", "topic": "email.received", "resource_type": "email",
 "resource_id": "em_3f1c0e9a6b2d4c8e1a7f5b90", "created_at": "2026-10-14T20:29:42Z",
 "payload": {"email_id": "em_3f1c0e9a6b2d4c8e1a7f5b90", "email_path": "/v1/emails/em_3f1c0e9a6b2d4c8e1a7f5b90",
             "thread_id": "4b6e…/deal-123@example-iso.com", "mailbox_id": "4b6e…",
             "from": "broker@example-iso.com", "subject": "New deal: Blue Fern Bakery", "outcome": "job",
             "job_id": "…", "routing_intent": "new_submission", "tags": ["client:lifetime"]}}
```

It carries the email's envelope and outcome, never its body or attachments: fetch `email_path` for the record and
`/raw` for the message. `job_id` is the job it started or the thread's job (null when there is none); the job's
own event comes when the job finishes. See [Emails and threads](/guides/emails).

## Verify the signature

Verify against the raw request body, before parsing it.

<CodeGroup>
  ```python Python theme={null}
  import hashlib, hmac, time

  def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
      parts = dict(item.split("=", 1) for item in header.split(","))
      signed = f"{parts['t']}.".encode() + raw_body
      expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
      return hmac.compare_digest(expected, parts["v1"]) and abs(time.time() - int(parts["t"])) <= tolerance
  ```

  ```javascript Node.js theme={null}
  import { createHmac, timingSafeEqual } from "node:crypto";

  export function verify(rawBody, header, secret, tolerance = 300) {
    const parts = Object.fromEntries(header.split(",").map((item) => item.split("=", 2)));
    const expected = createHmac("sha256", secret).update(`${parts.t}.`).update(rawBody).digest("hex");
    const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= tolerance;
    return fresh && expected.length === parts.v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
  }
  ```
</CodeGroup>

## Delivery

* **Answer `2xx` within 10 seconds.** Anything else (another status, a timeout, a refused connection) is
  retried on this schedule, then the delivery is `dead`:

  | Attempt | After the previous one | After the first |
  | - | - | - |
  | 1 | (as the job ends) | 0 |
  | 2 | 30 s | 30 s |
  | 3 | 2 min | 2 min 30 s |
  | 4 | 10 min | 12 min 30 s |
  | 5 | 30 min | 42 min 30 s |
  | 6 | 1 h | 1 h 42 min 30 s |
  | 7 | 3 h | 4 h 42 min 30 s |
  | 8 | 6 h | 10 h 42 min 30 s |
  | 9, the last | 12 h | 22 h 42 min 30 s |

  Each time is the earliest: deliveries are picked up every few seconds, and five failures in a row pause the
  endpoint for 5 minutes (its deliveries wait without spending an attempt), so the last attempt can come later.
* **Every job ends in an event.** The event is written in the same transaction as the job's final status, so it
  cannot be lost, and pending deliveries survive restarts.
* **Targets** must be `https` and resolve to public addresses. Redirects are not followed.

## Missed events

Your endpoint may be down longer than the retries last, or reject events by mistake. To see it,
`GET /v1/webhooks/{id}/deliveries` lists each delivery with its `status` (`pending`, `delivered`, `dead`,
`cancelled`), its attempts and your endpoint's last answer. To recover:

* **Re-send one delivery**: `POST /v1/webhooks/{id}/deliveries/{delivery_id}/resend`, for a `dead`, `delivered`
  or `cancelled` one (`409` while it is still pending).
* **Re-send every dead delivery since a time**: `POST /v1/webhooks/{id}/deliveries/resend?since=<ISO 8601>`,
  for instance from when an outage began. Each event whose delivery died (its last attempt) at or after `since`
  is sent once, unless it has been delivered or is pending since.

A re-sent event is the same event, byte for byte: the same body, `id` and `X-Parser-Event-Id`, signed afresh.
It is a new delivery with the full retry schedule; the earlier one stays in the history. Deduplicate on the
event id and a re-send is always safe.

**Polling** covers whatever no webhook brought: `GET /v1/jobs?finished_since=` lists every job that finished
since a time, oldest finish first, each once across its pages. See the
[sync recipe](/guides/platform-integration#never-miss-a-job).

## Manage endpoints

| Call | Does |
| - | - |
| `POST /v1/webhooks/{id}/test` | Sends a signed `ping`. |
| `GET /v1/webhooks/{id}/deliveries` | Every attempt, with your endpoint's answers. |
| `POST /v1/webhooks/{id}/deliveries/{delivery_id}/resend` | Sends one delivery's event again. |
| `POST /v1/webhooks/{id}/deliveries/resend?since=` | Sends every event that died since a time again. |
| `POST /v1/webhooks/{id}/rotate-secret` | A new signing secret. |
| `DELETE /v1/webhooks/{id}` | Removes the endpoint. |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.