ActiveCampaign

Lightweight, dependency-free, in-memory fake of the ActiveCampaign v3 HTTP REST API for testing application code that talks to ActiveCampaign directly with the axios HTTP client (the documented integration path). Speaks the exact wire protocol the real service uses — singular-keyed request/response bodies, Api-Token auth, plural-keyed list envelopes with meta.total, and HTTP 422 validation errors — with zero cost and zero side effects. State is in-memory, ephemeral, and resettable.

Default port: 4659

Quick start

Start the server:

import { ActivecampaignServer } from "./services/activecampaign/src/server.js";

const server = new ActivecampaignServer(4659);
await server.start();
// ... run your app / tests ...
await server.stop();

Point the real axios client at it. The v3 API lives under /api/3 and authenticates with an Api-Token request header:

import axios from "axios";

const ac = axios.create({
  baseURL: "http://127.0.0.1:4659/api/3", // point at the parlel fake
  headers: {
    "Api-Token": process.env.ACTIVECAMPAIGN_API_TOKEN,
    "Content-Type": "application/json",
  },
});

// Create a contact (singular-keyed body)
const { data } = await ac.post("/contacts", {
  contact: { email: "ada@parlel.test", firstName: "Ada", lastName: "Lovelace" },
});
// data.contact.id => a generated contact id (string)

// Upsert a contact by email (always responds 201, create or update — and the
// response wraps a top-level `fieldValues` array alongside `contact`)
await ac.post("/contact/sync", {
  contact: { email: "ada@parlel.test", firstName: "Ada B.", fieldValues: [{ field: "1", value: "VIP" }] },
});

// Create a tag and apply it to the contact
const { data: tag } = await ac.post("/tags", { tag: { tag: "VIP", tagType: "contact" } });
await ac.post("/contactTags", { contactTag: { contact: data.contact.id, tag: tag.tag.id } });

// Subscribe the contact to a list (status "1" = subscribe, "2" = unsubscribe)
const { data: list } = await ac.post("/lists", { list: { name: "Newsletter" } });
await ac.post("/contactLists", {
  contactList: { list: list.list.id, contact: data.contact.id, status: "1" },
});

// List contacts (plural-keyed envelope + meta.total)
const { data: page } = await ac.get("/contacts?limit=20&offset=0");
// page.contacts => [...], page.meta.total => "N"

Errors raise HTTP status codes (axios throws on >= 400); inspect error.response.status and error.response.data.errors for validation failures.

Wire conventions

Implemented operations

Contacts

MethodPathDescription
GET/api/3/contactsList contacts. Filters: ?email=, ?search=, ?listid=. Paginated.
POST/api/3/contactsCreate a contact (requires valid, unique email). Accepts inline contact.fieldValues: [{ field, value }] (unknown field ids ignored).
GET/api/3/contacts/{id}Retrieve a contact.
PUT/api/3/contacts/{id}Update a contact.
DELETE/api/3/contacts/{id}Delete a contact (cascades tags/lists/field values).
POST/api/3/contact/syncUpsert a contact by email. Always returns 201 (create or update) and wraps a top-level fieldValues array alongside contact. Accepts inline contact.fieldValues: [{ field, value }].
GET/api/3/contacts/{id}/contactTagsTags applied to a contact.
GET/api/3/contacts/{id}/contactListsList memberships for a contact.
GET/api/3/contacts/{id}/fieldValuesCustom field values for a contact.

Tags

MethodPathDescription
GET/api/3/tagsList tags.
POST/api/3/tagsCreate a tag (unique tag name).
GET/api/3/tags/{id}Retrieve a tag.
PUT/api/3/tags/{id}Update a tag.
DELETE/api/3/tags/{id}Delete a tag (cascades contactTags).

Contact Tags

MethodPathDescription
GET/api/3/contactTagsList all contact-tag links.
POST/api/3/contactTagsApply a tag to a contact (idempotent).
DELETE/api/3/contactTags/{id}Remove a tag from a contact.

Lists & Contact Lists

