# Background processing and callbacks

> Run parse, evaluate, summary, transcribe and translate as background tasks. Get a task_id at once, then poll for the result or receive it by signed callback.

**Source:** /docs/background-processing/ (Curriculo Docs)  
**Updated:** 12 October 2026  
**Contact:** hello@curriculo.me

Parsing a backlog of CVs or analysing an hour-long interview should not hold a connection open. The async endpoints accept links or uploads, answer immediately with a `task_id`, and run the work on our side. Collect the result by polling, or have it delivered to your server.

## Async endpoints

| Endpoint | Input | Result |
|---|---|---|
| `POST /parse/async` | Up to 25 CV links (`file_urls`), or uploaded `files` | One parse result per CV, in order, same shape as `POST /parse/` |
| `POST /evaluate/async` | Up to 25 CV links (`resume_urls`), or uploaded `resume_pdfs`, plus `job_details` or `job_id` | One evaluation per CV, in order, same shape as `POST /evaluate/` |
| `POST /summary/async` | A recording link (`media_url`), or one uploaded `media` file; optional `interview_id` and `language` | `transcript_text`, `transcript_entries` and `summary` |
| `POST /transcribe/async` | A recording link (`media_url`), or one uploaded `media` file | `transcript_text` and `transcript_entries` |
| `POST /translate/async` | `text`, `texts` or document links (`file_urls`), or uploaded `files`; `source_language` and `target_language` | Same shape as `POST /translate/` |

Every async request may also include a `callback_url`.

| Polling endpoint | Returns |
|---|---|
| `GET /{op}/tasks/{task_id}` | The task's status and, once finished, its result. `{op}` is `parse`, `evaluate`, `summary`, `transcribe` or `translate`. |

## Sending a task

Send `application/json` with a list of links, or `multipart/form-data` with uploads. In multipart, a link field can be repeated or sent one link per line.

```bash
curl -X POST https://api.curriculo.me/public/parse/async \
  -H 'Authorization: Bearer key_abc123_yoursecret' \
  -H 'Content-Type: application/json' \
  -d '{"file_urls": ["https://example.com/cvs/candidate_a.pdf", "https://example.com/cvs/candidate_b.pdf"], "callback_url": "https://example.com/curriculo/callback"}'
```

```bash
curl -X POST https://api.curriculo.me/public/summary/async \
  -H 'Authorization: Bearer key_abc123_yoursecret' \
  -H 'Content-Type: application/json' \
  -d '{"media_url": "https://example.com/interviews/interview.mp4", "interview_id": "int_0042", "language": "ko"}'
```

```bash
curl -X POST https://api.curriculo.me/public/translate/async \
  -H 'Authorization: Bearer key_abc123_yoursecret' \
  -H 'Content-Type: application/json' \
  -d '{"texts": ["Senior backend engineer", "Remote within Europe"], "source_language": "en", "target_language": "fr"}'
```

> [!NOTE]
> For JSON requests to `/translate/async`, `source_language` is required. For `/evaluate/async`, send the job as `job_details` or `job_id`.

The API answers at once with `202`:

```json
{ "task_id": "…", "status": "queued", "callback_secret": "whsec_…" }
```

`callback_secret` is returned only when you sent a `callback_url`. It is shown exactly once, like an API key, so store it.

## Getting the result

### Polling

```bash
curl https://api.curriculo.me/public/parse/tasks/<task_id> \
  -H 'Authorization: Bearer key_abc123_yoursecret'
```

A task is readable with the same key that created it, whether or not a callback was delivered. The response never includes the callback URL or secret.

| Field | Meaning |
|---|---|
| `task_id` | The task's id. |
| `op` | Which operation the task runs. |
| `status` | `queued`, `processing`, `succeeded` or `failed`. |
| `status_code` | The HTTP status the operation itself produced, once finished. |
| `result` | The operation's response body once `status` is `succeeded`; `null` before. |
| `error` | The error message when the task failed. |
| `delivery` | Callback delivery status: `not_requested`, `pending`, `delivered` or `dead`, with the attempt count. |
| `created_at`, `completed_at` | Timestamps. |

### Callbacks

If you sent a `callback_url`, the finished task is POSTed there as `application/json` with these headers:

| Header | Value |
|---|---|
| `X-Curriculo-Signature` | `sha256=<hex>`: the lowercase hex HMAC-SHA256 of the exact raw request body, keyed with your `callback_secret` |
| `X-Curriculo-Task-Id` | The task's id |
| `Content-Type` | `application/json` |

The body is `{task_id, op, status, status_code, result, error, completed_at}`.

There is no timestamp header, so de-duplicate deliveries on `task_id`: the same task can arrive more than once if your server answered slowly or with an error.

## Verify the signature

Compute the HMAC-SHA256 of the raw request body, using your `callback_secret` exactly as returned (including the `whsec_` prefix) as the key. Compare `sha256=` plus the lowercase hex digest with `X-Curriculo-Signature` in constant time. Use the raw bytes as received, before any JSON parsing, and reject anything that does not match.

Python:

```python
import hashlib
import hmac

def is_valid(raw_body: bytes, signature_header: str, callback_secret: str) -> bool:
    # callback_secret is used as-is, including the "whsec_" prefix
    expected = "sha256=" + hmac.new(
        callback_secret.encode(), raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature_header or "")
```

JavaScript (Node.js):

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

// rawBody must be the unparsed request body (a Buffer)
export function isValid(rawBody, signatureHeader, callbackSecret) {
  const expected =
    "sha256=" +
    crypto.createHmac("sha256", callbackSecret).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signatureHeader || "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

Then de-duplicate on the `X-Curriculo-Task-Id` header (or `task_id` in the body) and answer `2xx`.

## Delivery and retries

- Any non-`2xx` answer counts as a failed delivery, and redirects are not followed.
- Delivery retries with increasing delays (1, 5, 15 and 60 minutes, then 4 hours), for about 5 hours 21 minutes in total, before the delivery is marked `dead`.
- The result stays readable at `GET /{op}/tasks/{task_id}` either way, so nothing is lost to a missed delivery.

## Requirements for URLs

> [!WARNING]
> Callback URLs must be `https`, publicly reachable, and carry no credentials. Addresses on private networks are refused.

- Links you send (`file_urls`, `resume_urls`, `media_url`) must be public `https` links. A public link or a pre-signed storage URL (for example S3) both work.
- Links that redirect are refused.
- Recordings by link can be up to 1 GB and 2 hours. Uploaded files are limited to 12 MB each.

## Billing

- A task is billed once, at its final outcome, as one request of its operation: an async parse of 25 links costs $0.01.
- A task that fails (a dead link, an unreadable file) is never charged.
- Recording tasks (`/summary/async`, `/transcribe/async`) reserve the charge for the 2-hour maximum when the task is accepted. Without enough credit for that reservation, the request returns `400`. The reservation is released if the task fails.

See [Pricing and billing](/docs/pricing/).

## Data kept by tasks

Unlike the synchronous operations, a background task keeps its result so you can poll for it, and uploaded files are stored under your account. See [Data handling](/docs/data-handling/).
