> ## 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.

# Volunteer Certificates

> The Certificate resource — training credentials and achievements awarded to volunteers, the naming inconsistency in the spec, and the patterns for working with certification data.

A Certificate in the Volunteer API represents a training credential, badge, or achievement that volunteers can earn. Common uses include:

* Required safety training (e.g., food handler certification for food bank volunteers)
* Background check completion
* Specialized role qualifications (e.g., team leader training, CPR certification)
* Milestone achievements (e.g., 100-hour volunteer recognition)
* Compliance credentials for regulated programs

Certificates have an expiration period — partner integrations can use this to detect when a volunteer's credential is about to expire and trigger renewal outreach.

## The one endpoint

| Endpoint        | Method | What it returns                                 |
| --------------- | ------ | ----------------------------------------------- |
| `/certificates` | GET    | List Certificates available in the organization |

That's it. The API exposes only Certificate definitions (the "what certificates exist") — not who has earned which Certificate, or when they earned it. Earned-certificate data isn't exposed through a dedicated endpoint.

<Note>
  Information about which Users have earned which Certificates may be embedded in the User detail response (`GET /users/{id}`) and the Project detail response (which lists required certificates). For workflows that need to know who has which Certificate, fetch User details and inspect the user's earned certifications.
</Note>

***

## The naming inconsistency

The Certificate resource has the most pronounced naming inconsistency of any resource in the Volunteer API:

| Where it appears   | Name used                  |
| ------------------ | -------------------------- |
| Endpoint path      | `/certificates`            |
| OpenAPI tag        | `Certifications`           |
| operationId        | `listCertifications`       |
| Schema name        | `CertificateResource`      |
| Schema description | `"List of Certifications"` |

