Skip to content
AXOWLDevelopers
Quickstart

Teams & reporting

A team is a Connected ID of type Group (see Connected ID). Reporting structure is modeled as edges between Connected IDs, not columns — three relationship types: member_of (team membership), reporting (reports-to), reference (dotted-line).

Every route on this page needs a signed-in member of the organization. Where a scope is listed, a caller without it gets 403.

Method · RouteScopePurpose
GET /—List teams
POST /org.member.team_createCreate a team. Body: name, kind?, leadConnectedId?, parentTeamId? (teams nest). Returns { teamId }. 403 with error = "UPGRADE_REQUIRED", feature = "teams" at the plan’s team limit.
DELETE /{teamId}org.member.team_createDelete a team
PUT /{teamId}/leadorg.member.team_lead_setSet or clear the team lead ({ leadConnectedId }, null clears). Re-syncs every member’s automatic reporting line (member → lead) and emits reporting.team.lead_changed
GET /{teamId}/members—Team members (?includeSubTeams=true)
POST /{teamId}/membersorg.member.team_member_addAdd a member. Body: connectedIdId, resolution?. Returns edgeId, needsResolution, leadConnectedId, leadName, existingManagers
DELETE /{teamId}/members/{memberId}org.member.team_member_removeRemove a member

When a member is added to a team with a lead, an automatic reporting edge (member → lead) is created. If the member already has a manager, the response carries needsResolution + existingManagers; the client calls again with resolution = add / replace / none.

The Board of Directors is not managed here: these routes answer 400 for the board group. Use the board routes.

Method · RouteScopePurpose
GET /api/org/{slug}/reporting/graph—The full tree — people + teams + all three edge types
POST /api/org/{slug}/relationshipsorg.member.relationship.addAdd a reporting or reference edge. Body: sourceConnectedId, targetConnectedId, type
DELETE /api/org/{slug}/relationships?sourceId=&targetId=&type=org.member.relationship.addRemove an edge

member_of edges are managed through the team-members endpoints above; /relationships accepts reporting and reference only and answers 400 for any other type. Edges are many-to-many and nestable. Related: Members, Roles & permissions.

A member’s Reporting Manager is simply the target of a reporting edge (member → manager). It is created three ways, all producing the same edge:

  • Automatically from a team lead — adding a member to a team that has a lead creates a reporting edge (member → lead). Such edges remember which team they came from, so that later team changes (member moved or removed, lead changed) re-sync only the automatic edges and never touch manually-set ones. If the member already has a manager, the add returns needsResolution and the client picks add / replace / none.
  • On invite — the “Reporting Manager (optional)” dropdown on Invite Member seeds the edge when the invitee is accepted.
  • Manually — the member kebab → “Reporting Manager” (multi-select, with cycle detection) or a drag on the tree, via POST /relationships.

The reporting tree these edges form is what the approval line walks up to resolve role columns (below).

Board of Directors — /api/org/{slug}/board

Section titled “Board of Directors — /api/org/{slug}/board”

The board is one group per organization that reuses the team machinery: its member_of edges are the director roster (and the voting register), and its lead is the chairman. A sole proprietorship cannot have a board. The board group is excluded from team lists, plan team limits, and automatic reporting lines.

Method · RouteScopePurpose
GET /—Board + roster. { exists, boardId, name, chairmanConnectedId, members }; members carry sharePercent (shareholding %, for minutes). { exists: false } when there is no board
POST /org.member.team_createCreate the board (one per org; also created automatically when a Director invitation is accepted). 400 for a sole proprietorship; 409 if a board exists
POST /membersorg.member.team_member_addAppoint a director (connectedIdId) — personal seal required
POST /members/{memberId}/removeorg.member.team_member_removeRemove a director — personal seal required
PUT /chairmanorg.member.team_lead_setSet or clear the chairman (leadConnectedId, null clears) — personal seal required

Every composition change is a governance boundary and requires a fresh personal-seal (passkey) step-up, sent as sealAssertionJson: purpose board.member.change, subject {slug}|{targetConnectedId}|{appoint|remove|chairman}. The three change routes answer 404 when the organization has no board yet.

Appointing via invitation is the primary path: invite a member with the Director or Representative Director role (see Members → Director invitations). The seal is collected when the invite is sent; on acceptance the member is seated automatically (with optional chairman designation and shareholding %).

