Embedded SDK

Add Wingspan screens for your payees and payers to your own app with the @wingspan/embedded-sdk package, for accounts payable and accounts receivable.

The Embedded SDK renders Wingspan screens inside your product for both sides of a payment, so one integration covers accounts payable and accounts receivable. Payees, the contractors and vendors who get paid, can finish onboarding, choose how they get paid, fix a tax verification problem and manage Wingspan Wallet cards. Payers, the companies that pay, can finish onboarding, save the bank account or card they pay invoices with, pick the one used for autopay and set how they fund payroll.

This page covers SDK 2.x, which runs on the V3 API. If you embed with SDK 1.x today, read Upgrading from 1.x first.

The SDK embeds the screens listed under Components. It doesn't embed the full Wingspan app.

Payees and payers

PayeesPayers
Who they areContractors and vendors who get paidCompanies that pay contractors, and customers that pay your invoices
Onboarding modulescreate_account as a payee, tax information, payout method, requirements, tax withholdingscreate_account as a payer, payroll funding, payroll settings, payment method
Accounts payablePayout method, tax verification fixes and Wallet cardsPayroll funding source
Accounts receivablePayout method, which decides where invoice payments landThe bank account or card that pays invoices, and the autopay default
Session is forThe payee's AccountThe payer's Account

How it works

  1. Your backend holds an API key that belongs to a ServiceAccount. When a signed-in user opens a Wingspan screen, your backend uses that key to create a short-lived session for the user's Wingspan Account.
  2. Your frontend loads the SDK and gives it a fetchToken function, which asks your backend for a session token.
  3. The SDK loads the screen in an iframe from Wingspan's embedded host and passes the token to it with postMessage. When the session expires, the SDK calls fetchToken again and the screen keeps working.

The API key stays on your server. The browser only holds session tokens, and each one lasts 15 minutes unless you ask for a different expiry.

Before you start

You need:

  • A Wingspan Account for your platform in the sandbox, and later in production. See Environments and authentication.
  • An active ServiceAccount owned by that Account, with an API key that holds the scopes your components use. Step 1 covers this.
  • A Wingspan Account for each payee or payer you render screens for, created inside your ServiceAccount's tree. Step 2 covers this.
  • If your site sends a Content Security Policy, a frame-src entry for the embedded host. See Security checklist.

Environments

EnvironmentAPI base URLEmbedded host (baseUrl)
Productionhttps://api.wingspan.apphttps://embedded-v3.wingspan.app
Sandboxhttps://stagingapi.wingspan.apphttps://staging-embedded-v3.wingspan.app

Your backend calls the API base URL. The browser loads components from the embedded host. Use sandbox keys with the sandbox hosts and production keys with the production hosts, because Accounts and credentials don't carry over between environments.

Install

Install 2.x with the next tag or an exact version. A bare npm install @wingspan/embedded-sdk gives you 1.x.

npm install @wingspan/embedded-sdk@next

To load the SDK from a CDN, pin the version in the URL. The script defines a global Wingspan with the same API as the npm package.

<script src="https://unpkg.com/@wingspan/[email protected]/dist/index.global.js"></script>

The package ships ES modules, CommonJS and TypeScript types, and it has no runtime dependencies.

Quickstart

Step 1: Set up your ServiceAccount

Your backend creates sessions with a ServiceAccount, a machine identity owned by your platform's Account. Set it up once in each environment, as an admin of your platform's Account. If you call with a person's login session, each of these three calls needs a recent MFA step-up (ApiKeyChange). Without one, the call returns 403 StepUpMfaRequired. Create and verify a challenge at /v3/platform/mfa-challenges with requiredFor: "ApiKeyChange", then retry. A backend calling with an API key has no session to step up. Sessions your backend mints for a customer's Account can't manage ServiceAccounts or API keys; those calls return 403.

  1. Create the ServiceAccount with the scopes your components use. It starts in Created so you can review its scopes.
  2. Activate it before your backend uses its key. Until then it stays in Created.
  3. Create an API key owned by the ServiceAccount.
# 1. Create the ServiceAccount, owned by your platform's Account
curl -X POST https://stagingapi.wingspan.app/v3/platform/service-accounts \
  -H "Authorization: Bearer $WINGSPAN_ADMIN_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Embedded components",
    "ownerType": "Account",
    "ownerId": "Nw7kQ2pLx9RtVb3mHc5dZa",
    "scopes": [
      "platform.account-session:write",
      "users.account:read",
      "users.account:write",
      "users.authorization:read",
      "users.authorization:write",
      "platform.compliance-entity:read",
      "platform.compliance-entity:write",
      "users.identity-verification:write",
      "payments.requirement:read",
      "payments.payer:read",
      "payments.payer:write",
      "search.payer-payee:read",
      "payments.payee:write",
      "payments.payee-identity:write",
      "payments.payout-settings:read",
      "payments.payout-settings:write",
      "payments.funding-source:read",
      "payments.funding-source:write",
      "bookkeeping.business-banking-account:read",
      "bookkeeping.business-banking-account:write",
      "bookkeeping.business-banking-balance:read",
      "payments.card:read",
      "payments.card:write"
    ]
  }'

