# Tasks, approvals & files

> Complete, reopen, approve and reject tasks under the engine's gates, and attach files to File fields with a two-phase upload.

Source: https://developers.processcart.com/guides/tasks-approvals-files

Operations: [Tasks endpoints](https://developers.processcart.com/reference/tasks) · [Files endpoints](https://developers.processcart.com/reference/files).

## Tasks

A run's tasks are in `tasks[]` on the Run (and at `GET /v1/runs/{id}/tasks`). Each has a stable `key` from the template, a `kind` (`standard`, `approval` or `email`), a `state` (`todo`, `done`, `rejected_redo`) and the engine's current `visible` and `locked` verdicts. Every action is `POST /v1/runs/{id}/tasks/{key}/<action>`, honors `Expected-Revision`, answers the Run with its new `revision` ([Concurrency](https://developers.processcart.com/guides/concurrency)), and is idempotent where it can be.

| Action | Scope | What happens |
| --- | --- | --- |
| `complete` | `runs:write` | Marks a standard task done after the engine's gates: the task must be visible and unlocked, and its required fields satisfied (every offender is listed in `errors[]` with `code: "required"`, 422 `required_fields_unsatisfied`). A task already done answers 200 unchanged. An approval task cannot be completed this way (409 `wrong_task_kind`); an email task with no send configuration is 409 `task_not_configured`. |
| `reopen` | `runs:write` (+ `approvals:decide` for an approval) | Puts a done task back to to-do; a completed run becomes active again. A task that is not done answers 200 unchanged. |
| `approve` | `approvals:decide` | Records an approval with an optional `comment` (send `{}` when there is nothing to add). An approval already approved is refused (409 `task_completed`) until it is reopened. |
| `reject` | `approvals:decide` | Records a rejection — the `comment` is **required** (1–5,000 characters) — and sends the configured earlier tasks back for redo (`rejected_redo`). A duplicate rejection answers 200 unchanged. An approval already approved is refused (409 `task_completed`) until it is reopened. |

Gates the actions share: on `complete`, `approve` and `reject`, a hidden task is 409 `task_hidden` and a task behind an incomplete stop task or a sent-back approval is 409 `task_locked` (`reopen` has neither gate, so a hidden or locked done task can still be reopened); on every action, an archived run is 409 `run_archived`, a run in Trash is 409 `run_trashed` and a key missing the scope is 403.

### Approvals and the key's authority

In the app an approval is decided only by its assigned approvers. An API key is never an assigned approver, so the **`approvals:decide` scope** is what grants it the authority — a key that holds it decides as an Admin would. `runs:write` alone cannot approve, reject or reopen an approval (403 `approver_only`). Grant the scope deliberately, to the integration that genuinely decides.

When an approval's reviewed values change after it was approved, the approval is voided and must be decided again — the same rule as in the app. A field write that voids an approval records it in the activity trail.

```bash
curl -X POST "https://app.processcart.com/api/v1/runs/{id}/tasks/review/approve" \
  -H "Authorization: Bearer pc_live_…" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1042-approve" \
  -d '{ "comment": "Looks right." }'
```

### Email tasks

An email task's send happens in the app (its own send flow, or a mail client for `mailto:` tasks); the API does not send email. `complete` on an email task records the "mark as sent" confirmation — the task is done, nothing is sent — under the same gates as a standard task. An email task with no send configuration cannot be completed at all (409 `task_not_configured`).

## Files
Files live on **File fields**. Because a single request to the API is capped at 1 MiB, an upload is a three-step flow: ask for a ticket, PUT the bytes straight to storage, then finalize. Files on comments are app-only; a key can list and download them but cannot add or remove them.

### 1. Request an upload ticket

```bash
curl -X POST https://app.processcart.com/api/v1/runs/{id}/files \
  -H "Authorization: Bearer pc_live_…" \
  -H "Content-Type: application/json" \
  -d '{ "filename": "contract.pdf", "size": 184233, "contentType": "application/pdf",
        "target": { "kind": "field", "fieldKey": "contract" } }'
```

```json
{
  "attachmentId": "7d0e…",
  "upload": {
    "method": "PUT",
    "url": "https://…/storage/v1/object/upload/sign/…",
    "headers": { "Content-Type": "application/pdf" },
    "expiresAt": "2026-10-07T20:42:11.000Z"
  },
  "finalize": {
    "method": "POST",
    "url": "https://app.processcart.com/api/v1/runs/{id}/files/7d0e…/finalize",
    "body": { "filename": "contract.pdf", "target": { "kind": "field", "fieldKey": "contract" } }
  }
}
```

The answer is **200** (nothing exists yet — an abandoned ticket leaves no trace on the run). `size` is at most **25 MB**; `contentType` becomes the stored type. A hidden field (422 `field_not_writable`, `errors[].code` `hidden`), a non-File field or a comment target is refused. The upload URL is a credential: never log it. It is valid **two hours**; an `Idempotency-Key` replay past that hands back the expired URL — request a new ticket under a new key.

### 2. PUT the bytes

```bash
curl -X PUT "<upload.url>" \
  -H "Content-Type: application/pdf" \
  --data-binary @contract.pdf
```

Send exactly the headers in `upload.headers` and **no other credential** (no API key, no bearer). Finalized bytes are immutable: a second PUT to the same URL is refused — storage answers **HTTP 400** with a JSON body like `{ "statusCode": "409", "error": "Duplicate", "code": "KeyAlreadyExists" }`, and the stored bytes stay the first upload's. **Key on the body's `code` (`KeyAlreadyExists`), not the HTTP status**: it means the bytes are already there — go on to finalize. Any other refusal (an expired or altered URL) means: request a new ticket.

### 3. Finalize

```bash
curl -X POST "https://app.processcart.com/api/v1/runs/{id}/files/7d0e…/finalize" \
  -H "Authorization: Bearer pc_live_…" \
  -H "Content-Type: application/json" \
  -H "Expected-Revision: 7" \
  -d '{ "filename": "contract.pdf", "target": { "kind": "field", "fieldKey": "contract" } }'
```

Send the **same `filename` and `target`** as the ticket. The server verifies the stored object (its size and type are read from storage, never from you) and writes the file into the field under the run lock: a single-file field replaces its current file, a multi-file field appends. The answer is **200 `{ attachment, run }`** — the one composite response in the API — with the Run's new revision in `run.revision`. Before the bytes arrived it is 409 `upload_incomplete`; a stored object that is empty or over 25 MB is 413 `payload_too_large` (`errors[].code` `too_large` on `size`) and is removed; a field of a completed task is 409 `task_completed` — reopen the task first; finalizing the same ticket twice is a no-op. An `attachment.created` event fires.

### Reading and removing

- `GET /v1/runs/{id}/files` lists every attachment (field files and comment files), oldest first, without download links; a bounded list that takes no query parameters (an unknown one is a 422).
- `GET /v1/runs/{id}/files/{attachmentId}` returns the attachment with a signed **`downloadUrl` valid one hour** (`Content-Disposition: attachment`). Mint a fresh one per download rather than storing it.
- `DELETE /v1/runs/{id}/files/{attachmentId}` removes a **field** file (the field's value drops it under the run lock; the required-field gate reacts like any other change) and the stored object; 204 with no body (read the run for its new revision), `Expected-Revision` honored. A file in a field of a completed task is 409 `task_completed` (reopen the task first). A comment's file cannot be removed through the API (403 `not_allowed_for_key`).

Every file write — a ticket, finalize, remove — is refused on an archived run (409 `run_archived`) and on a run in Trash (409 `run_trashed`).

The Attachment resource: `{ id, runId, taskKey, fieldKey, commentId, filename, size, contentType, uploadedByUserId, uploadedBy, createdAt }` — `uploadedBy` is an actor (`user`, `api`, `guest` or `unknown`), `fieldKey` is null for a comment's file and `commentId` null for a field's.
