Address autocomplete on a checkout form
Address autocomplete on a checkout form
What you build: a delivery address field on a checkout page. The customer types three characters and picks the address; the element puts the chosen address’s id, postcode and state into hidden fields and the page lets the customer pay. The element is one script from the CDN; your server signs its calls through a relay route, so the page holds no credential.
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 Address 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 Address 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 page
checkout.html:
<!doctype html>
<!-- A checkout form with Australian address autocomplete. The element calls your own relay
route; the chosen address lands in the hidden fields your server reads. -->
<form action="/checkout" id="checkout" method="post">
<label>Name <input name="name" required></label>
<comms-id-address label="Delivery address" name="address" relay="/api/comms-id/address" required></comms-id-address>
<input name="address_id" type="hidden">
<input name="postcode" type="hidden">
<input name="state" type="hidden">
<button disabled type="submit">Pay</button>
</form>
<script src="https://cdn.comms.id/address/v1.js"></script>
<script>
"use strict";
const form = document.getElementById("checkout");
const field = form.querySelector("comms-id-address");
const pay = form.querySelector("button");
const set = (name, value) => {
form.elements[name].value = value ?? "";
};
// A chosen address: fill the hidden fields and let the customer pay.
field.addEventListener("comms-id-select", (event) => {
const { address } = event.detail;
set("address_id", address.id);
set("postcode", address.postcode);
set("state", address.state);
pay.disabled = false;
});
// An outage is not "no match": say so, and keep the button off until an address is chosen.
field.addEventListener("comms-id-error", (event) => {
const { outcome, message } = event.detail.error;
form.querySelector("[name=address_id]").value = "";
pay.disabled = true;
if (outcome === "source-unavailable") {
console.warn(`Address search is unavailable: ${message}`);
}
});
</script>
The element is a WAI-ARIA combobox that never moves the page while someone types and always shows the G-NAF attribution. comms-id-select carries the structured address (id, displayAddress, postcode, state and more); comms-id-error carries outcome: source-unavailable is an outage, which the page must not show as “no match”.
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 1 synth into the field: the three synthetic addresses appear; choosing 1 SYNTHETIC STREET, EXAMPLETOWN NSW 2000 fills the hidden fields with SYNTH0001, 2000 and NSW. unavailable as a query gives the outage branch, ratelimit the fair-use ceiling.
A site with no server can skip the relay: <comms-id-address pk="pk_test_…" base-url="https://api-test.comms.id"> with a development app’s public key and a verified origin, then pk="pk_live_…" and no base-url in production. The pk is an identifier, not a secret.
LIVE
Remove baseUrl from the relay route and deploy with the production key. Pin the element to an exact version with its integrity hash from https://cdn.comms.id/manifest.json when you go live (upgrade instructions). Reference: Address API.