Board meetings (POST /api/org/{slug}/meetings with Type = BoardMeeting) require the board to exist and to have a chairman — otherwise the create fails with BOARD_REQUIRED / BOARD_CHAIRMAN_REQUIRED. See Meetings.

Approval line — /api/org/{slug}/approvals/work-items/{id}/approval-line

Section titled “Approval line — /api/org/{slug}/approvals/work-items/{id}/approval-line”

Per-Form columns of the approval seal grid. Configured in the Create Form wizard (Step 2) — the config lives on the Form because a Form already is a Role and carries its own routing.

Forms come in three kinds, and the edit dialog shows all three:

docTypeStampsGrants on approval
permission — Access Approvalyesthe linked role’s scopes (or linkedPermissionScope)
document — Document Approvalyesnothing
report — Reportingno — sent to recipientsnothing
Method · RoutePurpose
GET /api/org/{slug}/approvals/work-items/{id}/approval-lineOrdered positions for this Form. Empty → the Form defines no positions of its own; approvers are then taken from the requester’s managers (up the reporting tree) who hold the requested permission.
PUT /api/org/{slug}/approvals/work-items/{id}/approval-lineReplace all positions ({ positions: [...] }). Returns { count }. Allowed for the Owner, an Admin, or the member who created the Form (the standard forms shared by every organization: Owner or Admin only); anyone else gets 403 with errorCode = "PERMISSION_DENIED", and an invalid request or a Form outside the org gets 400 with the reason in error. Emits reporting.approvalline.changed (RPT038).
GET /api/org/{slug}/approvals/work-items/{id}/resolve-linePreview — resolves the line for the current requester and returns { approverCount }, without changing anything. approverCount is null when the Form has no positions.
GET /api/org/{slug}/reporting/default-lineThe default line used by forms with no positions of their own: { workItemId, positions[] }.

Each position has per-locale labels (labelKo/labelEn/labelJa) and a resolve type — how it maps to a person when an approval is submitted:

resolveTypeResolves to
drafterThe requester — no approval step
roleNearest holder of resolveRoleId up the requester’s reporting tree; falls back to resolveMemberConnectedId if no one up the tree holds the role
memberA fixed approver (resolveMemberConnectedId)
representativeThe organization’s representative director, wherever they sit in the reporting tree; falls back to resolveMemberConnectedId, and the column is skipped when there is neither

An unknown resolveType is stored as role.

The line is derived from the Form’s positions — one stamp if the same person fills two columns; on submit each approver is emailed the document. A request that resolves to 0 approvers is approved on submit only when the requester may decide alone: the organization’s Owner, its representative director, or the account owner. What the form grants is then given to the requester under the same checks as a stamped approval (lifecycle controls, separation of duties; an Owner-only scope only to the Owner), and a request that fails them stays submitted. For any other requester it stays submitted until an approver is set.

Machine requesters never self-approve. When the requester is not a human (an agent filing through MCP), the risk floor is raised to at least 2 on the tree path and a positions path that resolves to 0 approvers returns unroutable instead of auto-grant — the document stays submitted until a person above the agent who holds the scope can be found. The human path is unchanged. This lives in line derivation, not in the MCP tools, so it holds for any client. Approval-line positions are sealed records, scoped per Form.

An approval form that resolves to 0 approvers (e.g. an Account Owner with no one above them) would produce a pointless approval. The New Request screen calls resolve-line on load and, when the count is 0, shows a blocking popup and disables Submit. That popup is a dashboard guard; the API applies the rule above — approved on submit for an Owner, representative director or account owner, left submitted for everyone else.

