Skip to main content
A common partner product: a volunteer self-service portal — a customer-facing web app where individual volunteers can see their own data (participations, hours, certificates, profile, upcoming shifts) without needing access to the VOMO admin UI. The customer wants the simplicity of “volunteers manage their own profile” without giving volunteers admin permissions; you build the experience by acting as the trusted intermediary. This recipe is different from the others — it’s not about data flow into or out of VOMO. It’s about product shape: how to architect a portal that lets each volunteer see only their own data, securely, while still using just one VOMO Bearer token (the partner’s). The architecture is what makes this work.

What you’ll build

A customer-facing volunteer portal that:
  • Authenticates volunteers via the customer’s identity provider (or your own auth layer)
  • Lets each volunteer see their own profile, participation history, hours total, certificates, and upcoming shifts
  • Lets volunteers update their own profile fields (which the partner pushes to VOMO via upsert)
  • Prevents volunteers from accessing other volunteers’ data
  • Handles the lookup challenge — VOMO doesn’t know which web session is which volunteer

When this recipe fits

ScenarioThis recipe fits
Volunteer-facing portal as part of a partner integration
“My volunteer dashboard” view inside a customer’s existing app
Branded volunteer experience separate from the VOMO admin UI
Embedding volunteer data in a corporate intranet
Volunteer signup for new shifts✗ Not possible — participation writes aren’t exposed
Volunteer-initiated record changes that should be visible to other volunteers✗ Same — this is a read-mostly product

The architecture problem

VOMO doesn’t have per-volunteer API tokens. The Bearer token belongs to the customer’s organization — it has full read access to all volunteers, projects, groups, etc. for that customer. This is the opposite of what a portal needs: each volunteer should see only their own data. The partner integration is the bridge — it authenticates the volunteer (using identity outside VOMO), then uses its admin-level VOMO token to fetch only that volunteer’s data and serve it back. The security boundary is the partner backend. It’s the only thing that holds the VOMO token; the volunteer’s browser never sees it.

Step 1: identity — who is this volunteer?

The first design choice: how does the portal know which VOMO user the logged-in volunteer corresponds to?

Option A: customer SSO + email mapping

The customer already has an identity system (Active Directory, Okta, Google Workspace, etc.). Volunteers log in with their corporate identity; the portal extracts the verified email; the partner maps email to VOMO User ID.
JavaScript
async function resolveVomoUserFromSession(session, customerId) {
  // 1. Trust the email from the authenticated session
  const email = session.user.email; // SSO-verified
  if (!email) throw new Error('No verified email in session');

  // 2. Look up the VOMO user by email
  const vomoToken = await credentials.getVomoToken(customerId);
  const params = new URLSearchParams({ email_like: email });
  const response = await fetch(
    `https://api.vomo.org/v1/users?${params}`,
    { headers: { Authorization: `Bearer ${vomoToken}` } }
  );

  const result = await response.json();
  const exactMatch = result.data.find(
    (u) => u.email?.toLowerCase() === email.toLowerCase()
  );

  if (!exactMatch) {
    throw new NotFoundError(`No VOMO user for ${email}`);
  }

  return exactMatch.id;
}
Pros: Customer’s existing identity is reused; no separate volunteer accounts to manage. Cons: Requires SSO integration; the email mapping breaks if a volunteer’s email changes in either system.

Option B: partner-managed accounts

