> ## Documentation Index
> Fetch the complete documentation index at: https://docs.virtuous.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a Volunteer Self-Service Portal

> The end-to-end recipe for a customer-facing volunteer portal — letting volunteers see their own data without VOMO admin access. Authentication architecture, what to expose, security boundaries, and the partner-side patterns that make it work.

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

| Scenario                                                                      | This 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.

```mermaid theme={null}
graph LR
  Volunteer["Volunteer"]
  Portal["Partner portal<br/>(frontend + backend)"]
  Auth["Identity provider<br/>(customer SSO, partner auth, etc.)"]
  Backend["Partner backend"]
  VOMO["VOMO API<br/>(partner's admin token)"]

  Volunteer --> Portal
  Portal --> Auth
  Auth --> Portal
  Portal --> Backend
  Backend --> VOMO
  VOMO --> Backend
  Backend --> Portal
  Portal --> Volunteer
```

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 JavaScript theme={null}
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 JavaScript theme={null}
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.

### Option C: magic-link-only

A simpler partner-managed flow — no passwords, just emailed magic links:

```javascript JavaScript theme={null}
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**:

```mermaid theme={null}
graph LR
  Frontend["Frontend<br/>(volunteer's browser)"]
  Backend["Partner backend<br/>(holds VOMO token)"]
  VomoAPI["VOMO API"]

  Frontend -->|"GET /portal/api/me<br/>session cookie"| Backend
  Backend -->|"GET /users/&#123;myId&#125;<br/>VOMO token"| VomoAPI
  VomoAPI --> Backend
  Backend -->|"only this user's data"| Frontend
```

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 JavaScript theme={null}
// 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 JavaScript theme={null}
// ❌ 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 JavaScript theme={null}
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

| Field                                         | Show?                                                                    |
| --------------------------------------------- | ------------------------------------------------------------------------ |
| `first_name`, `last_name`, `full_name`        | ✓ Their own name                                                         |
| `email`                                       | ✓ Their own email                                                        |
| `phone`                                       | ✓                                                                        |
| `address`                                     | ✓                                                                        |
| `birthday`                                    | ✓                                                                        |
| `gender`                                      | ✓                                                                        |
| `user_status`                                 | Maybe — show "Verified" badge if `VERIFIED`; don't expose internal codes |
| `membership_role`                             | Maybe — show "Volunteer," "Organizer," etc. as friendly label            |
| Profile fields (custom data per organization) | ✓ Show their own values                                                  |

### Participation data: safe to expose

| Field                                            | Show?                                     |
| ------------------------------------------------ | ----------------------------------------- |
| 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

| Field                                                  | Show?                                      |
| ------------------------------------------------------ | ------------------------------------------ |
| 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

| Field                           | Show?         |
| ------------------------------- | ------------- |
| Groups the volunteer belongs to | ✓ (typically) |
| Other members of those Groups   | ✗ Privacy     |
| Group descriptions              | ✓             |

### Upcoming Project Dates they're signed up for

| Field                                                | Show? |
| ---------------------------------------------------- | ----- |
| 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

| Field                                                          | Why 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 names                        | Privacy                                                 |
| Project draft/internal-notes fields                            | May contain organizer-only context                      |
| Profile fields marked as private/internal in customer settings | Respect the customer's privacy classification           |

### A reference profile endpoint

```javascript JavaScript theme={null}
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 JavaScript theme={null}
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](/volunteer/workflows/create-or-update-a-user#the-email-change-problem)) 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 JavaScript theme={null}
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 JavaScript theme={null}
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 JavaScript theme={null}
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 request           | Why 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](/volunteer/workflows/understand-write-limitations) for the full picture.

***

## Architecture summary

```mermaid theme={null}
graph TD
  Volunteer["Volunteer"]
  Auth["Identity provider<br/>(SSO or partner auth)"]
  Frontend["Portal frontend<br/>(React/Vue/etc.)"]
  Backend["Portal backend<br/>(holds VOMO token)"]
  Cache["Per-session cache<br/>(60s TTL)"]
  VOMO["VOMO API"]
  ExternalUI["VOMO portal UI<br/>(for signup)"]

  Volunteer -->|"login"| Auth
  Auth -->|"verified email"| Backend
  Backend -->|"resolve to VOMO User ID"| VOMO
  Volunteer -->|"view profile/history"| Frontend
  Frontend -->|"/portal/api/me/*<br/>session cookie"| Backend
  Backend -->|"cache check"| Cache
  Cache -.->|"hit"| Backend
  Backend -->|"cache miss"| VOMO
  VOMO --> Backend
  Backend -->|"filtered data"| Frontend
  Frontend --> Volunteer
  Volunteer -.->|"signup (not via API)"| ExternalUI
```

The components:

| Component              | Role                                                  |
| ---------------------- | ----------------------------------------------------- |
| Identity provider      | Authenticates the volunteer; provides verified email  |
| Portal backend         | Holds the VOMO token; mediates all API access         |
| Portal frontend        | Renders the volunteer's view; never holds tokens      |
| Per-session cache      | Reduces API calls; invalidated on writes              |
| VOMO admin UI link-out | For 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](/volunteer/rate-limits) and [API Performance Tips](/volunteer/best-practices/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

<CardGroup cols={2}>
  <Card title="Combine Volunteer Data with CRM+ Data" icon="layer-group" href="/volunteer/recipes/combine-volunteer-data-with-crm-plus-data">
    The cross-API recipe — joins beautifully with the portal experience.
  </Card>

  <Card title="Sync Users to External System" icon="arrows-rotate" href="/volunteer/recipes/sync-users-to-external-system">
    The foundational sync pattern this recipe builds on.
  </Card>

  <Card title="Security and Credential Management" icon="lock" href="/volunteer/best-practices/security-and-credential-management">
    The security patterns this portal architecture depends on.
  </Card>

  <Card title="Understand Write Limitations" icon="ban" href="/volunteer/workflows/understand-write-limitations">
    The "what's not possible via API" reference for portal product decisions.
  </Card>
</CardGroup>
