Environments and authentication
This page shows you where to send V3 API requests, how to get a credential, and how to make your first authenticated call.
Environments
Wingspan runs two environments. Use the sandbox to build and test, and production for real money.
| Environment | API base URL | Web app |
|---|---|---|
| Production | https://api.wingspan.app | https://my.wingspan.app |
| Sandbox | https://stagingapi.wingspan.app | https://staging-my.wingspan.app |
Every V3 path starts with /v3/, so a full URL looks like https://api.wingspan.app/v3/payments/payees. Accounts, credentials, and data don't carry over between environments.
Getting a sandbox. Your Wingspan team sets up your sandbox Account for the flows you plan to build, because some features need configuration before they work there. Before you start, tell us which flows you want to test (for example, inviting contractors, paying payables, or collecting invoices). We'll configure the sandbox, confirm those flows work, and send you instructions for testing them. If something doesn't work the way you expect, send us the request, the response, and its requestId.
Headers
Send these on every request:
| Header | Value | When |
|---|---|---|
Authorization | Bearer <token> | Every authenticated call. The token is a session token, an API key secret, or an OAuth access token. |
Content-Type | application/json | Any request with a body. (The OAuth token endpoint is the exception: it takes form-encoded bodies, as OAuth requires.) |
Idempotency-Key | A unique string you generate, such as a UUID | Creates and anything that moves money. See Idempotency. |
X-Wingspan-Account | An Account ID | Only when you act on an Account other than your own. See Acting on behalf of Accounts. |
Your own ID is never part of a URL. Wingspan works out who you are from the token, and there is no /me endpoint.
Choose a way to authenticate
| Method | Who it's for | How you get it |
|---|---|---|
| Session token | A person logging in: your app's users, or you while exploring the API | POST /v3/platform/sessions with email and password |
| API key | Scripts and server code acting as a person or a service account | POST /v3/platform/api-keys |
| Service account plus API key | Backend integrations with no human in the loop | POST /v3/platform/service-accounts, then an API key owned by it |
| OAuth 2.0 access token | Third-party apps that act on a Wingspan customer's behalf with their consent | POST /v3/platform/oauth/token |
For a server-to-server integration, use a service account and an API key. For a quick test, start with a session token.
Important: Tokens from the V1 or V2 API, and tokens copied from the V1 web app, don't work on V3 endpoints. They return
401 Unauthenticated. Create a V3 session or API key. The first time a person signs in through V3, their Account moves to V3, so read what happens to your Account before you sign in to production.
Log in with a session
POST /v3/platform/sessions exchanges an email and password for a session token. It doesn't need an Authorization header.
curl -X POST https://stagingapi.wingspan.app/v3/platform/sessions \
-H "Content-Type: application/json" \
-d "{
\"sessionType\": \"Person\",
\"email\": \"[email protected]\",
\"password\": \"$WINGSPAN_PASSWORD\"
}"// 201 Created (trimmed)
{
"id": "Sn4bT8yK1pWq_Mz6RxLd2v",
"sessionType": "Person",
"token": "[redacted]",
"refreshToken": "[redacted]",
"personId": "Hs1mX6kQ9wLp_Rv3ZtNc8a",
"expiresAt": "2026-09-24T18:00:00Z",
"boundAccountId": null,
"accountAccess": [
{ "accountId": "Nw7kQp2LmX9vRt4ZcY1bHd", "isPrincipal": true, "roleIds": [], "scopes": [], "allowedScopeGroupIds": [], "scopePermissions": [] }
]
}Send token as Authorization: Bearer <token> on later calls. accountAccess lists the Accounts this person can act on, which is where you find your Account ID. Never log token or refreshToken.
Related session calls:
| Call | What it does |
|---|---|
POST /v3/platform/sessions/refresh | Trades refreshToken for a new token and a new refreshToken. Each refresh token works once. Reusing a spent one revokes the session. |
POST /v3/platform/sessions/otp and PATCH /v3/platform/sessions/otp | Passwordless login: send a one-time code by email or SMS, then verify it to get a session. |
POST /v3/platform/sessions/social/google | Sign in with a verified Google ID token. An existing identity is matched by Google's stable subject. A new identity must come from an invite and include the invited Person and the invite-session proof. Sign-up isn't inferred from email ownership alone. |
POST /v3/platform/sso/authorize and POST /v3/platform/sso/callback | Enterprise single sign-on. authorize looks up the identity provider by the email's domain and returns a redirect URL. After the provider redirects back, callback exchanges the code and the single-use state for a session. The response adds wasPersonProvisioned, which is true when the login created a new Person. |
PATCH /v3/platform/sessions/{sessionId} | Binds your session to one Account (see below) and returns a replacement token. |
DELETE /v3/platform/sessions/{sessionId} | Logs out. If a retry of this call returns 401, the logout already worked. |
If your account requires MFA, login returns 403 with code: StepUpMfaRequired. The same code appears on some sensitive calls, such as creating an API key or paying a payable, when your login session hasn't completed MFA recently. Calls made with an API key aren't challenged: an API key has no session, and /v3/platform/mfa-challenges returns 403 for it. The response's extensions tell you which challenge to complete through /v3/platform/mfa-challenges. Complete it and retry the call.
To complete a challenge:
- Enroll a factor, if the person has none, with
POST /v3/platform/mfa-factors.typeisTotp,Sms, orEmail. The factor starts inPendingVerification. An SMS or email code expires after 10 minutes and stops working after five failed attempts. - Confirm the factor with
POST /v3/platform/mfa-factors/{mfaFactorId}/verify, sendingtotpCode,smsCode, oremailCode. The factor becomesActive. - Create a challenge with
POST /v3/platform/mfa-challenges. SetrequiredForto the reason named in the403response, such asApiKeyChangeorHighRiskWriteAction. - Answer it with
POST /v3/platform/mfa-challenges/{challengeId}/verify, sending a code and, when the person has several factors, thefactorId. The elevation is time-limited and tied to therequiredForreason. If a later call returnsStepUpMfaRequiredagain, create a new challenge.
Create an API key
An API key is a long-lived credential for code. You create it while logged in, and it acts as the person or service account that owns it.
curl -X POST https://stagingapi.wingspan.app/v3/platform/api-keys \
-H "Authorization: Bearer $WINGSPAN_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{ "name": "Northwind payroll sync (sandbox)" }'// 201 Created (trimmed)
{
"id": "Ak7pW2rN5tQz_Lm8XcVb3d",
"name": "Northwind payroll sync (sandbox)",
"status": "Active",
"ownerType": "Person",
"ownerId": "Hs1mX6kQ9wLp_Rv3ZtNc8a",
"scopes": [],
"secret": "[redacted]"
}Important:
secretis returned once, on this response only. Store it in your secrets manager right away. If you lose it, rotate the key withPOST /v3/platform/api-keys/{apiKeyId}/rotate. Replaying the create with the sameIdempotency-Keyreturns409 ResourceConflict, not the secret.
Use the secret as a bearer token:
export WINGSPAN_TOKEN="$WINGSPAN_API_KEY"
curl https://stagingapi.wingspan.app/v3/platform/api-keys \
-H "Authorization: Bearer $WINGSPAN_TOKEN"A 200 with a list of your keys means the key works. Keys can carry an expiresAt. To rename a key or change its scopes or expiry, use PATCH /v3/platform/api-keys/{apiKeyId} with at least one field (an empty body returns 422). DELETE /v3/platform/api-keys/{apiKeyId} revokes a key immediately.
A service account's key can rotate itself with POST /v3/platform/api-keys/current/rotate. It needs an Idempotency-Key and returns the new secret once. The old secret stops working immediately, so a lost response can't be retried. Another administrator must rotate the key to recover it. Sessions, including sessions that a service account minted, get 403.
Use a service account for backend integrations
A service account is a machine identity owned by an Account (or an Organization). Its permissions come from a role (roleId) plus any extra scopes you grant, written as domain.resource:read or domain.resource:write (for example, payments.payable:write).
-
Create the service account with
POST /v3/platform/service-accounts:curl -X POST https://stagingapi.wingspan.app/v3/platform/service-accounts \ -H "Authorization: Bearer $WINGSPAN_SESSION_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "name": "Payroll sync", "ownerType": "Account", "ownerId": "Nw7kQp2LmX9vRt4ZcY1bHd", "scopes": ["payments.payee:write", "payments.payable:write"] }'It starts in
Createdso you can review its access. -
Activate it with
POST /v3/platform/service-accounts/{serviceAccountId}/activate. -
Create an API key for it:
POST /v3/platform/api-keyswith"ownerType": "ServiceAccount"and"ownerId"set to the service account's ID. The key'sscopesmust stay within the service account's own.
Two calls take a service account out of use. POST .../disable also disables every API key it owns. Treat it as terminal. POST .../inactivate keeps the keys but makes them stop working until you call POST .../reactivate.
Mint a short-lived session for one Account
A service account can mint a session bound to a single Account with POST /v3/platform/accounts/{accountId}/sessions. This is useful when you embed Wingspan and want to hand a narrowly scoped token to a front end. Send X-Wingspan-Account equal to the accountId in the path. You can set its expiresAt up to one hour ahead. The session acts as the Account, not a person, so endpoints that need a Person (such as a person's notification inbox) reject it with 400 AccountScopeNotApplicable. Set shouldIncludeDescendants: true to let it act on that Account's children too.
OAuth 2.0 for connected apps
If you're building an app that other Wingspan customers connect to, use OAuth 2.0 instead of asking them for an API key. The token endpoint, POST /v3/platform/oauth/token, supports the authorization_code (with PKCE) and refresh_token grants, sent form-encoded. The client_credentials grant is only for machine clients that Wingspan provisions and that sign in with private_key_jwt. A client you register through the API can't use it. Clients discover the endpoints and signing keys from https://api.wingspan.app/.well-known/openid-configuration and https://api.wingspan.app/.well-known/oauth-authorization-server.
Register a client with POST /v3/platform/oauth/clients (RFC 7591 dynamic registration). With no bearer token, the registration creates a public client, so token_endpoint_auth_method must be none. To register a confidential client, create an initial access token with POST /v3/platform/oauth/clients/initial-access-tokens and send it as the bearer token. The new client can use only the scopes that token lists. The response carries a registration access token for managing the client. A client can also identify itself with an https URL as its client_id, pointing to a client ID metadata document it hosts. For those clients, the consent screen shows the host of that URL next to the app's name and logo, so the person approving can see where the app comes from.
To start the authorization code flow, send the user's browser to GET /v3/platform/oauth/authorize with client_id, a registered redirect_uri, response_type=code, state, and a PKCE code_challenge (S256). Wingspan redirects the user to its own sign-in and consent screen. The screen calls getOAuthAuthorizeContext and decideOAuthAuthorization for you. You don't call those two yourself. After the user approves, Wingspan redirects to your redirect_uri with a code to exchange at the token endpoint. prompt=none is only for verified clients.
Use POST /v3/platform/oauth/revoke to revoke an access or refresh token as the client that holds it. A person can see the apps they've connected with GET /v3/platform/oauth/grants and disconnect one with DELETE /v3/platform/oauth/grants/{grantId}. To manage an Account's connected apps, use GET /v3/platform/accounts/{accountId}/oauth/grants and the matching revoke and disconnect calls.
Acting on behalf of another Account
By default, a request acts on the Account your credential belongs to. To act on a child Account, or on an Account that has granted you access, add X-Wingspan-Account:
curl https://api.wingspan.app/v3/payments/payees \
-H "Authorization: Bearer $WINGSPAN_TOKEN" \
-H "X-Wingspan-Account: Ch6wP3nL8qTr_Kv1ZxMb4s"Everything in that request (reads, writes, webhooks, and rate limits) applies to the target Account. If you can't act on it, you get 403 AccountMismatch. Endpoints that belong to a person, such as /v3/platform/persons/{personId}/notifications, reject the header with 400 AccountScopeNotApplicable.
A person's session can also be bound to one Account with PATCH /v3/platform/sessions/{sessionId}. A bound session acts on that Account when you leave out the header. Acting on behalf of Accounts covers the full rules, including caching.
What can go wrong
| Status | code | What it means | What to do |
|---|---|---|---|
401 | Unauthenticated | The token is missing, malformed, revoked, wrong for this environment, or a V1/V2 token. | Check the header, that you're using a sandbox token against the sandbox, and that the token came from a V3 session or API key. |
401 | TokenExpired | The session expired. | Refresh it, or log in again. |
403 | StepUpMfaRequired | This action needs recent MFA. | Complete the challenge in extensions, then retry. |
403 | Forbidden, ScopeInsufficient | Your credential lacks the role or scope. | Grant the scope, or use a credential that has it. |
403 | AccountMismatch | X-Wingspan-Account names an Account you can't act on. | Check the ID and your access. |
400 | AccountScopeNotApplicable | You sent X-Wingspan-Account to a person-only endpoint, or used an Account session there. | Drop the header, or use a person's session. |
429 | RateLimitExceeded | You're over a rate limit. | Wait Retry-After seconds. See Rate limiting. |
Every error body has the same shape. See Errors.
Next steps
Updated about 1 hour ago