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.

EnvironmentAPI base URLWeb app
Productionhttps://api.wingspan.apphttps://my.wingspan.app
Sandboxhttps://stagingapi.wingspan.apphttps://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:

HeaderValueWhen
AuthorizationBearer <token>Every authenticated call. The token is a session token, an API key secret, or an OAuth access token.
Content-Typeapplication/jsonAny request with a body. (The OAuth token endpoint is the exception: it takes form-encoded bodies, as OAuth requires.)
Idempotency-KeyA unique string you generate, such as a UUIDCreates and anything that moves money. See Idempotency.
X-Wingspan-AccountAn Account IDOnly 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

MethodWho it's forHow you get it
Session tokenA person logging in: your app's users, or you while exploring the APIPOST /v3/platform/sessions with email and password
API keyScripts and server code acting as a person or a service accountPOST /v3/platform/api-keys
Service account plus API keyBackend integrations with no human in the loopPOST /v3/platform/service-accounts, then an API key owned by it
OAuth 2.0 access tokenThird-party apps that act on a Wingspan customer's behalf with their consentPOST /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:

CallWhat it does
POST /v3/platform/sessions/refreshTrades 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/otpPasswordless login: send a one-time code by email or SMS, then verify it to get a session.
POST /v3/platform/sessions/social/googleSign 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/callbackEnterprise 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:

  1. Enroll a factor, if the person has none, with POST /v3/platform/mfa-factors. type is Totp, Sms, or Email. The factor starts in PendingVerification. An SMS or email code expires after 10 minutes and stops working after five failed attempts.
  2. Confirm the factor with POST /v3/platform/mfa-factors/{mfaFactorId}/verify, sending totpCode, smsCode, or emailCode. The factor becomes Active.
  3. Create a challenge with POST /v3/platform/mfa-challenges. Set requiredFor to the reason named in the 403 response, such as ApiKeyChange or HighRiskWriteAction.
  4. Answer it with POST /v3/platform/mfa-challenges/{challengeId}/verify, sending a code and, when the person has several factors, the factorId. The elevation is time-limited and tied to the requiredFor reason. If a later call returns StepUpMfaRequired again, 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: secret is returned once, on this response only. Store it in your secrets manager right away. If you lose it, rotate the key with POST /v3/platform/api-keys/{apiKeyId}/rotate. Replaying the create with the same Idempotency-Key returns 409 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).

  1. 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 Created so you can review its access.

  2. Activate it with POST /v3/platform/service-accounts/{serviceAccountId}/activate.

  3. Create an API key for it: POST /v3/platform/api-keys with "ownerType": "ServiceAccount" and "ownerId" set to the service account's ID. The key's scopes must 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

StatuscodeWhat it meansWhat to do
401UnauthenticatedThe 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.
401TokenExpiredThe session expired.Refresh it, or log in again.
403StepUpMfaRequiredThis action needs recent MFA.Complete the challenge in extensions, then retry.
403Forbidden, ScopeInsufficientYour credential lacks the role or scope.Grant the scope, or use a credential that has it.
403AccountMismatchX-Wingspan-Account names an Account you can't act on.Check the ID and your access.
400AccountScopeNotApplicableYou sent X-Wingspan-Account to a person-only endpoint, or used an Account session there.Drop the header, or use a person's session.
429RateLimitExceededYou're over a rate limit.Wait Retry-After seconds. See Rate limiting.

Every error body has the same shape. See Errors.

Next steps


Did this page help you?