Also available as raw Markdown: tutorial.md. Companion to the OpenAPI reference.
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.
| 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). |
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:
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./dashboard → actionThe 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
Three orthogonal grains of “how done is this?” are exposed without requiring the agent to recompute them from the workflow graph.
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 }
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?”.
curl -H "Authorization: Bearer $TOKEN" \
"$BASE/api/v2/contacts?per_page=100" \
| jq '.data[] | select(.attributes.workflows_status == "pending") | .id'
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.
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.
Idempotent-Replay: true and
Idempotent-Replay-Original-At: <iso8601>.409 idempotency_key_in_progress. Wait briefly and retry.422 idempotency_key_payload_mismatch.400 invalid_idempotency_key.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.
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"
/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.
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:
{
"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).
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.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. |
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" }
]
}
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).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.
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.
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. |
Files are never directly accessible. The pattern is always:
GET /api/v2/<resource>/:id/url returns a short-lived URL
containing a token query param (expires_at 5 min from now).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:
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.GET /api/v2/downloads/:id every 2–5 seconds until status:
"completed" (or "failed" / "expired").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.
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.
These are conscious design choices, not bugs. They may evolve, but your integration should not rely on them changing without notice.
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 machineTags (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.
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.
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.
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.
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.
available_actions / unavailable_actions
on each resource — that’s the source of truth, not the docs.