# Batch jobs

> For more than one call can carry: many calls to one route from one file you upload, answered off the API’s clock, the result kept 24 hours.

Page: https://payerworks.com/docs/jobs · Markdown: https://payerworks.com/docs/jobs.md

> Payerworks writes and explains files; it sends nothing to a payer. Every file a route writes says `transmitted: false`, and is yours to send on your own connection. A test key is for invented data only, never a real patient.

For more than one call can carry. Make a job naming the one route it runs, upload one file of calls to the signed form it answers, and read the job until it finishes: each call is answered as that route answers it, one after another, off the API’s ten-second clock, and the result is a file you fetch by a signed link. A job runs claim validation, 837P writing, the remittance, eligibility and claim status explanations, analytics, payment variance and claim tracking; POST /v1/eligibility/270 as a job writes one 270 of up to 7,500 questions.

A job is the one part of the API that keeps what you send for a while: your file until the job finishes, then it is deleted, and the result for 24 hours after, in storage of its own that keeps no earlier copy and is in no backup. Batch jobs take test keys only, for invented data, and are off until Payerworks turns them on: until then every jobs route answers 501 jobs_not_offered.

## Routes

- [POST /v1/jobs](#post-v1-jobs): Make a batch job
- [GET /v1/jobs](#get-v1-jobs): Your batch jobs
- [GET /v1/jobs/{id}](#get-v1-jobs-id): One batch job
- [POST /v1/jobs/{id}/cancel](#post-v1-jobs-id-cancel): Cancel a batch job
- [DELETE /v1/jobs/{id}](#delete-v1-jobs-id): Delete a batch job

Each route shows its call with curl and with the two clients, then its real answer, shortened, and the fields it holds. The client calls assume one client made once, `const pw = new Payerworks()` in JavaScript or `pw = payerworks.Payerworks()` in Python, each reading PAYERWORKS_API_KEY and PAYERWORKS_BASE_URL, and `body` is the request.json shown.

### POST /v1/jobs

**Make a batch job.** For more than one call can carry: name the route (route, as POST /v1/claims/validate) and, if every call carries one, the query. You get the job and a signed upload form for its file: { calls: [ ... ] }, each entry exactly the body that route takes in one call, up to 1,000 calls and 50 MB, uploaded within an hour. Each call is answered as the route answers it and held to its limits (2 MB a call; a larger one is answered 413 too_large at its index). A job runs claim validation, 837P writing, remittance, eligibility and claim status explanations, analytics, payment variance and claim tracking. POST /v1/eligibility/270 as a job is different: its file is that route’s own body with up to 7,500 requests, written as one 270. A file whose requests would write more segments than one 270 is read in fails the job job_270_too_many_segments. A job keeps your file until it finishes and its result for 24 hours, which no other route does: use invented data with a test key. Off until Payerworks turns it on; test keys only for now; 50 jobs a UTC day for an account’s keys together. Nothing is sent to a payer.

Request

```bash
curl -sS -X POST "$PAYERWORKS_BASE_URL/v1/jobs" \
  -H "Authorization: Bearer $PAYERWORKS_API_KEY" \
  -H "Content-Type: application/json" \
  -d @request.json
```

With the JavaScript client

```js
const answer = await pw.postJobs(body);
```

With the Python client

```python
answer = pw.post_jobs(body)
```

request.json

```json
{
  "route": "POST /v1/claims/validate"
}
```

The answer (201)

```json
{
  "id": "job_9e6d21650c98b40f5b5848da",
  "object": "job",
  "route": "POST /v1/claims/validate",
  "query": "",
  "mode": "test",
  "status": "awaiting_upload",
  "status_words": "Waiting for the request file: upload it with the signed form in `upload`. Nothing runs until it lands.",
  "created_at": "2026-10-20T19:12:00.142Z",
  "started_at": null,
  "finished_at": null,
  "upload": {
    "method": "POST",
    "url": "https://pw-jobs-example.s3.us-east-1.amazonaws.com/",
    "fields": {
      "key": "requests/pwk_0123456789abcdef01/job_9e6d21650c98b40f5b5848da.json",
      "Content-Type": "application/json",
      "x-amz-server-side-encryption": "aws:kms",
      "x-amz-server-side-encryption-aws-kms-key-id": "arn:aws:kms:us-east-1:111111111111:key/example",
      "x-amz-algorithm": "AWS4-HMAC-SHA256",
      "x-amz-credential": "AKIAEXAMPLE/20261020/us-east-1/s3/aws4_request",
      "x-amz-date": "20261020T191200Z",
      "policy": "eyJleHBpcmF0aW9uIjoiMjAyNi0xMC0yMFQyMDoxMjowMC4xNDJaIiwiY29uZGl0aW9ucyI6W3siYnVja2V0IjoicHctam9icy1leGFtcGxlIn0seyJrZXkiOiJyZXF1ZXN0cy9wd2tfMDEyMzQ1Njc4OWFiY2RlZjAxL2pvYl85ZTZkMjE2NTBjOThiNDBmNWI1ODQ4ZGEuanNvbiJ9LHsiQ29udGVudC1UeXBlIjoiYXBw…",
      "x-amz-signature": "492a2aacfb34613b509d8d879e7e11a35e16289190fffc040a722cebd08ffc59"
    },
    "expires_at": "2026-10-20T20:12:00.142Z",
    "max_bytes": 50000000,
    "content_type": "application/json",
    "file_field": "file"
  },
  "calls": null,
  "result": null,
  "result_kept_until": null,
  "error": null,
  "kept": "A job keeps your request file until the job finishes, then deletes it, and keeps the result for 24 hours after the job finishes, fetched by a signed link; after that the job is gone and the files are deleted from storage in the day or so af…",
  "stored": true,
  "transmitted": false
}
```

Shortened here: lists show their first two items and long strings are cut. The whole answer is this route’s example in [/openapi.json](https://payerworks.com/openapi.json).

| Field | Type | What it is |
| --- | --- | --- |
| `id` | string | job_ and 24 lowercase hexadecimal characters. |
| `object` | the value "job" |  |
| `route` | string |  |
| `query` | string |  |
| `mode` | one of "test", "live" |  |
| `status` | one of "awaiting_upload", "running", "completed", "partial", "failed", "canceled", "expired" |  |
| `status_words` | string |  |
| `created_at` | string |  |
| `started_at` | string or null |  |
| `finished_at` | string or null |  |
| `upload` | JobUpload or null |  |
| `calls` | object or null |  |
| `result` | object or null |  |
| `result_kept_until` | string or null |  |
| `error` | object or null |  |
| `cancel_requested` | boolean | Present while a cancel waits for the run to stop. |
| `kept` | string | A job keeps your request file until the job finishes, then deletes it, and keeps the result for 24 hours after the job finishes, fetched by a signed link; after that the job is gone and the files are deleted from storage in the day or so after. Every other route answers and keeps nothing. |
| `stored` | the value true |  |
| `transmitted` | the value false |  |

### GET /v1/jobs

**Your batch jobs.** This key’s jobs, newest first, at most 100: each one’s route, state and counts. A job whose result’s 24 hours are over is listed as expired, with nothing kept, until its record is removed.

Request

```bash
curl -sS -X GET "$PAYERWORKS_BASE_URL/v1/jobs" \
  -H "Authorization: Bearer $PAYERWORKS_API_KEY"
```

With the JavaScript client

```js
const answer = await pw.getJobs();
```

With the Python client

```python
answer = pw.get_jobs()
```

The answer (200)

```json
{
  "data": [
    {
      "id": "job_9e6d21650c98b40f5b5848da",
      "object": "job",
      "route": "POST /v1/claims/validate",
      "query": "",
      "mode": "test",
      "status": "awaiting_upload",
      "status_words": "Waiting for the request file: upload it with the signed form in `upload`. Nothing runs until it lands.",
      "created_at": "2026-10-20T19:12:00.142Z",
      "started_at": null,
      "finished_at": null,
      "upload": null,
      "calls": null,
      "result": null,
      "result_kept_until": null,
      "error": null,
      "kept": "A job keeps your request file until the job finishes, then deletes it, and keeps the result for 24 hours after the job finishes, fetched by a signed link; after that the job is gone and the files are deleted from storage in the day or so af…",
      "stored": true,
      "transmitted": false
    }
  ],
  "kept": "A job keeps your request file until the job finishes, then deletes it, and keeps the result for 24 hours after the job finishes, fetched by a signed link; after that the job is gone and the files are deleted from storage in the day or so af…"
}
```

Shortened here: lists show their first two items and long strings are cut. The whole answer is this route’s example in [/openapi.json](https://payerworks.com/openapi.json).

| Field | Type | What it is |
| --- | --- | --- |
| `data` | list of Job | At most 100, newest first. |
| `kept` | string | A job keeps your request file until the job finishes, then deletes it, and keeps the result for 24 hours after the job finishes, fetched by a signed link; after that the job is gone and the files are deleted from storage in the day or so after. Every other route answers and keeps nothing. |

### GET /v1/jobs/{id}

**One batch job.** Its state (awaiting_upload, running, completed, partial, failed, canceled or expired), its calls by status, and once it has finished, a link to its result good for 15 minutes; ask again for a new one. The result is { answers: [ { index, status, body } ] }, each answer what that call would have answered. A job that ran out of time is partial: the answered calls are in the result and not_run_from is the first to send again. A job that failed says why in error. A job.completed or job.failed event reaches your webhook and destinations when it ends. A job whose file arrived but could not be started on our side within an hour fails with job_not_started.

Request

```bash
curl -sS -X GET "$PAYERWORKS_BASE_URL/v1/jobs/job_9e6d21650c98b40f5b5848da" \
  -H "Authorization: Bearer $PAYERWORKS_API_KEY"
```

With the JavaScript client

```js
const answer = await pw.getJobsById("job_9e6d21650c98b40f5b5848da");
```

With the Python client

```python
answer = pw.get_jobs_by_id("job_9e6d21650c98b40f5b5848da")
```

The answer (200)

```json
{
  "id": "job_9e6d21650c98b40f5b5848da",
  "object": "job",
  "route": "POST /v1/claims/validate",
  "query": "",
  "mode": "test",
  "status": "awaiting_upload",
  "status_words": "Waiting for the request file: upload it with the signed form in `upload`. Nothing runs until it lands.",
  "created_at": "2026-10-20T19:12:00.142Z",
  "started_at": null,
  "finished_at": null,
  "upload": {
    "method": "POST",
    "url": "https://pw-jobs-example.s3.us-east-1.amazonaws.com/",
    "fields": {
      "key": "requests/pwk_0123456789abcdef01/job_9e6d21650c98b40f5b5848da.json",
      "Content-Type": "application/json",
      "x-amz-server-side-encryption": "aws:kms",
      "x-amz-server-side-encryption-aws-kms-key-id": "arn:aws:kms:us-east-1:111111111111:key/example",
      "x-amz-algorithm": "AWS4-HMAC-SHA256",
      "x-amz-credential": "AKIAEXAMPLE/20261020/us-east-1/s3/aws4_request",
      "x-amz-date": "20261020T191200Z",
      "policy": "eyJleHBpcmF0aW9uIjoiMjAyNi0xMC0yMFQyMDoxMTo1OS4xNDdaIiwiY29uZGl0aW9ucyI6W3siYnVja2V0IjoicHctam9icy1leGFtcGxlIn0seyJrZXkiOiJyZXF1ZXN0cy9wd2tfMDEyMzQ1Njc4OWFiY2RlZjAxL2pvYl85ZTZkMjE2NTBjOThiNDBmNWI1ODQ4ZGEuanNvbiJ9LHsiQ29udGVudC1UeXBlIjoiYXBw…",
      "x-amz-signature": "2270f9df8728a7b0c33005d9d2100aa75795c1fb302c710b879b83442299f4a6"
    },
    "expires_at": "2026-10-20T20:11:59.147Z",
    "max_bytes": 50000000,
    "content_type": "application/json",
    "file_field": "file"
  },
  "calls": null,
  "result": null,
  "result_kept_until": null,
  "error": null,
  "kept": "A job keeps your request file until the job finishes, then deletes it, and keeps the result for 24 hours after the job finishes, fetched by a signed link; after that the job is gone and the files are deleted from storage in the day or so af…",
  "stored": true,
  "transmitted": false
}
```

Shortened here: lists show their first two items and long strings are cut. The whole answer is this route’s example in [/openapi.json](https://payerworks.com/openapi.json).

| Field | Type | What it is |
| --- | --- | --- |
| `id` | string | job_ and 24 lowercase hexadecimal characters. |
| `object` | the value "job" |  |
| `route` | string |  |
| `query` | string |  |
| `mode` | one of "test", "live" |  |
| `status` | one of "awaiting_upload", "running", "completed", "partial", "failed", "canceled", "expired" |  |
| `status_words` | string |  |
| `created_at` | string |  |
| `started_at` | string or null |  |
| `finished_at` | string or null |  |
| `upload` | JobUpload or null |  |
| `calls` | object or null |  |
| `result` | object or null |  |
| `result_kept_until` | string or null |  |
| `error` | object or null |  |
| `cancel_requested` | boolean | Present while a cancel waits for the run to stop. |
| `kept` | string | A job keeps your request file until the job finishes, then deletes it, and keeps the result for 24 hours after the job finishes, fetched by a signed link; after that the job is gone and the files are deleted from storage in the day or so after. Every other route answers and keeps nothing. |
| `stored` | the value true |  |
| `transmitted` | the value false |  |

### POST /v1/jobs/{id}/cancel

**Cancel a batch job.** Before its file is uploaded, nothing will run. While it runs, it stops between two calls and keeps no result. A finished job cannot be canceled: delete it.

Request

```bash
curl -sS -X POST "$PAYERWORKS_BASE_URL/v1/jobs/job_9e6d21650c98b40f5b5848da/cancel" \
  -H "Authorization: Bearer $PAYERWORKS_API_KEY"
```

With the JavaScript client

```js
const answer = await pw.postJobsByIdCancel("job_9e6d21650c98b40f5b5848da");
```

With the Python client

```python
answer = pw.post_jobs_by_id_cancel("job_9e6d21650c98b40f5b5848da")
```

The answer (200)

```json
{
  "id": "job_9e6d21650c98b40f5b5848da",
  "object": "job",
  "route": "POST /v1/claims/validate",
  "query": "",
  "mode": "test",
  "status": "canceled",
  "status_words": "Canceled: nothing more runs and no result is kept.",
  "created_at": "2026-10-20T19:12:00.142Z",
  "started_at": null,
  "finished_at": "2026-10-20T19:12:00.150Z",
  "upload": null,
  "calls": null,
  "result": null,
  "result_kept_until": null,
  "error": null,
  "kept": "A job keeps your request file until the job finishes, then deletes it, and keeps the result for 24 hours after the job finishes, fetched by a signed link; after that the job is gone and the files are deleted from storage in the day or so af…",
  "stored": true,
  "transmitted": false
}
```

Shortened here: lists show their first two items and long strings are cut. The whole answer is this route’s example in [/openapi.json](https://payerworks.com/openapi.json).

| Field | Type | What it is |
| --- | --- | --- |
| `id` | string | job_ and 24 lowercase hexadecimal characters. |
| `object` | the value "job" |  |
| `route` | string |  |
| `query` | string |  |
| `mode` | one of "test", "live" |  |
| `status` | one of "awaiting_upload", "running", "completed", "partial", "failed", "canceled", "expired" |  |
| `status_words` | string |  |
| `created_at` | string |  |
| `started_at` | string or null |  |
| `finished_at` | string or null |  |
| `upload` | JobUpload or null |  |
| `calls` | object or null |  |
| `result` | object or null |  |
| `result_kept_until` | string or null |  |
| `error` | object or null |  |
| `cancel_requested` | boolean | Present while a cancel waits for the run to stop. |
| `kept` | string | A job keeps your request file until the job finishes, then deletes it, and keeps the result for 24 hours after the job finishes, fetched by a signed link; after that the job is gone and the files are deleted from storage in the day or so after. Every other route answers and keeps nothing. |
| `stored` | the value true |  |
| `transmitted` | the value false |  |

### DELETE /v1/jobs/{id}

**Delete a batch job.** Its file, its result and the job itself, now rather than after 24 hours. A running job must be canceled first.

Request

```bash
curl -sS -X DELETE "$PAYERWORKS_BASE_URL/v1/jobs/job_9e6d21650c98b40f5b5848da" \
  -H "Authorization: Bearer $PAYERWORKS_API_KEY"
```

With the JavaScript client

```js
const answer = await pw.deleteJobsById("job_9e6d21650c98b40f5b5848da");
```

With the Python client

```python
answer = pw.delete_jobs_by_id("job_9e6d21650c98b40f5b5848da")
```

The answer (200)

```json
{
  "id": "job_9e6d21650c98b40f5b5848da",
  "deleted": true,
  "kept": "A job keeps your request file until the job finishes, then deletes it, and keeps the result for 24 hours after the job finishes, fetched by a signed link; after that the job is gone and the files are deleted from storage in the day or so af…"
}
```

Shortened here: lists show their first two items and long strings are cut. The whole answer is this route’s example in [/openapi.json](https://payerworks.com/openapi.json).

| Field | Type | What it is |
| --- | --- | --- |
| `id` | string |  |
| `deleted` | the value true |  |
| `kept` | string | A job keeps your request file until the job finishes, then deletes it, and keeps the result for 24 hours after the job finishes, fetched by a signed link; after that the job is gone and the files are deleted from storage in the day or so after. Every other route answers and keeps nothing. |

## Reading the answer

- A job’s `status` is one of awaiting_upload, running, completed, partial, failed, canceled or expired, with its `status_words`. `upload` is the signed form while the upload window is open; post every field as given, then the file under `file_field`. Once it closes, a job still awaiting_upload says so in its `status_words`, and is called expired only 65 minutes after the window closed, because a file that landed in time may still be waiting to start.
- Once it has finished, `calls` counts the answers by status and `result.url` is a link good for 15 minutes; ask again for a new one. The result is `{ answers: [ { index, status, body } ] }`, each `body` what that call would have answered.
- A job that ran out of time is `partial`: the answered calls are in the result, and `not_run_from` is the first call to send again. A failed job says why in `error.code`.
- A `job.completed` or `job.failed` event reaches your webhook and destinations when the job ends. It carries the job’s id, route, state and counts, never the result or its link.

## Troubleshooting

- `jobs_not_offered` (501): Batch jobs are off on this host until Payerworks turns them on. A 501 is never retried by the clients: it will not pass by waiting.
- `jobs_live_not_offered` (501): A job keeps your file and its result for a while, so it takes test keys only for now: use a test key, with invented data.
- `invalid_job_route` (400): A job runs one of the routes listed in the message. A store route, a sandbox, a PDF or a model read is never a job.
- `job_running` (409): A running job cannot be deleted: cancel it, then delete it once it says canceled.
- `jobs_daily_limit` (429): An account’s keys together may make 50 jobs a UTC day.
- One call is never made larger by a job: a call over 2 MB is answered 413 too_large at its index in the result. Split the file, or send many files as many calls.

Every refusal these routes may answer, with its status. Any route may also answer the codes every route shares, on [Every error](https://payerworks.com/docs/errors).

| Code | Status | What it means |
| --- | --- | --- |
| `invalid_job` | 400 | A job is { route, query? }: route the one route it runs, query the query string every call carries (no leading ?), or left out. |
| `invalid_job_id` | 400 | A job id is job_ and 24 letters or digits, as POST /v1/jobs answered it. |
| `invalid_job_route` | 400 | route names the one route the job runs, as "POST /v1/claims/validate". A job runs: POST /v1/claims/validate, POST /v1/claims/837p, POST /v1/remittances/explain, POST /v1/eligibility/explain, POST /v1/claim-status/explain, POST /v1/analytics/summary, POST /v1/remittances/variance, POST /v1/claims/track, POST /v1/eligibility/270. |
| `invalid_query` | 400 | A query parameter's name or value is over 200 characters, or holds a percent escape that does not decode. |
| `job_not_found` | 404 | No job of this key has that id. A finished job is kept 24 hours. |
| `job_not_cancellable` | 409 | This job has already finished, so there is nothing to cancel: DELETE it to forget it now. |
| `job_running` | 409 | This job is running: cancel it first (POST /v1/jobs/{id}/cancel), then delete it once it says canceled. |
| `jobs_daily_limit` | 429 | This account's keys have made the most jobs an account may make today (50 a UTC day, its keys together). Try again tomorrow. |
| `jobs_live_not_offered` | 501 | Batch jobs answer test keys only for now, because a job keeps the request and its result for a while: use a test key, with invented data. |
| `jobs_not_offered` | 501 | Batch jobs are not offered on this host: Payerworks turns them on when they are ready. This is not a passing fault, so sending the request again answers the same. |
| `store_unavailable` | 503 | We could not read or write this key's records just now; the message says what was not done. Retry in a moment. |

## Code lists

### Why a job failed (error.code)

| Code | What it means |
| --- | --- |
| `job_file_not_json` | The uploaded file is not JSON. |
| `job_file_shape` | The uploaded file is not this route's job: send { "calls": [ <body>, ... ] }, each body what the route takes in one call (for POST /v1/eligibility/270, the route's own body with its `requests`). |
| `job_too_many_calls` | The uploaded file holds more calls than one job runs: at most 1,000. Split it into more jobs. |
| `job_too_many_requests` | The batch 270 holds more requests than one file is written from: at most 7,500. |
| `job_file_too_large` | The uploaded file is larger than a job reads: at most 50,000,000 bytes. |
| `job_key_not_usable` | The key that made this job was revoked, expired or replaced before the job ran, so nothing was run. |
| `job_stopped` | The run stopped before it finished (on our side): nothing was kept. Make the job again. |
| `job_internal` | Something failed on our side while the job ran: nothing was kept. Make the job again; if it keeps failing, write to us with the job id. |
| `job_result_too_large` | Not even the first answer fits a job's result (250,000,000 bytes): send less in each call. |
| `job_270_too_many_segments` | The batch 270 written from these requests holds more segments than one 270 is read in (`limit_segments`, our own reader's bound): nothing was kept. Send fewer requests in each job, or fewer procedures in each request. |
| `job_not_started` | Your file arrived, but the job could not be started on our side within 60 minutes of its arrival: nothing was run and the file was deleted. Make the job again. |

## Example

[jobs.mjs](https://payerworks.com/docs/examples/jobs.mjs) runs every step below against the API with a test key. It needs [payerworks.mjs](https://payerworks.com/sdk/payerworks.mjs) and [sample-data.mjs](https://payerworks.com/docs/examples/sample-data.mjs) beside it.

jobs.mjs

```js
// Payerworks example: a batch job, many calls to one route from one file. Docs: https://payerworks.com/docs/jobs
//
// Run it with Node 18 or later, with the JavaScript client (https://payerworks.com/sdk/payerworks.mjs)
// and sample-data.mjs beside this file:
//   PAYERWORKS_API_KEY=pw_test_... PAYERWORKS_BASE_URL=<the base URL shown with your key> node jobs.mjs
// A test key and invented data only. A job keeps its file until it finishes and its result for 24 hours.
// Payerworks sends nothing to a payer: every file it writes is yours.
import { pathToFileURL } from 'node:url';
import { Payerworks, PayerworksError } from './payerworks.mjs';
import { CLAIM, copy, refusalOf } from './sample-data.mjs';

/** @param {Payerworks} pw */
export async function run(pw) {
  // Batch jobs are off until Payerworks turns them on. Where they are not, every jobs route answers
  // 501 jobs_not_offered (never retried: it will not pass by waiting), and each call is the same
  // work sent to its own route.
  let job = null;
  const off = await refusalOf(async () => { job = await pw.postJobs({ route: 'POST /v1/claims/validate' }); }, { PayerworksError });
  if (off?.code === 'jobs_not_offered') return { offered: false, refused: off };
  if (off) return { offered: null, refused: off };
  // Where they are on: one file of calls, each exactly the body POST /v1/claims/validate takes.
  const read = await pw.uploadJobFile(job, { calls: [{ claim: copy(CLAIM) }, { claim: copy(CLAIM) }] });
  // The failure path: a route a job never runs.
  const refused = await refusalOf(() => pw.postJobs({ route: 'POST /v1/remittances/from-image' }), { PayerworksError });
  return { offered: true, status: read.status, kept: read.kept, refused };
}

// Run as a script: node <this file>, with PAYERWORKS_API_KEY and PAYERWORKS_BASE_URL set.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  console.log(JSON.stringify(await run(new Payerworks()), null, 2));
}
```

What it answered, run against the API in Payerworks’ own tests (your request ids will differ)

```json
{
  "offered": false,
  "refused": {
    "status": 501,
    "code": "jobs_not_offered",
    "request_id": "req_1"
  }
}
```
