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.

View this page as Markdown

Operations: Tasks endpoints · Files endpoints.

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), 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.

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

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" } }'
{
  "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

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

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.