Export membership dates into a private JSON file you can refresh from your own worker. Start by checking one API page, then run the complete exporter below. It reads all pages and replaces the file only after the scan succeeds.
The file uses campai contact IDs as keys. It gives your integration a repeatable read side; it does not automatically update a CRM, match website accounts, or write changes back to campai. Those decisions are covered after the working export.
Read Understand money, dates, and data types before mapping amounts, dates, IDs, or missing fields into your own system.
What you’ll have at the end
A private, repeatable member snapshot with stable IDs, membership dates, and a completed-scan timestamp.
- Read every page
- Project by contact ID
- Finish the scan
- Replace private snapshot
Before you start
- Mandates Read and Members Read
- A private writable directory outside your web root
- Node.js 22+ or PHP 8.2+ for the complete exporter
On this page
The key needs Mandates → Read and Members → Read. Start with a single mandate. Replace YOUR_API_KEY, YOUR_ORGANIZATION_ID, and YOUR_MANDATE_ID directly in your chosen example. Each tab is complete and uses only cURL or the runtime’s built-in functions.
This List contacts request reads one page without changing records:
Example
curl --fail-with-body --silent --show-error --max-time 30 --globoff \
--request POST 'https://cloud.campai.com/api/YOUR_ORGANIZATION_ID/YOUR_MANDATE_ID/crm/contacts/list' \
--header 'X-API-Key: YOUR_API_KEY' \
--header 'Content-Type: application/json' \
--data-raw '{
"types": [
"member"
],
"sort": {
"_id": "asc"
},
"limit": 100,
"offset": 0
}'The response contains contacts and count. For a complete scan, repeat with offsets 100, 200, and so on until a page has fewer than 100 contacts. A full final page requires one more request, which can return an empty array. Keep the same filter and sort throughout the scan.
In your worker, deduplicate by _id and retain only the fields your integration needs, such as member.entryAt, member.exitAt, and member.terminatedAt. Do not forward the full contact response to a public page. A missing field can reflect field-level access; it is not an instruction to erase a local value.
Offset pagination is not an atomic database snapshot. Records can change during the scan, so reconcile again after a busy interval. Only publish a replacement snapshot after every page succeeds.
- Download and unzip the exporter into a private directory outside your website’s public root. Choose either Node.js 22+ or PHP 8.2+ with HTTPS streams enabled.
- Open
export.mjsorexport.phpand replaceYOUR_API_KEY,YOUR_ORGANIZATION_ID, andYOUR_MANDATE_IDat the top. Keep the file private and out of source control once it contains a key. - In a terminal, change into that directory. Run
node export.mjsorphp export.php. You need only that runtime; there is no package installation. - Open
member-snapshot.jsonlocally. Look for your full scope IDs, acompletedAttimestamp, andrecordskeyed by contact ID.
The complete member exporter
Choose export.mjs for Node.js or export.php for PHP. Each file runs independently, reads every page, and replaces member-snapshot.json only after a complete scan.
Download ZIPView and copy the complete source
The complete member exporter
import { open, rename, unlink } from 'node:fs/promises';
import { resolve } from 'node:path';
// Run on your server, in a private directory outside the website's public root.
const apiKey = 'YOUR_API_KEY';
const organization = 'YOUR_ORGANIZATION_ID';
const mandate = 'YOUR_MANDATE_ID';
const output = resolve('member-snapshot.json');
if (!/^[a-f0-9]{24}$/i.test(organization) || !/^[a-f0-9]{24}$/i.test(mandate) || apiKey === 'YOUR_API_KEY') {
throw new Error('Replace the key and full organization/mandate IDs first.');
}
const url = `https://cloud.campai.com/api/${organization}/${mandate}/crm/contacts/list`;
const limit = 100;
const records = new Map();
const lockPath = output + '.lock';
const temporary = output + '.' + process.pid + '.tmp';
// An existing lock means a run is active, or a stopped run needs investigation.
const lock = await open(lockPath, 'wx', 0o600);
try {
let finished = false;
for (let page = 0; page < 10_000; page++) {
const response = await fetch(url, {
method: 'POST',
headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
body: JSON.stringify({ types: ['member'], sort: { _id: 'asc' }, limit, offset: page * limit }),
redirect: 'error', signal: AbortSignal.timeout(30_000),
});
if (!response.ok) throw new Error(`campai HTTP ${response.status}; previous output kept.`);
const result = await response.json();
const rows = result.contacts;
if (!Array.isArray(rows) || rows.length > limit) throw new Error('Unexpected contacts response.');
for (const row of rows) {
if (!row || typeof row._id !== 'string' || !/^[a-f0-9]{24}$/i.test(row._id)) throw new Error('Invalid record ID.');
if (records.has(row._id)) throw new Error('Records moved during pagination. Run a fresh scan.');
const member = {};
for (const field of ['entryAt', 'exitAt', 'terminatedAt']) {
if (Object.hasOwn(row.member ?? {}, field)) member[field] = row.member[field];
}
records.set(row._id, { _id: row._id, member });
}
if (rows.length < limit) { finished = true; break; }
}
if (!finished) throw new Error('Page limit reached; previous output kept.');
const content = JSON.stringify({ organization, mandate, completedAt: new Date().toISOString(), records: Object.fromEntries(records) }, null, 2) + '\n';
const file = await open(temporary, 'wx', 0o600);
try { await file.writeFile(content); await file.sync(); } finally { await file.close(); }
// Both files are in the same directory: readers see either complete version.
await rename(temporary, output);
console.log(`Saved ${records.size} records to ${output}`);
} finally {
await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error; });
await lock.close();
await unlink(lockPath);
}
Each record contains _id and the available member.entryAt, member.exitAt, and member.terminatedAt fields. Missing fields stay missing; explicit null values stay null. The output excludes names, email addresses, and the API key.
You should see: Saved N records to …/member-snapshot.json. Run it again: the record keys stay unique and completedAt advances. Compare one member’s dates with campai. A 100-record page always causes another request, including when the following page is empty.
The exporter writes to a temporary file and renames it only after all pages succeed. A failed request, invalid response, or duplicate ID during a moving scan leaves the previous snapshot untouched. The .lock file prevents overlapping runs. After a forcibly stopped process, confirm no worker is still running before removing its leftover lock and temporary file.
An offset scan is still not an atomic database transaction. Run it again after a busy period, and never delete downstream records solely because they are absent from one completed file.
Use this explicit mapping when adapting your importer:
| Snapshot field | Local use | Update rule |
|---|---|---|
organization + contact _id |
Durable external identity | Upsert the same local person; never match by display name. |
member.entryAt, exitAt, terminatedAt |
Membership date projection | Update fields present in the record; preserve the distinction between missing and null. |
completedAt |
Successful-scan checkpoint | Advance only after your own import finishes too. |
| A previously seen ID is absent | Reconciliation candidate | Investigate scope, access, and deletion before removing anything. |
First import into a staging table and compare one known person. Import the same file twice: it must leave one local row per mapped person. Then change one designated test member’s date in campai, run the exporter again, and verify only that mapped row’s projection changes.
Persist the environment, organization ID, contact ID, external ID, and the last successful synchronization time. If one contact is accessible through several mandates, deduplicate using the organization and contact ID; mandate access is a scope, not a new person.
Choose the owner of each synchronized field. For example, campai might own membership dates while your website owns newsletter display preferences. Avoid two workers repeatedly overwriting each other's changes. Store your own previous projection so you can distinguish a real upstream change from a repeated delivery.
For an initial match, present ambiguous candidates for review. Do not silently link the first email match. Keep an explicit merge/unlink workflow for duplicates and account changes.
Use Get contact to refresh a mapped record before a change. Update contact accepts a partial update through POST; it is not a replacement of the complete GET response. Send only the writable fields your integration owns, using the current input schema.
Contact updates can propagate to linked debtor data. Review that behavior before changing names, addresses, or communication fields. Updating member data requires the corresponding member edit permission. Do not send derived fields, signed image URLs, or the full read response back as an update payload.
If a request times out, reconcile the record before retrying. See Handle API errors and retries for uncertain outcomes. Creating a contact and updating an existing contact are separate operations; a failed lookup must not automatically become a create.
Contact-created, contact-updated, and contact-deleted webhooks can prompt a refresh. Build a webhook receiver explains the actual event names and signatures. Treat a notification as a reason to inspect current state, not a complete ordered history.
Run a periodic full scan as well. Do not immediately delete local records because they are absent from one response: a narrower key, a mandate change, a partial scan, or an API error can all cause apparent disappearance. Mark a successful scan complete before evaluating missing records, then distinguish loss of access from confirmed deletion.
Test a repeated record, a full final page, a changed membership date, a contact outside the allowed mandate, and an interrupted scan. No incomplete scan should publish a complete replacement snapshot.
Check your result
- Exactly 100 records
- The exporter requests the next page too.
- A later page fails
- The previous complete snapshot stays in place.
- Same records twice
- The keyed snapshot has no duplicate contacts.