The portal has its own login (email + password, magic link, etc.); volunteers register with the email they use in VOMO; the portal maps the registered email to VOMO User ID.
JavaScript
async function registerVolunteer(email, password) {
  // 1. Verify the email exists in VOMO (otherwise the registration is meaningless)
  const vomoUser = await lookupVomoUserByEmail(email);
  if (!vomoUser) {
    throw new ValidationError('Email not registered as a volunteer');
  }

  // 2. Create the partner-side account
  const partnerAccount = await partnerAuth.createAccount({
    email,
    password,
    verifiedEmail: false,
  });

  // 3. Send verification email
  await emailService.sendVerification(partnerAccount.id, email);

  // 4. Store the mapping
  await db.upsert('portal_accounts', {
    partner_account_id: partnerAccount.id,
    vomo_user_id: vomoUser.id,
    email: email.toLowerCase(),
    verified: false,
  });
}
Pros: No SSO dependency; works for customers without existing identity systems. Cons: Volunteer must register; password recovery and verification are now your problem. A simpler partner-managed flow — no passwords, just emailed magic links:
JavaScript
async function startLogin(email) {
  // 1. Verify VOMO existence
  const vomoUser = await lookupVomoUserByEmail(email);
  if (!vomoUser) {
    // Return success even if user doesn't exist — don't reveal whether the email is registered
    return { sent: true };
  }

  // 2. Generate a single-use token
  const token = generateSecureToken();
  await db.insert('login_tokens', {
    token,
    vomo_user_id: vomoUser.id,
    email: email.toLowerCase(),
    expires_at: new Date(Date.now() + 15 * 60 * 1000), // 15 min
    used: false,
  });

  // 3. Email the link
  await emailService.send({
    to: email,
    subject: 'Your volunteer portal sign-in link',
    body: `Click to sign in: https://portal.example.com/auth?t=${token}`,
  });

  return { sent: true };
}

async function completeLogin(token) {
  const record = await db.getLoginToken(token);
  if (!record || record.used || record.expires_at < new Date()) {
    throw new AuthError('Invalid or expired link');
  }

  await db.markLoginTokenUsed(token);

  // Issue a session
  return sessionManager.createSession({
    vomo_user_id: record.vomo_user_id,
    email: record.email,
  });
}
Pros: No password management; no SSO required; low-friction for volunteers. Cons: Email deliverability matters; users without email access can’t log in. Most partner portals use Option A (when customer has SSO) or Option C (for broader reach). Option B requires the most operational work.

Step 2: the security boundary

The cardinal rule: the partner backend is the only thing that holds the VOMO token. The frontend (browser) must not have it; the volunteer must not have it; nothing untrusted gets the token. The backend exposes endpoints scoped to the authenticated volunteer’s identity: The portal API (/portal/api/me) returns only the authenticated volunteer’s data. The backend resolves the session to a VOMO User ID, then queries VOMO for that specific user — never for “all users.”

A reference portal API endpoint

JavaScript
// Portal backend endpoint: GET /portal/api/me
app.get('/portal/api/me', requireAuthenticatedSession, async (req, res) => {
  const session = req.session;
  const customerId = session.customer_id;
  const vomoUserId = session.vomo_user_id;

  // The session ties the request to a specific VOMO User ID
  // Resolved at login time; trusted throughout the session lifetime

  try {
    const vomoToken = await credentials.getVomoToken(customerId);
    const response = await fetch(
      `https://api.vomo.org/v1/users/${vomoUserId}`,
      {
        headers: {
          Authorization: `Bearer ${vomoToken}`,
          Accept: 'application/json',
        },
      }
    );

    if (!response.ok) {
      return res.status(response.status).json({ error: 'Failed to load profile' });
    }

    const data = (await response.json()).data;

    // Return ONLY the fields the volunteer should see — don't blindly forward
    res.json({
      id: data.id,
      first_name: data.first_name,
      last_name: data.last_name,
      email: data.email,
      phone: data.phone,
      address: data.address,
      birthday: data.birthday,
      // ... selected safe fields ...
    });
  } catch (err) {
    res.status(500).json({ error: 'Internal error' });
  }
});
The endpoint doesn’t take a user ID parameter from the request — the ID comes from the authenticated session. A volunteer can’t request /portal/api/me?userId=999 and get someone else’s data; the parameter is ignored if present, and the session’s ID is always used.

What NOT to do

JavaScript
// ❌ Anti-pattern — taking the ID from the request
app.get('/portal/api/user/:id', requireAuthenticatedSession, async (req, res) => {
  const userId = req.params.id; // Whatever the client passes in
  // ... fetch and return that user's data ...
});
This is an authorization vulnerability — any logged-in volunteer can fetch any other volunteer’s data by changing the URL. The frontend has no business specifying which user’s data to load; the session implicitly knows.

Defense in depth

