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

# Track a Bulk Job

> Poll a submitted bulk operation for progress, read per-target results, and download the result file.

`submitBulkOperation` returns immediately with a `jobId`. Everything after that is polling: **`getBulkOperationJob`** for the job's counters, **`getBulkOperationItems`** for the per-target outcomes.

Completion is polling-only — there are no bulk webhooks.

## Requirements

* `Authorization: Basic <API_KEY>` (your application API key)
* The **bulk API capability** on your application
* The job must belong to your application — another app's job id returns not-found, identical to an unknown id

## Which one to call

| You want                                       | Use                                                        |
| ---------------------------------------------- | ---------------------------------------------------------- |
| Is it done? How many succeeded?                | `getBulkOperationJob` — one row read, cheap enough to poll |
| Which targets failed, and why                  | `getBulkOperationItems(status: FAILED)`                    |
| The Fluz resource a write produced             | `getBulkOperationItems` → `resultResourceId`               |
| The export file, or a whole-job results ledger | `getBulkOperationJob` → `resultUrl`                        |

`getBulkOperationJob` reads only the job row — no per-item scan — so it's the one to poll on an interval. Reach for `getBulkOperationItems` once the job is terminal, or when you specifically need per-target detail.

## Poll the job

```graphql theme={null}
query TrackBulkJob($bulkJobId: UUID!) {
  getBulkOperationJob(bulkJobId: $bulkJobId) {
    jobId
    status
    operation
    targetMode
    requestedTargetCount
    acceptedItemCount
    skippedItemCount
    succeededItemCount
    failedItemCount
    createdAt
  }
}
```

```json theme={null}
{
  "data": {
    "getBulkOperationJob": {
      "jobId": "9f53f64d-898d-45cc-b7fb-890935cca664",
      "status": "COMPLETED_WITH_ERRORS",
      "operation": "CREATE_TRANSFER",
      "targetMode": "SELECTED",
      "requestedTargetCount": 250,
      "acceptedItemCount": 240,
      "skippedItemCount": 10,
      "succeededItemCount": 236,
      "failedItemCount": 4,
      "createdAt": "2026-08-05T14:12:27.556Z"
    }
  }
}
```

### Job fields

