# 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

1. `npx comms-id init` makes a development key in `.env.local` and 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.local` as `COMMS_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.
2. When it works in TEST, `npx comms-id key --worker <your worker>` makes the production key straight into your server's `COMMS_ID_PRIVATE_JWK` secret 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`:

```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):

```ts
// 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:

```ts
// 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](/docs/reference/address/upgrade)). Reference: [Address API](/docs/reference/address).
