WalletWallet API
Back to Blog

How to Build and Run a Digital Membership Card Program

Plan member fields and check-in, then build a runnable Node.js lifecycle to issue, renew, upgrade, notify, and revoke digital membership cards.

2026-07-08 By Alen Todorov membership membership-card apple-wallet google-wallet node-js tutorial api

A digital membership card is the member-facing view of a membership record. It shows who the member is, what access they have, and whether that access is current. The membership system behind it still controls renewals, benefits, check-ins, household relationships, and cancellations.

This guide explains how to design that system and then builds a working card lifecycle in one Node.js file. Apple Wallet and Google Wallet are the natural home for that lifecycle: a member adds the card from a welcome or renewal link without installing an organization app, then the same credential can be verified, renewed, upgraded, lapsed, notified, and revoked. Email still carries receipts and the recovery link, while the installed pass keeps the current membership state in one focused place at the front desk.

A museum entrance framed by an open iron gate
Photo by Valeria Klys on Unsplash.

In this guide

What a digital membership card does

The card is a credential, not the membership database. A museum, gym, club, or association may already keep member records in a CRM, subscription system, or spreadsheet. A digital card projects the small part of each record that a member and front-desk employee need:

  • an opaque member ID in a barcode;
  • member name and level;
  • active, grace, lapsed, or suspended status;
  • renewal or expiry date; and
  • the main benefit or admission entitlement.

That separation keeps the card current. A printed card can still say Gold after a downgrade or show last year’s expiry date. A connected card can be updated while preserving the same member ID, install link, and barcode.

The lifecycle should be explicit:

StateMeaningFront-desk decisionNext action
IssuedRecord and card existFollow the initial statusDeliver the install link
ActiveMembership and payment are currentAdmit within the entitlementRecord the check-in
GraceRenewal is late but access is temporarily allowedAdmit and explain the ruleRenew or lapse
LapsedAccess period endedDeny or send to member servicesRenew the same card if allowed
SuspendedAccess is held for an operational reasonFollow staff policyRestore or cancel
CancelledMembership is closedDenyRevoke the card; rejoining creates a new one

Smartphone reach is broad but not universal. Pew Research Center’s 2025 survey of 5,022 US adults found that 91 percent owned a smartphone, including 78 percent of adults aged 65 or older (Pew Research Center). That supports phone delivery for many programs while also making the case for a staff lookup, printed fallback, or both.

Apple lists membership cards as an Apple Wallet use case and supports barcode scanning, updates, and distribution through the web, email, and apps without requiring a companion app (Apple Developer). Google documents membership cards as a use for its generic pass and supports issuance through Android, the web, email, and SMS (Google for Developers). An Android member may still need to install Google Wallet, depending on the device and market.

Plan the membership program before choosing software

Define the membership before designing the card. A useful rule sheet answers:

  • Who is the member: one person, a household, an organization, or a primary member with dependants?
  • What does each level grant: admission, classes, rooms, discounts, voting, or guest passes?
  • Does everyone renew on one fixed date, or does each membership roll forward from its own start date?
  • Is there a grace period, and does it permit admission?
  • What distinguishes lapsed, suspended, and cancelled?
  • Can a lapsed membership be restored under the same ID?
  • Which changes deserve a member notification?
  • What is the fallback when the phone, scanner, or network is unavailable?

Fixed and rolling renewal models both exist in real associations; the 2025 Trade Association Forum benchmarking survey reports fixed-cycle, rolling, and mixed approaches across respondents (survey PDF). The software should implement the model your terms promise rather than assuming that every member expires a year after issuance.

The card cannot rescue a membership with weak benefits. In a 2025 survey of 191 associations, budget restraints were the most frequently reported reason for non-renewal at 69.1 percent, followed by retirement and lack of perceived value (Associations Forum). A current card can make access and renewal easier to administer; retention still depends on value, price, and the member experience.

Choose what appears on the card

Put decision-making information on the front and supporting information in the details view.

LocationGood fieldsAvoid
FrontName, level, status, renewal date, primary entitlementAddresses, long terms, internal notes
BarcodeOpaque member ID such as MEM-7F2A...Email, name, access decision, or a secret
DetailsBenefits, renewal URL, support contact, termsData staff do not need to expose

