# Superdocu API v2 — tutorial

This walkthrough takes an agent (or a human) from "I have a JWT token"
to "I have validated a contact's dossier end to end". Every command is
copy-pasteable, every response shape is real, and every error code
mentioned here also appears in the OpenAPI reference.

> **Format toggle.** This file is published as Markdown alongside the
> OpenAPI spec. Tools that render the spec usually render this file as
> HTML; agents that fetch it directly receive raw Markdown. Both views
> are authoritative.

---

## 1. Vocabulary in one minute

| Term | What it means in the API |
|---|---|
| **contact** | The business user's end-customer (the person who uploads documents). |
| **consumer** | The business admin acting on the API (the agent's principal). |
| **workflow** | A collection process — a sequence of states each holding step_requests. |
| **step_request** | One slot inside a workflow — either a form input *or* a documents_group. |
| **documents_group** | A bucket of files for the same logical document type. |
| **shared_document** | A document the consumer pushes to the contact (not a request). |

---

## 2. Authentication

Every request carries:

```
Authorization: Bearer <jwt>
Accept: application/vnd.api+json
```

Tokens are issued from the admin UI and can be revoked there at any
time. Tokens minted for the Zapier integration are rejected on v2
(403 `zapier_token_not_allowed`) — generate a standard API token
instead.

The first call should always be:

```bash
curl -H "Authorization: Bearer $TOKEN" "$BASE/api/v2/me"
```

The response tells you:

- `data.attributes.permissions` — the list of permission keys this
  token can wield (e.g. `validate_documents`, `see_all_contacts`,
  `manage_contacts`). If you need one that's absent, stop early.
- `data.attributes.company` — id, name, slug, default locale.
- `data.attributes.token.expires_at` — when to rotate.

---

## 3. The agent loop: `/dashboard` → action

The dashboard is the canonical entry point for backlog work. It returns
stats plus a paginated list of `pending_action` items each describing
exactly one decision the consumer has to make.

```bash
curl -H "Authorization: Bearer $TOKEN" "$BASE/api/v2/dashboard?per_page=50"
```

```json
{
  "data": {
    "type": "dashboard",
    "attributes": {
      "stats": {
        "contacts_total": 47,
        "contacts_enabled": 45,
        "contacts_disabled": 2,
        "documents_waiting_validation": 12,
        "documents_approved": 230,
        "step_requests_waiting_validation": 5,
        "workflows_active": 38
      }
    },
    "relationships": {
      "pending_actions": { "data": [ /* ids */ ] }
    }
  },
  "included": [
    {
      "id": "step_request:42:validate_form_answer",
      "type": "pending_action",
      "attributes": {
        "action_type": "validate_form_answer",
        "resource_type": "step_request",
        "resource_id": "42",
        "contact_id": "7",
        "contact_first_name": "Jean",
        "contact_last_name": "Dupont",
        "workflow_id": "12",
        "state_id": "3",
        "label": "IBAN",
        "priority_reason": "recently_updated",
        "updated_at": "2026-05-20T08:00:00.000000Z",
        "value": "FR76 4061 8410 0047",
        "input_kind": "text"
      }
    }
  ],
  "meta": { "has_more": true, "next_cursor": "..." }
}
```

Three `action_type` values, each carries enough context to decide
without a follow-up `GET show`:

| `action_type` | `resource_type` | Extra attributes | Endpoints to call |
|---|---|---|---|
| `validate_form_answer` | `step_request` | `value`, `input_kind`, `input_schema` (radio_matrix / multiple_choices / select only) | `POST /step_requests/:id/approve`, `/reject`, `PATCH /step_requests/:id` |
| `validate_document` | `documents_group` | `docs_count`, `validation_instructions` (≤240 chars), `required` | `POST /documents_groups/:id/approve`, `/request`, or per-document `POST /documents/:id/{approve,reject}` |
| `review_not_applicable` | `documents_group` | `not_applicable_reason` | `POST /documents_groups/:id/not_applicable/{approve,refuse}` |

Sort order: `[updated_at DESC, resource_type ASC, resource_id DESC]`.
Walk with `?after=<meta.next_cursor>` until `meta.has_more=false`.

DocuSign step_requests and Screen step_requests are **excluded** from
pending_actions — they're either non-actionable from the consumer side
(DocuSign) or display-only (Screen).

A minimal triage loop in pseudo-code:

```python
me = GET /api/v2/me
assert "validate_documents" in me.permissions

cursor = None
while True:
    page = GET f"/api/v2/dashboard?per_page=100&after={cursor or ''}"
    for pa in page.included:
        rt = pa.attributes.resource_type
        rid = pa.attributes.resource_id
        at = pa.attributes.action_type

        if at == "validate_form_answer":
            sr = GET f"/api/v2/step_requests/{rid}"
            decision = llm_decide(sr)
            if decision.action == "approve":
                POST f"/api/v2/step_requests/{rid}/approve"
            elif decision.action == "patch":
                PATCH f"/api/v2/step_requests/{rid}" {value: decision.value}
            elif decision.action == "reject":
                POST f"/api/v2/step_requests/{rid}/reject" {rejection_message: decision.reason}

        elif at == "validate_document":
            dg = GET f"/api/v2/documents_groups/{rid}"
            for doc in dg.documents:
                url_resp = GET f"/api/v2/documents/{doc.id}/url"
                analysis = llm_analyze(download(url_resp.data.attributes.url))
                if analysis.valid:
                    POST f"/api/v2/documents/{doc.id}/approve"
                else:
                    POST f"/api/v2/documents/{doc.id}/reject" {rejection_message: analysis.reason}

        elif at == "review_not_applicable":
            dg = GET f"/api/v2/documents_groups/{rid}"
            if llm_says_legitimate_na(dg):
                POST f"/api/v2/documents_groups/{rid}/not_applicable/approve"
            else:
                POST f"/api/v2/documents_groups/{rid}/not_applicable/refuse"

    if not page.meta.has_more: break
    cursor = page.meta.next_cursor
```

---

## 3.bis. Tracking completion (contact, contact_workflow, workflow template)

Three orthogonal grains of "how done is this?" are exposed without
requiring the agent to recompute them from the workflow graph.

### Per contact_workflow

`GET /api/v2/contacts/:id/workflows` and
`GET /api/v2/contacts/:id/workflows/:wid` both serialize:

| Attribute | Type | Meaning |
|---|---|---|
| `progress_percentage` | integer 0..100 | Cached server-side; refreshed when documents or form answers change |
| `completed_states_count` | integer | states fully completed |
| `total_states_count` | integer | states in the workflow |

```bash
curl -H "Authorization: Bearer $TOKEN" \
  "$BASE/api/v2/contacts/7/workflows/12" \
  | jq '.data.attributes | {progress_percentage, completed_states_count, total_states_count}'
```

```json
{ "progress_percentage": 75, "completed_states_count": 3, "total_states_count": 4 }
```

### Per contact

`GET /api/v2/contacts/:id` (and any `data: { type: contact }` payload)
ships `workflows_status` ∈ `no_workflow` | `complete` | `pending`. It's
the cheapest yes/no signal for "should I still chase this contact?".

```bash
curl -H "Authorization: Bearer $TOKEN" \
  "$BASE/api/v2/contacts?per_page=100" \
  | jq '.data[] | select(.attributes.workflows_status == "pending") | .id'
```

### Per workflow template — opt-in only

`GET /api/v2/workflows/:id?include_stats=1` returns 4 aggregates over
all contact_workflows instancing the template:

| Attribute | Type | Meaning |
|---|---|---|
| `assigned_contacts_count` | integer | total contact_workflows instantiated |
| `completed_contacts_count` | integer | those at 100% completion |
| `pending_contacts_count` | integer | incomplete AND not archived |
| `completion_rate` | float 0..100 | `completed / total * 100`, 2 decimals |

```bash
curl -H "Authorization: Bearer $TOKEN" \
  "$BASE/api/v2/workflows/42?include_stats=1" \
  | jq '.data.attributes | {completion_rate, assigned_contacts_count,
                            completed_contacts_count, pending_contacts_count}'
```

```json
{
  "completion_rate": 62.50,
  "assigned_contacts_count": 24,
  "completed_contacts_count": 15,
  "pending_contacts_count": 9
}
```

Without `include_stats=1`, the response keeps the same shape as the
list endpoint (`name`, `repeatable`, `enable`) and skips the aggregates
entirely. The opt-in matters because computing the four numbers is
expensive on templates with thousands of assigned contacts — expect
seconds of latency, not milliseconds. Pay it only when you actually
need the numbers (typically once per dashboard refresh, not per
polling loop).

On the list endpoint (`GET /api/v2/workflows`), the aggregates are
**always absent** by design — computing them for every template at
once would not scale, even with the opt-in.

To enumerate the *individual* contacts that are still pending on a
template, combine the two coarser endpoints:

```python
templates = GET "/api/v2/workflows?per_page=100"
target = next(w for w in templates.data if w.attributes.name == "Onboarding")
detail = GET f"/api/v2/workflows/{target.id}"
print(detail.data.attributes.pending_contacts_count, "contacts still pending")

cursor = None
laggards = []
while True:
    page = GET f"/api/v2/contacts?per_page=100&after={cursor or ''}"
    for c in page.data:
        if c.attributes.workflows_status != "pending":
            continue
        cws = GET f"/api/v2/contacts/{c.id}/workflows?per_page=50"
        for cw in cws.data:
            if cw.attributes.workflow_id == int(target.id) \
               and cw.attributes.progress_percentage < 100:
                laggards.append((c.id, cw.attributes.progress_percentage))
    if not page.meta.has_more: break
    cursor = page.meta.next_cursor
```

`workflows_status == "pending"` is the cheap short-circuit; the
contact_workflows-level call is only paid for contacts that need it.

---

## 4. Mutating safely: Idempotency-Key

`POST`, `PATCH` and `DELETE` accept an optional `Idempotency-Key`
header holding a **UUID v4**. The key is scoped by token + method +
path and cached for 48 hours.

- Same key + same payload → server replays the cached response. The
  reply carries `Idempotent-Replay: true` and
  `Idempotent-Replay-Original-At: <iso8601>`.
- Same key + same payload while the first request is still in-flight →
  `409 idempotency_key_in_progress`. Wait briefly and retry.
- Same key + different payload → `422 idempotency_key_payload_mismatch`.
- Non-UUID v4 → `400 invalid_idempotency_key`.
- **No header sent** → the server generates a UUID, runs the action,
  and returns the key in the `Idempotent-Generated-Key` response
  header. Send it back as `Idempotency-Key` on a later call to replay
  the same response.

Generate a fresh UUID per logical operation. Reuse it across retries
of the *same* operation; that's the whole point. Letting the server
generate one is fine for fire-and-forget calls — keep the response
header value if you may ever need to retry.

```bash
curl -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/vnd.api+json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"data":{"type":"step_request","attributes":{"rejection_message":"IBAN invalide"}}}' \
  "$BASE/api/v2/step_requests/603/reject"
```

---

## 5. Permissions in one paragraph

`/me.attributes.permissions` lists the permission keys this token is
granted. The common ones an agent will look for:

- `see_all_contacts` — read every contact in the company (without it,
  list endpoints are scoped to "assigned" contacts only).
- `manage_contacts` — create, update, invite, enable/disable contacts;
  manage tags; import/export CSV.
- `validate_documents` — approve / reject documents and step_requests,
  request more, mark not applicable.
- `manage_workflows`, `manage_company`, `manage_billing`,
  `manage_roles`, `manage_portals`, `manage_developer_settings`,
  `manage_integrations`, `see_stats` — admin-side things the agent
  rarely needs.

A missing permission for a given endpoint returns
`403 permission_denied` with `detail` naming the permission that was
checked.

---

## 6. Pagination

Index endpoints accept three query params:

- `?per_page=<1-100>` — default 25.
- `?after=<cursor>` — forward pagination.
- `?before=<cursor>` — backward pagination (mutually exclusive with `after`).

The response carries:

```json
{
  "data": [ /* … */ ],
  "meta": {
    "has_more": true,
    "next_cursor": "eyJ1cGRhdGVkX2F0Ijoi…",
    "prev_cursor": null,
    "api_version": "2.0"
  }
}
```

`meta.api_version` is stamped on every v2 response (also exposed as
the `X-API-Version` header). Cursors are opaque base64url; never assume
they encode anything in particular. Pass them back unchanged. A
malformed or expired cursor returns `400 invalid_cursor`.

The default sort is `[updated_at DESC, id DESC]`. A few endpoints
override it (e.g. `/shared_documents` uses `position ASC`).

---

## 7. Errors at a glance

Every error response follows JSON:API:

```json
{
  "errors": [
    {
      "status": "422",
      "code": "<stable_code>",
      "title": "Unprocessable content",
      "detail": "<human-readable>",
      "source": { "pointer": "/data/attributes/<field>" },
      "meta": { /* optional, code-specific */ }
    }
  ]
}
```

`code` is the canonical key — keys are stable; `detail` may be
rephrased over time. The OpenAPI reference documents every code that
can appear for each HTTP status.

Codes worth knowing:

| Code | Status | Meaning |
|---|---|---|
| `missing_token` / `invalid_token` / `revoked_token` / `zapier_token_not_allowed` | 401/403 | Auth failed. |
| `permission_denied` | 403 | The token lacks the required permission for this endpoint. |
| `not_found` | 404 | Resource missing or outside this token's scope. `meta: {resource, id}`. |
| `parameter_missing` | 400 | JSON:API root key missing in the body. |
| `missing_required_attributes` | 422 | `meta.missing` lists the keys. |
| `missing_idempotency_key` / `invalid_idempotency_key` / `idempotency_key_payload_mismatch` / `idempotency_key_in_progress` | 400/422/409 | Idempotency contract. |
| `invalid_cursor` | 400 | `?after=` or `?before=` is unreadable. |
| `not_approveable` / `not_rejectable` / `status_must_be_*` / `not_a_form_input` | 422 | State machine refuses the transition. Re-GET the resource and read `available_actions`. |
| `no_request_to_invite` | 422 | Contact has no workflow/documents_group assigned. `meta.next_action: assign_workflow`. |
| `not_reminderable` | 422 | `meta.reason` ∈ `disabled` / `not_invited` / `no_pending_items`. |
| `workflow_already_assigned` | 409 | Non-repeatable workflow already assigned to the contact. |
| `disabled_workflow` | 422 | Tried to assign a workflow whose `enable: false`. |
| `active_contacts_limit_reached` | 422 | Plan quota reached on enable. |
| `rate_limited` | 429 | Slow down. See section 9. |
| `download_not_completed` / `file_token_invalid` / `file_token_expired` | 422/403/410 | File access bookkeeping. |

---

## 8. Discovering what's possible: `available_actions`

Don't hardcode state machines on the client. Every resource embeds two arrays in its `attributes` telling you what you can — and cannot — do right now, given the token's permissions and the resource's current state.

```json
{
  "available_actions": [
    {
      "name": "approve",
      "method": "POST",
      "href": "/api/v2/documents_groups/42/approve",
      "required_attributes": []
    },
    {
      "name": "upload",
      "method": "POST",
      "href": "/api/v2/documents_groups/42/documents",
      "required_attributes": ["files[]"]
    }
  ],
  "unavailable_actions": [
    { "name": "mark_not_applicable", "reason": "required_document" },
    { "name": "reject", "reason": "not_rejectable" }
  ]
}
```

### How to read it

- `available_actions[]` — the action is callable right now. Each entry is a complete recipe:
  - `name` — canonical action key (`approve`, `reject`, `upload`, `mark_not_applicable`, …).
  - `method` + `href` — fire as-is. The `href` already has `:id` substituted; just prepend `$BASE` and add headers.
  - `required_attributes` — keys the request body must carry. Empty array means no body needed beyond JSON:API envelope.
- `unavailable_actions[]` — the action exists for this resource type but is blocked. `reason` is the same coded key you would receive as the error `code` if you forced the call (e.g. `not_approveable`, `status_must_be_waiting_for_validation`, `permission_denied`).

### Why this matters

`available_actions` / `unavailable_actions` and the server's own
permission + state-machine checks share the same source of truth:
whatever the response advertises as available is exactly what the API
will accept right now, and whatever it lists as unavailable would be
refused with that same `reason` as the error `code`. An agent that
consults `available_actions` before every mutation never sees a "the
docs said I could" surprise.

### Practical loop

```python
resource = GET f"/api/v2/{type}/{id}"
actions = {a["name"]: a for a in resource["data"]["attributes"]["available_actions"]}

if "approve" in actions:
    a = actions["approve"]
    call(a["method"], a["href"])  # no need to reconstruct the URL
else:
    blocked = next((u for u in resource["data"]["attributes"]["unavailable_actions"]
                    if u["name"] == "approve"), None)
    log(f"cannot approve: {blocked['reason'] if blocked else 'action not defined'}")
```

If `reason` is `permission_denied`, the token is missing a permission — escalate, don't retry. If it's a state-machine code (`not_approveable`, `status_must_be_*`), the resource has to transition first; re-`GET` after the transition and re-check.

---

## 9. Rate limiting

Throttles are per-token. The 429 response carries `Retry-After` plus
`X-RateLimit-Limit` / `X-RateLimit-Remaining` / `X-RateLimit-Reset`.
The same three headers are stamped on every successful response so an
agent can pace itself proactively:

```
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 547
X-RateLimit-Reset: 1716227160
```

Throttle buckets:

| Bucket | Limit | Scope |
|---|---|---|
| `api_v2_reads` | 600 / min | All GETs (except file streams). |
| `api_v2_writes_general` | 200 / min | Most POST/PATCH/DELETE. |
| `api_v2_writes_email` | 30 / min | Anything that sends mail (`/invite`, `/reject`, `/request`, `/reminders`). |
| `api_v2_downloads_imports` | 10 / min | `/contacts/:id/download`, `/workflows/:id/download`, `/contacts/import`. |
| `api_v2_file_tokens` | 1000 / min | `/file?token=…` streams. |

---

## 10. File access flow

Files are never directly accessible. The pattern is always:

1. `GET /api/v2/<resource>/:id/url` returns a short-lived URL
   containing a `token` query param (`expires_at` 5 min from now).
2. `GET <url>` streams the binary. Bearer auth is still required.

If the link expires, just request a fresh `/url`.

For ZIP exports (whole dossier or one workflow), the flow is async:

1. `POST /api/v2/contacts/:id/download` (or `…/workflows/:id/download`)
   returns `202 Accepted` with `data.attributes.status: "processing"`
   and `poll_url` / `id`. Small dossiers may already report
   `"completed"` on the first poll.
2. Poll `GET /api/v2/downloads/:id` every 2–5 seconds until `status:
   "completed"` (or `"failed"` / `"expired"`).
3. Download via `data.attributes.url` while `expires_at` is still in
   the future.

Precondition: the contact must have at least one approved document,
otherwise the POST returns `422 no_downloadable_documents`.

---

## 11. CSV import / export

```bash
curl -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -F "file=@contacts.csv;type=text/csv" \
  "$BASE/api/v2/contacts/import"
```

The CSV header is `email,first_name,last_name` plus optional `phone`,
`company_name`, `notes`, `tags`. The response shape:

```json
{
  "data": {
    "type": "import_result",
    "attributes": {
      "success": true,
      "created": 12,
      "errors": 0,
      "contact_ids": [101, 102, /* … */],
      "error_details": [],
      "warning_message": null
    }
  }
}
```

When `errors > 0`, `error_details` lists `{row_number, email, field,
message}` entries. The HTTP status stays `200` for partial successes
and switches to `422` for total failures (zero created). The agent
should always inspect `success` and `error_details`, not just the
status code.

Export:

```bash
curl -H "Authorization: Bearer $TOKEN" \
  "$BASE/api/v2/contacts/export?filter[status]=critical&filter[tag_id]=1" \
  -o critical.csv
```

The export is a streamed CSV (`Content-Type: text/csv; charset=utf-8`)
beginning with a UTF-8 BOM (Excel-friendly). Export columns include
tracking params, status, assigned consumer — the import accepts a
subset, so the round-trip is intentionally lossy.

---

## 12. Known limitations

These are conscious design choices, not bugs. They may evolve, but
your integration should not rely on them changing without notice.

### Permissions are static — assigned-only consumers cannot read

The v2 API is permission-driven: each endpoint declares which
permission(s) it requires (`see_all_contacts`, `manage_contacts`,
`validate_documents`, …), and a token either has it or hits
`403 permission_denied` at the gate.

Internally, Superdocu also supports a per-consumer "assignment"
model where the back-office admin can give a human consumer access
to specific contacts without granting them company-wide read rights.
That model is honored by the web UI; it is **not** honored by the
v2 API. A token whose role has neither `see_all_contacts` nor
`manage_contacts` — even when the underlying consumer has assigned
contacts — returns 403 on `GET /contacts`, `GET /contacts/:id`,
`GET /contacts/export`, etc.

If you need API access for an assigned-only consumer, issue them a
role that includes at least `see_all_contacts` (read) or
`manage_contacts` (read + mutate). Either satisfies the OR-rule on
the read endpoints.

### `available_actions` only covers resources with a state machine

Tags (`POST /api/v2/contacts/tags`, `DELETE /api/v2/contacts/tags/:id`)
and workflow templates (`GET /api/v2/workflows`,
`GET /api/v2/workflows/:id`) do not emit `available_actions` /
`unavailable_actions` arrays. Their CRUD is "always allowed if you
have the permission" — there is no state machine to advertise.

Practically this means an agent cannot use `available_actions` to
preflight a tag delete or a workflow listing. Use the permission
matrix (`GET /api/v2/me`) instead: if the token has
`manage_contacts`, tag CRUD is available; reading workflow
templates requires `see_all_contacts` or `manage_contacts`.

### No bulk endpoints

Every mutating endpoint is per-record. You cannot
`PATCH /contacts` with an array of contact IDs and a shared diff;
you have to loop one PATCH per contact. Same for tag application,
workflow assignment, document approval, etc. Combined with the
per-token rate buckets (200 writes/min general, 30 writes/min on
email-sending paths), large batch operations need pacing.

The CSV import endpoint (`POST /api/v2/contacts/import`) is the
only bulk-create surface; it is intentionally limited to contact
creation.

### Mutations are blocked while the company is in build mode

When a company is still in **build mode** (the trial-ish state
before subscribing) **and** has no active subscription, every
mutating endpoint of the v2 API returns:

```
HTTP/1.1 403 Forbidden
Content-Type: application/vnd.api+json

{
  "errors": [
    {
      "status": "403",
      "code": "build_mode_active",
      "detail": "Mutations are blocked while the company is in build mode without an active subscription."
    }
  ]
}
```

Reads (GET / HEAD) keep working in build mode — only state-changing
verbs (POST / PUT / PATCH / DELETE) are gated. This matches the
behaviour of the web UI and the v1 API, where the same lockout
prevents trial-stage companies from running real operations before
paying.

To unblock the API, the company admin must either pick a paid plan
or, for legacy / sandbox cases, have the build_mode flag cleared
from the Superdocu admin side. There is no API call to escape build
mode by yourself.

### Webhooks exist, but are managed outside the v2 API

Superdocu has a webhook delivery system covering 11 event types
(documents_group lifecycle, contact lifecycle, contact_workflow
lifecycle, step_request lifecycle). When the underlying state
changes — whether the trigger was the web UI, the v1 API, or the
v2 API — every subscribed endpoint receives an HTTP POST signed
with an HMAC-SHA256 of `verify_token + timestamp`. So a document
approved via `POST /api/v2/documents/:id/approve` fires
`step_request_approved` (and related) webhooks just like the web
UI would.

The v2 API does **not** expose CRUD for webhook subscriptions:
configure them via the Superdocu admin UI ("Developers" →
"Webhooks") and they will then react to v2 traffic transparently.

If you cannot receive inbound webhooks (firewalled outbound-only
environment), the dashboard polling pattern stays available:
`GET /api/v2/dashboard` is designed for exactly that workload, and
the per-token rate buckets (600 reads/min) accommodate polling
every 5–10s comfortably.

### CSV import vs. export round-trip is intentionally lossy

`GET /api/v2/contacts/export` returns more columns than
`POST /api/v2/contacts/import` accepts (tracking params, computed
status, assigned consumer). Round-tripping a contact through
export then import loses every column the import does not
recognise. This is by design — the export is a snapshot for
spreadsheets and audits, not a backup format.

---

## 13. Where to read next

- The full operation list is rendered as Swagger UI at
  [https://developers.superdocu.com/api](https://developers.superdocu.com/api).
  Every endpoint there links back to the vocabulary, error codes and
  idempotency rules above.
- For state machines and precondition wording (which action transitions
  to which status), inspect `available_actions` / `unavailable_actions`
  on each resource — that's the source of truth, not the docs.