# 2. Activate the ServiceAccount
curl -X POST https://stagingapi.wingspan.app/v3/platform/service-accounts/Sv8cAe1mRk5tNq3pZx7bHd/activate \
  -H "Authorization: Bearer $WINGSPAN_ADMIN_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)"

# 3. Create the ServiceAccount's API key
curl -X POST https://stagingapi.wingspan.app/v3/platform/api-keys \
  -H "Authorization: Bearer $WINGSPAN_ADMIN_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Embedded components (sandbox)", "ownerType": "ServiceAccount", "ownerId": "Sv8cAe1mRk5tNq3pZx7bHd" }'

These scopes cover payee onboarding, payout methods, Wallet cards and the Step 2 calls. Payer screens and the eligibility_requirements module need more. See Scopes.

The API key response includes secret once. Store it in your secrets manager before you do anything else. If the response is lost, rotate the key with POST /v3/platform/api-keys/{apiKeyId}/rotate. Your backend sends the secret as Authorization: Bearer <secret> in step 3.

Step 2: Create an Account for each user

Every session belongs to one Wingspan Account. Your ServiceAccount can create sessions for the Account that owns it and for every Account beneath it. Each payee or payer you render screens for needs an Account in that tree.

Who uses the componentCreate the session for
A payee: onboarding, payout method, complete verification, Wallet cardsThe payee's Account
A payer: onboarding, payment method, funding methodThe payer's Account. If your platform is the payer, that's your own Account.

For a payer, create their Account under your platform's Account with POST /v3/platform/accounts, setting parentAccountId to your Account ID. Save the ID on your record for that customer.

For a payee, create their Account, create the Payee that their payer pays, and link the two:

  1. POST /v3/platform/accounts with parentAccountId set to the payer's Account creates the payee's Account.
  2. POST /v3/payments/payees, sent as the payer with X-Wingspan-Account, creates the Payee.
  3. POST /v3/payments/payees/{payeeId}/associate-account links the Payee to the payee's Account.
# 1. Create the payee's Account under the payer's Account
curl -X POST https://stagingapi.wingspan.app/v3/platform/accounts \
  -H "Authorization: Bearer $WINGSPAN_SERVICE_ACCOUNT_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "parentAccountId": "Ap4tYs8KqW2nLm6xRb1cVe", "externalId": "northwind-c-1042" }'

# 2. Create the payer's Payee record
curl -X POST https://stagingapi.wingspan.app/v3/payments/payees \
  -H "Authorization: Bearer $WINGSPAN_SERVICE_ACCOUNT_KEY" \
  -H "X-Wingspan-Account: Ap4tYs8KqW2nLm6xRb1cVe" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "profile": { "email": "[email protected]" }, "context": "Contractor", "externalId": "northwind-c-1042" }'

# 3. Link the Payee to the payee's Account
curl -X POST https://stagingapi.wingspan.app/v3/payments/payees/Py3mQw7kLx2tRb9nVd4sHa/associate-account \
  -H "Authorization: Bearer $WINGSPAN_SERVICE_ACCOUNT_KEY" \
  -H "X-Wingspan-Account: Ap4tYs8KqW2nLm6xRb1cVe" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "payeeAccountId": "Kc2vRn8mTq4wLx6pZb1dHs",
    "authority": {
      "type": "PlatformAsserted",
      "basis": "ContractorAgreement",
      "externalAgreementId": "northwind-c-1042"
    }
  }'

Save the payee's Account ID on your own user record. Your session endpoint reads it from there in step 3.

Your ServiceAccount key can make these three calls when the payer's Account is inside its tree. They need the users.account:write, payments.payee:write and payments.payee-identity:write scopes. See Associate a payee with an Account for the other authority values.

Step 3: Create sessions on your backend

Your backend creates an Account session with the ServiceAccount key. X-Wingspan-Account must name the same Account as the path. Without the header, the call returns 422.

curl -X POST https://stagingapi.wingspan.app/v3/platform/accounts/Kc2vRn8mTq4wLx6pZb1dHs/sessions \
  -H "Authorization: Bearer $WINGSPAN_SERVICE_ACCOUNT_KEY" \
  -H "X-Wingspan-Account: Kc2vRn8mTq4wLx6pZb1dHs" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{}'
// 201 Created (trimmed)
{
  "id": "Ss1dKw6tQm3pXn8vLr2bHc",
  "sessionType": "Account",
  "boundAccountId": "Kc2vRn8mTq4wLx6pZb1dHs",
  "expiresAt": "2026-10-08T18:15:00Z",
  "token": "<opaque>"
}

Expose this through an endpoint on your own backend that the SDK can call. Here's one in Node with Express:

