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

# Campaigns

> The Campaign resource — read-only umbrella initiatives that group multiple Projects, plus the patterns for filtering and attributing volunteer activity by Campaign.

A Campaign in the Volunteer API represents a longer-running initiative that spans multiple Projects. Where Projects represent specific volunteer opportunities ("Saturday Food Bank Shift"), Campaigns represent the broader effort that ties multiple opportunities together ("Summer Outreach 2025", "Holiday Giving Drive", "Earth Day Initiative").

For partner integrations, Campaigns are the natural reporting and attribution dimension — "how did the Summer Outreach campaign perform?", "show me all Projects in the Holiday Drive". They're read-only from the API; Campaign creation and management happens in the VOMO admin UI.

## The two endpoints

| Endpoint          | Method | What it returns                        |
| ----------------- | ------ | -------------------------------------- |
| `/campaigns`      | GET    | List Campaigns (paginated, filterable) |
| `/campaigns/{id}` | GET    | Get a single Campaign                  |

There are no write endpoints for Campaigns. The two endpoints are sufficient for the read-oriented integration patterns Campaigns support.

<Warning>
  **⚠️ Spec gap (audit #4):** Both Campaign endpoints have empty `schema: {}` in the spec — the response shape is only documented through inline examples. The `CampaignResource` schema component exists but isn't referenced from either endpoint. Build parsers from actual response shapes.
</Warning>

***

## The Campaign resource

The `CampaignResource` schema documents these fields:

| Field               | Type    | Description                                        |
| ------------------- | ------- | -------------------------------------------------- |
| `type`              | string  | `"campaign"` — the VOMO object type                |
| `id`                | integer | The Campaign's stable ID                           |
| `campaign_name`     | string  | The Campaign's display name                        |
| `description`       | string  | The Campaign's description                         |
| `url`               | string  | A URL to the Campaign's page in VOMO               |
| `organization`      | string  | The Organization name the Campaign belongs to      |
| `organization_slug` | string  | The Organization's slug                            |
| `author_id`         | integer | The User ID of the person who created the Campaign |
| `logo_url`          | string  | URL for the Campaign logo image                    |
| `updated_at`        | string  | ISO 8601 datetime                                  |
| `created_at`        | string  | ISO 8601 datetime                                  |

<Warning>
  **⚠️ Spec gap (audit #13):** `CampaignResource.description` reads `"A VOMO Project"` — a copy-paste error. The schema describes a Campaign, not a Project. The description will be corrected in a future spec revision.
</Warning>

### About the `campaign_name` field

The `campaign_name` field name is unusual — it includes a redundant resource-type prefix. The audit flagged this (#10) as inconsistent with the standard convention of plain `name` used in other Virtuous APIs.

| API                      | Resource name field                            |
| ------------------------ | ---------------------------------------------- |
| CRM+                     | `Name` (PascalCase)                            |
| Raise                    | `name`                                         |
| **Volunteer (Project)**  | **Both `name` AND `project_name`** (duplicate) |
| **Volunteer (Campaign)** | **Only `campaign_name`** (prefixed)            |

For partner integrations:

```javascript JavaScript theme={null}
function parseCampaign(raw) {
  return {
    id: raw.id,
    name: raw.campaign_name,  // Map to a normalized 'name' field
    description: raw.description,
    url: raw.url,
    organization: raw.organization,
    organizationSlug: raw.organization_slug,
    authorId: raw.author_id,
    logoUrl: raw.logo_url,
    createdAt: new Date(raw.created_at),
    updatedAt: new Date(raw.updated_at),
  };
}
```

Mapping `campaign_name` to `name` in your integration's internal representation produces consistency across CRM+, Raise, and Volunteer Campaign records.

### Organization attribution

Campaigns belong to specific organizations within the family. The `organization` and `organization_slug` fields identify which:

| Use case                       | Field to use                                                   |
| ------------------------------ | -------------------------------------------------------------- |
| Display the org name in a UI   | `organization`                                                 |
| Filter Campaign queries by org | `organization_slug` (used with the `org_slug` query parameter) |
| Programmatic routing           | `organization_slug` (stable, URL-safe)                         |

There's no `organization_id` field documented on Campaigns (unlike Projects, which expose all three: `organization`, `organization_id`, `organization_slug`). Use `organization_slug` as the identifier for org-based logic.

***

## Listing campaigns

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

### Available filters

| Parameter        | What it filters by                      |
| ---------------- | --------------------------------------- |
| `page`           | Page number (default 1)                 |
| `org_slug`       | One or more org slugs (comma-separated) |
| `name_like`      | Substring match against Campaign name   |
| `created_before` | Campaigns created on or before a date   |
| `created_after`  | Campaigns created on or after a date    |
| `updated_before` | Campaigns updated on or before a date   |
| `updated_after`  | Campaigns updated on or after a date    |

These mirror the filter set on `/projects` (minus the Project-specific date filters). The `org_slug` parameter is the canonical way to filter Campaigns to specific organizations within the family.

### Common list patterns

**Campaigns for a specific organization:**

```javascript JavaScript theme={null}
async function campaignsForOrg(orgSlug) {
  const params = new URLSearchParams({ org_slug: orgSlug });
  return paginate(`https://api.vomo.org/v1/campaigns?${params}`);
}
```

**Campaigns for multiple child organizations:**

```javascript JavaScript theme={null}
async function campaignsForOrgs(orgSlugs) {
  const params = new URLSearchParams({ org_slug: orgSlugs.join(',') });
  return paginate(`https://api.vomo.org/v1/campaigns?${params}`);
}
```

**Recently-created campaigns:**

```javascript JavaScript theme={null}
async function recentCampaigns(sinceDate) {
  const params = new URLSearchParams({
    created_after: sinceDate.toISOString(),
  });
  return paginate(`https://api.vomo.org/v1/campaigns?${params}`);
}
```

**Find a Campaign by partial name (interactive search):**

```javascript JavaScript theme={null}
async function findCampaignsLike(searchTerm) {
  const params = new URLSearchParams({ name_like: searchTerm });
  const response = await fetch(
    `https://api.vomo.org/v1/campaigns?${params}`,
    { headers: { Authorization: `Bearer ${token}` } }
  );
  const page = await response.json();
  return page.data;
}
```

***

## Fetching a single campaign

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

Returns the `CampaignResource` wrapped in `data`:

```javascript JavaScript theme={null}
async function getCampaign(campaignId) {
  const response = await fetch(
    `https://api.vomo.org/v1/campaigns/${campaignId}`,
    { headers: { Authorization: `Bearer ${token}` } }
  );

  if (response.status === 404) return null;
  if (!response.ok) throw new Error(`Failed: ${response.status}`);

  const result = await response.json();
  return parseCampaign(result.data);
}
```

<Note>
  **⚠️ Spec gap (audit #4):** `GET /campaigns/{id}` has empty `schema: {}` in the spec. The response likely includes additional fields beyond what's in `CampaignResource` (such as an embedded Project list), but the exact extended shape isn't formally specified.
</Note>

***

## Campaigns and Projects

Campaigns group Projects. The relationship is captured on the Project side — each Project's `ProjectResource` includes a `campaigns` array showing which Campaigns it belongs to:

```json theme={null}
{
  "type": "project",
  "id": 789,
  "name": "Saturday Food Bank Shift",
  "campaigns": [
    { "id": 456, "campaign_name": "Summer Outreach 2025" },
    { "id": 457, "campaign_name": "Food Security Initiative" }
  ]
}
```

A Project can belong to multiple Campaigns (an outreach event can be both part of the seasonal "Summer Outreach" Campaign and the issue-focused "Food Security" Campaign).

### Finding Projects in a Campaign

Two approaches:

| Approach                                              | When to use                                                                                      |
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| **Query Projects, filter client-side by Campaign ID** | Simpler; works for any Campaign                                                                  |
| **Fetch Campaign detail, hope for embedded Projects** | Depends on whether `GET /campaigns/{id}` returns embedded Projects (spec doesn't formalize this) |

The first approach is more reliable:

```javascript JavaScript theme={null}
async function projectsInCampaign(campaignId) {
  const allProjects = await paginate('https://api.vomo.org/v1/projects');
  return allProjects.filter((p) =>
    (p.campaigns ?? []).some((c) => c.id === campaignId)
  );
}
```

For partner integrations doing this query frequently, cache the Campaign → Projects mapping rather than re-querying on every request.

### Counting volunteers in a Campaign

A common reporting question: "how many people volunteered for this Campaign?"

```javascript JavaScript theme={null}
async function uniqueVolunteersInCampaign(campaignId) {
  const projects = await projectsInCampaign(campaignId);
  const userIds = new Set();

  for (const project of projects) {
    const projectDetail = await getProjectDetail(project.id);
    // Walk all dates of this project; collect unique user IDs from participations
    for (const projectDate of projectDetail.all_dates ?? []) {
      const dateDetail = await getProjectDate(projectDate.id);
      for (const participant of dateDetail.participants ?? []) {
        userIds.add(participant.user_id);
      }
    }
  }

  return userIds.size;
}
```

This is expensive (N+1+M — one Projects query, one Project detail per Project, one Project Date detail per Date). For large Campaigns with many Projects and many Project Dates, consider:

* Caching the Campaign's project list (changes infrequently)
* Caching Project Date participants by date ID (each date's participants don't change after the date passes)
* Doing the aggregation offline as a reconciliation job rather than per-request

See [API Performance Tips](/volunteer/best-practices/api-performance-tips) for the broader pattern.

***

## Common workflows

### Campaign performance dashboard

For a "how is Campaign X doing?" dashboard:

```javascript JavaScript theme={null}
async function buildCampaignDashboard(campaignId) {
  const campaign = await getCampaign(campaignId);
  if (!campaign) return null;

  const projects = await projectsInCampaign(campaignId);

  const activeProjects = projects.filter((p) => /* check active status */).length;
  const totalParticipations = await countParticipationsForProjects(projects);
  const uniqueVolunteers = await uniqueVolunteersInCampaign(campaignId);

  return {
    name: campaign.name,
    description: campaign.description,
    url: campaign.url,
    logoUrl: campaign.logoUrl,
    organization: campaign.organization,
    metrics: {
      totalProjects: projects.length,
      activeProjects,
      totalParticipations,
      uniqueVolunteers,
    },
    daysActive: daysSince(campaign.createdAt),
  };
}
```

Aggregate the Campaign's Project-level data into Campaign-level metrics. Cache aggressively — these dashboard reads are expensive.

### Cross-Campaign comparison

```javascript JavaScript theme={null}
async function compareCampaigns(campaignIds) {
  const results = await Promise.all(
    campaignIds.map(async (id) => {
      const campaign = await getCampaign(id);
      const projects = await projectsInCampaign(id);
      return {
        campaign,
        projectCount: projects.length,
      };
    })
  );

  return results.map((r) => ({
    id: r.campaign.id,
    name: r.campaign.name,
    projectCount: r.projectCount,
  }));
}
```

Useful for side-by-side comparisons of similar Campaigns ("how did Summer 2024 compare to Summer 2025?").

### Sync Campaign list to an external system

For partner integrations mirroring Campaigns into an external CRM or reporting tool:

```javascript JavaScript theme={null}
async function syncCampaignsToExternal(customerId) {
  const lastSync = await getCheckpoint(customerId);

  const params = new URLSearchParams({
    updated_after: lastSync.toISOString(),
  });

  let url = `https://api.vomo.org/v1/campaigns?${params}`;
  while (url) {
    const page = await fetchPage(url);
    for (const campaign of page.data) {
      await externalSystem.upsertCampaign({
        externalId: `vomo-campaign-${campaign.id}`,
        name: campaign.campaign_name,
        description: campaign.description,
        organizationSlug: campaign.organization_slug,
        updatedAt: new Date(campaign.updated_at),
      });
    }
    url = page.links.next;
  }

  await advanceCheckpoint(customerId);
}
```

The `updated_after` filter combined with `links.next` pagination produces a clean incremental sync. Schedule this daily or hourly depending on the customer's reporting cadence.

***

## What can't be done via the API

| Capability                               | Status                                                           |
| ---------------------------------------- | ---------------------------------------------------------------- |
| Create a Campaign                        | Not exposed — admin UI only                                      |
| Update a Campaign's metadata             | Not exposed                                                      |
| Delete a Campaign                        | Not exposed                                                      |
| Add a Project to a Campaign              | Not exposed — Project ↔ Campaign association is admin-UI-managed |
| Schedule a Campaign with start/end dates | Campaign timing is captured in the admin UI; not API-exposed     |

If partner integrations need to programmatically create or modify Campaigns, coordinate with VOMO's admin team. The most common request — creating a per-customer Campaign from an external system — typically becomes an admin-team workflow rather than an API workflow.

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

***

## A reference Campaigns client

```javascript JavaScript theme={null}
class VomoCampaigns {
  constructor({ token }) {
    this.token = token;
    this.baseUrl = 'https://api.vomo.org/v1';
  }

  async list(filters = {}) {
    const params = new URLSearchParams(filters);
    const campaigns = [];
    let url = `${this.baseUrl}/campaigns?${params}`;

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

  async getById(campaignId) {
    const response = await this._fetch(`${this.baseUrl}/campaigns/${campaignId}`);
    if (response.status === 404) return null;
    if (!response.ok) throw new Error(`Failed: ${response.status}`);
    const result = await response.json();
    return this._parseCampaign(result.data);
  }

  async forOrg(orgSlug, additionalFilters = {}) {
    return this.list({ org_slug: orgSlug, ...additionalFilters });
  }

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

  _parseCampaign(raw) {
    return {
      id: raw.id,
      name: raw.campaign_name,  // Normalize to 'name'
      description: raw.description ?? '',
      url: raw.url,
      organization: raw.organization,
      organizationSlug: raw.organization_slug,
      authorId: raw.author_id,
      logoUrl: raw.logo_url ?? null,
      createdAt: new Date(raw.created_at),
      updatedAt: new Date(raw.updated_at),
    };
  }
}
```

The `campaign_name` → `name` normalization is the key transformation — it produces a Campaign object that looks consistent with how other systems represent Campaign records.

***

## Where to go next

<CardGroup cols={2}>
  <Card title="Projects and Project Dates" icon="calendar-days" href="/volunteer/concepts/projects-and-project-dates">
    The Projects that Campaigns group together.
  </Card>

  <Card title="Organizations and Org Family" icon="sitemap" href="/volunteer/concepts/organizations-and-org-family">
    Organizations own Campaigns within the family.
  </Card>

  <Card title="Forms and Form Completions" icon="clipboard-list" href="/volunteer/concepts/forms-and-form-completions">
    Forms are typically attached to Projects within Campaigns.
  </Card>

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