Even with session-scoped IDs, defend against attacks that might somehow bypass the session check:
JavaScript
async function loadVolunteerData(session, requestedAction) {
  // 1. Re-verify the session is valid every request
  if (!session?.vomo_user_id) throw new AuthError('No session');

  // 2. Re-verify the VOMO user still exists and matches the session
  const vomoUser = await fetchVomoUserDetail(session.customer_id, session.vomo_user_id);
  if (vomoUser.email?.toLowerCase() !== session.email?.toLowerCase()) {
    // Session email and VOMO email no longer match — log out
    await sessionManager.invalidate(session.id);
    throw new AuthError('Session invalidated due to identity change');
  }

  // 3. Now safe to proceed
  return doAction(requestedAction, vomoUser);
}
Re-verifying the email match catches the case where a volunteer’s email was changed in VOMO since they logged in — the session may still be valid by token but no longer match reality.

Step 3: what to expose

The portal should show the volunteer’s own data — but what subset? Not everything VOMO returns is appropriate to surface.

Profile data: safe to expose

FieldShow?
first_name, last_name, full_name✓ Their own name
email✓ Their own email
phone
address
birthday
gender
user_statusMaybe — show “Verified” badge if VERIFIED; don’t expose internal codes
membership_roleMaybe — show “Volunteer,” “Organizer,” etc. as friendly label
Profile fields (custom data per organization)✓ Show their own values

Participation data: safe to expose

FieldShow?
Participation history (per-Project Date entries)✓ Their own only
Hours per participation
Total hours summary
Per-project hours breakdown
Other volunteers at the same Project Date✗ Privacy — don’t expose other volunteers
For the “other volunteers at the same Date” case: while GET /projects/date/{id} returns the full participant list, the portal should filter to show only the logged-in volunteer’s own entry.

Certificate data: safe to expose

FieldShow?
Earned certificates✓ Their own
Expiration dates✓ Useful for volunteer awareness
Certificates they could earn (e.g., from completing X)Optional — depends on customer product fit

Group membership: usually safe

FieldShow?
Groups the volunteer belongs to✓ (typically)
Other members of those Groups✗ Privacy
Group descriptions

Upcoming Project Dates they’re signed up for

FieldShow?
Project Date times and locations
Project descriptions
Participant counts (number of volunteers, not names)
Point-of-contact info if customer has configured it

What NOT to expose

FieldWhy not
Internal IDs (User ID, Project ID, Project Date ID)Information leakage — opaque from volunteer perspective
Admin-only metadata (when records were modified, by whom)Implementation detail
Other volunteers’ contact info or namesPrivacy
Project draft/internal-notes fieldsMay contain organizer-only context
Profile fields marked as private/internal in customer settingsRespect the customer’s privacy classification

A reference profile endpoint

JavaScript
app.get('/portal/api/me/profile', requireAuth, async (req, res) => {
  const { customer_id, vomo_user_id } = req.session;

  const vomoToken = await credentials.getVomoToken(customer_id);
  const response = await fetch(
    `https://api.vomo.org/v1/users/${vomo_user_id}`,
    { headers: { Authorization: `Bearer ${vomoToken}` } }
  );

  if (!response.ok) return res.status(response.status).json({ error: 'Failed to load' });

  const data = (await response.json()).data;

  // Whitelist exposed fields
  res.json({
    name: {
      first: data.first_name,
      last: data.last_name,
      full: data.full_name,
    },
    contact: {
      email: data.email,
      phone: data.phone,
    },
    address: data.address,
    birthday: data.birthday,
    gender: data.gender,
    // Translate internal enums to friendly labels
    verifiedStatus: data.user_status === 'VERIFIED',
    role: prettifyRole(data.membership_role),
    profileFields: filterPublicProfileFields(data.profile_field_values ?? []),
  });
});
Whitelisting is safer than blacklisting — explicitly list what to expose, and anything new in VOMO’s response (e.g., a future-added field) defaults to “not exposed” until you’ve decided.

Step 4: profile update — writing back to VOMO

