Check a charity by ABN in a donation form
Check a charity by ABN in a donation form
What you build: a React donation form. The donor types the charity’s ABN; the field looks it up in the ACNC register through your relay and shows the legal name; the chosen charity’s ABN and name travel to your server in hidden fields, so a gift is only ever recorded against a registered charity. “No charity found” and “lookup unavailable” are different answers.
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 Charity 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 Charity 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 form
pnpm add @comms-id/charity @comms-id/charity-react react
DonationForm.tsx:
// A donation form that checks the charity's ABN against the ACNC register as the donor types.
// The browser calls your own relay route; the chosen charity's ABN and legal name go to your
// server in hidden fields, so the gift is recorded against a registered charity.
import type { CharityClient } from "@comms-id/charity";
import { createCharityBrowserClient } from "@comms-id/charity/browser";
import { CharityLookup, type CharityRecord } from "@comms-id/charity-react";
import { useState } from "react";
export const relayClient = (): CharityClient => createCharityBrowserClient({ relayUrl: "/api/comms-id/charity" });
export function DonationForm({ client = relayClient() }: { readonly client?: CharityClient }) {
const [charity, setCharity] = useState<CharityRecord | null>(null);
return (
<form action="/donate" method="post">
<CharityLookup client={client} label="Charity ABN" onSelect={(record) => setCharity(record)} required />
<input name="charity_abn" type="hidden" value={charity?.data.abn ?? ""} />
<input name="charity_name" type="hidden" value={charity?.data.legalName ?? ""} />
<p>{charity ? `Donating to ${charity.data.legalName}` : "Enter the charity's ABN to continue."}</p>
<label>
Amount (AUD) <input min="1" name="amount" required type="number" />
</label>
<button disabled={charity === null} type="submit">
Donate
</button>
</form>
);
}
CharityLookup is the finished field: it refuses anything that is not eleven digits before sending, looks a complete ABN up at once, shows one line for the found charity with the ACNC attribution, and offers “Try again” on an outage. onSelect gives you the whole register record.
Your relay: one route, one signer
The browser calls your own server at /api/comms-id/<product>/<operation>; your server signs each call as your registered app with @comms-id/relay, so the page holds no credential. The relay forwards only the operations the product descriptors list, and never the user’s cookies.
pnpm add @comms-id/relay @comms-id/address @comms-id/asx @comms-id/charity @comms-id/logo @comms-id/sanctions
app/api/comms-id/[product]/[operation]/route.ts (Next.js; @comms-id/relay/hono is the same for Hono):
// app/api/comms-id/[product]/[operation]/route.ts (Next.js). One route serves every product
// your pages use; the relay forwards only the operations these descriptors list.
import { addressProduct } from "@comms-id/address/descriptor";
import { asxProduct } from "@comms-id/asx/descriptor";
import { charityProduct } from "@comms-id/charity/descriptor";
import { logoProduct } from "@comms-id/logo/descriptor";
import { createNextRelay } from "@comms-id/relay/next";
import { sanctionsProduct } from "@comms-id/sanctions/descriptor";
import { createSigner } from "./signer";
export const { POST } = createNextRelay({
products: [addressProduct, asxProduct, charityProduct, logoProduct, sanctionsProduct],
// Your rule: who may use these routes. Return true, false (401) or your own Response.
allow: (request) => request.headers.get("cookie") !== null,
signer: createSigner(),
// TEST: a development app's key and the TEST service. Going live: delete this line.
baseUrl: "https://api-test.comms.id",
});
The signer is your own (the relay takes any function that returns a two-minute token for an audience and scope). This one reads your key from the environment and uses WebCrypto only, so it runs in Node 20+, Workers, Deno and Bun:
// The signer your relay needs: a two-minute EdDSA (Ed25519) token for one product, signed with
// your app's private key. The key is read once from the environment and never leaves this
// module. Runs in Node 20+, Workers, Deno and Bun (WebCrypto only).
import type { RelaySigner } from "@comms-id/relay";
const PADDING = /=+$/;
const encode = (bytes: ArrayBuffer | Uint8Array): string =>
btoa(String.fromCharCode(...new Uint8Array(bytes)))
.replaceAll("+", "-")
.replaceAll("/", "_")
.replace(PADDING, "");
const json = (value: unknown): string => encode(new TextEncoder().encode(JSON.stringify(value)));
/** A signer for `@comms-id/relay` from COMMS_ID_CLIENT_ID and COMMS_ID_PRIVATE_JWK. */
export const createSigner = (env: Record<string, string | undefined> = process.env): RelaySigner => {
const clientId = env.COMMS_ID_CLIENT_ID?.trim();
const jwk = env.COMMS_ID_PRIVATE_JWK;
if (!(clientId && jwk)) {
return () => undefined; // the relay then refuses with NOT_CONFIGURED; nothing is forwarded
}
const key = crypto.subtle.importKey("jwk", JSON.parse(jwk), { name: "Ed25519" }, false, ["sign"]);
return async ({ audience, scope }) => {
const now = Math.floor(Date.now() / 1000);
const claims = {
iss: clientId,
sub: crypto.randomUUID(),
jti: crypto.randomUUID(),
aud: audience,
scope,
iat: now,
exp: now + 120,
};
const input = `${json({ alg: "EdDSA" })}.${json(claims)}`;
const signature = await crypto.subtle.sign("Ed25519", await key, new TextEncoder().encode(input));
return `${input}.${encode(signature)}`;
};
};
TEST first: the route above names baseUrl: "https://api-test.comms.id" and your development key answers there. LIVE: delete that line and deploy with the production key. The code between does not change.
TEST first
With the relay pointed at TEST, type 50169561394 (the published example) in the form: the field shows a found charity and the hidden fields fill. 10000000000 is TEST’s documented “no charity”, 10000000032 its outage.
In your component tests, pass createCharityFixtureClient() from @comms-id/charity/fixtures as client: the form renders and answers with no network.
LIVE
Remove baseUrl from the relay route and deploy with the production key. Reference: Charity API. The attribution the field shows (ACNC, via data.gov.au, CC BY 3.0 AU) is part of the licence: do not hide it.