Also available as raw Markdown: tutorial.md. Companion to the OpenAPI reference.

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:

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

The response tells you:


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.

curl -H "Authorization: Bearer $TOKEN" "$BASE/api/v2/dashboard?per_page=50"
{
  "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:

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
curl -H "Authorization: Bearer $TOKEN" \
  "$BASE/api/v2/contacts/7/workflows/12" \
  | jq '.data.attributes | {progress_percentage, completed_states_count, total_states_count}'
{ "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_statusno_workflow | complete | pending. It’s the cheapest yes/no signal for “should I still chase this contact?”.

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

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.

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.

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:

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:

The response carries:

{
  "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:

{
  "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.reasondisabled / 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.

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

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

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

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:

{
  "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:

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.