Skip to main content
POST /api/Contact/Query is the primary endpoint for retrieving Contact records by criteria other than ID. This workflow walks through three of the most common scenarios for partner integrations: incremental sync by modification date, tag-based or custom-field-based queries, and bulk export of large result sets. If you have not read Pagination and Filtering, start there — this page builds on the structured-filter pattern introduced there.

Scenario

Your integration needs to retrieve Contacts that match some criteria. The most common cases for partners:
  • Incremental sync. Find Contacts modified since your last sync run to keep your local copy current.
  • Targeted retrieval. Find Contacts matching a specific tag, custom field value, or attribute for a specific workflow (e.g., everyone tagged “Major Donor” for a major-gift campaign).
  • Bulk export. Retrieve every Contact for an initial data load or reporting export.
All three use the same endpoint and pattern — they differ in the filter structure and the loop strategy.

Prerequisites

  • A valid CRM+ API token — see Authentication.
  • Understanding of the filter structure (groups[].conditions[]) — see Pagination and Filtering.
  • The list of valid filter parameters and operators for the customer’s organization, retrieved from GET /api/Contact/QueryOptions.

Step 1: discover valid filter parameters

Before constructing filters, retrieve the parameters and operators valid for the current organization. Contact Type, Last Modified Date, and similar parameters exist in most organizations but the exact set — including custom-field-derived parameters — varies.
curl https://api.virtuoussoftware.com/api/Contact/QueryOptions \
  -H "Authorization: Bearer YOUR_API_TOKEN"
Cache the result at integration startup. The available parameters change rarely; refresh daily at most.

Pattern 1: incremental sync by modification date

The most common use of Contact Query in a partner integration: find Contacts modified since the last sync run.
JavaScript
async function pullModifiedContacts(lastSyncTimestamp) {
  const allContacts = [];
  let skip = 0;
  const take = 1000;
  let total = null;
  let highestModifiedDate = lastSyncTimestamp;

  do {
    const response = await fetch(
      'https://api.virtuoussoftware.com/api/Contact/Query',
      {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${process.env.VIRTUOUS_API_TOKEN}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          groups: [
            {
              conditions: [
                {
                  parameter: 'Last Modified Date',
                  operator: 'Is After',
                  value: lastSyncTimestamp,
                },
              ],
            },
          ],
          sortBy: 'last modified date',
          descending: false,           // oldest first — process in order
          skip,
          take,
        }),
      }
    );

    if (!response.ok) {
      throw new Error(`Query failed: ${response.status}`);
    }
    const page = await response.json();

    if (total === null) {
      total = page.total;
      console.log(`Found ${total} modified contacts since ${lastSyncTimestamp}`);
    }

    for (const contact of page.list) {
      allContacts.push(contact);
      if (contact.modifiedDateTimeUtc > highestModifiedDate) {
        highestModifiedDate = contact.modifiedDateTimeUtc;
      }
    }

    skip += take;
  } while (skip < total);

  return { contacts: allContacts, nextSyncTimestamp: highestModifiedDate };
}
Three patterns this gets right:
  • Sort by modification date ascending. Processing oldest-to-newest means that if the sync is interrupted partway through, you can resume from the highest modifiedDateTimeUtc you’ve fully processed.
  • Track the highest modification date observed. Use this as the floor for the next sync run, not the wall-clock time when you started the run. This avoids a race condition where records modified during the sync would be missed.
  • Use take=1000. Large page sizes minimize round trips and rate-limit budget consumption. See Rate Limits.

Picking the sync interval

Run the incremental sync on an interval driven by your customer’s tolerance for data staleness:
IntervalUse case
Every 5–10 minutesNear-real-time integrations. Combine with webhooks for true real-time; use polling as a backstop.
HourlyMost partner integrations. Catches the typical rate of donor data changes without excess load.
DailyReporting integrations, data warehouses, or any consumer with daily-batch downstream processing.
Webhooks are still the preferred mechanism for change detection — see Webhooks Overview. Use this incremental query pattern as a reconciliation backstop, not as the primary signal. Run the query on a slower cadence than your webhook handler processes events.

Pattern 2: targeted retrieval by tag or custom field

Find Contacts matching a specific tag, custom field value, or attribute. Useful for partner workflows that target specific donor segments.

By tag

{
  "groups": [
    {
      "conditions": [
        { "parameter": "Tag", "operator": "Is", "value": "Major Donor" }
      ]
    }
  ],
  "sortBy": "name",
  "skip": 0,
  "take": 100
}

By custom field value

{
  "groups": [
    {
      "conditions": [
        { "parameter": "Newsletter Subscriber", "operator": "Is", "value": "true" }
      ]
    }
  ],
  "sortBy": "name",
  "skip": 0,
  "take": 100
}
The exact parameter value for a custom field depends on the field’s name configured in the organization. Discover via GET /api/Contact/QueryOptions — see Pagination and Filtering.

By contact type and state

{
  "groups": [
    {
      "conditions": [
        { "parameter": "Contact Type", "operator": "Is", "value": "Household" },
        { "parameter": "State", "operator": "Is", "value": "AZ" }
      ]
    }
  ],
  "sortBy": "name",
  "skip": 0,
  "take": 100
}
Multiple conditions within a single group typically combine with AND logic. See Pagination and Filtering for the structure and the flagged human-input on group/condition combination logic.

Pattern 3: bulk export

