A small, explicit Laravel wrapper around the ActiveCampaign API v3.
It builds on Laravel's HTTP client, so you keep timeouts, retries, Http::fake() and the rest of the
framework's tooling, while the package takes care of authentication, the /api/3 base path, the request/response
envelopes ActiveCampaign uses (contact, tag, field, fieldValue, …) and error handling.
use Datomatic\ActiveCampaign\Facades\ActiveCampaign;
$contact = ActiveCampaign::contacts()->sync([
'email' => 'john@example.com',
'firstName' => 'John',
'lastName' => 'Doe',
]);
ActiveCampaign::contacts()->tag($contact['id'], 5);- PHP 8.3+
- Laravel 12 or 13
composer require datomatic/laravel-active-campaignPublish the config file:
php artisan vendor:publish --tag="active-campaign-config"Then add your credentials to .env:
ACTIVE_CAMPAIGN_BASE_URL=https://your-account.api-us1.com
ACTIVE_CAMPAIGN_API_KEY=your-api-keyThe base URL is your account URL without the
/api/3suffix — the package appends it. You can find both values in your ActiveCampaign account under Settings → Developer.
return [
'base_url' => env('ACTIVE_CAMPAIGN_BASE_URL'),
'api_key' => env('ACTIVE_CAMPAIGN_API_KEY'),
// Request timeout, in seconds.
'timeout' => 100,
// How many times a request is attempted before giving up.
// Only connection errors, 429 and 5xx responses are retried.
'retry_times' => 3,
// How long to wait between two attempts, in milliseconds.
'retry_sleep' => 1000,
// Map your ActiveCampaign custom field ids to the names you want to use in your code.
'custom_fields' => [
// 'is_email_verified' => 50,
],
];Every resource is reachable from the ActiveCampaign facade (or by injecting
Datomatic\ActiveCampaign\ActiveCampaign):
| Method | Resource | ActiveCampaign endpoints |
|---|---|---|
ActiveCampaign::contacts() |
contacts, contact tags, list subscriptions | /contacts, /contact/sync, /contactTags, /contactLists |
ActiveCampaign::import() |
bulk contact importer | /import/bulk_import, /import/info |
ActiveCampaign::lists() |
contact lists | /lists |
ActiveCampaign::automations() |
automations (read only) | /automations |
ActiveCampaign::contactAutomations() |
contact ↔ automation enrolments | /contactAutomations |
ActiveCampaign::deals() |
deals | /deals |
ActiveCampaign::dealStages() |
stages inside a pipeline | /dealStages |
ActiveCampaign::pipelines() |
pipelines | /dealGroups |
ActiveCampaign::accounts() |
CRM accounts | /accounts |
ActiveCampaign::accountContacts() |
account ↔ contact associations | /accountContacts |
ActiveCampaign::notes() |
notes on any record | /notes |
ActiveCampaign::tags() |
tags | /tags |
ActiveCampaign::fields() |
custom field definitions, their options and list relations | /fields, /fieldOption/bulk, /fieldRels |
ActiveCampaign::fieldOptions() |
selectable values of a field | /fieldOptions, /fieldOption/bulk |
ActiveCampaign::fieldRels() |
field ↔ list relations | /fieldRels |
ActiveCampaign::fieldValues() |
custom field values of a contact | /fieldValues |
All of them share the same CRUD surface:
ActiveCampaign::tags()->list(); // Collection<int, array> — first page only
ActiveCampaign::tags()->list('filters[tagType]=contact');
ActiveCampaign::tags()->get(1); // array
ActiveCampaign::tags()->create([...]); // array
ActiveCampaign::tags()->update(1, [...]); // array
ActiveCampaign::tags()->delete(1); // voidResponses are returned as plain arrays, already unwrapped from the ActiveCampaign envelope and
stripped of the links key.
The API returns 20 records per page by default and 100 at most, so list() alone will quietly
give you a slice of a large collection. Four methods cover the rest:
ActiveCampaign::contacts()->list(limit: 100, offset: 200); // one page, explicitly
ActiveCampaign::contacts()->count(); // total matching records
ActiveCampaign::contacts()->paginate(perPage: 50); // LengthAwarePaginator, for views
ActiveCampaign::contacts()->lazy(); // LazyCollection over every page
ActiveCampaign::contacts()->all(); // Collection over every pagelazy() fetches one page at a time and only when you consume it, so it is the safe way to walk a
large account:
ActiveCampaign::contacts()->lazy()
->filter(fn (array $contact) => $contact['email'])
->each(fn (array $contact) => ProcessContact::dispatch($contact));paginate() returns Laravel's LengthAwarePaginator, so ->links() works in a Blade view:
$contacts = ActiveCampaign::contacts()->paginate(perPage: 50);
$contacts->total(); // from the API's meta.total
$contacts->lastPage();
$contacts->items();A perPage above 100 is clamped to 100, and count() reads the meta.total the API sends back
(it returns 0 for the few endpoints that do not report one).
All of them accept the same query string as list(), and it is applied to every page:
ActiveCampaign::contacts()->count('filters[created_after]=2024-01-01');
ActiveCampaign::contacts()->all('filters[created_after]=2024-01-01');Note on the query string. It is parsed and re-encoded so that pagination params can be merged into it, so
email=john@example.comgoes out asemail=john%40example.com. When the raw query and an explicitlimit/offsetargument set the same key, the argument wins.
Anywhere a query string is accepted you can pass a Query instead, which spares you the API's
filters[...] / orders[...] syntax:
use Datomatic\ActiveCampaign\Enums\FilterOperator;
use Datomatic\ActiveCampaign\Support\Query;
$contacts = ActiveCampaign::contacts()->all(
Query::make()
->filter('created_after', new DateTimeImmutable('-30 days'), FilterOperator::GreaterThan)
->filter('status', 1)
->orderByDesc('cdate')
->include('contactTags')
);| Method | Produces |
|---|---|
filter('email', 'a@b.c') |
filters[email]=a@b.c |
filter('cdate', $date, FilterOperator::GreaterThan) |
filters[cdate][gt]=... |
filters(['a' => 1, 'b' => 2]) |
several equality filters at once |
orderBy('cdate') / orderByDesc('cdate') |
orders[cdate]=ASC / DESC |
include('contactTags', 'contactLists') |
include=contactTags,contactLists |
limit(50) / offset(100) |
limit=50 / offset=100 |
where('search', 'john') |
a top-level param the API defines outside filters, such as contacts' email, search, listid or id_greater |
Values are normalised for you: booleans become 1/0, backed enums become their value, arrays are
joined with commas, and DateTimeInterface becomes an ISO-8601 string. FilterOperator covers the
operators the API supports (eq, neq, lt, lte, gt, gte, contains, starts_with).
A Query is a Stringable, so (string) $query still gives you the raw query string.
ActiveCampaign recommends
paginating contacts with id_greater rather than offset, because a deep offset on a large account
is slow and can skip records while the list shifts underneath the walk. contacts()->lazy() and
contacts()->all() do that for you, adding orders[id]=ASC&id_greater=<last id> to each page.
If your query already sets orders[...], id_greater or id_less, the generic offset walk is used
instead so your ordering is preserved. paginate() is always offset-based, since it needs
addressable page numbers.
use Datomatic\ActiveCampaign\Facades\ActiveCampaign;
// List / search / filter — the query string is passed through to the API
$contacts = ActiveCampaign::contacts()->list('email=john@example.com');
$contact = ActiveCampaign::contacts()->get(1);
// Create — 'email' is required, unknown keys are dropped
$contact = ActiveCampaign::contacts()->create([
'email' => 'john@example.com',
'firstName' => 'John',
'lastName' => 'Doe',
'phone' => '+39 000 000 0000',
]);
// Create or update by email in a single call
$contact = ActiveCampaign::contacts()->sync([
'email' => 'john@example.com',
'firstName' => 'John',
]);
ActiveCampaign::contacts()->update(1, ['email' => 'john@example.com', 'lastName' => 'Doe']);
ActiveCampaign::contacts()->delete(1);create(), update() and sync() accept only email, firstName, lastName, phone and the
custom field names you declared in the config. Everything else is ignored, so you can hand them a
model's toArray() without filtering it first. All three require email and throw an
ActiveCampaignException without it.
ActiveCampaign identifies custom fields by numeric id. Map them once in the config file:
'custom_fields' => [
'is_email_verified' => 50,
'city' => 51,
],and then use their names on both sides of the call:
$contact = ActiveCampaign::contacts()->sync([
'email' => 'john@example.com',
'is_email_verified' => '1',
'city' => 'Rome',
]);
$contact['city']; // 'Rome'Empty values are skipped, so a field you do not pass is never overwritten with an empty string.
ActiveCampaign::contacts()->tags(1); // the contactTag rows of contact 1
ActiveCampaign::contacts()->tag(1, 5); // apply tag 5 to contact 1
ActiveCampaign::contacts()->untag(1, 5); // throws if the contact is not tagged
ActiveCampaign::contacts()->tryUntag(1, 5); // no-op if the contact is not tagged
ActiveCampaign::contacts()->getContactTagId(1, 5); // ?intRemoving a tag needs the id of the association, not of the tag, so untag() and tryUntag()
resolve it for you with an extra GET before the DELETE.
use Datomatic\ActiveCampaign\Enums\ListStatus;
ActiveCampaign::contacts()->lists(1); // current subscriptions, with their status
ActiveCampaign::contacts()->updateListStatus(1, [
2 => ListStatus::Subscribed,
3 => ListStatus::Unsubscribed,
]);The array is keyed by list id. Plain integers (1 / 2) are accepted as well. One request per
list is sent, because the API only accepts a single contactList object per call.
ActiveCampaign::contacts()->automations(1); // automations the contact is in
ActiveCampaign::contacts()->addToAutomation(1, 42);
ActiveCampaign::contacts()->removeFromAutomation(1, 42); // throws if the contact is not in it
ActiveCampaign::contacts()->tryRemoveFromAutomation(1, 42);
ActiveCampaign::contacts()->getContactAutomationId(1, 42); // ?intAs with tags, removing needs the id of the enrolment rather than of the automation, so the
remove methods resolve it with an extra GET before the DELETE.
Writing contacts one at a time means one request each, against a limit of 5 requests per second. The importer queues up to 250 contacts per request instead:
use Datomatic\ActiveCampaign\Enums\BulkImportStatus;
$result = ActiveCampaign::import()->bulk([
['email' => 'a@example.com', 'firstName' => 'Jane', 'city' => 'Rome'],
['email' => 'b@example.com', 'tags' => ['customer'], 'subscribe' => [1, 2]],
]);
$result['batchId']; // "0641fbdd-..."Contacts are accepted in the same shape as contacts()->sync() — firstName, lastName and the
custom field names from your config — and translated to the different one this endpoint expects
(first_name, fields: [{id, value}]). Its own keys are passed through untouched if you prefer
writing them directly, and subscribe/unsubscribe accept plain list ids as well as
[['listid' => 1]].
bulkAll() splits anything larger into batches the API accepts, and takes a LazyCollection so a
large import never has to sit in memory:
$batches = ActiveCampaign::import()->bulkAll(
User::lazy()->map(fn (User $user) => ['email' => $user->email, 'firstName' => $user->name]),
);The import is asynchronous, so poll for the outcome:
$status = ActiveCampaign::import()->statusOf($result['batchId']); // ?BulkImportStatus
if ($status?->isFinished()) {
$info = ActiveCampaign::import()->status($result['batchId']);
$info['success']; // ids of the contacts created
$info['failure']; // emails the API rejected
}
ActiveCampaign::import()->info(); // outstanding and recently completed batches, account widestatusOf() returns null while the API has not set a status yet, which is the case for the first
moment after queueing — leave a short delay before polling.
ActiveCampaign::lists()->list();
ActiveCampaign::lists()->list('filters[name]=Newsletter');
ActiveCampaign::lists()->get(1);
ActiveCampaign::lists()->createList(
name: 'Newsletter',
stringId: 'newsletter',
senderUrl: 'https://example.com',
senderReminder: 'You subscribed on our website.',
);
ActiveCampaign::lists()->delete(1);The four arguments of createList() are the ones the API requires; a fifth array is merged into
the list object for anything else (channel, user, send_last_broadcast, …).
ActiveCampaign does not document an update endpoint for lists, so
lists()->update()is inherited but unverified. See API-COVERAGE.md.
Automations themselves are read only in the API — you build them in the ActiveCampaign UI and enrol contacts through the API:
ActiveCampaign::automations()->list();
ActiveCampaign::automations()->get(42);
ActiveCampaign::contactAutomations()->add(1, 42); // same as contacts()->addToAutomation()
ActiveCampaign::contactAutomations()->list('filters[automation]=42');Pipelines are called dealGroups in the API; this package calls them pipelines.
$pipeline = ActiveCampaign::pipelines()->createPipeline('Qualifications', [
'currency' => 'eur',
'autoassign' => 1,
]);
$stage = ActiveCampaign::dealStages()->createStage('Initial contact', $pipeline['id'], [
'order' => 1,
'color' => '32B0FC',
]);
$deal = ActiveCampaign::deals()->createDeal(
title: 'New business',
value: 150000, // in cents
currency: 'eur', // 3-letter ISO code
groupId: $pipeline['id'],
stageId: $stage['id'],
ownerId: 1,
contactId: 7,
);value is in cents and currency is lower-cased for you. A deal needs a primary contact or an
account — passing neither throws rather than letting the API reject it.
$account = ActiveCampaign::accounts()->createAccount('Example Ltd', [
'accountUrl' => 'https://example.com',
'owner' => 1,
]);
ActiveCampaign::accounts()->addContact($account['id'], 7, 'Product Manager');
ActiveCampaign::accountContacts()->list('filters[account]='.$account['id']);Notes attach to any of the record types the API supports:
use Datomatic\ActiveCampaign\Enums\NoteRelType;
ActiveCampaign::notes()->createNote('Called them back', 7); // a contact
ActiveCampaign::notes()->createNote('Budget approved', $deal['id'], NoteRelType::Deal);
ActiveCampaign::notes()->createNote('Renewal in March', $account['id'], NoteRelType::Account);use Datomatic\ActiveCampaign\Enums\TagType;
ActiveCampaign::tags()->list();
ActiveCampaign::tags()->createTag('customer', 'a paying user');
ActiveCampaign::tags()->createTag('header', '', TagType::Template);
ActiveCampaign::tags()->updateTag(1, 'customer', 'updated description');
ActiveCampaign::tags()->delete(1);tagType defaults to contact when you do not pass one.
use Datomatic\ActiveCampaign\Enums\FieldType;
ActiveCampaign::fields()->list();
ActiveCampaign::fields()->createField('City');
ActiveCampaign::fields()->createField('Birthday', FieldType::Date, [
'perstag' => 'BIRTHDAY',
'visible' => 1,
]);
ActiveCampaign::fields()->updateField(1, 'Town', FieldType::Text);
ActiveCampaign::fields()->delete(1);The third argument is merged into the field object, so any attribute the API supports
(descript, perstag, defval, visible, ordernum, isrequired, …) can be passed through.
Two things are easy to miss, and the API will not warn you about either:
- A custom field stays invisible until it is related to a list. A contact only sees a field if one of the lists it belongs to has a relation to that field.
- Dropdown, listbox, radio, checkbox and multiselect fields need their options created
separately.
FieldType::requiresOptions()tells you which types those are.
createField() can do all three steps in one call:
$field = ActiveCampaign::fields()->createField(
'Department',
FieldType::DropDown,
options: ['Sales', 'Engineering', 'Support'],
lists: [1, 2],
);That creates the field, bulk-creates its options in the order given, and relates it to lists 1 and 2.
Omit options/lists and nothing extra is sent.
Each step is also available on its own:
ActiveCampaign::fields()->createOptions(34, ['Sales', 'Engineering']);
ActiveCampaign::fields()->options(34); // Collection of the field's options
ActiveCampaign::fields()->relate(34, 1); // relate field 34 to list 1
ActiveCampaign::fields()->relate(34); // relate it to every list
ActiveCampaign::fields()->relations(34); // Collection of the field's list relationsOptions accept plain strings, or full arrays when you need more control. A string becomes an option
whose label and value match, and orderid follows the array order unless you set it yourself:
ActiveCampaign::fields()->createOptions(34, [
['value' => 'sales', 'label' => 'Sales', 'isdefault' => true],
['value' => 'eng', 'label' => 'Engineering'],
]);Options and relations have their own resources too, if you want to work with them directly:
ActiveCampaign::fieldOptions()->createMany([
['field' => 34, 'value' => 'Sales', 'label' => 'Sales', 'orderid' => 1],
]);
ActiveCampaign::fieldRels()->relate(34, 1);ActiveCampaign creates options only through its bulk endpoint, so
fieldOptions()->create()sends a one-element bulk request and hands you back the created option.
// Set custom field 3 of contact 7
ActiveCampaign::fieldValues()->createFieldValue(7, 3, 'Rome');
ActiveCampaign::fieldValues()->updateFieldValue(1, 3, 'Milan');
ActiveCampaign::fieldValues()->list('filters[fieldid]=3');
ActiveCampaign::fieldValues()->delete(1);For contacts you own, contacts()->sync() with the custom_fields mapping is usually the shorter
path — this resource is there for the cases where you need to address a field value directly.
Every failing response raises a Datomatic\ActiveCampaign\Exceptions\ActiveCampaignException
carrying the endpoint and the error body returned by ActiveCampaign:
use Datomatic\ActiveCampaign\Exceptions\ActiveCampaignException;
try {
ActiveCampaign::contacts()->get(999999);
} catch (ActiveCampaignException $e) {
// The request to "contacts/999999" generated this error: [{"title":"No Result found ..."}]
report($e);
}A misconfigured package raises Datomatic\ActiveCampaign\Exceptions\InvalidConfig instead, on the
first call that needs the missing value.
Connection errors, 429 and 5xx responses are retried according to retry_times / retry_sleep
before the exception is raised. 4xx responses are not retried.
request() is public on every resource, so an endpoint the package does not wrap yet is still one
line away:
use Datomatic\ActiveCampaign\Enums\Method;
$lists = ActiveCampaign::contacts()->request(
method: Method::GET,
path: 'lists',
responseKey: 'lists',
);You can also resolve the client directly and skip the resource layer entirely:
use Datomatic\ActiveCampaign\Contracts\ActiveCampaignClientContract;
$response = resolve(ActiveCampaignClientContract::class)
->send(Method::GET, 'campaigns'); // Illuminate\Http\Client\ResponseThe package uses Laravel's HTTP client, so Http::fake() works as usual. ActiveCampaignFake
saves you from writing base urls and response envelopes by hand:
use Datomatic\ActiveCampaign\Enums\Method;
use Datomatic\ActiveCampaign\Testing\ActiveCampaignFake;
ActiveCampaignFake::fake([
'contacts' => ActiveCampaignFake::list('contacts', [
['id' => '1', 'email' => 'john@example.com'],
], total: 42),
'contacts/1' => ActiveCampaignFake::single('contact', ['id' => '1']),
'contacts/2' => ActiveCampaignFake::error(['No Result found'], 404),
]);
// ... exercise your code ...
ActiveCampaignFake::assertSent(Method::POST, 'contactTags');
ActiveCampaignFake::assertSentJson(Method::POST, 'contactTags', [
'contactTag' => ['contact' => 1, 'tag' => 5],
]);
ActiveCampaignFake::assertNotSent(Method::DELETE, 'contacts/*');
ActiveCampaignFake::assertSentCount(2);Paths are relative to /api/3 and may contain a * wildcard. A path you do not list answers with
an empty 200, so a test only describes the calls it cares about, and query strings are ignored
when matching so a paginated call still matches its bare path.
composer test
composer analyse
composer formatAPI-COVERAGE.md lists exactly which ActiveCampaign endpoints this package wraps and which it doesn't. ROADMAP.md is what is still to be built, in the order it is worth doing.
An endpoint the package does not wrap is still one line away — see Escape hatches.
Please see CHANGELOG for more information on what has changed recently.
Please see CONTRIBUTING for details.
Please review our security policy on how to report security vulnerabilities.
The MIT License (MIT). Please see License File for more information.