Verify a supplier's ABN on your server
Verify a supplier’s ABN on your server
What you build: before a supplier is paid, your server checks the ABN they gave you. A typo is caught locally and costs no call; a well-formed number is looked up on the Australian Business Register; the answer tells you whether the entity is registered, its name and status, and which source answered. An outage is reported as an outage, never as “not registered”.
Before you start: a development app, then a production app
npx comms-id initmakes a development key in.env.localand prints its public key. In the Companion App, register it as a development app, enable ABN for it, and put the app id in.env.localasCOMMS_ID_CLIENT_ID. This key reaches only the TEST service (https://api-test.comms.id): the same API, errors and authentication as LIVE, with synthetic replies.- When it works in TEST,
npx comms-id key --worker <your worker>makes the production key straight into your server’sCOMMS_ID_PRIVATE_JWKsecret and prints the public key; register it as a production app with ABN enabled. Going live changes only the environment and the key: nothing in the code below.
A .env.local never holds a production key, and a browser never holds any key.
The code
pnpm add @comms-id/abn
verify-supplier.ts:
// Verify a supplier's ABN on your server: check the number locally first (no call for a typo),
// then ask the register. An outage is reported as such, never as "not registered".
import { type AbnClient, outcomeOf } from "@comms-id/abn";
import { formatAbn, validateAbn } from "@comms-id/abn/local";
export type SupplierCheck =
| { readonly ok: true; readonly abn: string; readonly name: string; readonly status: string; readonly source: string }
| { readonly ok: false; readonly reason: "invalid" | "not-registered" | "unavailable"; readonly message: string };
export const verifySupplier = async (input: string, abn: AbnClient): Promise<SupplierCheck> => {
const local = validateAbn(input);
if (!local.valid) {
return { ok: false, reason: "invalid", message: local.message };
}
try {
const reply = await abn.lookup({ abn: local.abn });
const entity = reply.entity;
if (entity === null) {
return { ok: false, reason: "not-registered", message: `${formatAbn(local.abn)} is not on the register.` };
}
// Both sources (the live register and the dated snapshot) carry the name and the status.
return {
ok: true,
abn: formatAbn(local.abn),
name: entity.entityName,
status: entity.entityStatusCode ?? "unknown",
source: reply.source,
};
} catch (error) {
if (outcomeOf(error) === "source-unavailable") {
return { ok: false, reason: "unavailable", message: "The register did not answer; try again later." };
}
throw error;
}
};
validateAbn runs the check-digit rule in your process; the lookup reaches the register through the client you give the function, so the same function serves your tests (the fixtures client), TEST and LIVE.
TEST first
import { createAbnClient } from "@comms-id/abn/server";
import { verifySupplier } from "./verify-supplier";
const abn = createAbnClient({
clientId: process.env.COMMS_ID_CLIENT_ID!,
privateJwk: JSON.parse(process.env.COMMS_ID_PRIVATE_JWK!),
environment: "test",
});
console.log(await verifySupplier("51 824 753 556", abn)); // the published example: found, with a name and status
console.log(await verifySupplier("51 824 753 557", abn)); // invalid check digit: refused here, no call
console.log(await verifySupplier("10000000000", abn)); // TEST's documented miss: not registered
console.log(await verifySupplier("10000000032", abn)); // TEST's documented outage: unavailable
The four calls above exercise a match, an invalid check digit, a missing registration and a source outage in TEST. In your own unit tests, createAbnFixtureClient() from @comms-id/abn/fixtures answers the same shapes with no network.
LIVE
Remove environment: "test" (LIVE is the default) and run with the production key. COMMS_ID_CLIENT_ID and COMMS_ID_PRIVATE_JWK come from your server’s secrets; nothing else changes.
Reference: ABN API. Every reply is one business, with source saying whether the live register or the dated snapshot answered; the snapshot answers when the register is down, and recheckAfter says when to ask again.