MethodPathDescription
GET/api/3/listsList lists (each carries a live subscriber_count).
POST/api/3/listsCreate a list.
GET/api/3/lists/{id}Retrieve a list.
PUT/api/3/lists/{id}Update a list.
DELETE/api/3/lists/{id}Delete a list (cascades contactLists).
GET/api/3/contactListsList all subscription records.
POST/api/3/contactListsSubscribe (status:"1") / unsubscribe (status:"2") a contact. Upserts.

Custom Fields & Field Values

MethodPathDescription
GET/api/3/fieldsList custom field definitions.
POST/api/3/fieldsCreate a custom field (requires title, type).
GET/api/3/fields/{id}Retrieve a custom field.
PUT/api/3/fields/{id}Update a custom field.
DELETE/api/3/fields/{id}Delete a custom field (cascades field values).
GET/api/3/fieldValuesList field values.
POST/api/3/fieldValuesSet a contact's field value (upsert per contact+field).
GET/api/3/fieldValues/{id}Retrieve a field value.
PUT/api/3/fieldValues/{id}Update a field value.
DELETE/api/3/fieldValues/{id}Delete a field value.

Deals, Pipelines (Deal Groups) & Stages

MethodPathDescription
GET/api/3/dealsList deals.
POST/api/3/dealsCreate a deal (requires title; validates stage). Response wraps { "contacts": [...], "deal": {...}, "dealStages": [...] }; the deal carries hash, nextdate, winProbability, account, isDisabled, and a fields array of { customFieldId, fieldValue, dealId }. value is in cents.
GET/api/3/deals/{id}Retrieve a deal.
PUT/api/3/deals/{id}Update a deal.
DELETE/api/3/deals/{id}Delete a deal.
GET/api/3/dealGroupsList pipelines (a default pipeline is seeded).
POST/api/3/dealGroupsCreate a pipeline.
GET/api/3/dealGroups/{id}Retrieve a pipeline.
PUT/api/3/dealGroups/{id}Update a pipeline.
DELETE/api/3/dealGroups/{id}Delete a pipeline.
GET/api/3/dealStagesList stages (5 default stages are seeded).
POST/api/3/dealStagesCreate a stage (requires title, valid group).
GET/api/3/dealStages/{id}Retrieve a stage.
PUT/api/3/dealStages/{id}Update a stage.
DELETE/api/3/dealStages/{id}Delete a stage.

Notes

MethodPathDescription
GET/api/3/notesList notes.
POST/api/3/notesCreate a note (requires note text).
GET/api/3/notes/{id}Retrieve a note.
PUT/api/3/notes/{id}Update a note.
DELETE/api/3/notes/{id}Delete a note.

Accounts (CRM)

MethodPathDescription
GET/api/3/accountsList CRM accounts.
POST/api/3/accountsCreate an account (requires name).
GET/api/3/accounts/{id}Retrieve an account.
PUT/api/3/accounts/{id}Update an account.
DELETE/api/3/accounts/{id}Delete an account.

Contact Automations

MethodPathDescription
GET/api/3/contactAutomationsList automation enrolments.
POST/api/3/contactAutomationsEnrol a contact into an automation.
GET/api/3/contactAutomations/{id}Retrieve an enrolment.
DELETE/api/3/contactAutomations/{id}Remove a contact from an automation.

Webhooks

MethodPathDescription
GET/api/3/webhooksList webhooks.
POST/api/3/webhooksCreate a webhook (requires name, url).
GET/api/3/webhooks/{id}Retrieve a webhook.
PUT/api/3/webhooks/{id}Update a webhook.
DELETE/api/3/webhooks/{id}Delete a webhook.

Read-only resources (seeded)

MethodPathDescription
GET/api/3/campaigns, /api/3/campaigns/{id}Campaigns (one seeded).
GET/api/3/automations, /api/3/automations/{id}Automations (one seeded).
GET/api/3/segments, /api/3/segments/{id}Segments (one seeded).
GET/api/3/users, /api/3/users/{id}Users (one seeded).

Infrastructure / control (parlel-specific)

