If you sell to businesses in Taiwan, your checkout or invoicing form will ask for a Unified Business Number (統一編號, often shortened to 統編 or UBN). It's the 8-digit tax ID printed on every B2B e-invoice, and it carries a check digit, so you can catch most typos on the client before the form is submitted.
The catch: a lot of validators on npm, Stack Overflow and old blog posts still implement the pre-2023 rule, which requires the weighted sum to be divisible by 10. Taiwan's Ministry of Finance relaxed that to divisible by 5, and numbers issued under the new rule fail the old check. This guide covers the current algorithm, a tested TypeScript implementation, and how WebberUI's tax-id-input component handles it.
The source: Ministry of Finance, December 2021
On December 22, 2021, the Fiscal Information Agency (財政資訊中心) under the Ministry of Finance published a notice titled 營利事業統一編號檢查碼邏輯修正說明 ("Revision of the UBN check-digit logic"). The key points:
- The existing number pool was expected to run out in 2024.
- To add capacity while keeping the format unchanged, the check changes from "divisible by 10" to "divisible by 5".
- Anyone running UBN validation, public or private, had to update it by March 31, 2023.
- From April 2023, newly generated numbers would be released gradually as the old pool ran down.
The notice links an attachment PDF with the full calculation and worked examples. Every sample number in this article comes from that attachment.
In January 2025 the agency followed up with a guide for retailers whose POS systems reject UBNs. New-rule numbers have been issued since December 2023, and stores still running the old logic couldn't record them on invoices. The guide gives two numbers to test with: 04595252 and 10458570.
How the check digit works
A UBN is always 8 digits. To validate one:
- Multiply each digit by the weight in the same position:
1, 2, 1, 2, 1, 2, 4, 1. - For each product, add its tens digit and ones digit together (18 becomes 1 + 8 = 9).
- Add up the eight results. If the total is divisible by 5, the number is valid.
Here is 04595252 from the attachment:
| Position | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| Digit | 0 | 4 | 5 | 9 | 5 | 2 | 5 | 2 |
| Weight | 1 | 2 | 1 | 2 | 1 | 2 | 4 | 1 |
| Product | 0 | 8 | 5 | 18 | 5 | 4 | 20 | 2 |
| Tens + ones | 0 | 8 | 5 | 9 | 5 | 4 | 2 | 2 |
The sum is 35. It's divisible by 5, so the number is valid under the current rule, and it fails the old divisible-by-10 rule. That is exactly the bug the POS guide describes.
The change is backward compatible by construction: anything divisible by 10 is also divisible by 5, so every number that passed before still passes. You don't need a separate code path for older UBNs.
The special case: 7 in the seventh position
The seventh digit has a weight of 4. If that digit is 7, the product is 28, and 2 + 8 = 10, which is still two digits. The rule in the attachment is that this position may count as either 1 or 0. If either resulting sum is divisible by 5, the number is valid.
Take 10458570. Counting the seventh position as 1 gives 16, counting it as 0 gives 15. Since 15 is divisible by 5, the number is valid. Under the old rule neither 16 nor 15 is divisible by 10, so old validators reject it.
A TypeScript implementation
const WEIGHTS = [1, 2, 1, 2, 1, 2, 4, 1] as const;
/**
* Validate an 8-digit Taiwan Unified Business Number.
* divisor defaults to 5 (the rule since 2023). Pass 10 to reproduce the
* old rule when you're testing a legacy system.
*/
export function isValidUBN(input: string, divisor: 5 | 10 = 5): boolean {
if (!/^\d{8}$/.test(input)) return false;
let sum = 0;
for (let i = 0; i < 8; i++) {
const product = Number(input[i]) * WEIGHTS[i];
// tens digit + ones digit, e.g. 18 -> 1 + 8 = 9
sum += Math.floor(product / 10) + (product % 10);
}
if (sum % divisor === 0) return true;
// Seventh digit is 7: 7 * 4 = 28, 2 + 8 = 10, and that position may count
// as 1 or 0. The loop added 10; counting 0 leaves the remainder unchanged,
// counting 1 is the same as sum - 9.
return input[6] === "7" && (sum - 9) % divisor === 0;
}The function expects a clean 8-character string. If users paste values like 0459-5252 or include spaces, strip non-digits first with input.replace(/\D/g, "").
We ran it in Node.js against the numbers from the official attachment and the POS guide:
| Number | Current rule (÷5) | Old rule (÷10) |
|---|---|---|
04595257 | valid | valid |
04595252 | valid | invalid |
10458575 | valid | valid |
10458574 | valid | valid |
10458570 | valid | invalid |
04595253 | invalid | invalid |
10458571 | invalid | invalid |
We also generated 2 million random 8-digit strings and compared the result against the validateTaxId function shipped in WebberUI's component. There were no disagreements.
Common mistakes
- Storing the UBN as a number.
04595252becomes4595252and fails the length check. Keep it as a string in your database and API. - Using a validator written before 2023. Run
04595252through whatever library or backend check you already have. If it returns false, it's on the old rule. - Treating a valid checksum as proof the business exists. The check digit only catches typos. To confirm a company is registered, you need to look it up against the Ministry of Finance's business tax registration records.
The WebberUI tax-id-input component
If your project uses React and Tailwind, you can install tax-id-input instead of building the field yourself. It wraps the same check in an 8-cell input that validates as soon as the eighth digit is entered: a green check on success, and a short shake with an explanation on failure.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add tax-id-input.enInstall it with the WebberUI CLI:
npx webberui@latest add https://webberui.com/r/tax-id-input.en.jsonYou can also set up registries in components.json and install it as @webberui/tax-id-input. WebberUI components are source-available: the file is copied into your components/ui/ directory and you can edit it like any other code in your project.
import { TaxIdInput, validateTaxId } from "@/components/ui/tax-id-input";
<TaxIdInput
label="Uniform Number"
onChange={(value) => console.log(value)}
onValid={(ok, value) => ok && console.log(`${value} can be invoiced`)}
/>
// The validator also works on its own, e.g. a final check before submit
validateTaxId("12345675"); // true (an algorithm test number, not a real company)
validateTaxId("12345678"); // falseThe bundled validateTaxId handles the seventh-digit case slightly differently from the function above: it checks (sum + 1) % 5 === 0. Since sum + 1 and sum - 9 differ by exactly 10, they leave the same remainder when divided by 5, so the two produce identical results. The component also runs input through sanitizeTaxId, which strips non-digits and truncates to 8 characters, so a pasted 0459-5252 becomes 04595252.
A few implementation details worth knowing:
- Input goes through a single native
inputwithinputMode="numeric", so mobile users get a number pad. The eight cells are display only and hidden from assistive technology. - Status messages live in an
aria-live="polite"region, andaria-invalidis set on failure. - When the user has reduced motion enabled, the shake and pulse animations are turned off and only color and text feedback remain.
See the tax-id-input docs for the full props table and manual installation steps. If your form also collects personal IDs, the companion guide on validating Taiwan national ID numbers in React covers that algorithm.
Summary
- Weights are
1,2,1,2,1,2,4,1. Reduce each product to the sum of its digits, then add them up. - The current rule is divisible by 5. March 31, 2023 was the deadline for updating validators.
- If the seventh digit is 7, that position counts as 1 or 0, and either sum may satisfy the rule.
- Test your system with
04595252and10458570. If either is rejected, it still uses the old rule.