Method · RoutePurpose
GET /api/org/{slug}/approvals/instancesList the requester’s / approver’s instances (each row carries isRequester, isReceiver, myStepStatus, creatorName, per-step approverName).
POST /api/org/{slug}/approvals/instancesSubmit a request. Body: workItemId, title, bodyJson?, templateKey?, templateLocale?, attachments?, draftInstanceId?, sealAssertionJson. Derives the approval line, seals the instance, assigns the document number, and emails approvers. Returns { reportingInstanceId }. Requires a WebAuthn assertion — see below. 400 OP_NOT_ALLOWED if the body carries an action to run on approval; 400 with an error starting USES_NOT_SUPPORTED if the request asks for a finite uses on a scope other than a Roost management operation.
POST /api/org/{slug}/approvals/instances/draftSave or update a draft (instanceId to update). No seal needed.
POST /api/org/{slug}/approvals/instances/{id}/stampAn approver stamps. Body: approve (bool), reason?, sealAssertionJson. 403 when the caller has no pending step or no longer holds the required capability; 409 already_closed when the document is already closed.
POST /api/org/{slug}/approvals/instances/{id}/withdrawThe requester takes a submitted request back (reason?).
POST /api/org/{slug}/approvals/instances/{id}/acknowledgeA recipient marks a submitted report as read. 403 for anyone who is not a recipient; 400 for approval documents.
POST /api/org/{slug}/approvals/instances/{id}/archiveArchive an instance.
DELETE /api/org/{slug}/approvals/instances/{id}Delete a draft, or an instance that has been archived.
GET /api/org/{slug}/approvals/instances/by-number/{documentNumber}Look up a sealed instance by its {slug}-{yyyy}-{000001} document number.
POST /api/org/{slug}/approvals/attachmentsUpload an attachment as multipart/form-data (at most 10 MB). 403 with error = "UPGRADE_REQUIRED", feature = "storage" when the plan’s storage is full.
GET /api/org/{slug}/approvals/instances/{id}/attachments/{index}/downloadDownload one attachment of an instance. Allowed for the requester, its approvers, and the Owner or an Admin; 403 otherwise.

Seal step-up failures on submit and stamp use these error values: seal_stepup_required (no assertion sent), seal_stepup_failed (verification failed or the passkey is not the signed-in account’s), seal_binding_mismatch (the seal was issued for a different action).

Submitting (“stamp & submit”) is a human-presence-verified action. The client requests a challenge (api/passkey/login/options), calls navigator.credentials.get (Windows Hello / TPM), and sends the assertion on the submit request. The server verifies it and requires that the passkey belongs to the session owner — mirroring the account-switch step-up. A member with no passkey cannot submit and is asked to register a personal seal first.

An approval stamp signs the document, not just a random challenge. When the client asks for a step-up challenge (POST /api/passkey/stepup/options with purpose: "approval.stamp", subject: "{instanceId}|approve|reject"), the server reads the instance from the database, writes the key items of what is being approved into one canonical line, and builds the challenge as

challenge = SHA-256(canonical line) // 32 bytes — the same size as a random challenge

The line starts with a fresh random nonce, so every challenge is still unique, and the authenticator’s signature now covers the content. Nothing is stored in the authenticator (TPM) — only the challenge value changes. This is the same idea as PSD2 dynamic linking (authentication bound to amount and payee). The response carries the signed fields back as binding:

{
"hasPasskey": true,
"options": { "challenge": "…", "…": "…" },
"binding": {
"version": "axowl-stepup-v1",
"nonce": "9c0e…(64 hex)",
"purpose": "approval.stamp",
"subject": "3f1c…|approve",
"fields": [
{ "key": "org", "value": "…" },
{ "key": "instance", "value": "3f1c…" },
{ "key": "number", "value": "acme-2026-000042" },
{ "key": "title", "value": "Travel expenses" },
{ "key": "decision", "value": "approve" },
{ "key": "requester", "value": "…" },
{ "key": "body.sha256", "value": "…" },
{ "key": "attachments.sha256", "value": "…" },
{ "key": "grant", "value": "org.expense.submit" },
{ "key": "input.reason", "value": "" }
]
}
}
  • Canonical line (v1) — axowl-stepup-v1 followed by |key=<UTF-8 byte length>:<value> per item, in this order: nonce, purpose, subject, then fields as listed (a key may repeat, e.g. one grant item per scope, sorted ordinally). Example: axowl-stepup-v1|nonce=64:9c0e…|purpose=14:approval.stamp|subject=44:3f1c…|approve|org=36:…|decision=7:approve|…. The length prefix means a value cannot forge another item with an embedded |. body.sha256 / attachments.sha256 are lowercase hex SHA-256 of the stored BodyJson / AttachmentsJson (the attachment manifest already lists each file’s SHA-256, so files go in by hash, not by content); grant items are the scopes the requester receives on approval (empty on reject).
  • Inputs — values the approver types (the reject reason) are sent with the options request as inputs: [{ "key": "input.reason", "value": "…" }] (trimmed) and must equal the reason sent to /stamp. Only keys allowed for the purpose are kept.
  • Before signing, the console rebuilds the line from binding and checks that its SHA-256 equals the challenge; on mismatch it refuses to sign. It then shows the fields in a confirmation window, taking every displayed value from those items only — what the approver sees is what the passkey signs.
  • At /stamp, the server rebuilds the fields from the database at execution time and compares them to the signed challenge. A changed body, attachments, granted scopes or reason, a swapped decision, or a seal issued for another instance is refused with 403 seal_binding_mismatch. The email decide page (/a/approval/{approve|reject}) takes the document number, title and grants it displays from the same items and lists them under “Your passkey signs exactly this”.