The barcode should remain stable while the membership is live. It identifies a record for the scanner; it does not prove that the current holder is active or entitled to enter. The backend makes that decision from current state.

Household memberships need a deliberate model. You can issue one shared record, but separate cards for each adult usually provide a clearer audit trail. Each card can point to the same household account while carrying its own credential ID and name. Guest entitlements belong in the database and staff result rather than in a barcode that can be copied.

Design the check-in and verification flow

A scanner reads the barcode value and sends it to a staff-controlled system. That system should:

  1. look up the member by opaque ID;
  2. calculate current status from the stored state and renewal rule;
  3. check the requested entitlement, location, and any duplicate-use rule;
  4. return a clear ALLOW, ALLOW — GRACE, or DENY result; and
  5. record the visit, staff actor, device, time, and reason.

The saved wallet card can remain visible without connectivity. Live verification cannot. Decide in advance whether an offline front desk admits from the visible date, uses a downloaded allow-list, or moves the member to manual review. A strict venue may require ID when the name matters; a community library may accept a name lookup.

Choose how to implement it

ApproachWhat it ownsSetup and controlBest fit
All-in-one membership platformRecords, payments, renewals, cards, check-inFaster setup, vendor workflowA new program willing to move its data
Existing CRM plus wallet-card layerCRM remains authoritative; pass layer delivers cardsAdd once from a link; updates follow the membership lifecycleThe default for an established organization
Direct Apple and Google integrationYour system owns records, signing, issuance, updatesHighest engineering and platform workA large product team with unusual needs
Pass API plus your backendYour system owns membership rules; API owns pass deliveryHigh program control with less wallet infrastructureA focused custom workflow
PDF or image cardStatic credential onlyEasy issuance, stale status and weak update pathA temporary or low-risk program

Why Apple Wallet and Google Wallet fit membership cards

Most organizations already have a membership system and need a better member-facing credential rather than another system of record. Members add an Apple Wallet or Google Wallet pass from a link with no dedicated app install; the card stays available at the point of entry and follows issue, check-in, renewal, level change, grace, lapse, notice, and cancellation.

That gives members the useful lifecycle communication of an app or repeated email updates in a less crowded surface. Keep email for payment receipts, long-form policy, and pass recovery; use Apple Wallet and Google Wallet for the current entitlement a member or staff person needs to see now. Build an app only when bookings, content, community, or other regular interactions justify keeping one installed.

The example below uses WalletWallet as the delivery layer for digital membership cards. Your local record remains authoritative; the API creates and refreshes the Apple Wallet and Google Wallet versions.

Architecture for a maintainable membership program

Keep four responsibilities separate:

  1. Membership system: member, level, benefits, status, renewal, household, and payment state.
  2. Event log: renewals, level changes, check-ins, corrections, and staff overrides.
  3. Verification service: turns a barcode and requested action into an admission decision.
  4. Pass projection: shows current member-facing fields and receives updates after the source record changes.

Payment webhooks and staff actions update the membership system first. The pass update follows and can be retried. If delivery fails, the database remains correct and a reconciliation job can bring the card back in sync.

Try the lifecycle on your laptop

This optional example runs locally, without deploying an application. It keeps test members in members.json so you can try issue, check-in, renewal, status, notification, and cancellation from one computer. You can skip the code if you only need the membership model and rollout guidance.

To follow along, install Node.js 22 or newer, use any text editor, and get a WalletWallet API key from signup.

1. Set up the example

mkdir digital-membership-cards
cd digital-membership-cards

Create .env:

WALLETWALLET_API_KEY=ww_live_replace_with_your_key
PROGRAM_TIME_ZONE=America/New_York

Set PROGRAM_TIME_ZONE to the IANA time zone where the program decides daily access, such as Europe/London or America/New_York. That keeps a date-only renewal valid through the same local calendar day at every front desk.

Create .gitignore so the key and local member records are not committed:

.env
members.json

2. Add the complete program

Create membership-cards.mjs and copy the program below. The complete listing is collapsed by default so the operating guidance remains the main reading path.

import { readFile, writeFile } from "node:fs/promises";

