Bank Verification
Before payroll can be enabled for a company, its bank account must be added and verified. This guide walks through that flow: adding a bank account, verifying it through Plaid (when available), and completing any bank provider agreement Worklio requires.
Bank Setup runs independently of Company Verification (KYC) — you can work on both at the same time. The one dependency between them: if a bank provider is enabled for the company (see Bank provider types below), KYC cannot be submitted for review until Bank Setup reports a Completed status.
Two ways to call these APIs
Every step below can be done either of two ways:
- Using
@worklio/sdk. The package exports functions likewepGetBankSetup(companyId). Import the one you need and call it. - Calling the REST endpoint directly, without the package, using any HTTP client.
Every step documents both.
@worklio/sdk functions don't make network requests
@worklio/sdk functions don't make network requestsThis is the one thing to understand before using the package: functions like wepGetBankSetup and wepCreateBankAccount don't call the API themselves. They're pure functions that return a plain object describing the request to make:
| Property | Type | Description |
|---|---|---|
method | string | The HTTP method, e.g. 'GET', 'POST'. |
url | string | The relative endpoint path, e.g. companies/123/bankaccounts. |
createRequestBody | function (optional) | Builds the request body from the data you pass in. Present only on functions that send a body. |
useResponseBody | function (optional) | Transforms the raw JSON response into the shape the function's caller expects. |
You take that descriptor and execute the HTTP request yourself. A small wrapper makes this reusable:
async function callWepApi<TReply, TResult>(
descriptor: {
method: string;
url: string;
noContentResponse?: boolean;
useResponseBody?: (reply: TReply) => TResult;
},
body?: unknown, // the value returned by descriptor.createRequestBody(...)
) {
const token = await wepAuth.getToken();
const base = new URL('wep', CONFIG.authority).toString().replace(/\/$/, '');
const url = `${base}/${descriptor.url.replace(/^\//, '')}`;
const hasBody = body !== undefined;
const isForm = body instanceof FormData;
const res = await fetch(url, {
method: descriptor.method,
headers: {
Authorization: `Bearer ${token}`,
'api-version': '2.0',
Accept: 'application/json',
// For FormData, fetch sets multipart/form-data and the boundary itself.
...(hasBody && !isForm && { 'Content-Type': 'application/json' }),
},
...(hasBody && { body: isForm ? body : JSON.stringify(body) }),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`${res.status} ${res.statusText}${text ? ` — ${text}` : ''}`);
}
if (descriptor.noContentResponse) return undefined;
const json = (await res.json()) as TReply;
return descriptor.useResponseBody ? descriptor.useResponseBody(json) : json;
}wepAuth.getToken() and CONFIG.authority above stand in for however your app manages its access token and base URL — see How to Get API Access.
For a POST/PATCH call that needs a body, pass your data through the descriptor's createRequestBody:
const { method, url, createRequestBody } = wepCreateCompanyKYCDocument(companyId);
const formData = new FormData();
formData.append("file", fileInput.files[0]);
const body = createRequestBody(formData);
// pass `body` as the fetch request's body, alongside method/url as aboveIf you're calling the REST API directly instead of through the package, none of this applies — just call the endpoint shown in each step below.
Prerequisites
| Requirement | Description |
|---|---|
| FEIN | The company's Federal Employer Identification Number must be valid and registered before onboarding begins. |
| Company ID | Required for every call below. Returned as id when you create the company. |
| Authorization | Only administrative roles can call these endpoints. |
Bank Setup flow
Step 1: Get bank setup status
Fetch the company's current bank setup snapshot: account fields, provider status, and verification status.
- wep:
wepGetBankSetup(companyId) - Endpoint:
GET /wep/companies/{cliendId}/banksetup
require("dotenv").config();
const TOKEN = process.env.API_KEY
const CLIENT_ID = process.env.CLIENT_ID
const URL = process.env.URL
const url = `${URL}/wep/companies/${CLIENT_ID}/banksetup
`
async function getBankSetup() {
const response = await fetch(url, {
method: "GET",
headers: {
accept: "application/json",
"api-version": "2.0",
authorization: `Bearer ${TOKEN}`,
"content-type": "application/json",
"x-api-version": "2.0"
},
});
console.log(response.status);
const raw = await response.text();
console.log(JSON.stringify(JSON.parse(raw), null, 2));
}
getBankSetup();
Example response:
{
"isCompleted": false,
"bankAccountStatus": 1,
"bankAccounts": [],
"kotapayStatus": 0,
"bankProviderStatus": 0,
"bankProviderType": 2,
"verificationStatus": 0
}Step 2: Create or update the primary bank account
Collect account type, routing number, and account number, then create the account.
- wep (create):
wepCreateBankAccount(companyId) - wep (update):
wepUpdateBankAccount(companyId, accountId) - Endpoint (create):
POST https://api.worklio.com/wep/companies/{companyId}/bankaccounts— see BankAccountBasics for the full field table, request, and response.
Save the accountId from the create response — every later step needs it.
Step 3: Request a Plaid link token
If Plaid is available for this company, request a link token before launching Plaid Link.
- wep:
wepGetPlaidLinkToken(companyId, accountId) - Endpoint:
GET /wep/companies/{companyId}/plaid/{accountId}/token
Example request:
require("dotenv").config();
const TOKEN = process.env.API_KEY;
const COMPANY_ID = process.env.COMPANY_ID;
const ACCOUNT_ID = process.env.ACCOUNT_ID;
const URL = process.env.URL
const url = `${URL}/wep/companies/${COMPANY_ID}/plaid/${ACCOUNT_ID}/token`;
async function getPlaidLinkToken() {
const response = await fetch(url, {
method: "GET",
headers: {
accept: "application/json",
"api-version": "2.0",
authorization: `Bearer ${TOKEN}`,
"x-api-version": "2.0"
},
});
console.log(response.status);
console.log(JSON.stringify(await response.json(), null, 2));
}
getPlaidLinkToken();Example response:
{
"linkToken": "link-sandbox-774abd1a-5253-457c-9223-757533aef6a0",
"expiration": "2026-09-23T10:26:24Z"
}linkToken expires quickly. Request a fresh one immediately before launching Plaid Link — don't cache it.
Step 4: Launch Plaid Link and get a public token
This step happens entirely on the client, through Plaid's own Link SDK — there's no Worklio wep function or endpoint involved.
Pass the linkToken from Step 3 into Plaid Link. When the user finishes connecting their bank, Plaid's onSuccess callback gives you:
- a
public_token— a short-lived token identifying the Link session. This is a different token from thelinkTokenyou requested in Step 3; don't confuse the two. - account metadata for the account the user selected: its Plaid
account_id, name, type, subtype, and mask.
Example code:
<!DOCTYPE html>
<html>
<head>
<title>Plaid Link Test</title>
<script src="https://cdn.plaid.com/link/v2/stable/link-initialize.js"></script>
</head>
<body>
<button id="link-button">Open Plaid Link</button>
<pre id="output"></pre>
<script>
// Paste the link_token you got from Worklio's GET .../plaid/{companyBankId}/token call
const LINK_TOKEN = "link-sandbox-fc5eede9-833b-4849-8f97-bbccbf3d6197";
const handler = Plaid.create({
token: LINK_TOKEN,
onSuccess: (public_token, metadata) => {
document.getElementById("output").textContent =
"public_token:\n" + public_token + "\n\nmetadata:\n" + JSON.stringify(metadata, null, 2);
console.log("public_token:", public_token);
console.log("metadata:", metadata);
},
onExit: (err, metadata) => {
console.log("Exited:", err, metadata);
}
});
document.getElementById("link-button").addEventListener("click", () => {
handler.open();
});
</script>
</body>
</html>Example response:
metadata:
{
"status": null,
"link_session_id": "01a0c911-4f88-7def-b07d-3cf0a7746816",
"institution": {
"name": "Tartan Bank",
"institution_id": "ins_109511"
},
"accounts": [
{
"id": "xob7LBqX6VFPrQxvMDXgC7P6agke7VIyx8vnZ",
"name": "Plaid Checking",
"mask": "0000",
"type": "depository",
"subtype": "checking",
"verification_status": null,
"class_type": null
}
],
"account": {
"id": "xob7LBqX6VFPrQxvMDXgC7P6agke7VIyx8vnZ",
"name": "Plaid Checking",
"mask": "0000",
"type": "depository",
"subtype": "checking",
"verification_status": null,
"class_type": null
},
"account_id": "xob7LBqX6VFPrQxvMDXgC7P6agke7VIyx8vnZ",
"transfer_status": null,
"wallet": null,
"add_card": null,
"session_group_id": null,
"public_token": "public-sandbox-5c39ca09-2093-4fa3-b0f0-5f7e089558a9"
}You'll pass the public token and this account metadata into Step 5.
Step 5: Submit the Plaid connection
Exchange the public token and submit the selected account's details so Worklio can complete verification.
- wep:
wepCreatePlaidConnection(companyId, accountId) - Endpoint:
POST /wep/companies/{companyId}/plaid/{accountId}/link
Fields:
| Field | Type | Required | Description |
|---|---|---|---|
accountId | string | Yes | Plaid's identifier for the selected account (the account_id from Step 4's metadata). Not the same value as the {accountId} path segment, which is Worklio's bank account id from Step 2. |
accountType | string | Yes | Plaid's account type, e.g. "depository". |
publicToken | string | Yes | The public token from Step 4's onSuccess callback. |
accountName | string | Yes | The account's display name, as reported by the bank through Plaid. |
accountSubtype | string | Yes | Plaid's account subtype, e.g. "Checking". |
accountMask | string | Yes | Last 4 digits of the account number, as reported by Plaid. |
Example request:
require("dotenv").config();
const TOKEN = process.env.API_KEY;
const COMPANY_ID = process.env.COMPANY_ID;
const ACCOUNT_ID = process.env.ACCOUNT_ID;
const URL = process.env.URL
const url = `${URL}/wep/companies/${COMPANY_ID}/plaid/${ACCOUNT_ID}/link`;
const payload = {
accountId: "xob7LBqX6VFPrQxvMDXgC7P6agke7VIyx8vnZ",
accountType: "depository",
publicToken: "public-sandbox-5c39ca09-2093-4fa3-b0f0-5f7e089558a9",
accountName: "Plaid Checking",
accountSubtype: "Checking",
accountMask: "0000"
};
async function submitPlaidConnection() {
const response = await fetch(url, {
method: "POST",
headers: {
accept: "application/json",
"api-version": "2.0",
authorization: `Bearer ${TOKEN}`,
"content-type": "application/json",
"x-api-version": "2.0"
},
body: JSON.stringify(payload)
});
console.log(response.status);
console.log(JSON.stringify(await response.json(), null, 2));
}
submitPlaidConnection();Example response:
{
"linkAccountStatus": 1,
"verifiedAt": "2026-09-22T12:25:10Z",
"verifiedExp": "2027-09-22T12:25:10Z",
"availableBalance": 100,
"currentBalance": 110,
"lastBalance": "2026-09-22T12:25:10Z",
"isRefreshEnabled": false,
"nextCheckOn": "2026-09-22T13:25:10Z",
"accountNumber": "13005110000",
"routingNumber": "011401533",
"accountMask": "0000",
"linkAccountNumber": "1111222233330000",
"linkRoutingNumber": "011401533",
"failedCount": 0
}| Field | Description |
|---|---|
linkAccountStatus | Integer status code for the linked account. Named values for this field aren't confirmed yet. |
verifiedAt / verifiedExp | When verification completed, and when it expires and needs to be refreshed. |
availableBalance / currentBalance | Balance snapshot at verification time. |
lastBalance | Timestamp of the last balance check. |
isRefreshEnabled | Whether Worklio will automatically refresh this account's balance. |
nextCheckOn | When the next scheduled balance check will run. |
accountNumber / routingNumber | The bank account and routing numbers on file. |
accountMask | Last 4 digits of the account number. |
linkAccountNumber / linkRoutingNumber | The account and routing numbers as reported through the Plaid link. |
failedCount | Number of failed verification attempts. |
Step 6: Collect provider-specific company details
If the enabled bank provider requires additional company details (name, address, and similar), display and collect them here.
- wep: provider-specific — for example
wepGetBankProvider,wepUpdateBankProvider. - Endpoint:
/wep/companies/clientId}/bankprovider/{bankaAccountId}
Example GET request and response:
require("dotenv").config();
const TOKEN = process.env.API_KEY_STAGING
const CLIENT_ID = process.env.CLIENT_ID_STAGING
const BANK_ACCOUNT_ID = process.env.COMPANY_BANK_ID_STAGING
const URL = process.env.URL_STAGING
const url = `${URL}/wep/companies/${CLIENT_ID}/bankprovider/${BANK_ACCOUNT_ID}
`
async function getBankProvider() {
const response = await fetch(url, {
method: "GET",
headers: {
accept: "application/json",
"api-version": "2.0",
authorization: `Bearer ${TOKEN}`,
"content-type": "application/json",
"x-api-version": "2.0"
},
});
console.log(response.status);
const raw = await response.text();
console.log(JSON.stringify(JSON.parse(raw), null, 2));
}
getBankProvider();
13005110000{
"bankProviderType": 2,
"status": 0,
"approvalStatus": 0,
"isMarijuanaInvolved": false
}Example POST request and response:
require("dotenv").config();
const TOKEN = process.env.API_KEY
const CLIENT_ID = process.env.CLIENT_IDG
const BANK_ACCOUNT_ID = process.env.COMPANY_BANK_ID
const URL = process.env.URL
const url = `${URL}/wep/companies/${CLIENT_ID}/bankprovider/${BANK_ACCOUNT_ID}
`
const payload = {
BusinessTyp: 0,
IsMarijuanaInvolved: true,
ShortName: "LN",
NAICS: "261543",
LegalName: "Legal Name",
TradeName: "Trade Name",
IsNonProfit: true,
AnticipatedFirstPayDate: "2026-10-05",
Involved: 0
}
async function updateBankProvider() {
const response = await fetch(url, {
method: "POST",
headers: {
accept: "application/json",
"api-version": "2.0",
authorization: `Bearer ${TOKEN}`,
"content-type": "application/json",
"x-api-version": "2.0"
},
body: JSON.stringify(payload)
});
console.log(response.status);
const raw = await response.text();
console.log(JSON.stringify(JSON.parse(raw), null, 2));
}
updateBankProvider();{
"bankProviderType": 2,
"status": 2,
"approvalStatus": 0,
"message": "",
"businessType": 4096,
"isMarijuanaInvolved": true,
"shortName": "LN",
"naics": "",
"legalName": "Legal Name",
"tradeName": "Trade Name",
"isNonProfit": false,
"involved": 0
}Step 8: Get the prefill
require("dotenv").config();
const TOKEN = process.env.API_KEY
const CLIENT_ID = process.env.CLIENT_IDG
const BANK_ACCOUNT_ID = process.env.COMPANY_BANK_ID
const URL = process.env.URL
const url = `${URL}/wep/companies/${CLIENT_ID}/bankprovider/${BANK_ACCOUNT_ID}/prefill
`
async function getBankProviderPrefill() {
const response = await fetch(url, {
method: "GET",
headers: {
accept: "application/json",
"api-version": "2.0",
authorization: `Bearer ${TOKEN}`,
"content-type": "application/json",
"x-api-version": "2.0"
},
body: JSON.stringify(payload)
});
console.log(response.status);
const raw = await response.text();
console.log(JSON.stringify(JSON.parse(raw), null, 2));
}
getBankProviderPrefill();Response Example:
{
"bankProviderType": 2,
"status": 2,
"approvalStatus": 0,
"previewFileName": "Approval_20_20261009_1421_preview.pdf",
"previewFileToken": "OFf9PpnEWXVLgIYLmb5wisNUf2xVvjJyhhPsIlBfs7FRgyWBJ7SfYWt62QrqcWazTnSTKoFLpi1XvlKJZ3SVZZpvW0kl1HfDsu0uFqS-54bbTTg@@",
"message": "",
"businessType": 1,
"isMarijuanaInvolved": false,
"shortName": "LN",
"naics": "221117",
"legalName": "Petrovaj Test, Inc.",
"tradeName": "My Trade Name",
"isNonProfit": false,
"involved": 0
}
If you are not getting previeFileName and previewFileToken your company doesn't have it's address. Please make sure it exists or next step will fail.
Step 9: Sign the provider agreement
If a bank provider is enabled (see Bank provider types), present the provider's agreement for review and signature before continuing. If no provider is enabled, skip this step.
Fields: agreement document, signatory job title, full name, and an electronic-consent checkbox.
- wep:
wepSignBankProvider(companyId, accountId) - Endpoint:
POST /wep/companies/{CLIENT_ID}/bankprovider/579/sign
Example request and response:
require("dotenv").config();
const TOKEN = process.env.API_KEY_STAGING;
const CLIENT_ID = process.env.CLIENT_ID_STAGING
const BANK_ACCOUNT_ID = process.env.COMPANY_BANK_ID_STAGING
const url = `${process.env.URL_STAGING}/wep/companies/${CLIENT_ID}/bankprovider/${BANK_ACCOUNT_ID}/sign`;
const payload = {
signName: "John Doe",
signTitle: "CEO"
}
async function createPayrollPolicy() {
const response = await fetch(url, {
method: "POST",
headers: {
accept: "application/json",
"api-version": "2.0",
authorization: `Bearer ${TOKEN}`,
"content-type": "application/merge-patch+json",
"x-api-version": "2.0"
},
body: JSON.stringify(payload)
});
console.log(response.status);
const raw = await response.text();
console.log(JSON.stringify(JSON.parse(raw), null, 2));
}
createPayrollPolicy();Example Response:
{
"bankProviderType": 2,
"status": 3,
"approvalStatus": 0,
"previewFileName": "Approval_20_20261009_1421_preview.pdf",
"previewFileToken": "SZDBIQ64l1OxxqT2CzhGuf2SIufeAsCuWFq7QUmzCZnS5Ltx0gPp6kNQS2N7YIoKrEufOVqmS2irIe1kR1o23ShFEtpO4FZjpLPDr9tQBQY1qRw@@",
"signedFileName": "Approval_20_20261009_1423_signed.pdf",
"signedFileToken": "tVv3vCoaXTwgKhjEiTcGN-STwTpERTf5av3cSX1vqHdDqcJRW2tgd9EslsrzhgfoGkxXm1BVgjFxzvBWSX4_IR9jeCd7YwzSPoxLqy1agm9ouyA@@",
"message": "",
"isMarijuanaInvolved": false,
"shortName": "LN",
"naics": "221117",
"legalName": "Petrovaj Test, Inc.",
"tradeName": "My Trade Name",
"isNonProfit": false,
"involved": 0
}Error response
You will get this error if your company doesn't have an address.
{
"status": 0,
"code": "400",
"errorCode": "NatPay",
"message": "10/9/2026 - 2:21:02 PM : Missing localization for key: Worklio.Business.Clients.BankProvider.BankProviderLogic.NatPay",
"stackTrace": "",
"pagination": {
"pageNo": 0,
"pageSize": 0,
"totalRecords": 0,
"totalPages": 0,
"dataToken": ""
},
"validationErrors": null
}Step 10: Confirm completion
Check that account, verification, and provider statuses are all complete before moving on to (or unlocking) payroll.
- wep:
wepGetBankSetup(companyId) - Endpoint: not yet confirmed — same call as Step 1.
require("dotenv").config();
const TOKEN = process.env.API_KEY_STAGING;
const CLIENT_ID = process.env.CLIENT_ID_STAGING
const url = `${process.env.URL_STAGING}/wep/companies/${CLIENT_ID}/banksetup`;
async function createPayrollPolicy() {
const response = await fetch(url, {
method: "GET",
headers: {
accept: "application/json",
"api-version": "2.0",
authorization: `Bearer ${TOKEN}`,
"content-type": "application/merge-patch+json",
"x-api-version": "2.0"
},
});
console.log(response.status);
const raw = await response.text();
console.log(JSON.stringify(JSON.parse(raw), null, 2));
}
createPayrollPolicy();{
"isCompleted": true,
"bankAccountStatus": 2,
"bankAccounts": [
{
"accountId": 17,
"accountName": "Petrovaj Co. Account",
"accountNumber": "13305110000",
"routingNumber": "011401533",
"type": 1,
"purpose": 2,
"bankAccountStatus": 2,
"bankProviderStatus": 2,
"bankProviderType": 2,
"kotapayStatus": 0,
"verificationStatus": 2,
"plaidLinkStatus": 1
}
],
"kotapayStatus": 1,
"bankProviderStatus": 1,
"bankProviderType": 2,
"verificationStatus": 2
}Bank setup status
| Value | Meaning |
|---|---|
NotAvailable | Bank setup has not started, or isn't applicable. |
Active | Bank setup is in progress. |
Completed | Bank Setup is complete. Required before payroll can run, and before KYC can be submitted for review if a bank provider is enabled. |
Bank provider types
| Value | Meaning |
|---|---|
0 | None |
2 | NatPay |
Bank account purpose
| Value | Meaning |
|---|---|
DepositAccount | The account is used to receive deposits. |
OperatingAccount | The account is used for the company's operating funds. |
Enabling payroll
Payroll can only be enabled once Bank Setup reports Completed and Company Verification (KYC) reports Completed (or KYC is disabled for this environment).
Error handling and edge cases
- Plaid link tokens expire quickly — request a fresh one immediately before each Plaid Link launch (Step 3).
- Only users with administrative privileges should call these endpoints.
- Bank providers can require different additional fields or steps — check the provider type before assuming a fixed flow.
Updated about 2 hours ago
