> Enrich.chat gives AI agents verified work emails and mobiles. To use it from an agent, read https://enrich.chat/agents.md . This is the Markdown version of https://enrich.chat/features/background-jobs/

# Bulk contact enrichment API: background jobs with signed webhooks

Start a job of up to 5,000 lookups, close the laptop and get a signed webhook when it finishes. Credits are spent as each row runs, and a row that finds nothing is free.

By Enrich.chat Editorial. Updated October 19, 2026.

**Short answer:** Enrich.chat runs up to 5,000 lookups as a background job. `POST /v1/jobs` returns a job id, the job keeps running after you close the tab or the laptop, and a signed `job.completed` or `job.stopped` webhook tells your system when it ends. Results download as CSV or JSON for 30 days. See the [API reference](https://enrich.chat/docs/).

## Key takeaways

- `POST /v1/jobs` takes up to 5,000 items of one kind: person, email, mobile, company, company_tech or email_pattern. The reply is `202` with a job id.
- The job runs on the server, so closing the tab or the laptop does not stop it. Up to three jobs run at once.
- Credits are spent as each lookup runs. A row that finds nothing is free, and the job stops by itself when credits or free lookups run out.
- Each webhook delivery carries an `Enrich-Signature` header: the HMAC-SHA256 of the timestamp, a dot and the body. Check it and reject timestamps older than five minutes.
- Webhooks announce finished jobs only. Results are kept 30 days, then deleted.

## A job you can start, leave and pick up later

- **Runs without you.** A job runs on the server. Close the tab or the laptop and it carries on. Check it later in the dashboard at `/app/#/jobs`.
- **Up to 5,000 rows.** Six kinds of job: find work emails, enrich people, find mobile numbers, enrich companies, company tech stacks and email formats.
- **Credits as rows run.** Same prices as a one-off lookup. A row that finds nothing is free, and the job stops by itself when credits run out.
- **Signed webhooks.** Each delivery has an `Enrich-Signature` header with a timestamp, so you can reject forged and replayed calls.
- **CSV or JSON results.** Download one file or page through JSON. Your own columns come first, then `enrich_` columns. Kept 30 days.
- **Your rules still apply.** A job applies your do-not-contact list and the opt-outs. A blocked row has blank contact columns.

## From one request to a signed callback

1. **Start the job.** `POST /v1/jobs` with an `op` and up to 5,000 items. The reply is 202 with a job id.
2. **Close the laptop.** The lookups run on the server. Credits are spent as each row runs, and a miss is free.
3. **Get the callback.** Your webhook receives `job.completed` or `job.stopped`. Then read the results as CSV or JSON.

## How do background jobs work in Enrich.chat?

A background job is a batch of up to 5,000 lookups that Enrich.chat runs on the server instead of in your browser tab. You send the batch once and get a job id back. The job keeps running after you close the tab or the laptop. When it ends, you download the results, and a webhook can tell your own system.

Start a job in the [dashboard](https://enrich.chat/app/) by choosing Run it on the server in Bulk enrich, or call `POST /v1/jobs` from your code or agent. Your jobs are listed at `/app/#/jobs`. Both routes use the same prices. The older browser-tab run, which sends 25 rows per call through `POST /v1/batch`, still exists for short lists. See [bulk enrichment](https://enrich.chat/features/bulk-enrichment/).

**Figure 1: A background job from request to signed webhook.** Your app or agent sends POST /v1/jobs with up to 5,000 rows. Enrich.chat queues the job, runs each lookup and spends credits for found rows, then sends a signed job.completed or job.stopped webhook to your URL. The laptop can be closed while the job runs.

*One request starts the job, and your webhook is called when it ends. Source: the [Enrich.chat API reference](https://enrich.chat/docs/).*

A job holds one kind of lookup, set by `op`. Each item is the same lookup as the matching single endpoint.

| op | Each item holds | Same as | Credits when found |
| --- | --- | --- | --- |
| email | A name and a company website, or a LinkedIn URL or person_id | GET /v1/email | 10 |
| person | A LinkedIn URL, a work email, a person_id, or a name and a company website | GET /v1/person | Up to 10 per email, 250 per mobile, 5 for a profile |
| mobile | A LinkedIn URL, a person_id or a work email | GET /v1/mobile | 250 |
| company | A company website, a LinkedIn company URL or a company_id | GET /v1/company | 5 |
| company_tech | A company website | GET /v1/company/tech | 5 |
| email_pattern | A company website | GET /v1/email-pattern | 5 |

Give each item an `id` and it comes back with its result. Work emails are the only email input, so personal mailboxes such as Gmail are not looked up, and companies go in as websites, not names. The [data enrichment API](https://enrich.chat/api/data-enrichment/) page covers the single lookups.

## How do you start a job and read its results?

Send one POST with an `op` and an `items` list. The reply is `202` with a job `id` and a `status_url`. A job belongs to an account, so sign in with an API key or an OAuth token, or start it from the dashboard. The code panel at the top of this page follows a three-row job. The people are made up and use example.com.

Three fields are optional: `label` names the job (up to 80 characters), `options` sets parameters every row shares, and `headers` with `rows` carry your own columns into the CSV.

| Call | What it does |
| --- | --- |
| POST /v1/jobs | Starts a job of 1 to 5,000 items. Replies 202 with the job id |
| GET /v1/jobs | Lists your last 50 jobs, newest first |
| GET /v1/jobs/{id} | Returns the status, the counts and the credits used |
| GET /v1/jobs/{id}/results | Returns the results as JSON, 100 rows at a time |
| GET /v1/jobs/{id}/results?format=csv | Returns the results as one CSV file |
| POST /v1/jobs/{id}/cancel | Stops a queued or running job. Finished rows keep their results and their charge |
| DELETE /v1/jobs/{id} | Cancels the job if it is running, then deletes it with its results |

### How do you read the results?

A job is queued, running, done, stopped or cancelled. The status call returns `total`, `done`, `found`, `not_found`, `errors` and `credits_used`. Each row is pending, done, error or skipped. The results call returns JSON, 100 rows at a time (page with `offset` and `limit`, up to 500), or one CSV with `format=csv`. The CSV starts with your own columns, then the `enrich_` columns, `enrich_status` and `enrich_do_not_contact`. Here is the three-row job as a file, with some columns left out.

| id | enrich_work_email | enrich_email_status | enrich_status |
| --- | --- | --- | --- |
| a | priya.nair@harborline.example.com | verified | found |
| b | felix.brandt@cedarworks.example.com | catch_all | found |
| c | blank | blank | not found |

Every work email found has a status label: verified, catch_all or unknown. It is a label and not a delivery guarantee. A job also applies your [do-not-contact list](https://enrich.chat/features/do-not-contact/) and the [opt-outs](https://enrich.chat/opt-out/), and a blocked row has blank contact columns.

## How do you set up and verify a signed webhook?

A webhook is an https address on a public host name that Enrich.chat calls when a job ends. Create one with `POST /v1/webhooks`, up to 10 per account. Pass `events` to pick `job.completed`, `job.stopped` or both. The reply shows a signing secret that starts with `whsec_`. It appears once, so store it straight away.

```
curl -X POST "https://enrich.chat/v1/webhooks" \
  -H "Authorization: Bearer $ENRICH_KEY" \
  -H "content-type: application/json" \
  -d '{"url": "https://example.com/hooks/enrich",
       "events": ["job.completed", "job.stopped"]}'

# 201 Created (illustrative)
{
  "id": "whk_Q3wErTyUiOp9AsDf",
  "url": "https://example.com/hooks/enrich",
  "events": ["job.completed", "job.stopped"],
  "secret": "whsec_ExampleSecretShownOnlyOnce0123456"
}
```

Every delivery is a POST with the headers `Enrich-Event`, `Enrich-Delivery` and `Enrich-Signature`. The JSON body holds an event `id`, a `type`, a `created_at` time and the job in `data`. This example is shortened and illustrative.

```
POST /hooks/enrich HTTP/1.1
Enrich-Event: job.completed
Enrich-Delivery: dlv_Zk4Nw8HqPd2Ycx7B
Enrich-Signature: t=1792418561,v1=3f9a…e71c

{"id": "evt_7Lp3Tq9WkXe2RmVa", "type": "job.completed",
 "created_at": "2026-10-19T14:02:41Z",
 "data": {"job": {"id": "job_2xYb9QwErTy8UiOp", "op": "email",
   "status": "done", "total": 3, "done": 3, "found": 2,
   "not_found": 1, "errors": 0, "credits_used": 20}}}
```

### How do you check the signature?

The header reads `Enrich-Signature: t=<unix seconds>,v1=<hex>`. The `v1` value is the HMAC-SHA256 of the timestamp, a dot and the raw body, keyed with your secret. [RFC 2104](https://www.rfc-editor.org/rfc/rfc2104) defines HMAC. Check each delivery in this order.

1. **Read the raw body.** Verify the bytes before a JSON parser touches them. [OWASP's webhook cheat sheet](https://cheatsheetseries.owasp.org/cheatsheets/Webhook_Security_Cheat_Sheet.html) warns that re-serializing JSON or changing whitespace breaks the signature.
2. **Reject old timestamps.** If `t` is more than five minutes from your clock, answer 401. OWASP names a signed timestamp as the defence against replay.
3. **Recompute the digest.** Take the HMAC-SHA256 of `t`, a dot and the raw body with your `whsec_` secret, as hex.
4. **Compare in constant time.** Use [Node's `crypto.timingSafeEqual`](https://nodejs.org/api/crypto.html) or Python's [`hmac.compare_digest`](https://docs.python.org/3/library/hmac.html), never `==`. The [Standard Webhooks specification](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md) gives the same advice.
5. **Answer fast and skip repeats.** Return a 2xx once the work is queued. A retry carries the same `Enrich-Delivery` id, so ignore one you have handled.

This is the whole check in Node with Express. The Python version is in the code panel at the top of the page. The sample compares lengths first, because `timingSafeEqual` throws if its inputs differ in length.

```
import crypto from "node:crypto";
import express from "express";

const SECRET = process.env.ENRICH_WEBHOOK_SECRET;   // the whsec_ value
const app = express();

function verify(rawBody, header, secret, maxAgeSeconds = 300) {
  const parts = {};
  for (const piece of String(header || "").split(",")) {
    const i = piece.indexOf("=");
    if (i > 0) parts[piece.slice(0, i).trim()] = piece.slice(i + 1).trim();
  }
  const t = Number(parts.t);
  if (!Number.isInteger(t) || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > maxAgeSeconds) return false;
  const expected = crypto.createHmac("sha256", secret)
    .update(t + ".")
    .update(rawBody)
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// express.raw keeps the body as the bytes that were signed.
app.post("/hooks/enrich", express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = req.get("Enrich-Signature");
    if (!verify(req.body, signature, SECRET)) return res.sendStatus(401);
    const event = JSON.parse(req.body.toString("utf8"));
    // Queue the work and store req.get("Enrich-Delivery"), then answer.
    res.sendStatus(200);
  });
```

**Figure 2: Checking an Enrich-Signature header.** Three inputs go into the check: the Enrich-Signature header with t and v1, the raw body as received, and your whsec_ secret. The check has three steps: confirm the timestamp is within five minutes, recompute the HMAC-SHA256 of t, a dot and the body as hex, and compare it to v1 in constant time. If all pass, answer 200 and do the work. If any fails, answer 401 and drop the request.

*The signature check. The header, the raw body and your secret go in. Source: the [OWASP Webhook Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Webhook_Security_Cheat_Sheet.html).*

Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, each with a new timestamp and signature. A webhook that fails ten deliveries in a row is switched off. Turn it back on with `POST /v1/webhooks/{id}/enable`. Use `POST /v1/webhooks/{id}/test` to send a signed `webhook.test` event now, before any real job runs.

These headers are not the Standard Webhooks ones. That spec signs a message id, a timestamp and the body, and writes base64. A library built for it will not verify Enrich.chat deliveries as they are, so use the sample above.

## What does a job cost, and when does it stop?

A job spends credits as each lookup runs, at the same prices as a one-off lookup: 10 credits per work email found, 250 per mobile, and 5 per company profile, tech stack or email format. A row that finds nothing uses 0 credits.

The most a job can use is every row finding something. Your real total is lower, because misses are free.

| Job | Most credits it can use | Plan that covers it |
| --- | --- | --- |
| 5,000 work emails | 50,000 | Growth, $29 a month |
| 5,000 company tech stacks | 25,000 | Growth, $29 a month |
| 1,000 people with an email and a mobile | 260,000 | Scale, $90 a month |

Plans bill monthly with unlimited seats. On a team, the owner's plan and its one credit pool pay for jobs that members start, and a viewer cannot start one. See [team workspaces](https://enrich.chat/features/team-workspaces/) and the [pricing page](https://enrich.chat/pricing/).

**Figure 3: The statuses of a background job.** A job starts as queued, then becomes running. It ends in one of three ways. Done means every row has run and a job.completed webhook is sent. Stopped means credits or free lookups ran out, rows not yet run are skipped, and a job.stopped webhook is sent. Cancelled means you cancelled it, and finished rows keep their charge.

*The five statuses of a job. Done and stopped send a webhook. Source: the [Enrich.chat API reference](https://enrich.chat/docs/).*

### What happens when credits run out?

The job stops by itself. Its status becomes `stopped`, and `stop_reason` says why: `credits_exhausted`, `free_limit_reached` or `plan_inactive`. Rows not yet run are marked `skipped`. Finished rows keep their results and their charge. Upgrade the plan or wait for the monthly renewal, then start again with the skipped rows.

## What are the limits of background jobs?

These are the limits to plan around.

- **Webhooks announce finished jobs only.** The events are `job.completed` and `job.stopped`. Nothing announces new matches or changes to a person, because Enrich.chat has no monitoring or alerts.
- **A job runs once.** It does not repeat on a schedule.
- **5,000 rows per job, three jobs at once.** On self-serve plans, split a bigger file. Enterprise is a custom plan with bulk files: write to sales@enrich.chat.
- **Results are kept 30 days.** Then they are deleted, so download the CSV first.
- **A job needs a signed-in account.** The free single lookups do not.
- **No job tool in the [MCP server](https://enrich.chat/features/mcp-server/).** An agent starts a job with plain HTTP.
- **No CRM sync and no sending.** The output is a file or JSON.

## Can an AI agent start a job and wait for the result?

Yes, if it can send HTTP requests and you give it a key. Let the agent poll the job's `status_url` instead of hosting a webhook, and point the webhook at your own small service when you want a push. The [install page](https://enrich.chat/install/) lists the ways to connect an agent, and the [API page](https://enrich.chat/features/api/) lists every endpoint.

```text
Use www.enrich.chat to start a background job that finds work emails for my CSV (first_name, last_name, company_website). Ask me for my API key, send the rows in one POST /v1/jobs call, check the status_url until the job is done or stopped, then give me the CSV and the total credits_used
```

Try one lookup first in the free [email finder](https://enrich.chat/tools/email-finder/), then move to a job when the list grows. The [CRM enrichment page](https://enrich.chat/use-cases/crm-enrichment/) shows where a finished file goes next.

## Frequently asked questions

### What happens to a background job if I close my laptop?

Nothing stops. A job started with `POST /v1/jobs`, or with Run it on the server in Bulk enrich, runs on the Enrich.chat server, so closing the tab or the laptop does not pause it. Credits are spent as each row runs. When the job ends, download the results or let a webhook tell your system. Your jobs are listed in the dashboard at `/app/#/jobs`.

### How do I verify the Enrich-Signature header?

Read the raw request body, split the header into `t` and `v1`, and reject a `t` more than five minutes from your clock. Compute the HMAC-SHA256 of `t`, a dot and the raw body with your `whsec_` secret, as hex. Compare it with `v1` using a constant-time function, such as `crypto.timingSafeEqual` in Node or `hmac.compare_digest` in Python.

### How many rows can one background job hold?

A job holds 1 to 5,000 rows of one kind, and up to three jobs run at once. For a larger file on a self-serve plan, split it into several jobs. Rows and results are kept 30 days, then deleted, so download the CSV before then. Enterprise is a custom plan for bulk files: write to sales@enrich.chat.

### What happens if I run out of credits during a job?

The job stops by itself. Its status becomes `stopped`, and rows not yet run are marked `skipped`. Rows that ran keep their results and their charge, and a `job.stopped` webhook is sent. A row that finds nothing never costs credits. Upgrade the plan or wait for the monthly renewal, then start again with the skipped rows. See the [pricing page](https://enrich.chat/pricing/).

### Can a webhook tell me when a new match appears or a person changes jobs?

No. Enrich.chat webhooks announce finished jobs only: `job.completed` and `job.stopped`. They say nothing about new matches or about people, because Enrich.chat has no monitoring or alerts. A job also runs once and does not repeat on a schedule. To look up the same list again, start another job.

### Who can start a background job?

A job belongs to an account, so you must be signed in. Use Run it on the server in the dashboard, or call the API with an API key or an OAuth token. On a team, members can start jobs, and the owner's plan and credits pay for them. Viewers cannot spend credits. Single lookups on the free tier still work without signing in.

## Sources

1. [Webhook Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Webhook_Security_Cheat_Sheet.html)
2. [Standard Webhooks specification](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md)
3. [HMAC: Keyed-Hashing for Message Authentication (RFC 2104)](https://www.rfc-editor.org/rfc/rfc2104)
4. [Node.js crypto module: crypto.timingSafeEqual](https://nodejs.org/api/crypto.html)
5. [hmac: keyed-hashing for message authentication (Python)](https://docs.python.org/3/library/hmac.html)

## Examples

**Start a job**

```bash
curl -X POST "https://enrich.chat/v1/jobs" \
  -H "Authorization: Bearer $ENRICH_KEY" \
  -H "content-type: application/json" \
  -d '{"op": "email", "label": "Q4 list", "items": [
    {"id": "a", "name": "Priya Nair",
     "domain": "harborline.example.com"},
    {"id": "b", "name": "Felix Brandt",
     "domain": "cedarworks.example.com"},
    {"id": "c", "name": "Ana Costa",
     "domain": "pier.example.com"}]}'

# 202 Accepted (illustrative)
{
  "id": "job_2xYb9QwErTy8UiOp",
  "status": "queued",
  "total": 3,
  "status_url": "https://enrich.chat/v1/jobs/job_2xYb9QwErTy8UiOp"
}
```

**Check the job**

```bash
curl "https://enrich.chat/v1/jobs/job_2xYb9QwErTy8UiOp" \
  -H "Authorization: Bearer $ENRICH_KEY"

{
  "id": "job_2xYb9QwErTy8UiOp",
  "op": "email",
  "status": "done",
  "label": "Q4 list",
  "total": 3,
  "done": 3,
  "found": 2,
  "not_found": 1,
  "errors": 0,
  "credits_used": 20,
  "finished_at": "2026-10-19T14:02:41Z",
  "expires_at": "2026-11-18T14:02:41Z"
}
```

**Results**

```bash
curl "https://enrich.chat/v1/jobs/job_2xYb9QwErTy8UiOp/results"
{
  "job": {"id": "job_2xYb9QwErTy8UiOp", "status": "done"},
  "count": 3,
  "results": [
    {"id": "a", "index": 0, "status": "done",
     "result": {"found": true,
       "work_email": {"value": "priya.nair@harborline.example.com",
                      "status": "verified"},
       "credits_used": 10}},
    {"id": "b", "index": 1, "status": "done",
     "result": {"found": true,
       "work_email": {"value": "felix.brandt@cedarworks.example.com",
                      "status": "catch_all"},
       "credits_used": 10}},
    {"id": "c", "index": 2, "status": "done",
     "result": {"found": false, "credits_used": 0}}
  ]
}
```

**Verify (Python)**

```python
import hashlib, hmac, time

def verify(raw_body, header, secret, max_age=300):
    parts = {}
    for piece in header.split(","):
        key, _, value = piece.strip().partition("=")
        parts[key] = value
    try:
        t = int(parts["t"])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - t) > max_age:  # too old, or in the future
        return False
    signed = str(t).encode() + b"." + raw_body
    mac = hmac.new(secret.encode(), signed, hashlib.sha256)
    expected = mac.hexdigest().encode()
    received = parts.get("v1", "").encode()
    return hmac.compare_digest(expected, received)
```

## Prices

| What you get | Credits |
| --- | --- |
| Work email found | 10 |
| Mobile number found | 250 |
| Company tech stack or profile found | 5 |

10 free lookups a day for every IP address, no signup. Nothing is charged when nothing is found. Monthly plans start at $29 for 100,000 credits.

## Try this prompt

Start a background job from your agent

```text
Use www.enrich.chat to start a background job that finds work emails for my CSV (first_name, last_name, company_website). Ask me for my API key, send the rows in one POST /v1/jobs call, check the status_url until the job is done or stopped, then give me the CSV and the total credits_used
```

## Related

- [Bulk enrichment from a CSV](https://enrich.chat/features/bulk-enrichment/)
- [Contact and company data API](https://enrich.chat/features/api/)
- [Team workspaces](https://enrich.chat/features/team-workspaces/)