For portals that let volunteers update their own profile (phone, address, birthday, etc.), the partner backend handles the upsert:
JavaScript
app.put('/portal/api/me/profile', requireAuth, async (req, res) => {
  const { customer_id, vomo_user_id, email } = req.session;
  const updates = req.body;

  // 1. Validate the input
  const allowedFields = ['phone', 'address', 'birthday'];
  const sanitized = {};
  for (const field of allowedFields) {
    if (updates[field] !== undefined) {
      sanitized[field] = sanitizeInput(field, updates[field]);
    }
  }

  // 2. Don't allow the user to change their email via the portal
  // (that's a customer-admin operation due to the email-change problem)
  if (updates.email && updates.email !== email) {
    return res.status(403).json({
      error: 'Email changes must be made through the customer admin',
    });
  }

  // 3. Fetch current state (we need full body for upsert)
  const vomoToken = await credentials.getVomoToken(customer_id);
  const currentResponse = await fetch(
    `https://api.vomo.org/v1/users/${vomo_user_id}`,
    { headers: { Authorization: `Bearer ${vomoToken}` } }
  );
  const current = (await currentResponse.json()).data;

  // 4. Upsert with merged data
  const upsertResponse = await fetch('https://api.vomo.org/v1/users', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${vomoToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      email: current.email,  // Keep existing
      first_name: current.first_name,
      last_name: current.last_name,
      ...sanitized,  // Apply updates
    }),
  });

  if (!upsertResponse.ok) {
    return res.status(upsertResponse.status).json({ error: 'Update failed' });
  }

  res.json({ success: true });
});

Critical: don’t let users change their email

The email-change problem (from the upsert workflow) means a volunteer changing their email via the portal would either:
  • Create a new VOMO user (because upsert matches by email)
  • Orphan the existing record
Block email changes at the portal level. Direct volunteers to the customer’s admin team for email updates — they have access to the admin UI’s merge tooling.

Step 5: caching for portal performance

A portal page might show: profile, recent participations, hour totals, upcoming shifts, certificates. That’s potentially 5+ VOMO API calls per page load. Without caching, the portal will be slow and rate-limit-bound.

Per-user, per-session cache

JavaScript
class PortalDataCache {
  constructor({ ttlSeconds = 60 }) {
    this.ttlMs = ttlSeconds * 1000;
    this.cache = new Map(); // sessionId → { data, fetchedAt }
  }

  async getOrFetch(sessionId, key, fetchFn) {
    const cacheKey = `${sessionId}:${key}`;
    const cached = this.cache.get(cacheKey);
    if (cached && Date.now() - cached.fetchedAt < this.ttlMs) {
      return cached.data;
    }

    const data = await fetchFn();
    this.cache.set(cacheKey, { data, fetchedAt: Date.now() });
    return data;
  }

  invalidate(sessionId, key) {
    this.cache.delete(`${sessionId}:${key}`);
  }
}
A 60-second TTL is reasonable for portal use — fresh enough that “I just signed up for a shift” appears within a minute, while reducing API calls dramatically. Invalidate on writes: after a profile update, invalidate the user’s profile cache.

Server-side render with cached data

For SSR or partial-prefetch patterns, pre-load the common data:
JavaScript
async function preloadPortalData(session) {
  const [profile, participations, certificates] = await Promise.all([
    cache.getOrFetch(session.id, 'profile', () =>
      fetchVomoUserDetail(session.customer_id, session.vomo_user_id)),
    cache.getOrFetch(session.id, 'participations', () =>
      fetchUserParticipations(session.customer_id, session.vomo_user_id)),
    cache.getOrFetch(session.id, 'certificates', () =>
      fetchUserCertificates(session.customer_id, session.vomo_user_id)),
  ]);

  return { profile, participations, certificates };
}
The three calls run in parallel; the page renders once they all complete (or progressively as each finishes).

Step 6: handle the volunteer-not-in-VOMO case

Sometimes a logged-in user has no corresponding VOMO record:
  • They were deleted in VOMO (but their portal account remains)
  • Their email changed in VOMO
  • They never had a VOMO record (registered for the portal but aren’t a volunteer)