Verifying a stamp without trusting the server

Section titled “Verifying a stamp without trusting the server”

Each stamp’s audit event (RPT031 approved / RPT032 rejected) carries SealProof (sp), a JSON object:

{ "v": "axowl-stepup-proof-v1", "canonical": "axowl-stepup-v1|nonce=64:…|purpose=14:approval.stamp|…",
"credentialId": "…", "authenticatorData": "…", "clientDataJSON": "…", "signature": "…" }

authenticatorData, clientDataJSON and signature are the base64url bytes the authenticator produced. A third party holding the credential’s public key checks:

  1. SHA-256(canonical) equals the base64url-decoded challenge inside clientDataJSON (32 bytes);
  2. the ES256 signature over authenticatorData || SHA-256(clientDataJSON) verifies with the public key;
  3. canonical matches the document (rebuild it from the instance as above).

SealProof is null for stamps made by clients that did not request a content-bound challenge.

Every approval stamp can be confirmed a second time on another device — usually the approver’s phone — with a different passkey, over the same content line. The phone screen shows what the server actually received, so a computer whose browser has been taken over cannot get a decision recorded that differs from what the person confirms on the phone.

  • Policy — per organization, on by default; only the Owner can turn it off. The settings route below reports enforced. While enforced is false, stamps are recorded with the computer passkey alone and the stamp response carries secondDevice: { notice: true, hasSecondDevice }.
  • When confirmation is required, POST /api/org/{slug}/approvals/instances/{id}/stamp does not record the decision. It returns:
    • 202 { status: "awaiting_second_device", secondDevice: { requestId, expiresAt, channel } } — a one-time link (10 minutes) was sent to the approver (channel: alimtalk, email, or null if nothing could be sent). KakaoTalk (alimtalk) is used only when the organization has the Korean Package add-on and the approver has a Korean mobile number; otherwise the link goes by email;
    • 409 { error: "second_device_required" } — the approver has no passkey other than the one just used; the console opens passkey registration in place (QR to the phone).
  • Organizations with phone confirmation on allow phone passkey registration automatically, except device-bound organizations (use a security key or another computer).
  • How the link is sent — KakaoTalk first (Korean Package organizations, Korean mobile numbers), then text message if the organization turned it on (Owner, Pro plan or higher; each message is charged to prepaid credit at the global SMS price) and the approver has a verified mobile number in a supported country, otherwise email. If a step cannot send (no credit, unsupported country, carrier failure) the next one is used — approvals are never blocked by delivery.
  • Phone verification is asked only in organizations that turned text messages on: once when the option is first turned on (email to members without a verified number) and once on the next workspace visit (sign-in or invitation acceptance). It can be postponed; links keep coming by email until then.
MethodPathPurpose
GET/api/org/{slug}/approvals/second-device/{requestId}The computer waits for the phone: { status: pending | completing | done | denied | refused | expired, expiresAt, channel, result }
GET / PUT/api/org/{slug}/settings/approval-second-device{ enabled, enforced, canEdit, smsEnabled, smsPlanAllowed, smsPricePerMessageUsd, koreanPackage } · PUT { enabled?, smsEnabled? } — Owner only (403 OWNER_REQUIRED); smsEnabled: true below Pro → 403 UPGRADE_REQUIRED
GET/api/v1/users/me/phone{ phone, verified } — verified is true only for a number confirmed with a one-time code
POST/api/v1/users/me/phone/verifiedAfter POST /api/org/otp/verify succeeds, records that verified number as yours (no body; the server uses the number it saw verified)
GET/api/v1/users/me/phone-promptsOrganizations that send links by text message where your number is not verified yet: [{ slug, name, enabledAt }]
GET/a/approval/confirm?r={requestId}&t={token}Phone page — shows only the items of the signed content line
GET/a/approval/confirm/request?r=&t=Same request as JSON: { requestId, status, expiresAt, version, purpose: "approval.confirm", subject, fields }
POST/a/approval/confirm/challenge (form r, t)WebAuthn options; challenge = SHA-256(canonical line) with purpose approval.confirm and subject {requestId}|{instanceId}|approve|reject; the first passkey is left out of allowCredentials
POST/a/approval/confirm/complete (form r, t, assertion)Verifies the phone assertion and records the decision
POST/a/approval/confirm/deny (form r, t)“I did not do this” — nothing is recorded