const API_BASE = "https://api.walletwallet.dev";
const API_KEY = process.env.WALLETWALLET_API_KEY;
const DB_FILE = new URL("./members.json", import.meta.url);
const PROGRAM_TIME_ZONE = process.env.PROGRAM_TIME_ZONE || "America/New_York";
const ADMIT_STATUSES = new Set(["active", "grace"]);
const MANUAL_STATUSES = new Set(["active", "grace", "lapsed", "suspended"]);

if (!API_KEY) throw new Error("Add WALLETWALLET_API_KEY to .env first.");

async function loadMembers() {
  try {
    return JSON.parse(await readFile(DB_FILE, "utf8"));
  } catch (error) {
    if (error.code === "ENOENT") return {};
    throw error;
  }
}

async function saveMembers(members) {
  await writeFile(DB_FILE, `${JSON.stringify(members, null, 2)}\n`);
}

async function walletRequest(path, method, body) {
  const response = await fetch(`${API_BASE}${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      ...(body ? { "Content-Type": "application/json" } : {}),
    },
    ...(body ? { body: JSON.stringify(body) } : {}),
  });
  const text = await response.text();
  let data;
  try { data = text ? JSON.parse(text) : {}; }
  catch { data = { message: text }; }
  if (!response.ok) {
    throw new Error(`${method} ${path} failed (${response.status}): ${JSON.stringify(data)}`);
  }
  return data;
}

function validDate(value) {
  if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
    throw new Error("Use a date in YYYY-MM-DD format.");
  }
  const [year, month, day] = value.split("-").map(Number);
  const parsed = new Date(Date.UTC(year, month - 1, day));
  if (
    parsed.getUTCFullYear() !== year
    || parsed.getUTCMonth() !== month - 1
    || parsed.getUTCDate() !== day
  ) {
    throw new Error(`${value} is not a real calendar date.`);
  }
  return value;
}

function todayInProgramTimeZone() {
  const parts = new Intl.DateTimeFormat("en", {
    timeZone: PROGRAM_TIME_ZONE,
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
  }).formatToParts(new Date());
  const values = Object.fromEntries(parts.map(({ type, value }) => [type, value]));
  return `${values.year}-${values.month}-${values.day}`;
}

function requireCurrentOrFutureDate(value) {
  const date = validDate(value);
  if (date < todayInProgramTimeZone()) {
    throw new Error(`${date} has already passed in ${PROGRAM_TIME_ZONE}.`);
  }
  return date;
}

function memberStatus(member) {
  if (member.status !== "active") return member.status;
  return member.renewsOn < todayInProgramTimeZone() ? "lapsed" : "active";
}

function passBody(member) {
  const status = memberStatus(member);
  return {
    barcodeValue: member.memberId,
    barcodeFormat: "QR",
    logoText: "Iron Peak Gym",
    organizationName: "Iron Peak Gym",
    description: "Iron Peak Gym membership card",
    colorPreset: status === "active" ? "dark" : "orange",
    sharingProhibited: true,
    headerFields: [{ label: "STATUS", value: status.toUpperCase() }],
    primaryFields: [{ label: "MEMBERSHIP", value: member.level }],
    secondaryFields: [
      { label: "MEMBER", value: member.name },
      { label: "RENEWS", value: member.renewsOn },
    ],
    backFields: [
      { label: "Member ID", value: member.memberId },
      { label: "Access", value: "Gym floor and standard group classes" },
      { label: "Renew", value: "https://example.com/renew" },
      { label: "Latest update", value: member.notification, changeMessage: "%@" },
    ],
  };
}

function getLiveMember(members, memberId) {
  const member = members[memberId];
  if (!member) throw new Error(`No member found for ${memberId}.`);
  if (member.revokedAt) throw new Error(`${memberId} is cancelled and revoked.`);
  return member;
}

async function updatePass(member) {
  const body = passBody(member);
  const result = await walletRequest(
    `/api/passes/${encodeURIComponent(member.serialNumber)}`,
    "PUT",
    body,
  );
  member.lastPassBody = body;
  member.lastPassApiUpdatedAt = new Date().toISOString();
  return result;
}

async function issue(members, memberId, name, level, renewsOn) {
  if (!memberId || !name || !level || !renewsOn) {
    throw new Error("issue needs MEMBER_ID NAME LEVEL RENEWS_ON.");
  }
  if (members[memberId]) {
    throw new Error(`A record already exists for ${memberId}; use a new opaque ID.`);
  }

  const member = {
    memberId,
    name,
    level,
    renewsOn: requireCurrentOrFutureDate(renewsOn),
    status: "active",
    notification: "Membership active.",
    checkIns: [],
    createdAt: new Date().toISOString(),
  };
  const body = passBody(member);
  const result = await walletRequest("/api/passes", "POST", body);
  member.serialNumber = result.serialNumber;
  member.shareUrl = result.shareUrl;
  member.googleSaveUrl = result.googleSaveUrl;
  member.lastPassBody = body;
  member.lastPassApiUpdatedAt = new Date().toISOString();
  members[memberId] = member;
  await saveMembers(members);
  console.log(`Issued ${memberId} for ${name}`);
  console.log(`Send this install link: ${member.shareUrl}`);
}

function verify(member) {
  const status = memberStatus(member);
  const allowed = ADMIT_STATUSES.has(status);
  console.log(`${allowed ? "ALLOW" : "DENY"}: ${member.name}`);
  console.log(`Status: ${status}; level: ${member.level}; renews: ${member.renewsOn}`);
  return allowed;
}

async function checkIn(members, memberId, eventId) {
  if (!eventId) throw new Error("check-in needs a unique visit ID.");
  const member = getLiveMember(members, memberId);
  if (!verify(member)) throw new Error("Check-in denied.");
  const duplicate = Object.values(members).some((candidate) =>
    candidate.checkIns.some((entry) => entry.eventId === eventId),
  );
  if (duplicate) {
    throw new Error(`Visit ${eventId} was already recorded.`);
  }
  member.checkIns.push({ eventId, checkedInAt: new Date().toISOString() });
  await saveMembers(members);
  console.log(`Check-in recorded: ${eventId}`);
}

async function renew(members, memberId, renewsOn) {
  const member = getLiveMember(members, memberId);
  member.renewsOn = requireCurrentOrFutureDate(renewsOn);
  member.status = "active";
  member.notification = `Renewed through ${renewsOn}.`;
  await updatePass(member);
  await saveMembers(members);
  console.log(`${memberId} renewed through ${renewsOn}.`);
}

async function level(members, memberId, newLevel) {
  if (!newLevel) throw new Error("level needs the new membership level.");
  const member = getLiveMember(members, memberId);
  member.level = newLevel;
  member.notification = `Your membership is now ${newLevel}.`;
  await updatePass(member);
  await saveMembers(members);
  console.log(`${memberId} moved to ${newLevel}.`);
}

async function setStatus(members, memberId, status) {
  if (!MANUAL_STATUSES.has(status)) {
    throw new Error("Status must be active, grace, lapsed, or suspended.");
  }
  const member = getLiveMember(members, memberId);
  if (status === "active" && member.renewsOn < todayInProgramTimeZone()) {
    throw new Error(`Renew ${memberId} before setting it active.`);
  }
  member.status = status;
  member.notification = `Membership status changed to ${status}.`;
  await updatePass(member);
  await saveMembers(members);
  console.log(`${memberId} status: ${status}`);
}

async function notify(members, memberId, message) {
  if (!message) throw new Error("notify needs message text.");
  const member = getLiveMember(members, memberId);
  if (member.notification === message) {
    throw new Error("Use different text so the notification field changes.");
  }
  member.notification = message;
  await updatePass(member);
  await saveMembers(members);
  console.log(`Pass update accepted for ${member.name}; wallet delivery is asynchronous.`);
}

async function cancel(members, memberId) {
  const member = getLiveMember(members, memberId);
  await walletRequest(
    `/api/passes/${encodeURIComponent(member.serialNumber)}`,
    "DELETE",
  );
  member.revokedAt = new Date().toISOString();
  member.status = "cancelled";
  await saveMembers(members);
  console.log(`${memberId} cancelled and revoked.`);
}

async function main() {
  const [command, memberId, ...args] = process.argv.slice(2);
  if (!command || !memberId) {
    throw new Error("Usage: node --env-file=.env membership-cards.mjs COMMAND MEMBER_ID [ARGS]");
  }
  const members = await loadMembers();

  if (command === "issue") return issue(members, memberId, args[0], args[1], args[2]);
  if (command === "verify") return verify(getLiveMember(members, memberId));
  if (command === "check-in") return checkIn(members, memberId, args[0]);
  if (command === "renew") return renew(members, memberId, args[0]);
  if (command === "level") return level(members, memberId, args.join(" "));
  if (command === "status") return setStatus(members, memberId, args[0]);
  if (command === "notify") return notify(members, memberId, args.join(" "));
  if (command === "cancel") return cancel(members, memberId);
  if (command === "show") return console.log(JSON.stringify(members[memberId] ?? null, null, 2));
  throw new Error(`Unknown command: ${command}`);
}

main().catch((error) => {
  console.error(error.message);
  process.exitCode = 1;
});

The program keeps the complete last-sent pass body with the member. WalletWallet updates use replacement semantics, so every PUT rebuilds and sends all fields rather than sending a partial patch. The notification anchor also exists on the initial card; later changes can therefore supply an Apple lock-screen message. Google’s update banner is generic, with the changed details visible in the card.

3. Issue and deliver a card

Arguments that contain spaces need quotes:

node --env-file=.env membership-cards.mjs issue MEM-1042 "Jordan Avery" "Unlimited" 2027-06-12

The response prints one hosted install link. Send it in the welcome email or member portal. It opens the appropriate wallet action for the member’s device. The local record saves the server-generated serial used for every later update.

The QR contains MEM-1042, not the member’s email or access status. In a real program, use a less guessable opaque ID and apply rate limits to the lookup endpoint.

4. Verify and check in the member

Check the current decision without writing a visit:

node --env-file=.env membership-cards.mjs verify MEM-1042

Record an allowed visit with a unique front-desk or booking event ID:

node --env-file=.env membership-cards.mjs check-in MEM-1042 visit-2026-07-18-0904

A USB or Bluetooth barcode scanner commonly behaves like a keyboard: it types the member ID into the focused staff screen. The live application should submit that value to an authenticated verification endpoint, show the decision, and record a check-in atomically. WalletWallet renders the barcode; your membership backend decides whether to admit.

5. Renew the same card

node --env-file=.env membership-cards.mjs renew MEM-1042 2028-06-12

The renewal date moves forward on the installed card and the status returns to active. The member keeps the same barcode, serial, and install link. The Apple notification can read Renewed through 2028-06-12; Google shows its standard update notification and the new date inside the card.

6. Change level or status

Upgrade the membership:

node --env-file=.env membership-cards.mjs level MEM-1042 "Gold"

Mark a grace period or lapse:

node --env-file=.env membership-cards.mjs status MEM-1042 grace
node --env-file=.env membership-cards.mjs status MEM-1042 lapsed

The example calculates an active record as lapsed after its renewal date even before a scheduled job changes the stored status. In production, run a daily job that writes the transition, updates the pass, and records why it happened. That keeps the visible card aligned with the verification result.

7. Send a relevant notice

node --env-file=.env membership-cards.mjs notify MEM-1042 "Your renewal is due on 12 June. Renew online or ask at reception."

A notice must change the anchored field value. Repeating the identical body is an unchanged update and sends no push. Reserve card notifications for useful membership events, respect consent and local marketing rules, and offer another channel for people who do not use either wallet app.

8. Cancel and revoke

node --env-file=.env membership-cards.mjs cancel MEM-1042

Cancellation uses DELETE /api/passes/<serial> and is terminal. Apple marks the pass void; Google expires it. The card may remain visible in an expired section until the member removes it. If the person rejoins later, create a new record and pass rather than trying to restore the revoked serial.

Before you use this with members

The local example is a safe way to learn the lifecycle. A real card should read from the membership system that already controls payment, renewal, access, and benefits. Before launch, confirm that:

  • every card has an opaque credential ID rather than personal data in its QR;
  • staff sign in before checking in, changing, or cancelling a member;
  • renewals and visits use unique event IDs, so retries cannot create a second extension or check-in;
  • a household receives separate credentials when people need independent entry; and
  • staff have a clear fallback for weak connectivity and members without a compatible phone.

Keep the membership record authoritative if a wallet update is delayed. A developer can connect that record to WalletWallet while the membership team defines statuses, benefits, grace periods, and the front-desk decision.

Roll out the cards without overwhelming members or staff

  1. Clean duplicate member IDs, invalid emails, and stale statuses.
  2. Pilot with staff and a small member group on iPhone and Android.
  3. Test every scanner, membership level, grace rule, and weak-network fallback.
  4. Batch-issue cards and place each install link in the member portal, welcome email, and renewal confirmation.
  5. Train staff on active, grace, lapsed, suspended, not found, and cancelled results.
  6. Resend the existing install link when somebody changes phones; avoid creating duplicate live cards.
  7. Keep a physical card or name-lookup path for accessibility and members without compatible phones.

The American Alliance of Museums offers a useful real-world pattern: its member cards are digital by default and update when a membership renews (AAM). The Wichita Art Museum also documents a practical mix of barcodes, household delivery, renewal updates, and physical-card fallback (WAM).

Measure whether the system works

Track operational outcomes before making retention claims:

  • delivery and successful wallet-add rate where observable;
  • scan success, median lookup time, and manual-review rate;
  • renewals, lapses, and reactivations by cohort;
  • denied or duplicate scans;
  • support contacts per 1,000 members;
  • pass-sync failures and time to repair; and
  • check-in frequency by membership age and level.

A digital card may reduce stale credentials and front-desk handling, but a rise in renewal after launch does not prove the card caused it. Price, benefits, season, campaigns, and member mix also change. Use a pilot, staggered rollout, or comparable cohorts if you want to estimate an effect.

Common implementation problems

SymptomLikely causeFix
Renewed member still shows lapsedDatabase changed but pass sync failedQueue retries and reconcile from the member record
Card loses fields after renewalPartial body was sent on PUTRebuild and send the complete pass body
Screenshot admits two peopleBarcode was treated as proofValidate live status and record each credential’s use
Renewal extends twiceBilling webhook retriedMake the billing event ID unique and idempotent
Reminder does not appearNotification anchor was absent or unchangedSeed it on issue and change its value
Cancelled member still presents a cardVisible pass was trustedCheck the backend; revocation timing can vary by device
Household members overwrite one anotherEveryone shares one credentialIssue distinct credential IDs tied to one household

Digital membership card questions

Does a member need a separate app?

An iPhone can add a pass to Apple Wallet without a related app. On Android, Google Wallet availability varies and the member may need to install it. Keep a web, staff-lookup, or physical fallback.

Will the membership card work offline?

The saved card and barcode can remain visible offline. Current status, entitlement, duplicate-use checks, and newly issued updates require a connected verification system unless you build an offline allow-list and reconciliation process.

Can somebody share a screenshot?

Yes. A static barcode can be copied. Use it to identify the credential, then make an online admission decision and record the visit. Require ID when your membership terms call for it.

Does a renewal require a new card?

No. Update the renewal date and status on the existing live pass. The same serial, install link, and barcode continue. A revoked card is the exception; rejoining after terminal cancellation requires a new pass.

Should the barcode contain a URL or member ID?

Use an opaque credential ID. A scanner can submit it to an authenticated staff application. Avoid putting email, access status, or a reusable authorization secret in the code.

Can one membership cover two people?

Yes, but distinct cards are easier to verify and audit. Map each credential to the same household membership and state exactly which benefits are shared.

Start with the front-desk decision

Before importing every member, make one credential pass the complete test: issue, install, verify, check in, renew, change level, lapse, notify, and cancel. The card design matters, but the clearest sign of a sound membership system is a front desk that can make the right decision quickly and explain it to the member.

Use the digital membership card platform when you are ready to issue both wallet versions. Send the add link in the welcome or renewal message, then let the installed pass remain the current card through renewals, level changes, and cancellations. Keep the API create, update, and revoke references beside your membership rules.

Build your first wallet pass

Turn one JSON request into a pass that installs in Apple Wallet and Google Wallet, with live updates that reach both.