<Warning>
  **⚠️ Spec gap (audit #14):** The resource is referred to as "Certificate", "Certification", and "Certifications" in different places. The path is `/certificates` (the canonical URL); the schema is `CertificateResource` (the canonical type name).

  For partner integrations, **use the path (`/certificates`) and the schema name (`CertificateResource`) as your reference points.** The variations will be reconciled in a future spec revision.
</Warning>

For the rest of this page, the resource is referred to as **Certificate** — matching the path and schema name.

***

## The Certificate resource

The `CertificateResource` schema documents these fields:

| Field                  | Type    | Description                                            |
| ---------------------- | ------- | ------------------------------------------------------ |
| `type`                 | string  | `"certificate"` — the VOMO object type                 |
| `id`                   | integer | The Certificate's stable ID                            |
| `name`                 | string  | The Certificate's display name                         |
| `requirement`          | string  | Description of what's required to earn the Certificate |
| `slug`                 | string  | A slug for the Certificate (see warning below)         |
| `expiration_in_months` | integer | How long the Certificate remains valid after issuance  |
| `creator_id`           | integer | The User ID of the Certificate's creator               |
| `organization_id`      | integer | The Organization the Certificate belongs to            |
| `created_at`           | string  | ISO 8601 datetime                                      |
| `updated_at`           | string  | ISO 8601 datetime                                      |

<Warning>
  **⚠️ Spec gap (audit #17):** The `CertificateResource` schema is incorrectly typed as `array` in the spec — the same issue affecting `FormResource` and `FormFieldResource`. The actual response is a single Certificate object per item in the response's `data` array. Code generated from the spec may need manual adjustment.
</Warning>

<Warning>
  **⚠️ Spec gap (audit #15):** The `slug` field has the same enum-vs-example contradiction documented on `FormResource`. The enum lists form-field-like values (`SHORTTEXT`, `LONGTEXT`, etc.) and the example is a UUID. Treat the slug as a UUID string per the example; the misleading enum will be removed in a future spec revision.
</Warning>

<Warning>
  **⚠️ Spec gap (audit #16):** The `id` field has `minimum: 3, maximum: 45` constraints — string-length-style constraints applied to an integer. Treat IDs as unconstrained integers.
</Warning>

### Expiration semantics

The `expiration_in_months` field captures how long a Certificate remains valid after a User earns it:

| Value       | Meaning                                            |
| ----------- | -------------------------------------------------- |
| `12`        | Valid for 12 months from issuance                  |
| `24`        | Valid for 24 months                                |
| `0` or null | Likely "never expires" (confirm against live data) |

For partner integrations, this is the dimension that drives renewal workflows — knowing the validity window for each Certificate type lets the integration compute "when does this user's certification expire?" given their earn date.

The expiration is on the **Certificate definition**, not on the per-user earned record. So the same Certificate has the same validity window for everyone who earns it.

***

## Listing certificates

```bash cURL theme={null}
curl https://api.vomo.org/v1/certificates \
  -H "Authorization: Bearer $VOMO_API_TOKEN"
```

Returns all Certificates in the customer's organization, paginated:

```json theme={null}
{
  "data": [
    {
      "type": "certificate",
      "id": 12,
      "name": "Food Handler Certification",
      "requirement": "Complete the food safety training module",
      "slug": "abc-def-123",
      "expiration_in_months": 12,
      "creator_id": 1,
      "organization_id": 100,
      "created_at": "2024-08-01T09:00:00Z",
      "updated_at": "2024-08-01T09:00:00Z"
    }
    /* ... more Certificates ... */
  ],
  "links": { /* ... */ },
  "meta": { /* ... */ }
}
```

<Note>
  The `/certificates` endpoint doesn't document explicit query filters in the spec. Pagination via `?page=N` likely works the same as other list endpoints — follow `links.next`.
</Note>

```javascript JavaScript theme={null}
async function listAllCertificates() {
  const certs = [];
  let url = 'https://api.vomo.org/v1/certificates';

  while (url) {
    const response = await fetch(url, {
      headers: { Authorization: `Bearer ${token}` },
    });
    if (!response.ok) throw new Error(`Failed: ${response.status}`);

    const page = await response.json();
    certs.push(...page.data);
    url = page.links.next;
  }

  return certs;
}
```

For most customers, the list of available Certificates is short (typically a handful to a few dozen) — fitting comfortably in memory and changing infrequently. Cache the result aggressively.

***

## Common workflows

### Cache and look up Certificates by ID

The most common pattern — read the Certificate list once, cache it, then look up by ID when needed:

```javascript JavaScript theme={null}
class CertificateCache {
  constructor({ token, ttlMs = 60 * 60 * 1000 }) {
    this.token = token;
    this.ttlMs = ttlMs;
    this.cached = null;
    this.cachedAt = 0;
  }

  async getAll() {
    if (this.cached && Date.now() - this.cachedAt < this.ttlMs) {
      return this.cached;
    }

    this.cached = await listAllCertificates();
    this.cachedAt = Date.now();
    return this.cached;
  }

  async getById(id) {
    const all = await this.getAll();
    return all.find((c) => c.id === id) ?? null;
  }

  async getByName(name) {
    const all = await this.getAll();
    return all.find((c) => c.name.toLowerCase() === name.toLowerCase()) ?? null;
  }
}
```

A 1-hour TTL is reasonable — Certificates change rarely.

### Map a Project's required Certificates

Projects can require Certificates as a prerequisite for volunteering. The Project's `certificates` array (on `ProjectResource`) lists which:

```json theme={null}
{
  "id": 789,
  "name": "Saturday Food Bank Shift",
  "certificates": [
    { "id": 12, "name": "Food Handler Certification" },
    { "id": 13, "name": "Background Check" }
  ]
}
```

For partner integrations displaying Project requirements:

```javascript JavaScript theme={null}
async function displayProjectRequirements(projectId) {
  const project = await getProjectDetail(projectId);
  const certCache = new CertificateCache({ token });

  const requirements = [];
  for (const certRef of project.certificates ?? []) {
    const cert = await certCache.getById(certRef.id);
    if (cert) {
      requirements.push({
        name: cert.name,
        description: cert.requirement,
        validForMonths: cert.expiration_in_months,
      });
    }
  }

  return requirements;
}
```

The result is a list of "what does a volunteer need to qualify for this Project?" — useful for signup pages, eligibility screens, or admin reports.

### Expiration renewal outreach

For workflows that watch for Certificates approaching expiration:

```javascript JavaScript theme={null}
async function findUsersWithExpiringCertificates(daysUntilExpiration = 30) {
  // Note: This pattern requires the User detail to expose earned certificates with dates.
  // The exact format isn't documented in the spec — confirm against live data.

  const allUsers = await paginate('https://api.vomo.org/v1/users');
  const certCache = new CertificateCache({ token });
  const expiringPerUser = [];

  for (const userSummary of allUsers) {
    const user = await getUserDetail(userSummary.id);
    const earnedCerts = user.earned_certificates ?? []; // confirm field name from live API

    for (const earned of earnedCerts) {
      const cert = await certCache.getById(earned.certificate_id);
      if (!cert?.expiration_in_months) continue;

      const earnedDate = new Date(earned.earned_at);
      const expirationDate = new Date(earnedDate);
      expirationDate.setMonth(expirationDate.getMonth() + cert.expiration_in_months);

      const daysUntilExp = (expirationDate - Date.now()) / (24 * 60 * 60 * 1000);

      if (daysUntilExp <= daysUntilExpiration && daysUntilExp > 0) {
        expiringPerUser.push({
          user,
          certificate: cert,
          expirationDate,
          daysUntilExpiration: Math.round(daysUntilExp),
        });
      }
    }
  }

  return expiringPerUser;
}
```

<Note>
  The exact field name and shape of earned-certificate data on the User Detail response (`GET /users/{id}`) isn't documented in the OpenAPI spec — the example UserDetailResource only formally documents `participations` and `profile_field_values`. The pattern above assumes a field like `earned_certificates` with `certificate_id` and `earned_at` per entry, but confirm against the live API before relying on it.
</Note>

The output is a list of "User X's Certificate Y expires in N days" entries — feed this into the customer's renewal outreach pipeline.

### Sync Certificate list to a compliance system

For organizations with regulatory compliance requirements that need Certificate data in an external system:

```javascript JavaScript theme={null}
async function syncCertificatesToComplianceSystem(customerId) {
  const lastSync = await getCheckpoint(customerId, 'certificate_sync');
  const certs = await listAllCertificates();

  for (const cert of certs) {
    const updatedAt = new Date(cert.updated_at);
    if (updatedAt > lastSync) {
      await complianceSystem.upsertCertification({
        externalId: `vomo-cert-${cert.id}`,
        name: cert.name,
        description: cert.requirement,
        validityMonths: cert.expiration_in_months,
        lastUpdated: updatedAt,
      });
    }
  }

  await advanceCheckpoint(customerId, 'certificate_sync', new Date());
}
```

Run daily or weekly. Certificates change infrequently; a less aggressive cadence is fine.

***

## What can't be done via the API

| Capability                                        | Status                                      |
| ------------------------------------------------- | ------------------------------------------- |
| Create a Certificate                              | Not exposed — admin UI only                 |
| Update a Certificate's requirement or expiration  | Not exposed                                 |
| Delete a Certificate                              | Not exposed                                 |
| Award a Certificate to a User                     | Not exposed                                 |
| Revoke a Certificate from a User                  | Not exposed                                 |
| Verify a Certificate's status for a specific User | No dedicated endpoint — inspect User detail |

If a partner integration needs to programmatically award Certificates (e.g., after a User completes external training), coordinate with VOMO's admin team for an alternative path.

See [Understand Write Limitations](/volunteer/workflows/understand-write-limitations).

***

## A reference Certificates client

```javascript JavaScript theme={null}
class VomoCertificates {
  constructor({ token, ttlMs = 60 * 60 * 1000 }) {
    this.token = token;
    this.baseUrl = 'https://api.vomo.org/v1';
    this.ttlMs = ttlMs;
    this.cache = null;
    this.cachedAt = 0;
  }

  async list() {
    const certs = [];
    let url = `${this.baseUrl}/certificates`;

    while (url) {
      const response = await this._fetch(url);
      const page = await response.json();
      certs.push(...page.data.map(this._parseCertificate));
      url = page.links.next;
    }
    return certs;
  }

  async getCached() {
    if (this.cache && Date.now() - this.cachedAt < this.ttlMs) {
      return this.cache;
    }
    this.cache = await this.list();
    this.cachedAt = Date.now();
    return this.cache;
  }

  async getById(certificateId) {
    const all = await this.getCached();
    return all.find((c) => c.id === certificateId) ?? null;
  }

  async getByName(name) {
    const all = await this.getCached();
    return all.find((c) => c.name.toLowerCase() === name.toLowerCase()) ?? null;
  }

  computeExpiration(certificate, earnedDate) {
    if (!certificate.expirationInMonths) return null; // No expiration
    const expirationDate = new Date(earnedDate);
    expirationDate.setMonth(expirationDate.getMonth() + certificate.expirationInMonths);
    return expirationDate;
  }

  _fetch(url, options = {}) {
    return fetch(url, {
      ...options,
      headers: {
        Authorization: `Bearer ${this.token}`,
        Accept: 'application/json',
        ...options.headers,
      },
    });
  }

  _parseCertificate(raw) {
    return {
      id: raw.id,
      name: raw.name,
      requirement: raw.requirement ?? '',
      slug: raw.slug, // Treat as UUID per example, not the misleading enum
      expirationInMonths: raw.expiration_in_months ?? null,
      creatorId: raw.creator_id,
      organizationId: raw.organization_id,
      createdAt: new Date(raw.created_at),
      updatedAt: new Date(raw.updated_at),
    };
  }
}
```

The `computeExpiration` helper handles the most common operation — given an earned date and a Certificate, compute the expiration date.

***

## Where to go next

<CardGroup cols={2}>
  <Card title="Users" icon="user" href="/volunteer/concepts/users">
    Users earn Certificates; the User detail response is where earned-certificate data lives.
  </Card>

  <Card title="Projects and Project Dates" icon="calendar-days" href="/volunteer/concepts/projects-and-project-dates">
    Projects can require Certificates as prerequisites.
  </Card>

  <Card title="Forms and Form Completions" icon="clipboard-list" href="/volunteer/concepts/forms-and-form-completions">
    The other read-only structured-data resource family.
  </Card>

  <Card title="The Volunteer Data Model" icon="diagram-project" href="/volunteer/concepts/data-model">
    The full data model context for Certificates.
  </Card>
</CardGroup>