The phone assertion is refused when the link is expired, used or wrong; when the document changed after the computer signed it; when the signed content differs from the request; when the passkey belongs to someone else; or when it is the same passkey the computer used. On success the audit event (RPT031/032) also carries SecondSealProof (sp2, same proof format as SealProof) and SecondCredentialId (cid2).

Every seal in the approval flow is drawn from the user’s signatureSpec (returned by /me):

  • Submit popup shows the drafter’s designed personal seal (not a name-based default circle).
  • Document seal columns render each participant’s designed seal; the name-based fallback is used only for participants with no spec.
  • The org seal on the approval document is a self-contained SVG — its fonts are embedded when the seal is created, so it renders the same wherever the document is shown.

Putting the pieces together — Form, seals, the Reporting Manager, and Requests & Approvals:

  1. Define the Form — created in the Create Form wizard. Type = Approval (picks a target action from the Action Library and creates the linked Role) or Reporting (picks recipients: team/dept, member, external email). The Form carries its seal columns, line mode (single / chain / quorum), risk level, and language.

  2. Compose a request — a member opens New Request, picks a Form, and fills the A4 approval document: letterhead with the company seal (issuer), an automatic header block (dept / drafter / date), recipients / cc, title, body, attachments, and the seal grid. The document number is previewed (resolve-line also pre-checks the approver count — a 0-approver Approval is blocked here; see No-approver block).

  3. Submit (POST /approvals/instances) — the “stamp & submit” popup shows the drafter’s personal seal, then a WebAuthn/TPM assertion is required (human presence). The server seals the instance, assigns the document number {slug}-{yyyy}-{NNNNNN} atomically, and derives the approval line.

  4. Route to the Reporting Manager — each position is resolved: drafter → the requester (no step); role → walk up the requester’s reporting tree and pick the nearest ancestor who holds that Role, falling back to a pinned member; member → a fixed person. Duplicates and the requester are de-duped. This is how a report reaches the right manager: the Form says “the person holding role X above me”, and the tree says who that is. One ordered pending step is written per approver and each one is emailed. A Reporting-type Form instead emails the resolved recipients (groups expanded to members). Zero approvers ⇒ approved on submit only for an Owner, representative director or account owner; otherwise the request waits as submitted.

  5. Approve / reject in Requests & Approvals — each approver sees the instance under the Received filter and stamps in place (POST /instances/{id}/stamp). The server re-checks the ceiling at stamp time (capability may have shrunk since submit), seals the step, emits RPT031/032, and recomputes progress (single → 1 approval; chain/parallel → all; quorum → K-of-N; any reject → rejected). The approver’s personal seal stamps their column.

    The ceiling is strict — an approver can only approve what they hold themselves, and the stamp is refused with 403 otherwise:

    • a permission the approver holds only until a date cannot be approved for longer than that date, or without an end;
    • a permission the approver holds with a use limit cannot be approved onward;
    • an exclusion on the approver’s own permission (key!=value) must be repeated in the request, or the request must name only values outside it;
    • Owner-only scopes (billing and Roost), and wildcards that cover them, are approved only by the Owner or by someone who holds that scope explicitly — an Admin’s broad org.* does not cover them.
  6. Grant on completion — when the line completes, the uses control variable decides the outcome. A finite uses=N (1 or more) is accepted only for Roost management operations and creates no standing permission: the approved instance carries a budget of N uses, spent one per use and gone when the count or the approved lifetime runs out. With uses=* or no uses, the approved scope becomes a permission for the requester (ending at the approved lifetime, when one was requested). A grant that would give the requester a conflicting pair of duties is refused — the completing stamp is answered with 403 (see Roles & permissions). The completion event (RPT033) fires when the request is approved.

Every step is sealed and emits an audit event — the document, its number, each seal, and the final grant are all tamper-evident. See Capability grants for the ceiling model behind the line.