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 like wepGetBankSetup(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

This 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:

PropertyTypeDescription
methodstringThe HTTP method, e.g. 'GET', 'POST'.
urlstringThe relative endpoint path, e.g. companies/123/bankaccounts.
createRequestBodyfunction (optional)Builds the request body from the data you pass in. Present only on functions that send a body.
useResponseBodyfunction (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 above

If 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

RequirementDescription
FEINThe company's Federal Employer Identification Number must be valid and registered before onboarding begins.
Company IDRequired for every call below. Returned as id when you create the company.
AuthorizationOnly 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 the linkToken you 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:

FieldTypeRequiredDescription
accountIdstringYesPlaid'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.
accountTypestringYesPlaid's account type, e.g. "depository".
publicTokenstringYesThe public token from Step 4's onSuccess callback.
accountNamestringYesThe account's display name, as reported by the bank through Plaid.
accountSubtypestringYesPlaid's account subtype, e.g. "Checking".
accountMaskstringYesLast 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
}
FieldDescription
linkAccountStatusInteger status code for the linked account. Named values for this field aren't confirmed yet.
verifiedAt / verifiedExpWhen verification completed, and when it expires and needs to be refreshed.
availableBalance / currentBalanceBalance snapshot at verification time.
lastBalanceTimestamp of the last balance check.
isRefreshEnabledWhether Worklio will automatically refresh this account's balance.
nextCheckOnWhen the next scheduled balance check will run.
accountNumber / routingNumberThe bank account and routing numbers on file.
accountMaskLast 4 digits of the account number.
linkAccountNumber / linkRoutingNumberThe account and routing numbers as reported through the Plaid link.
failedCountNumber 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

ValueMeaning
NotAvailableBank setup has not started, or isn't applicable.
ActiveBank setup is in progress.
CompletedBank 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

ValueMeaning
0None
2NatPay

Bank account purpose

ValueMeaning
DepositAccountThe account is used to receive deposits.
OperatingAccountThe 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.

Did this page help you?