| Field                  | Type                 | Meaning                                                                                  |
| ---------------------- | -------------------- | ---------------------------------------------------------------------------------------- |
| `jobId`                | `UUID!`              | Poll with this; also the key for results and cancellation.                               |
| `status`               | `BulkJobStatus!`     | See below.                                                                               |
| `operation`            | `BulkOperationType!` | Echoes what you submitted.                                                               |
| `targetMode`           | `BulkTargetMode!`    | `SELECTED` or `ALL_CONNECTED`.                                                           |
| `requestedTargetCount` | `Int!`               | Resolved targets the job addresses — one item per target.                                |
| `acceptedItemCount`    | `Int!`               | Items that will actually be processed.                                                   |
| `skippedItemCount`     | `Int!`               | Resolved targets rejected **at submission**, before any work — typically missing scopes. |
| `succeededItemCount`   | `Int!`               | Items finished successfully.                                                             |
| `failedItemCount`      | `Int!`               | Items that ran and failed.                                                               |
| `createdAt`            | `DateTime!`          | Submission time.                                                                         |
| `resultUrl`            | `String`             | Pre-signed download. See [Results file](#results-file).                                  |
| `resultUrlExpiresAt`   | `DateTime`           | When the current `resultUrl` stops working; `null` whenever `resultUrl` is.              |

**`skipped` and `failed` are not the same thing.** Skipped items never ran — they were rejected at submission and consumed no downstream call. Failed items ran and something went wrong. Reconciling a batch means reading both.

The counters relate as `requestedTargetCount = acceptedItemCount + skippedItemCount`, and once terminal, `acceptedItemCount = succeededItemCount + failedItemCount`.

### Job status

| Status                  | Meaning                                                                                                                        |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `QUEUED`                | Accepted, not started.                                                                                                         |
| `RUNNING`               | At least one item is in flight.                                                                                                |
| `COMPLETED`             | Every accepted item succeeded.                                                                                                 |
| `COMPLETED_WITH_ERRORS` | Finished, but at least one item failed. **This is a normal outcome, not an error** — one target's failure never fails the job. |
| `FAILED`                | The job as a whole could not be processed.                                                                                     |
| `CANCELLED`             | Cancelled via `cancelBulkOperation`.                                                                                           |

Terminal statuses are `COMPLETED`, `COMPLETED_WITH_ERRORS`, `FAILED` and `CANCELLED`. Stop polling on any of them.

<Note>
  `UPDATE_TRANSACTION_METADATA` executes **synchronously**. Its job is already terminal when `submitBulkOperation` returns, so there is nothing to poll — go straight to `getBulkOperationItems` for the per-edit results.
</Note>

## Read per-target results

```graphql theme={null}
query BulkJobItems($bulkJobId: UUID!, $status: BulkItemStatus, $limit: Int, $after: String) {
  getBulkOperationItems(bulkJobId: $bulkJobId, status: $status, limit: $limit, after: $after) {
    items {
      itemIndex
      externalReferenceId
      accountId
      status
      resultResourceId
      errorCode
      errorMessage
    }
    totalCount
    hasNextPage
    nextCursor
  }
}
```

```json theme={null}
{
  "bulkJobId": "9f53f64d-898d-45cc-b7fb-890935cca664",
  "status": "FAILED",
  "limit": 100,
  "after": null
}
```

### Arguments

| Argument    | Type             | Description                                                                 |
| ----------- | ---------------- | --------------------------------------------------------------------------- |
| `bulkJobId` | `UUID!`          | The job to read.                                                            |
| `status`    | `BulkItemStatus` | Filter to one status. Omit for all items.                                   |
| `limit`     | `Int`            | Items per page. Default and maximum are both `100`.                         |
| `after`     | `String`         | Opaque cursor from a previous page's `nextCursor`. Omit for the first page. |

### Item fields

| Field                 | Type                        | Meaning                                                                               |
| --------------------- | --------------------------- | ------------------------------------------------------------------------------------- |
| `itemIndex`           | `Int!`                      | Position within the job, matching submission order.                                   |
| `externalReferenceId` | `String`                    | Your reference id for the target, when the grant has one.                             |
| `accountId`           | `UUID`                      | The target's Fluz account id.                                                         |
| `status`              | `BulkItemStatus!`           | `QUEUED`, `RUNNING`, `SUCCEEDED`, `FAILED`, `SKIPPED`, `CANCELLED`.                   |
| `resultResourceId`    | `String`                    | The Fluz resource this item produced — e.g. a transfer id. Present on success.        |
| `errorCode`           | `String`                    | Machine-readable reason when `FAILED` or `SKIPPED`.                                   |
| `errorMessage`        | `String`                    | Human-readable detail.                                                                |
| `metadataResults`     | `[BulkMetadataEditResult!]` | Per-edit results for `UPDATE_TRANSACTION_METADATA`. `null` for every other operation. |

**`totalCount` is the job's unfiltered item total**, read from the job's own counters — *not* a count of the filtered page. Filtering to `FAILED` on a 240-item job still reports `totalCount: 240`. Page with `hasNextPage`/`nextCursor`; don't infer the end from `totalCount`.

`itemIndex` is the stable join key back to your submission: item *n* corresponds to the *n*-th entry in the `items` array you submitted, so you can map a failure to the exact transfer or deposit you asked for even when `externalReferenceId` is absent.

### Reconciling a batch

```graphql theme={null}
# Everything that ran and failed.
{ getBulkOperationItems(bulkJobId: "…", status: FAILED, limit: 100) {
    items { itemIndex externalReferenceId errorCode errorMessage }
    hasNextPage nextCursor
} }

# Everything rejected before it ran — usually a scope or permission problem on your side.
{ getBulkOperationItems(bulkJobId: "…", status: SKIPPED, limit: 100) {
    items { itemIndex externalReferenceId errorCode errorMessage }
    hasNextPage nextCursor
} }

# Map successes back to Fluz resources.
{ getBulkOperationItems(bulkJobId: "…", status: SUCCEEDED, limit: 100) {
    items { itemIndex externalReferenceId resultResourceId }
    hasNextPage nextCursor
} }
```

### Metadata edit results

`UPDATE_TRANSACTION_METADATA` reports per **edit**, not just per target — one item per target user, each carrying a `metadataResults` entry for every transaction it tried to change:

```graphql theme={null}
{ getBulkOperationItems(bulkJobId: "…") {
    items {
      externalReferenceId
      status
      metadataResults { recordId success errorCode errorMessage }
    }
} }
```

An item can be `SUCCEEDED` while individual edits inside it failed, so check `metadataResults[].success` rather than the item status alone.

## Results file

For read exports (`GET_TRANSACTIONS_EXPORT`, `GET_BALANCES_EXPORT`) the output is an **NDJSON file**. For the write operations (`CREATE_TRANSFER`, `DEPOSIT_CASH_BALANCE`) it's a per-target results ledger — one row per target with its status, `resultResourceId` and any error.

```graphql theme={null}
{ getBulkOperationJob(bulkJobId: "…") { status resultUrl resultUrlExpiresAt } }
```

Four things to know:

* **`resultUrl` is minted only when you select it.** Polling `status` without it stays cheap; the URL is signed on request.
* **It's `null` until the job is terminal**, and stays `null` for jobs that produced no file — for example when every target was denied.
* **It expires shortly after issue.** Check `resultUrlExpiresAt`; to get a fresh URL, just select `resultUrl` again.
* Neither Fluz's API nor your integration is in the download path — the URL points straight at storage.

## Cancelling

```graphql theme={null}
mutation { cancelBulkOperation(bulkJobId: "…") { status succeededItemCount failedItemCount skippedItemCount } }
```

Best-effort and irreversible. Items not yet started are cancelled; **items already running are allowed to finish and record their results**, so counters can still move after you cancel. Safe to call more than once — an already-finished or already-cancelled job comes back unchanged rather than erroring.

## Errors

| Condition                                                 | `extensions.code`   | `extensions.errorName` | `statusCode` |
| --------------------------------------------------------- | ------------------- | ---------------------- | ------------ |
| Unknown job id, or a job belonging to another application | `APPLICATIONS-0006` | `BulkJobNotFound`      | 404          |
| Bulk API not enabled for your application                 | `APPLICATIONS-0003` | `BulkApiAccessDenied`  | 403          |
| Missing credentials, or a non-Basic scheme                | `AUTH-0002`         | `InvalidCredentials`   | 401          |

An id that belongs to another application returns the **same** not-found response as an id that doesn't exist — job ids are not enumerable across applications.

Branch on `extensions.code`, not on message text.

## Polling guidance

Poll `getBulkOperationJob` — it reads one row and never scans items. A few seconds between polls is reasonable for small jobs; back off for large ones, since a 10,000-item job is paced by queue throughput, not by how often you ask.

Stop on any terminal status. Then:

1. `COMPLETED` — nothing to reconcile.
2. `COMPLETED_WITH_ERRORS` — read `status: FAILED` items, and `SKIPPED` too if `skippedItemCount > 0`.
3. `FAILED` or `CANCELLED` — read the items to see how far it got before stopping.

For exports, select `resultUrl` only once the status is terminal; before that it's always `null`.
