Export ActiveCampaign contacts to Google Sheets, including the custom fields that live on a separate resource and the account-specific API URL people miss.
ActiveCampaign's API is REST and straightforward, with one modelling decision that catches everyone: custom field values and tags are not properties on a contact, they are separate related resources. A plain contacts request therefore returns names and emails and none of the segmentation data you actually wanted. This guide covers joining those back together, plus the account-specific API URL and pagination.
The two-minute option: CSV export
In ActiveCampaign, open Contacts, apply a filter or pick a list, and use the export option to receive a CSV. Large exports are emailed rather than downloaded directly. Import into Sheets via File → Import. This includes custom fields as columns already, which is the work the API leaves to you.
ActiveCampaign to Google Sheets for free (Apps Script + API v3)
Get your API URL and key from Settings → Developer. The URL is account-specific, in the form https://youraccount.api-us1.com, and the key goes in an Api-Token header.
The pattern that makes the result useful: request include=fieldValues so custom field values come back alongside contacts, and fetch the fields endpoint once to map their numeric IDs to readable names.
function importActiveCampaignContacts() { // Account-specific host, not a shared endpoint. const API_URL = "https://youraccount.api-us1.com"; const API_KEY = "YOUR_API_KEY"; const headers = { "Api-Token": API_KEY }; // Custom field values reference their definition by numeric ID. const fieldNames = {}; JSON.parse(UrlFetchApp.fetch( API_URL + "/api/3/fields?limit=100", { headers } ).getContentText()).fields.forEach((f) => { fieldNames[f.id] = f.title; }); const fieldIds = Object.keys(fieldNames); const rows = []; let offset = 0; let total = null; do { // include=fieldValues inlines custom values; without it they are absent. const url = API_URL + "/api/3/contacts" + "?limit=100&offset=" + offset + "&include=fieldValues"; const body = JSON.parse( UrlFetchApp.fetch(url, { headers, muteHttpExceptions: true }) .getContentText() ); if (!body.contacts) throw new Error(JSON.stringify(body)); // fieldValues arrive as a sibling array keyed by contact, not nested // inside each contact, so index them by contact ID first. const valuesByContact = {}; (body.fieldValues || []).forEach((fv) => { valuesByContact[fv.contact] = valuesByContact[fv.contact] || {}; valuesByContact[fv.contact][fv.field] = fv.value; }); body.contacts.forEach((c) => { const custom = valuesByContact[c.id] || {}; rows.push([ c.email, c.firstName || "", c.lastName || "", c.phone || "", c.cdate, c.udate, ...fieldIds.map((id) => custom[id] || ""), ]); }); // meta.total tells you how many exist; there is no next-page link. total = Number(body.meta.total); offset += 100; Utilities.sleep(250); // ~5 requests/second limit } while (offset < total); const header = ["Email","First name","Last name","Phone","Created","Updated", ...fieldIds.map((id) => fieldNames[id])]; const ss = SpreadsheetApp.getActiveSpreadsheet(); const sheet = ss.getSheetByName("Contacts") || ss.insertSheet("Contacts"); sheet.clearContents(); sheet.getRange(1, 1, 1, header.length).setValues([header]); if (rows.length) { sheet.getRange(2, 1, rows.length, header.length).setValues(rows); }}The detail worth noticing: included resources come back as sibling arrays at the top level of the response rather than nested inside each contact, so you have to index them by contact ID and join manually. Expecting them nested is why people conclude the include parameter did nothing.
Limits: around five requests a second per account, and Apps Script's six-minute cap, so large databases need chunking across scheduled runs. Tags require their own include or endpoint, joined the same way as field values.
The Brooked method
Brooked joins custom field values and tags onto contacts and resolves field IDs to their titles, so a contact export arrives as one wide, readable table. Contacts, lists, campaigns and deals can all import into one spreadsheet on a single schedule.
Step 1: Install Brooked
Install Brooked from the Google Workspace Marketplace. Free tier includes 100 imports per month, no credit card required.
Step 2: Connect ActiveCampaign
Choose ActiveCampaign in Brooked and enter your account-specific API URL and key from Settings → Developer. Pick contacts, lists, campaigns or deals, optionally filter by list or tag, and import.
Step 3: Schedule the refresh
Set the import to re-run daily so list growth, tag membership and campaign performance stay current in the sheet.
Method comparison
| Method | Cost | Setup | Refresh | Two-way | Best for |
|---|---|---|---|---|---|
| ActiveCampaign CSV export | Free with plan | 2 min | Manual | No | A one-off list export |
| Apps Script + API v3 | Free | 25 to 40 min | Time-driven trigger | DIY | Custom fields and tag joins |
| Zapier / Make | Free tier limited · paid for volume | 10 to 15 min | On contact change | DIY (second workflow) | Logging individual contact events |
| Brooked | Free tier · Pro $29/user/mo | ~3 min | Scheduled (hourly, daily, weekly) | Yes | Scheduled list and campaign reporting |
Common use cases
- List growth: new contacts per source and per week in one table
- Engagement audits: find contacts with no opens or clicks for re-engagement or suppression
- Campaign reporting: open and click rates joined against send dates and segments
- Data hygiene: spot missing or malformed custom field values across the database
- Revenue attribution: join contacts against Stripe or CRM data already in the sheet
Troubleshooting common issues
Related Articles
- How to Connect Pipedrive to Google Sheets
- How to Connect Monday.com to Google Sheets
- Connect Google Search Console to Google Sheets
Frequently asked questions
Where do I find my ActiveCampaign API URL and key?
Both are under Settings → Developer. The important detail is that the API URL is specific to your account, in the form https://youraccount.api-us1.com, and is not a shared endpoint. Requests go to that host with the key sent in an Api-Token header. Using a generic ActiveCampaign domain will simply fail to resolve to your data.
Why are my custom fields missing from contacts?
ActiveCampaign models custom field values as their own resource rather than properties on the contact. A plain contacts request returns only core fields. Either add include=fieldValues to inline them, or call the fieldValues endpoint separately, and fetch the fields endpoint too, because field values reference their definition by numeric ID rather than name.
Can I export ActiveCampaign contacts for free?
The CSV export in the interface is included with your plan and handles a one-off. For data that refreshes, the API is available on all plans and the Apps Script method here runs on a free trigger. Brooked's free tier covers 100 imports a month.
How does pagination work?
With limit and offset, where limit caps at 100. The response includes a meta.total count, so you can compute how many pages to expect up front. Increment offset by 100 until you have collected that total; the API does not return a next-page link.
Why are contact tags not on the contact?
Like custom fields, tags are a separate relationship. A contact does not carry a tag list; you either request include=contactTags,contactTags.tag or query the contactTags endpoint and join. This is a frequent cause of segmentation reports that come back empty.
Can I update contacts in ActiveCampaign from Sheets?
Yes for editable fields, and Brooked supports write-back, so bulk updates to custom fields or tags from a sheet are possible. Be careful with anything that triggers automations: adding a tag from a spreadsheet can enrol thousands of contacts into an automation and send real email.
Connect ActiveCampaign to Google Sheets today.
Free tier: 100 imports per month, no credit card required. Or see the full ActiveCampaign integration details.
Install Brooked free →