// The SDK calls this through fetchToken each time a component loads
// and whenever a session expires.
app.post("/api/wingspan/session", requireSignedInUser, async (req, res) => {
  // Read the Account from your own records. Never take it from the request.
  const accountId = await getWingspanAccountId(req.user);

  const response = await fetch(
    `${process.env.WINGSPAN_API_URL}/v3/platform/accounts/${accountId}/sessions`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.WINGSPAN_SERVICE_ACCOUNT_KEY}`,
        "X-Wingspan-Account": accountId,
        "Idempotency-Key": crypto.randomUUID(),
        "Content-Type": "application/json",
      },
      body: JSON.stringify({}),
    },
  );

  if (!response.ok) {
    return res.status(502).json({ error: "Could not start a Wingspan session" });
  }

  const { token } = await response.json();
  res.set("Cache-Control", "no-store").json({ token });
});

Return only token to the browser.

Step 4: Initialize the SDK

import { Wingspan } from "@wingspan/embedded-sdk";

const ws = Wingspan.init({
  baseUrl: "https://staging-embedded-v3.wingspan.app",
  fetchToken: async () => {
    const response = await fetch("/api/wingspan/session", { method: "POST" });
    if (!response.ok) throw new Error(`Session request failed: ${response.status}`);
    const { token } = await response.json();
    return token;
  },
  onError: (error) => console.error("[wingspan]", error),
});

Create components in browser code. In a server-rendered framework such as Next.js, call Wingspan.init from a client component, or load the package with a dynamic import().

Step 5: Show a component

const onboarding = ws.createOnboarding({
  modules: [
    { type: "create_account", accountType: "payee" },
    { type: "certify_tax_payer_info" },
    { type: "payout_method" },
  ],
  onComplete: ({ modules }) => markOnboarded(modules),
  onError: ({ message }) => showToast(message),
});

onboarding.openModal();

To place the component in your page instead of a modal, call mount() with a CSS selector or element:

onboarding.mount("#wingspan-onboarding");

The iframe fills its container. Give the container an explicit height, such as height: 640px. A container sized only by its content collapses to a thin strip.

Wingspan.init(config)

Wingspan.init returns an SDK instance. Create components from it with the factory methods under Components.

OptionTypeRequiredDescription
baseUrlstringYesThe embedded host for your environment. Must use https, except localhost and 127.0.0.1 for local development.
fetchToken() => Promise<string>One of fetchToken or tokenReturns a new session token from your backend. The SDK calls it when an iframe loads and when a session expires.
tokenstringOne of fetchToken or tokenA session token you created ahead of time. The SDK can't renew it, and the component stops working when it expires. Prefer fetchToken.
onError(error: EmbeddedSdkError) => voidNoReceives SDK failures you can act on. See Errors and telemetry.
onSupportRequest() => voidNoHandles "contact support" clicks inside every component. See Support requests.

Pass exactly one of fetchToken and token. Wingspan.init throws if you pass both or neither.

Sessions

Renewal

With fetchToken, the SDK asks your backend for a token:

  • each time a component's iframe loads, including when you reopen a modal
  • when a session expires partway through a flow. The iframe gets a 401, asks the SDK for a new token, and retries the request. The user doesn't see this.

When several components ask for a token at the same moment, the SDK makes one fetchToken call and shares the result. Create a new session on every call. Don't serve a cached token, because it may be the one that just expired.

If fetchToken throws, rejects or returns an empty string, the SDK reports token_fetch_failed to onError and sends nothing to the iframe. The iframe waits 15 seconds and then shows "Session expired".

With a static token, the SDK sends the same token again, and the component stops working once it expires. Call destroy() and create a new component with a new token.

Lifetime

A session lasts 15 minutes by default. To change that, send expiresAt as an ISO 8601 timestamp in the request body. The latest allowed value is one hour from now, and a later one returns 422. There's no refresh token for Account sessions. Renewing means creating another session.

Who you can create sessions for

Your ServiceAccount can create sessions for the Account that owns it and for every Account beneath it. For any other Account, the call returns 403.

Account sessions represent an Account, with no Person behind them. Nobody enters a password or completes MFA to use one.

Scopes

A session gets the scopes of the API key that created it, and it can't have more. Give the ServiceAccount and its API key the scopes your components use:

ScopeNeeded for
platform.account-session:writeCreating sessions (your backend)
users.account:readEvery component. The iframe reads the Account when it starts.
users.account:writeOnboarding: accepting the privacy policy, the terms and the tax certification
users.authorization:readOnboarding: reading the Account's Stakeholders
users.authorization:writeOnboarding: create_account saves the person, or a business's representative and owners, as Stakeholders
platform.compliance-entity:readOnboarding and complete verification: reading the tax identity
platform.compliance-entity:writeOnboarding and complete verification: saving the tax identity
users.identity-verification:writeOnboarding and complete verification: submitting the tax identity for verification
payments.requirement:readOnboarding and complete verification: reading onboarding status and missing fields
payments.payer:readcertify_tax_payer_info: listing the payee's payers
payments.payer:writecertify_tax_payer_info: sharing the certified tax information with each payer
search.payer-payee:readcertify_tax_payer_info: showing the payee's payers
payments.payout-settings:readThe payout_method module and the payout method components
payments.payout-settings:writeThe payout_method module and ManagePayoutMethod: saving the default payout method
payments.funding-source:readPayout, payment and funding method screens: listing bank accounts and cards
payments.funding-source:writePayout, payment and funding method screens: adding, verifying or removing a bank account or card
bookkeeping.business-banking-account:readPayout method screens, tax_withholdings and Wallet cards: reading the Wingspan Wallet
bookkeeping.business-banking-account:writeOpening a Wingspan Wallet, and saving tax_withholdings settings
bookkeeping.business-banking-balance:readShowing the Wingspan Wallet balance
payments.card:readWallet cards: listing cards and showing card details
payments.card:writeWallet cards: ordering a card, activating it, setting the PIN, suspending, reactivating and closing
payments.payroll-settings:readPayer screens: payroll_settings, payroll_funding_method and the funding method components
payments.payroll-settings:writePayer screens: saving payroll settings and the payroll funding source
payments.invoicing-config:readPayer onboarding: payables settings in payroll_settings
payments.invoicing-config:writePayer onboarding: saving payables settings in payroll_settings
payments.requirement:writeeligibility_requirements: completing a requirement

eligibility_requirements also needs the scopes for the requirement types your engagements use: signed-documents.signed-document:read and :write for signatures, shared-files.shared-file:read and :write with files.files:read and :write for document requests, users.identity-verification-request:read and :write for identity checks, compliance.insurance:read and :write for insurance, and background-checks.request:read and :write for background checks.

A call that needs a scope the session doesn't have returns 403 ScopeInsufficient.

Changing a ServiceAccount's scopes invalidates the API keys it already has. Create a new key after the change and deploy that one. Grant only the scopes you use, because the session token carries them and the API rejects a bearer token longer than 4,096 characters.

Operations that don't declare a scope only require the Account to be inside your ServiceAccount's tree.

Ending a session early

The session response includes an id. To end the session before it expires, call DELETE /v3/platform/sessions/{id} with the session's own token as the bearer token. Keep the id on your server if you plan to do this.

Components

Factory methodWho uses itWhat it doesModal closes on
createOnboarding(options)Payee or payerA step-by-step onboarding flow built from the modules you pickcomplete, error
createDefaultPayoutMethod(options?)PayeeRead-only view of the default payout methoderror
createManagePayoutMethod(options?)PayeeAdd a payout method, change the default, switch between standard and instantcomplete, error
createDefaultPaymentMethod(options?)PayerRead-only view of the default payment methoderror
createManagePaymentMethod(options?)PayerAdd a bank account or card and choose the default for autopaycomplete, error
createPreviewFundingMethod(options?)PayerRead-only view of the default payroll funding sourceerror
createManageFundingMethod(options?)PayerAdd a payroll funding source (bank account, Wallet or card) and choose the defaultcomplete, error
createCompleteVerification(options)PayeeFix a tax verification that came back UpdateRequired after onboardingcomplete, close, error
createManageWalletCards(options?)PayeeView, order and manage Wingspan Wallet debit cardserror

Every component also has the methods and lifecycle events in Component methods.

Onboarding

ws.createOnboarding(options) builds an onboarding flow from the modules you list, in the order you list them.

OptionTypeRequiredDescription
modulesOnboardingModule[]YesThe steps in the flow.
onComplete(data: OnboardingCompletePayload) => voidNoSame as onboarding.on("complete", ...).
onError(data: OnboardingErrorPayload) => voidNoSame as onboarding.on("error", ...).
EventPayload
complete{ modules: OnboardingModule["type"][] }
error{ code?: string; message: string; module?: OnboardingModule["type"] }

complete fires when the user presses Continue on the last step. In a modal, the SDK closes the modal. A mounted component stays on its last screen, so destroy it in your handler and move the user on. If you leave it mounted, pressing Continue again sends complete again.

complete means the user finished the steps. For a payee, it doesn't mean they can be paid yet. Read paymentsEligibility on their payee engagement afterward. See Check eligibility.

const onboarding = ws.createOnboarding({
  modules: [
    { type: "create_account", accountType: "payee" },
    { type: "certify_tax_payer_info" },
    { type: "payout_method" },
  ],
});

onboarding.on("complete", async () => {
  onboarding.destroy();
  await refreshPaymentsEligibility();
});

onboarding.mount("#wingspan-onboarding");

create_account

Collects the account type and the details Wingspan needs to verify it.

PropertyTypeRequiredDescription
type"create_account"Yes
accountType"payer" or "payee"Yespayer is a business account with banking-level verification. payee is an individual or business account with tax-level verification.
countrystringNoISO 3166-1 alpha-2 code, such as "US". Pre-selects the country and locks the selector.
allowUSInternationalPersonbooleanNoWhen true, people outside the U.S. see an "Are you a U.S. person?" step. If they check it, the flow also collects a U.S. SSN. Hidden by default.
allowedEntityTypes("business" | "individual")[]NoLimits what a payee can sign up as. Leave it out, or pass an empty array, to offer both. Ignored for payers, who are always businesses.
{ type: "create_account", accountType: "payee", country: "US", allowedEntityTypes: ["business"] }

An Account session has no Person behind it, so create_account saves the identity on the Account:

  • For an individual, the details go on a principal Stakeholder of the Account, with a ComplianceEntity for their tax identity.
  • For a business, the business details go on the Account's ComplianceEntity. The authorized representative becomes a Stakeholder with isController: true, and each beneficial owner becomes a Stakeholder too.

The representative's SSN is optional in the form. A business can finish tax verification and get paid without it. Without it, the Account's Banking verification shows UpdateRequired, which only affects Wingspan banking products such as the Wallet. To clear it, add the SSN to the representative's ComplianceEntity and call POST /v3/onboarding/verify with level: "Banking".

certify_tax_payer_info

Collects and certifies tax information on a W-9 or W-8BEN, then shares it with each of the payee's payers. On an Account session, Wingspan records the certification for the principal Stakeholder of an individual, or for the representative of a business.

{ type: "certify_tax_payer_info" }

payout_method

Sets up how a payee gets paid: bank account, instant payout or Wingspan Wallet.

PropertyTypeRequiredDescription
type"payout_method"Yes
allowedUsMethodsUsPayoutMethod[]NoMethods offered to U.S. payees. Leave it out to offer all of them.
allowedInternationalMethodsInternationalPayoutMethod[]NoMethods offered to payees outside the U.S. Leave it out to offer all of them.
localCurrencyOnlybooleanNoWhen true, the currency selector for an international bank account only shows currencies Wingspan pays through a local corridor in the payee's country. It leaves out currencies that need an international wire. If the country has no local corridor, the selector falls back to fallbackCurrency when the SWIFT list offers it, and otherwise shows the full SWIFT list. Has no effect for U.S. payees. Defaults to false.
fallbackCurrencystringNoThe currency to use, such as "USD", when localCurrencyOnly is on and the payee's country has no local corridor. Ignored unless localCurrencyOnly is true.

UsPayoutMethod is "ach", "wallet" or "instant". InternationalPayoutMethod is "us_bank_account", "international_bank_account" or "platform_balance". The iframe applies the list that matches the payee's country.

{
  type: "payout_method",
  allowedUsMethods: ["ach", "wallet"],
  allowedInternationalMethods: ["international_bank_account"],
  localCurrencyOnly: true,
  fallbackCurrency: "USD",
}

eligibility_requirements

Has the payee complete the requirements their payer set, such as signing agreements.

{ type: "eligibility_requirements" }

tax_withholdings

Lets the payee set aside part of each payment for taxes.

{ type: "tax_withholdings" }

payroll_funding_method

Sets up how a payer funds payroll.

PropertyTypeRequiredDescription
type"payroll_funding_method"Yes
allowedMethodsFundingMethod[]No"ach" or "wallet". Leave it out to offer both. "wallet" covers Wingspan Wallet funding in every currency Wingspan offers.

payroll_settings

Sets up the payer's payroll settings.

{ type: "payroll_settings" }

payment_method

Sets up the bank account or card a payer pays with.

PropertyTypeRequiredDescription
type"payment_method"Yes
allowedMethodsPaymentMethod[]No"ach" or "credit_card". Leave it out to offer both.

Example flows

// A payee
ws.createOnboarding({
  modules: [
    { type: "create_account", accountType: "payee" },
    { type: "certify_tax_payer_info" },
    { type: "payout_method" },
    { type: "eligibility_requirements" },
    { type: "tax_withholdings" },
  ],
});

// A payer
ws.createOnboarding({
  modules: [
    { type: "create_account", accountType: "payer" },
    { type: "payroll_funding_method" },
    { type: "payroll_settings" },
    { type: "payment_method" },
  ],
});

DefaultPayoutMethod

ws.createDefaultPayoutMethod(options?) shows the payee's current default payout method. It's read-only. Pair it with ManagePayoutMethod if you want an edit button next to it.

OptionTypeRequiredDescription
allowedUsMethodsUsPayoutMethod[]NoSame values as the payout_method module.
allowedInternationalMethodsInternationalPayoutMethod[]NoSame values as the payout_method module.
onError(data: EmbeddedErrorPayload) => voidNoSame as component.on("error", ...).
EventPayload
error{ code?: string; message: string }
ws.createDefaultPayoutMethod({ allowedUsMethods: ["ach", "wallet"] }).mount("#payout-method");

ManagePayoutMethod

ws.createManagePayoutMethod(options?) lets the payee add a payout method, change their default and switch between standard and instant payouts. Adding a new method happens inside the component.

OptionTypeRequiredDescription
allowedUsMethodsUsPayoutMethod[]NoSame values as the payout_method module.
allowedInternationalMethodsInternationalPayoutMethod[]NoSame values as the payout_method module.
localCurrencyOnlybooleanNoSame behavior as the payout_method module.
fallbackCurrencystringNoSame behavior as the payout_method module.
countrystringNoLocks the country selector in the bank account form to this country.
onComplete(data: { changed: boolean }) => voidNoSame as component.on("complete", ...).
onError(data: EmbeddedErrorPayload) => voidNoSame as component.on("error", ...).

Pass the same restrictions you use in onboarding so the payee sees the same choices in both places.

EventPayload
complete{ changed: boolean }. changed is false when the payee left without saving.
error{ code?: string; message: string }
const manage = ws.createManagePayoutMethod({
  allowedUsMethods: ["ach", "wallet"],
  onComplete: ({ changed }) => {
    if (changed) refreshPayoutMethod();
  },
});
manage.openModal();

DefaultPaymentMethod

ws.createDefaultPaymentMethod(options?) shows the payer's current default payment method. It's read-only.

OptionTypeRequiredDescription
onError(data: EmbeddedErrorPayload) => voidNoSame as component.on("error", ...).
EventPayload
error{ code?: string; message: string }
ws.createDefaultPaymentMethod().mount("#payment-method");

ManagePaymentMethod

ws.createManagePaymentMethod(options?) lets a payer admin add a bank account or card and choose which one Wingspan uses for autopay.

OptionTypeRequiredDescription
allowedMethodsPaymentMethod[]No"ach" or "credit_card". Limits what the admin can add. Leave it out to offer both.
onComplete(data: { changed: boolean }) => voidNoSame as component.on("complete", ...).
onError(data: EmbeddedErrorPayload) => voidNoSame as component.on("error", ...).
EventPayload
complete{ changed: boolean }. changed is false when the admin left without saving.
error{ code?: string; message: string }
ws.createManagePaymentMethod({ allowedMethods: ["ach"] }).openModal();

PreviewFundingMethod

ws.createPreviewFundingMethod(options?) shows the payer's current default payroll funding source. It's read-only. Pair it with ManageFundingMethod for editing.

OptionTypeRequiredDescription
onError(data: EmbeddedErrorPayload) => voidNoSame as component.on("error", ...).
EventPayload
error{ code?: string; message: string }
ws.createPreviewFundingMethod().mount("#funding-method");

ManageFundingMethod

ws.createManageFundingMethod(options?) lets a payer admin add a payroll funding source (bank account, Wallet or card) and choose which one funds payroll runs.

OptionTypeRequiredDescription
allowedMethodsFundingMethod[]No"ach" or "wallet". Limits what the admin can add. Leave it out for no limit.
onComplete(data: { changed: boolean }) => voidNoSame as component.on("complete", ...).
onError(data: EmbeddedErrorPayload) => voidNoSame as component.on("error", ...).
EventPayload
complete{ changed: boolean }. changed is false when the admin left without saving.
error{ code?: string; message: string }
ws.createManageFundingMethod().openModal();

CompleteVerification

ws.createCompleteVerification(options) handles a payee whose tax verification comes back UpdateRequired after they've finished onboarding. The payee lands on what needs fixing: a form for the fields that failed, or a document upload when Wingspan can't name the fields. They don't repeat onboarding.

Your app decides when to offer it. Read the payee's status from GET /v3/onboarding/status with your ServiceAccount key and X-Wingspan-Account set to the payee's Account, then check verifications.tax:

verifications.taxWhat to show
UpdateRequiredA button that opens this component
PendingYour own message saying the information is under review. Don't open the component.
FailedYour own message asking the payee to contact support
VerifiedNothing to do
NoneThe payee hasn't reached tax verification yet
OptionTypeRequiredDescription
level"tax"YesThe verification to complete. "tax" is the supported value. Any other value throws.
onComplete(data: { level: "tax" }) => voidNoSame as component.on("complete", ...).
onClose() => voidNoSame as component.on("close", ...).
onError(data: EmbeddedErrorPayload) => voidNoSame as component.on("error", ...).
EventPayload
complete{ level: "tax" }
closeNone
error{ code?: string; message: string }

complete means the payee submitted what was asked, not that verification passed. Read the status again afterward. It usually moves to Pending while Wingspan reviews the submission.

close fires when the payee dismisses the modal with the close button, the backdrop or Escape, or cancels inside the flow. It doesn't fire on completion, on errors, or when you call closeModal() or destroy().

If you open the component for a payee who has nothing to fix, it emits error. Check the status first.

The component handles corrections to the payee's top-level tax information.

const verification = ws.createCompleteVerification({
  level: "tax",
  onComplete: async () => {
    // Submitted, not passed. Read the status again.
    await refreshVerificationStatus();
  },
});

verification.openModal();

Use openModal() for this component. The flow can move to a document capture step that needs the space.

ManageWalletCards

ws.createManageWalletCards(options?) lets the payee see their Wingspan Wallet debit cards, order a virtual or physical card, and manage a card they have. Managing covers activating a physical card when it arrives, showing a virtual card's details, changing the PIN, and suspending, reactivating or closing a card. Ordering takes the payee through the cardholder agreements and, for a physical card, the shipping address.

A payee holds one virtual and one physical card at a time. After they close a card, they can order a new one of that type. A virtual card order uses the address on the payee's profile, and the component shows an error if there's no valid address on file.

Your ServiceAccount and its API key need the payments.card:read and payments.card:write scopes for this component, in addition to bookkeeping.business-banking-account:read. See Scopes.

The payee needs an active Wingspan Wallet. Only U.S. payees can open one, which they do by choosing Wingspan Wallet in ManagePayoutMethod or in the payout_method onboarding module. Both offer it unless your allowedUsMethods leaves out "wallet".

OptionTypeRequiredDescription
allowedCardTypesWalletCardType[]No"virtual" or "physical". Limits which cards appear and can be ordered. Leave it out, or pass an empty array, to offer both. Any other value throws.
onCardOrdered(data: ManageWalletCardsCardOrderedPayload) => voidNoSame as component.on("cardOrdered", ...).
onCardUpdated(data: ManageWalletCardsCardUpdatedPayload) => voidNoSame as component.on("cardUpdated", ...).
onError(data: EmbeddedErrorPayload) => voidNoSame as component.on("error", ...).
EventPayload
cardOrdered{ cardType: WalletCardType; status: WalletCardStatus }. A new virtual card is ACTIVE. A new physical card is PENDING_FULFILLMENT until it ships.
cardUpdated{ cardType: WalletCardType; action: WalletCardAction; status: WalletCardStatus }. status is the card's status after action.
error{ code?: string; message: string }

WalletCardStatus is ACTIVE, SUSPENDED, CANCELLED, PENDING_FULFILLMENT or PENDING_ACTIVATION. WalletCardAction is activated, pin_changed, suspended, reactivated or closed. No event includes card numbers, card IDs or personal details.

The component sends these error codes:

codeMeaningWhat to do
wallet_not_foundThe payee has no active Wallet, or their new Wallet is still waiting for activationShow message or your own prompt. Word it so it also fits a Wallet that's waiting for activation.
session_expiredThe session couldn't be renewed. With fetchToken, your token endpoint failed. With a static token, the token expired.Call destroy() and create a new component.

Only error closes the modal. cardOrdered and cardUpdated fire while the payee is still in the component. After a physical card order, for example, the component shows a delivery confirmation and stays open until the payee closes it. In a modal, wallet_not_found closes the modal as soon as the component loads.

Refresh your own view of the payee's cards on cardOrdered, cardUpdated and modal:closed. If the payee closes the modal while a request is running, its event may not arrive.

const cards = ws.createManageWalletCards({
  allowedCardTypes: ["virtual", "physical"],
  onCardOrdered: () => refreshCards(),
  onCardUpdated: () => refreshCards(),
  onError: ({ code, message }) => {
    if (code === "wallet_not_found") showWalletRequired(message);
    else showError(message);
  },
});

cards.openModal();

The component scrolls inside its container, so give the container at least 700px of height and 400px of width. The card art alone is 334px wide. At 992px wide or more, the virtual and physical cards sit side by side, and below that they stack. The default modal size of 600px by 700px shows the stacked layout. Pass { width: "1000px" } to openModal() to get the side-by-side layout on screens that wide.

Component methods

Every component has these methods.

mount(target)

Renders the component inside an element. target is a CSS selector or an HTMLElement.

  • Calling mount() again with the same target does nothing.
  • Calling mount() with a different target throws.
  • Calling mount() while the modal is open throws. Close the modal first.
  • Calling mount() after destroy() throws. Create a new component.

openModal(options?)

Opens the component in a modal over your page. The modal closes on the events listed for each component in the Components table.

OptionTypeDefaultDescription
widthstring"600px"Modal width
heightstring"700px"Modal height
overlayColorstring"rgba(0, 0, 0, 0.5)"Backdrop color
zIndexnumber2147483647Stack order. The default sits above chat and support widgets such as Intercom, Drift, Pendo and Zendesk.

The user can also close the modal with Escape or the close button. The SDK ignores Escape when your page already handled the key (event.defaultPrevented) or while the user is typing with an input method editor (event.isComposing).

  • Calling openModal() while the modal is open does nothing.
  • Calling openModal() on a mounted component throws.
  • Calling openModal() after destroy() throws.

closeModal()

Closes the modal from your code.

on(event, handler)

Subscribes to an event and returns a function that unsubscribes.

const unsubscribe = onboarding.on("complete", (data) => {});

onboarding.on("ready", () => {});
onboarding.on("load:error", ({ reason }) => {});
onboarding.on("modal:opened", () => {});
onboarding.on("modal:closed", ({ reason }) => {});

// Every event, including the component's own
onboarding.on("*", (event, data) => {});

The onComplete, onError and similar options on each factory method register the same handlers when you create the component. Use on() when you need to unsubscribe or add handlers later.

Every component emits these lifecycle events:

EventPayloadWhen it fires
readyNoneThe iframe finished loading and completed its handshake with the SDK.
load:error{ reason: "timeout" | "iframe-error"; timeoutMs?: number }The iframe didn't reach ready within 30 seconds (timeout), or it fired a DOM error event (iframe-error).
modal:openedNoneThe iframe inside the modal is ready. You can move focus or measure layout.
modal:closed{ reason: "manual" | "overlay" | "escape" | "button" | "event" | "destroy" }The modal closed.

destroy()

Removes the component, its event listeners and any open modal. You can call it more than once. After destroy(), mount() and openModal() throw. Create a new component for the next flow.

Support requests

Components show "contact support" links. By default, a click opens the support portal configured for your Wingspan organization in a popup window. If your organization has no portal, the link opens https://www.wingspan.app/support in a new tab.

To send users to your own support tools, pass onSupportRequest to Wingspan.init:

const ws = Wingspan.init({
  baseUrl: "https://embedded-v3.wingspan.app",
  fetchToken,
  onSupportRequest: () => {
    window.Intercom("show");
  },
});

The callback takes no arguments. When you pass it, components don't open a popup or a tab, and handling the request is up to you. Set it in Wingspan.init, because the SDK tells each iframe which behavior to use when the iframe loads. If your callback throws, the SDK reports event_dispatch_failed to onError and the component keeps working.

Errors and telemetry

Components report problems in two places.

Each component's error event carries { code?: string; message: string }. message is safe to show to the user. It may be localized, so don't match on its text. code is present when the component knows the type of error, such as wallet_not_found.

The onError option on Wingspan.init receives SDK failures as an EmbeddedSdkError:

codeOther fieldsWhen it fires
token_fetch_failederror: unknownfetchToken threw, rejected or returned an empty string. error is what it threw.
iframe_load_errorNoneThe iframe fired a DOM error event, for example because a Content Security Policy blocked it or the network failed.
iframe_load_timeouttimeoutMs: numberThe iframe didn't complete its handshake in time.
origin_mismatchexpected: string; received: stringA message from another origin tried to pass as a Wingspan iframe message. The SDK ignored it.
post_message_parse_failedNoneA message claimed to come from the Wingspan iframe but had the wrong shape.
event_dispatch_failedevent: string; error: unknownOne of your event handlers threw. The SDK keeps running.

The SDK doesn't send telemetry to Wingspan. These errors only go to your onError callback. If onError itself throws, the SDK catches it so your page keeps working.

Security checklist

  • Keep the API keys in server code only. Never put them in a variable your bundler exposes to the browser, such as NEXT_PUBLIC_* or VITE_*.
  • Look up the Wingspan Account from your own signed-in session. If your session endpoint accepts an Account ID from the browser, any of your users can get a session for any Account your ServiceAccount reaches.
  • Protect the session endpoint with your app's sign-in, and send Cache-Control: no-store on its response.
  • Treat a session token like a password until it expires. Keep it in memory. Don't log it, put it in a URL or store it in localStorage.
  • If your site sends a Content Security Policy, add the embedded host for your environment to frame-src, such as frame-src https://embedded-v3.wingspan.app.
  • Don't block these features in your page's Permissions-Policy header: camera, microphone, payment, clipboard-write, publickey-credentials-get and otp-credentials. Identity and document checks in onboarding and complete verification use the camera, and an iframe only gets the features its parent page allows.
  • Don't forward on("*") output straight to a third-party service such as Sentry or Datadog. It includes every event the iframe sends, and some carry personal or verification data. Subscribe to the events you need and remove personal data before it leaves your systems.

Upgrading from 1.x

SDK 2.x loads Wingspan's V3 embedded app, which only accepts V3 session tokens. Components, options and events are the same as 1.x. Your integration changes in these places:

1.x2.x
CredentialOrganization API tokenServiceAccount API key
Session callGET /users/organization/user/USERID/sessionPOST /v3/platform/accounts/{accountId}/sessions
Session is forA member userA Wingspan Account, with no Person behind it
SDK configtokenfetchToken (token still works but can't renew)
baseUrlembedded.wingspan.app or staging-embedded.wingspan.appembedded-v3.wingspan.app or staging-embedded-v3.wingspan.app

To upgrade:

  1. Create and activate a ServiceAccount, then create its API key, as in Step 1.
  2. Make sure each user you embed for has a V3 Account inside your ServiceAccount's tree, and store its ID with the user.
  3. Change your session endpoint to call POST /v3/platform/accounts/{accountId}/sessions.
  4. Install @wingspan/embedded-sdk@next, switch baseUrl to the V3 embedded host, and pass fetchToken instead of token.

To stay on 1.x, pin ^1 on npm or use @wingspan/embedded-sdk@1 in CDN URLs. 1.x keeps receiving fixes.

Versions

As of October 13, 2026:

npm tagVersionRuns on
next2.0.0-rc.1V3 API and the embedded-v3 hosts
latest1.0.0V1 API and the embedded hosts

Common mistakes

  • Installing without a version. npm install @wingspan/embedded-sdk and an unpinned CDN URL give you 1.x, which doesn't accept V3 tokens.
  • Pointing baseUrl at the API host or the V1 embedded host. baseUrl is the embedded-v3 host for your environment.
  • Leaving out X-Wingspan-Account on the session call. It returns 422. The header must name the same Account as the path.
  • Creating a session for an Account outside your ServiceAccount's tree. The call returns 403. Create payee and payer Accounts under an Account your ServiceAccount owns.
  • Serving a cached token from your session endpoint. Create a new session on every call.
  • Mounting into a container with no height. The iframe fills its container, so give the container an explicit height.
  • Opening CompleteVerification without checking the status first. It's meant for UpdateRequired only.

Related pages


Did this page help you?