Team access and roles
Give teammates and backend systems access to Wingspan Accounts with Stakeholders, roles, Authorizations, ServiceAccounts, and API keys in the V3 API.
This page shows how to give people and systems access to an Account: invite a teammate with a role, define a custom role, grant extra access with an Authorization, and set up machine credentials. In V1 this was "team members" plus scope-group authorizations.
Who can act on an Account
| Principal | How it gets access | Use it for |
|---|---|---|
| Person (a human with a login) | A Stakeholder record on the Account with a roleId | Teammates who sign in to Wingspan or your app |
| The principal Person | The single Stakeholder with isPrincipal: true | The Account's authorized representative. Holds admin authority. |
| ServiceAccount (a machine identity) | Its owner (an Account or Organization), an optional custom roleId, and direct scopes | Backends, scheduled jobs, integrations |
| Anyone above, outside their normal reach | An Authorization grant from the Account | Access to a specific Account's resources beyond the role |
| Anyone who needs a parent's child Accounts | An Authorization on the parent with isAccountScopeInclusiveOfDescendants: true | Acting on child Accounts with X-Wingspan-Account. Being the parent's principal or a Stakeholder on it doesn't reach its children by itself. |
Roles are operational access only. Ownership (ownershipPercentage) and control (isController) are separate compliance attributes on the same Stakeholder record and grant no permissions. They are used for business verification; see Identity verification.
Security requirements for access changes
Changing who can do what is a high-risk action. The endpoints on this page that create or change Stakeholders, Roles, Authorizations, ServiceAccounts, and API keys require:
- A person signed in directly. ServiceAccount API keys and impersonation sessions can't make these changes.
- A recent step-up MFA check. Without one you get
403withcode: StepUpMfaRequiredandextensions.challengeUrito start the challenge.
Roles
A role is a named set of capabilities. List roles returns the system roles plus any custom roles you can see.
curl https://api.wingspan.app/v3/platform/roles \
-H "Authorization: Bearer $WINGSPAN_TOKEN" \
-H "X-Wingspan-Account: Nw7kQ2pLx9RtVb3mHc5dZa"System roles have stable IDs such as role_admin and role_accounts_payable, and can't be changed or deleted. Read the list from the API rather than hard-coding it, since the catalog can grow.
Create a custom role
Create a role owned by an Account (or by an Organization, to reuse it across every Account in the Organization). Each entry in scopePermissions names a capability and the actions allowed on it. List scopes returns the capability names.
curl -X POST https://api.wingspan.app/v3/platform/roles \
-H "Authorization: Bearer $WINGSPAN_TOKEN" \
-H "X-Wingspan-Account: Nw7kQ2pLx9RtVb3mHc5dZa" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"name": "Payables reviewer",
"description": "Reads payees and payables. No write access.",
"ownerType": "Account",
"ownerId": "Nw7kQ2pLx9RtVb3mHc5dZa",
"scopePermissions": [
{ "scope": "payments.payee", "actions": ["Read"] },
{ "scope": "payments.payable", "actions": ["Read"] }
]
}'Write includes Read. Update a custom role with PATCH /v3/platform/roles/{roleId} and remove it with DELETE /v3/platform/roles/{roleId}. Role changes fire Role.Created, Role.Updated, and Role.Deleted webhooks.
Add a teammate
A teammate is a Stakeholder with subjectType: Person and a roleId. Each Person holds at most one role per Account. For access beyond that role, add an Authorization (below).
1. Create the Stakeholder
You can create it with just an email, before the person has a Wingspan login. Use Create a Stakeholder:
curl -X POST https://api.wingspan.app/v3/platform/accounts/Nw7kQ2pLx9RtVb3mHc5dZa/stakeholders \
-H "Authorization: Bearer $WINGSPAN_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"subjectType": "Person",
"email": "[email protected]",
"roleId": "role_accounts_payable",
"externalId": "hr-5521"
}'// 201 Created (trimmed)
{
"id": "St6kPb3nXq8wLm2rVd5tHc",
"accountId": "Nw7kQ2pLx9RtVb3mHc5dZa",
"subjectType": "Person",
"subjectId": null,
"email": "[email protected]",
"roleId": "role_accounts_payable",
"events": { "createdAt": "2026-09-24T15:02:11Z" }
}subjectId stays null until the invite resolves to a Person. A second Stakeholder for the same Person on the same Account returns 409 ResourceConflict.
2. Send the invite
Invite a Stakeholder. Only the Account's principal can call this.
curl -X POST https://api.wingspan.app/v3/platform/accounts/Nw7kQ2pLx9RtVb3mHc5dZa/stakeholders/St6kPb3nXq8wLm2rVd5tHc/invite \
-H "Authorization: Bearer $WINGSPAN_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "expiresIn": "P14D" }'Wingspan emails the invite. The link is never returned to you. Invites last 14 days by default and 30 days at most. Set shouldSendEmail: false to skip the email; the person can then request a link from the sign-in page.
Change or remove access
| To | Call |
|---|---|
| Change the role | PATCH /v3/platform/accounts/{accountId}/stakeholders/{stakeholderId} with { "roleId": "role_admin" } |
| Remove the teammate | DELETE /v3/platform/accounts/{accountId}/stakeholders/{stakeholderId}. Returns 204. The record is kept with events.revokedAt for audit. |
| See who has access | GET /v3/platform/accounts/{accountId}/stakeholders |
The principal
Whoever creates a root Account becomes its principal and holds admin authority. A child Account has no principal until you create a Stakeholder with isPrincipal: true. There is at most one principal per Account.
To hand the role to someone else, call Transfer the Account principal with the new principal's personId. They must already be an active Person Stakeholder on the Account. You can't make this change with a Stakeholder PATCH, and you can't revoke the only active principal.
Transfers are blocked with 409 PrincipalLockedByActiveEngagement while the Account has an Employee or EmployeeOfRecord engagement in Created, Activated, or Suspended, because the principal is the person those W-2 records attach to.
Grant extra access with an Authorization
An Authorization lets a Person or ServiceAccount use specific capabilities on an Account it wouldn't otherwise reach. The Account granting access comes from X-Wingspan-Account (or an Account-bound session), never from the body.
curl -X POST https://api.wingspan.app/v3/platform/authorizations \
-H "Authorization: Bearer $WINGSPAN_TOKEN" \
-H "X-Wingspan-Account: Ap4tYs8KqW2nLm6xRb1cVe" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"granteeType": "Person",
"granteeId": "Pr5sHa2kQm8tXv4nLc7dWb",
"scopes": ["payments.payable", "payments.payee"],
"actions": ["Read"],
"expiresAt": "2026-12-31T23:59:59Z"
}'scopesare base names (payments.payable). PutReadorWriteinactions; don't append:reador:write.- Instead of
scopes, passallowedScopeGroupIdfrom List scope groups to grant a predefined bundle (the V3 form of V1 scope groups such as Finances or Documents). - The grant covers only the granting Account unless you set
isAccountScopeInclusiveOfDescendants: true. - Accounts can't be grantees. Grant access to a Person or ServiceAccount instead.
Review grants with GET /v3/platform/authorizations. Change one with PATCH /v3/platform/authorizations/{authorizationId}: send only the fields you're changing, and at least one field, since an empty body returns 422. Remove one with DELETE /v3/platform/authorizations/{authorizationId}.
Machine access: ServiceAccounts and API keys
Use a ServiceAccount for any process with no person in the loop. It's owned by an Account (covering that Account and its descendants) or an Organization (covering the Accounts in it), never by a Person. Its effective access is its custom roleId plus direct scopes such as payments.payable:write. API keys issued to it can only hold a subset of those scopes.
| ServiceAccount status | Meaning |
|---|---|
Created | Newly created. Review scopes, then activate. |
Active | Can authenticate. |
Inactive | Suspended. POST .../reactivate restores it. |
Disabled | Terminal. Disabling also disables every API key it owns. |
A Person can also hold personal API keys (ownerType: Person) for their own scripting. Actions taken with those keys are attributed to that Person. For backends shared by a team, use a ServiceAccount so access doesn't depend on one employee.
Setup steps and examples are in Embed Wingspan in your app. ServiceAccount and API key lifecycle events (ServiceAccount.Created, ServiceAccount.Disabled, ApiKey.Created, ApiKey.Revoked, and others) are available as webhooks.
What can go wrong
| Status | code | Why |
|---|---|---|
403 | StepUpMfaRequired | The change needs a recent MFA check. |
403 | Forbidden or ScopeInsufficient | The caller lacks the capability, or used an API key where a direct sign-in is required. |
409 | ResourceConflict | Duplicate Stakeholder, duplicate externalId, or an Authorization create that conflicts with the grant's current state. |
409 | PrincipalLockedByActiveEngagement | Principal transfer or revocation while a W-2 engagement is active. |
422 | ValidationError | For example, roleId or email on an Account-type Stakeholder, :write appended to an Authorization scope, or an empty body on an Authorization or API key update. |
Related pages
Updated 10 days ago