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
| Payees | Payers | |
|---|---|---|
| Who they are | Contractors and vendors who get paid | Companies that pay contractors, and customers that pay your invoices |
| Onboarding modules | create_account as a payee, tax information, payout method, requirements, tax withholdings | create_account as a payer, payroll funding, payroll settings, payment method |
| Accounts payable | Payout method, tax verification fixes and Wallet cards | Payroll funding source |
| Accounts receivable | Payout method, which decides where invoice payments land | The bank account or card that pays invoices, and the autopay default |
| Session is for | The payee's Account | The payer's Account |
How it works
- 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.
- Your frontend loads the SDK and gives it a
fetchTokenfunction, which asks your backend for a session token. - 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 callsfetchTokenagain 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-srcentry for the embedded host. See Security checklist.
Environments
| Environment | API base URL | Embedded host (baseUrl) |
|---|---|---|
| Production | https://api.wingspan.app | https://embedded-v3.wingspan.app |
| Sandbox | https://stagingapi.wingspan.app | https://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@nextTo 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.
- Create the ServiceAccount with the scopes your components use. It starts in
Createdso you can review its scopes. - Activate it before your backend uses its key. Until then it stays in
Created. - 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 component | Create the session for |
|---|---|
| A payee: onboarding, payout method, complete verification, Wallet cards | The payee's Account |
| A payer: onboarding, payment method, funding method | The 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:
POST /v3/platform/accountswithparentAccountIdset to the payer's Account creates the payee's Account.POST /v3/payments/payees, sent as the payer withX-Wingspan-Account, creates the Payee.POST /v3/payments/payees/{payeeId}/associate-accountlinks 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.
| Option | Type | Required | Description |
|---|---|---|---|
baseUrl | string | Yes | The 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 token | Returns a new session token from your backend. The SDK calls it when an iframe loads and when a session expires. |
token | string | One of fetchToken or token | A 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) => void | No | Receives SDK failures you can act on. See Errors and telemetry. |
onSupportRequest | () => void | No | Handles "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:
| Scope | Needed for |
|---|---|
platform.account-session:write | Creating sessions (your backend) |
users.account:read | Every component. The iframe reads the Account when it starts. |
users.account:write | Onboarding: accepting the privacy policy, the terms and the tax certification |
users.authorization:read | Onboarding: reading the Account's Stakeholders |
users.authorization:write | Onboarding: create_account saves the person, or a business's representative and owners, as Stakeholders |
platform.compliance-entity:read | Onboarding and complete verification: reading the tax identity |
platform.compliance-entity:write | Onboarding and complete verification: saving the tax identity |
users.identity-verification:write | Onboarding and complete verification: submitting the tax identity for verification |
payments.requirement:read | Onboarding and complete verification: reading onboarding status and missing fields |
payments.payer:read | certify_tax_payer_info: listing the payee's payers |
payments.payer:write | certify_tax_payer_info: sharing the certified tax information with each payer |
search.payer-payee:read | certify_tax_payer_info: showing the payee's payers |
payments.payout-settings:read | The payout_method module and the payout method components |
payments.payout-settings:write | The payout_method module and ManagePayoutMethod: saving the default payout method |
payments.funding-source:read | Payout, payment and funding method screens: listing bank accounts and cards |
payments.funding-source:write | Payout, payment and funding method screens: adding, verifying or removing a bank account or card |
bookkeeping.business-banking-account:read | Payout method screens, tax_withholdings and Wallet cards: reading the Wingspan Wallet |
bookkeeping.business-banking-account:write | Opening a Wingspan Wallet, and saving tax_withholdings settings |
bookkeeping.business-banking-balance:read | Showing the Wingspan Wallet balance |
payments.card:read | Wallet cards: listing cards and showing card details |
payments.card:write | Wallet cards: ordering a card, activating it, setting the PIN, suspending, reactivating and closing |
payments.payroll-settings:read | Payer screens: payroll_settings, payroll_funding_method and the funding method components |
payments.payroll-settings:write | Payer screens: saving payroll settings and the payroll funding source |
payments.invoicing-config:read | Payer onboarding: payables settings in payroll_settings |
payments.invoicing-config:write | Payer onboarding: saving payables settings in payroll_settings |
payments.requirement:write | eligibility_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 method | Who uses it | What it does | Modal closes on |
|---|---|---|---|
createOnboarding(options) | Payee or payer | A step-by-step onboarding flow built from the modules you pick | complete, error |
createDefaultPayoutMethod(options?) | Payee | Read-only view of the default payout method | error |
createManagePayoutMethod(options?) | Payee | Add a payout method, change the default, switch between standard and instant | complete, error |
createDefaultPaymentMethod(options?) | Payer | Read-only view of the default payment method | error |
createManagePaymentMethod(options?) | Payer | Add a bank account or card and choose the default for autopay | complete, error |
createPreviewFundingMethod(options?) | Payer | Read-only view of the default payroll funding source | error |
createManageFundingMethod(options?) | Payer | Add a payroll funding source (bank account, Wallet or card) and choose the default | complete, error |
createCompleteVerification(options) | Payee | Fix a tax verification that came back UpdateRequired after onboarding | complete, close, error |
createManageWalletCards(options?) | Payee | View, order and manage Wingspan Wallet debit cards | error |
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.
| Option | Type | Required | Description |
|---|---|---|---|
modules | OnboardingModule[] | Yes | The steps in the flow. |
onComplete | (data: OnboardingCompletePayload) => void | No | Same as onboarding.on("complete", ...). |
onError | (data: OnboardingErrorPayload) => void | No | Same as onboarding.on("error", ...). |
| Event | Payload |
|---|---|
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.
| Property | Type | Required | Description |
|---|---|---|---|
type | "create_account" | Yes | |
accountType | "payer" or "payee" | Yes | payer is a business account with banking-level verification. payee is an individual or business account with tax-level verification. |
country | string | No | ISO 3166-1 alpha-2 code, such as "US". Pre-selects the country and locks the selector. |
allowUSInternationalPerson | boolean | No | When 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")[] | No | Limits 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.
| Property | Type | Required | Description |
|---|---|---|---|
type | "payout_method" | Yes | |
allowedUsMethods | UsPayoutMethod[] | No | Methods offered to U.S. payees. Leave it out to offer all of them. |
allowedInternationalMethods | InternationalPayoutMethod[] | No | Methods offered to payees outside the U.S. Leave it out to offer all of them. |
localCurrencyOnly | boolean | No | When 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. |
fallbackCurrency | string | No | The 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.
| Property | Type | Required | Description |
|---|---|---|---|
type | "payroll_funding_method" | Yes | |
allowedMethods | FundingMethod[] | 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.
| Property | Type | Required | Description |
|---|---|---|---|
type | "payment_method" | Yes | |
allowedMethods | PaymentMethod[] | 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.
| Option | Type | Required | Description |
|---|---|---|---|
allowedUsMethods | UsPayoutMethod[] | No | Same values as the payout_method module. |
allowedInternationalMethods | InternationalPayoutMethod[] | No | Same values as the payout_method module. |
onError | (data: EmbeddedErrorPayload) => void | No | Same as component.on("error", ...). |
| Event | Payload |
|---|---|
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.
| Option | Type | Required | Description |
|---|---|---|---|
allowedUsMethods | UsPayoutMethod[] | No | Same values as the payout_method module. |
allowedInternationalMethods | InternationalPayoutMethod[] | No | Same values as the payout_method module. |
localCurrencyOnly | boolean | No | Same behavior as the payout_method module. |
fallbackCurrency | string | No | Same behavior as the payout_method module. |
country | string | No | Locks the country selector in the bank account form to this country. |
onComplete | (data: { changed: boolean }) => void | No | Same as component.on("complete", ...). |
onError | (data: EmbeddedErrorPayload) => void | No | Same as component.on("error", ...). |
Pass the same restrictions you use in onboarding so the payee sees the same choices in both places.
| Event | Payload |
|---|---|
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.
| Option | Type | Required | Description |
|---|---|---|---|
onError | (data: EmbeddedErrorPayload) => void | No | Same as component.on("error", ...). |
| Event | Payload |
|---|---|
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.
| Option | Type | Required | Description |
|---|---|---|---|
allowedMethods | PaymentMethod[] | No | "ach" or "credit_card". Limits what the admin can add. Leave it out to offer both. |
onComplete | (data: { changed: boolean }) => void | No | Same as component.on("complete", ...). |
onError | (data: EmbeddedErrorPayload) => void | No | Same as component.on("error", ...). |
| Event | Payload |
|---|---|
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.
| Option | Type | Required | Description |
|---|---|---|---|
onError | (data: EmbeddedErrorPayload) => void | No | Same as component.on("error", ...). |
| Event | Payload |
|---|---|
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.
| Option | Type | Required | Description |
|---|---|---|---|
allowedMethods | FundingMethod[] | No | "ach" or "wallet". Limits what the admin can add. Leave it out for no limit. |
onComplete | (data: { changed: boolean }) => void | No | Same as component.on("complete", ...). |
onError | (data: EmbeddedErrorPayload) => void | No | Same as component.on("error", ...). |
| Event | Payload |
|---|---|
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.tax | What to show |
|---|---|
UpdateRequired | A button that opens this component |
Pending | Your own message saying the information is under review. Don't open the component. |
Failed | Your own message asking the payee to contact support |
Verified | Nothing to do |
None | The payee hasn't reached tax verification yet |
| Option | Type | Required | Description |
|---|---|---|---|
level | "tax" | Yes | The verification to complete. "tax" is the supported value. Any other value throws. |
onComplete | (data: { level: "tax" }) => void | No | Same as component.on("complete", ...). |
onClose | () => void | No | Same as component.on("close", ...). |
onError | (data: EmbeddedErrorPayload) => void | No | Same as component.on("error", ...). |
| Event | Payload |
|---|---|
complete | { level: "tax" } |
close | None |
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".
| Option | Type | Required | Description |
|---|---|---|---|
allowedCardTypes | WalletCardType[] | 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) => void | No | Same as component.on("cardOrdered", ...). |
onCardUpdated | (data: ManageWalletCardsCardUpdatedPayload) => void | No | Same as component.on("cardUpdated", ...). |
onError | (data: EmbeddedErrorPayload) => void | No | Same as component.on("error", ...). |
| Event | Payload |
|---|---|
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:
code | Meaning | What to do |
|---|---|---|
wallet_not_found | The payee has no active Wallet, or their new Wallet is still waiting for activation | Show message or your own prompt. Word it so it also fits a Wallet that's waiting for activation. |
session_expired | The 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()afterdestroy()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.
| Option | Type | Default | Description |
|---|---|---|---|
width | string | "600px" | Modal width |
height | string | "700px" | Modal height |
overlayColor | string | "rgba(0, 0, 0, 0.5)" | Backdrop color |
zIndex | number | 2147483647 | Stack 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()afterdestroy()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:
| Event | Payload | When it fires |
|---|---|---|
ready | None | The 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:opened | None | The 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:
code | Other fields | When it fires |
|---|---|---|
token_fetch_failed | error: unknown | fetchToken threw, rejected or returned an empty string. error is what it threw. |
iframe_load_error | None | The iframe fired a DOM error event, for example because a Content Security Policy blocked it or the network failed. |
iframe_load_timeout | timeoutMs: number | The iframe didn't complete its handshake in time. |
origin_mismatch | expected: string; received: string | A message from another origin tried to pass as a Wingspan iframe message. The SDK ignored it. |
post_message_parse_failed | None | A message claimed to come from the Wingspan iframe but had the wrong shape. |
event_dispatch_failed | event: string; error: unknown | One 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_*orVITE_*. - 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-storeon 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 asframe-src https://embedded-v3.wingspan.app. - Don't block these features in your page's
Permissions-Policyheader:camera,microphone,payment,clipboard-write,publickey-credentials-getandotp-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.x | 2.x | |
|---|---|---|
| Credential | Organization API token | ServiceAccount API key |
| Session call | GET /users/organization/user/USERID/session | POST /v3/platform/accounts/{accountId}/sessions |
| Session is for | A member user | A Wingspan Account, with no Person behind it |
| SDK config | token | fetchToken (token still works but can't renew) |
baseUrl | embedded.wingspan.app or staging-embedded.wingspan.app | embedded-v3.wingspan.app or staging-embedded-v3.wingspan.app |
To upgrade:
- Create and activate a ServiceAccount, then create its API key, as in Step 1.
- Make sure each user you embed for has a V3 Account inside your ServiceAccount's tree, and store its ID with the user.
- Change your session endpoint to call
POST /v3/platform/accounts/{accountId}/sessions. - Install
@wingspan/embedded-sdk@next, switchbaseUrlto the V3 embedded host, and passfetchTokeninstead oftoken.
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 tag | Version | Runs on |
|---|---|---|
next | 2.0.0-rc.1 | V3 API and the embedded-v3 hosts |
latest | 1.0.0 | V1 API and the embedded hosts |
Common mistakes
- Installing without a version.
npm install @wingspan/embedded-sdkand an unpinned CDN URL give you 1.x, which doesn't accept V3 tokens. - Pointing
baseUrlat the API host or the V1 embedded host.baseUrlis theembedded-v3host for your environment. - Leaving out
X-Wingspan-Accounton 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
CompleteVerificationwithout checking the status first. It's meant forUpdateRequiredonly.
Related pages
Updated 11 days ago