MethodPathDescription
GET/healthHealth check (no auth). Returns { "status": "ok" }.
POST/__parlel/resetWipe all user data, keep seeded defaults.
GET/__parlel/stateResource counts for inspection.

Surface coverage

This emulator faithfully replicates the API surface most application code and agents exercise. Anything below the supported lines is either an intentional design choice for a fast, zero-cost local emulator (✓ By design) or a candidate for a future release (⟳ Roadmap) — never a silent inaccuracy.

Legend: ✅ fully supported · ◐ accepted (stored, not strictly enforced) · ✓ by design · ⟳ on the roadmap.

FeatureSupportedNotes
Api-Token header authAny non-empty token accepted; missing token → 403 { message }.
Contacts CRUD + contact/sync upsertEmail validation + duplicate detection; sync always returns 201.
Inline contact.fieldValues on create / syncUpserted against existing field defs; unknown ids ignored.
contact/sync top-level fieldValues in responseMirrors the real { fieldValues, contact } body.
Tags / contactTagsIdempotent tag application.
Lists / contactLists subscribe-unsubscribeLive subscriber_count, listid filter.
Custom fields / field valuesUpsert per contact+field.
Deals / pipelines / stagesDefault pipeline + 5 stages seeded; value in cents, owner supported.
Deal-create wrapped response (contacts, deal, dealStages)Matches the real 201 envelope; deal carries hash, fields[], etc.
Notes, accounts, webhooksFull CRUD.
Contact automations (enrol/remove)Read-only automation list seeded.
422 error envelope { errors: [{ title, detail, code }] }No fabricated source.pointer; real AC codes (duplicate, field_missing).
Pagination (limit/offset)Default limit 20, max 100.
email=, search=, listid= filters on contactsOther server-side filter DSL not modelled.
Campaigns, automations, segments, users◐ read-onlySeeded fixtures; create/update not modelled.
deal.value numeric typingStored/echoed as a string for wire consistency; clients read back what they sent.
?include= sideloading / nested embeds⟳ RoadmapNot modelled.
Tracked events (trackcmp.net/event)✓ By designDifferent host + form encoding; out of scope.
Real email sending / campaign delivery⟳ Roadmap
Webhook delivery (outbound HTTP callbacks)⟳ Roadmap
Persistence across restarts✓ By designIn-memory — fast, isolated, resets cleanly between tests.
Rate limiting✓ By designNever throttles — local tests run at full speed, zero cost.

Error codes / shapes

StatusShapeWhen
200resource / empty {}Successful GET / PUT / DELETE.
201{ "<resource>": { ... } }Successful create. contact/sync always returns 201 (create or update) as { "fieldValues": [...], "contact": { ... } }; POST /deals returns { "contacts": [...], "deal": {...}, "dealStages": [...] }.
204(empty)OPTIONS preflight.
400{ "message": "Invalid JSON in request body." }Malformed JSON.
403{ "message": "..." }Missing Api-Token.
404{ "message": "No Result." }Unknown resource / id.
405{ "message": "Method not allowed." }Unsupported method on a known path.
422{ "errors": [ { "title", "detail", "code" } ] }Validation failure (missing/invalid field, duplicate, bad relation). Matches the real AC v3 envelope — no source.pointer.

Environment variables

VariableDefaultPurpose
ACTIVECAMPAIGN_API_URLhttp://127.0.0.1:4659Base URL of the fake.
ACTIVECAMPAIGN_API_TOKENparlel-test-api-tokenToken sent via Api-Token.
ACTIVECAMPAIGN_ACCOUNTparlel-testAccount slug (informational).
<!-- parlel:testenv:start -->

Configuration — test.env

Copy these into your test.env (used by the bridge sidecar flow). Tokens are Parlel's seeded test credentials — any non-empty value is accepted by the emulator, so you rarely need to change them. Swap in real credentials only when pointing at the live service in prod.env.

ACTIVECAMPAIGN_API_URL=http://parlel-bridge:4659
ACTIVECAMPAIGN_API_TOKEN=parlel-test-api-token
ACTIVECAMPAIGN_ACCOUNT=parlel-test
<!-- parlel:testenv:end -->