For an initial backfill or a full-export reporting use case, retrieve every Contact:
JavaScript
async function exportAllContacts() {
  const allContacts = [];
  let skip = 0;
  const take = 1000;
  let total = null;

  do {
    const response = await fetch(
      'https://api.virtuoussoftware.com/api/Contact/Query',
      {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${process.env.VIRTUOUS_API_TOKEN}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          groups: [],                            // empty groups = all Contacts
          includeArchived: true,                 // include archived for completeness
          sortBy: 'id',                          // stable sort for resumability
          descending: false,
          skip,
          take,
        }),
      }
    );

    const page = await response.json();
    if (total === null) total = page.total;

    allContacts.push(...page.list);
    skip += take;

    console.log(`Exported ${allContacts.length} / ${total}`);
  } while (skip < total);

  return allContacts;
}
Three differences from the incremental sync:
  • Empty groups[] returns every Contact.
  • includeArchived: true includes archived records — important for completeness in an initial load.
  • Sort by id is the most stable sort field for resumability. If you sort by modifiedDateTimeUtc on a full export and records get modified mid-export, you can re-process the same record or miss others; sorting by id (an immutable field) is safer for full exports.

Resumable exports

For very large exports (hundreds of thousands of records) that may take hours, persist the last successfully-processed id between batches. If the export is interrupted, resume from that ID rather than restarting:
JavaScript
async function exportAllContactsResumable(resumeFromId = 0) {
  let skip = 0;
  const take = 1000;

  while (true) {
    const response = await fetch(
      'https://api.virtuoussoftware.com/api/Contact/Query',
      {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${process.env.VIRTUOUS_API_TOKEN}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          groups: [
            {
              conditions: [
                { parameter: 'Contact Id', operator: 'Greater Than', value: resumeFromId.toString() },
              ],
            },
          ],
          sortBy: 'id',
          descending: false,
          skip,
          take,
        }),
      }
    );

    const page = await response.json();
    if (page.list.length === 0) break;

    for (const contact of page.list) {
      await persistContact(contact);
      resumeFromId = contact.id;             // checkpoint after each record
    }

    skip += take;
  }
}
Replacing the skip-based loop with an ID-cursor-based loop also avoids the skip overhead — at high skip values, server-side query performance can degrade. ID-cursor pagination keeps each query bounded to a fresh range.
Confirm the exact parameter name (Contact Id vs. Id vs. ContactId) by calling GET /api/Contact/QueryOptions. The value used above is illustrative; the live API’s enum is the authoritative source.

Working with the response

POST /api/Contact/Query returns an abbreviated Contact representation in the list array — id, name, contactType, contactName, address, email, phone, and contactViewUrl. For most sync workflows this is enough; the email and phone in the result are sufficient to surface in your platform’s UI and to identify the donor for downstream processing. If you need the full Contact record (with ContactIndividuals, all addresses, custom fields, contactReferences, etc.), there are two options:
ApproachUse
Iterate the abbreviated results and GET /api/Contact/{contactId} for eachWhen you need full detail for a small subset (e.g., the contacts you intend to write back to).
Use POST /api/Contact/Query/FullContact insteadWhen you need full detail for the whole result set. Returns the same envelope but with complete Contact records. Slower per-request — use only when needed.
POST /api/Contact/Query/FullContact is meaningfully slower than the abbreviated variant. Use it only when your workflow actually needs the full payload — most incremental syncs do not. The abbreviated POST /api/Contact/Query plus a targeted full-detail fetch for the small subset you act on is usually more efficient.

Including archived contacts

By default, POST /api/Contact/Query excludes archived Contacts. Include them by setting includeArchived: true in the body:
{
  "groups": [
    { "conditions": [{ "parameter": "Last Modified Date", "operator": "Is After", "value": "2024-12-01T00:00:00Z" }] }
  ],
  "includeArchived": true,
  "sortBy": "last modified date",
  "skip": 0,
  "take": 1000
}
For incremental sync, this is important — when a Contact is archived in Virtuous, the archive operation updates modifiedDateTimeUtc, but the record is excluded from default queries. Without includeArchived: true, your sync sees the archived Contact as “missing” rather than detecting the archive event. See Statuses and Lifecycle States for the broader treatment.

Performance considerations

  • take=1000 for bulk operations. Each request returns up to 1,000 Contacts and consumes one rate-limit slot. At maximum throughput, you can retrieve 1.5 million Contacts per hour — well above the size of any typical nonprofit’s donor database.
  • take=25 or take=50 for interactive UIs. Smaller pages load quickly and avoid fetching records the user never sees.
  • Sort on indexed fields. Default sort orders are indexed. Custom sortBy values may be slow on large result sets.
  • Filter aggressively. A query that returns 500 Contacts after filtering is much cheaper than a query that returns 50,000 you discard client-side. Push filters into the request body wherever possible.

Error handling

StatusCauseAction
400 Bad RequestInvalid filter parameter or operatorConfirm with GET /api/Contact/QueryOptions; re-check casing of parameter names.
400 Bad Requesttake exceeds 1000Reduce take to 1000 or fewer.
401 UnauthorizedInvalid tokenRefresh credentials.
403 ForbiddenAPI key permissions insufficientVerify the permission group.
429 Too Many RequestsRate limit exceededBack off per Retry-After.
Bulk queries are the most rate-limit-sensitive class of request. A misconfigured loop that paginates without checking termination conditions can burn the full hourly budget in minutes. Always assert that the loop is making progress (the skip value increases or the result set is finite).

Where to go next

Query Donations by Date Range

The Gift equivalent of this workflow — same pattern, different resource.

Sync External Donations into Virtuous

Use this query pattern as a reconciliation backstop in a full sync architecture.

Pagination and Filtering

The underlying mechanics of skip/take and the filter structure.

Webhooks Overview

The preferred primary signal for change detection — use this query pattern as a backstop.
Last modified on May 21, 2026