JavaScript
async function ensureVomoUserAccessible(session) {
  const user = await fetchVomoUserDetail(session.customer_id, session.vomo_user_id);

  if (!user) {
    // VOMO user no longer exists or isn't accessible
    await db.markPortalAccountOrphaned(session.partner_account_id);
    throw new ResourceGoneError('No VOMO record for this account');
  }

  if (user.email?.toLowerCase() !== session.email?.toLowerCase()) {
    // Email mismatch — sessionEmail and VOMO email differ
    await db.markPortalAccountStale(session.partner_account_id);
    throw new AuthError('Account email no longer matches; please contact support');
  }

  return user;
}
Surface a clear “account needs reconciliation” UI for these cases — not a generic error.

Step 7: things volunteers might want that aren’t possible

A portal naturally raises product expectations. Some common requests don’t have API support:
Volunteer requestWhy it doesn’t work
”Sign me up for this shift”Participation writes aren’t exposed in the API
”Cancel my signup”Same
”Mark me as checked in”Same
”Award me this certificate”Certificate write isn’t exposed
”Update my email”Email-change problem — admin UI only
”Delete my account / data”User deletion isn’t exposed; partner can mark deleted in portal-side data only
Set these expectations clearly in the portal UI. For shift signup specifically, link out to the VOMO admin UI — https://portal.vomo.org/projects/{slug} is typically the user-facing signup path. See Understand Write Limitations for the full picture.

Architecture summary

The components:
ComponentRole
Identity providerAuthenticates the volunteer; provides verified email
Portal backendHolds the VOMO token; mediates all API access
Portal frontendRenders the volunteer’s view; never holds tokens
Per-session cacheReduces API calls; invalidated on writes
VOMO admin UI link-outFor operations the API doesn’t support (signup, etc.)

Things to watch for

Email is the join key — protect it

The portal-to-VOMO mapping depends on email. If the volunteer changes their email in the customer’s identity provider (SSO) but not in VOMO, the next login fails to find the VOMO user. Build the “email mismatch” alert path; ideally proactively detect and handle.

Don’t expose VOMO IDs in URLs or APIs

The portal frontend shouldn’t include vomo_user_id in URLs or any client-visible state. Internal references should be the partner’s own person ID (or session-scoped).

Profile fields can contain sensitive data

Some customers store sensitive data in custom profile fields (background-check status, accommodations, etc.). Default to hiding profile fields unless explicitly whitelisted; let the customer configure which fields the portal exposes.

Rate limits apply to portal traffic

Every page load is N API requests. For high-traffic customers (e.g., a corporate volunteer program with thousands of weekly portal visits), the portal can easily become the dominant rate-budget consumer. Cache aggressively, batch when possible, and consider per-customer rate budgets — see Rate Limits and API Performance Tips.

Session lifecycle and security

Standard web security practices apply — HTTPS only, secure cookies, CSRF protection, session timeouts, etc. The novel concern for this product is the “session VOMO User ID is stale” case; build re-verification into session refresh.

The “lapsed volunteer” UX

Volunteers whose VOMO record has gone inactive (no recent participations, possibly archived) may still log into the portal and see a confusing empty state. Build a friendly “you haven’t volunteered with us recently — here’s how to get involved again” experience for low-activity users.

What you’ve built

After this recipe:
  • ✅ Identity flow connecting external auth to VOMO User IDs
  • ✅ Backend API exposing per-volunteer scoped endpoints
  • ✅ Strict authorization — session implicit, no client-supplied IDs
  • ✅ Whitelisted field exposure with friendly labels
  • ✅ Profile update flow with email-change protection
  • ✅ Caching layer for portal performance
  • ✅ Handling for the orphaned-account and email-mismatch cases
  • ✅ Clear product expectations for what the API doesn’t allow
This is a product-shape recipe — the architecture matters more than any single piece of code. The patterns transfer to any partner-built customer-facing experience on top of the Volunteer API.

Where to go next

Combine Volunteer Data with CRM+ Data

The cross-API recipe — joins beautifully with the portal experience.

Sync Users to External System

The foundational sync pattern this recipe builds on.

Security and Credential Management

The security patterns this portal architecture depends on.

Understand Write Limitations

The “what’s not possible via API” reference for portal product decisions.
Last modified on May 22, 2026