How external campaign automation works
External campaign automation uses a compositional model: you create a campaign draft, attach the pieces that make it complete to launch, then commit it to a send. Understanding the model — particularly how facets compose, how data variables resolve, and where errors carry structured self-correction hints — is the foundation for writing a reliable client, whether that client is your own script or an AI agent.
To jump straight to the step-by-step launch sequence, see Launch an external campaign with automation.
The compositional model
A campaign is a draft plus a set of facets — associations attached to it. You create the draft with POST /external, supplying only a name, then attach the facets that make the campaign composition complete. A draft is a valid, incomplete state: MessageGears does not reject a campaign for being incomplete until a commit point — arming a schedule, or calling launch directly. Both run the same validation check and both require an explicit confirm: true.
Facets may be attached in any order after the draft is created, and most can be changed or removed at any time before launch. The campaign's own scalar fields — name, description, and category — are the one exception: there is no endpoint to edit them after creation. Get them right when you create the draft, or delete the campaign and recreate it.
The facets
| Facet | Presence | How many | Notes |
|---|---|---|---|
account | Required | One | Auto-assigned to the platform default on creation. Reassignable, never removable — there is no DELETE for this facet. |
audience | Required | One | A SQL-based audience source, the fields you're guaranteeing delivery of, and any data-variable overrides. |
destination | Required, at least one | One or more | Where the audience is delivered. POST to add, PATCH one to update it in place. |
brand | Conditional - Required only if the brands feature is enabled, at least one | One or more | Auto-seeded from the brand association of the user who owns the API key, on creation, so the campaign is launch-ready on this facet immediately. The last remaining brand cannot be removed. |
schedule | Optional | One | Arms a future send. Setting an active schedule is a guarded commit point. |
trigger | Optional | Up to two — pre and post | Set both in one call with PATCH; clear one with DELETE and a required role query parameter (pre or post). |
audienceRecording | Optional | One | Points at the configuration that records the audience extraction. Requires an audience already attached. |
Facets split into two shapes with matching operation patterns:
- Singletons (
account,audience,schedule,audienceRecording) -PUTto set or replace,GETto read,DELETEto remove where aDELETEexists. - Collections (
brand,destination) -POSTto add, which is additive and accepts an array;DELETE /{facet}/{entityId}to remove one member by path.
trigger follows neither pattern exactly — it's partial-updated with PATCH rather than fully replaced with PUT, since each role is independent.
Commit points and the validation check
MessageGears validates the full campaign viability only at a commit point: an activating PUT /schedule, or POST /launch. Both run the same six-item check:
- An account is attached — usually already true from the create-time default.
- An audience is attached.
- At least one destination is attached.
- At least one brand is attached, if the brands feature is enabled.
- Every field named in the audience facet's
requiredFieldsis covered by the combinedfieldMappingsof all attached destinations. - The request body includes
"confirm": true.
A campaign that fails checks 1 through 5 returns 409 INVALID_STATE with a details object that identifies the gap:
{
"code": "INVALID_STATE",
"message": "Campaign is not launchable.",
"retryable": false,
"requestId": "a1b2c3d4",
"details": {
"unmappedRequiredFields": ["PHONE"]
}
}
details keys vary by which check failed:
unmappedRequiredFields- Names therequiredFieldsentries not covered by any destination'sfieldMappings.requiredBy- Appears when a facet can't be removed because something else depends on it — for example, removing an audience from a scheduled campaign fails withrequiredBy: ["schedule"]. Unschedule first.destinations- Per-destination detail when a launch fails due to staleness in one destination's configuration.
A launch against a campaign that already has a job running returns 409 CONFLICT instead — poll the existing job with GET /external/job/{jobId} rather than retrying.
Data variables
Some audience sources parameterize their SQL query with data variables — named placeholders substituted at launch. In the SQL definition of an audience source, a placeholder takes the form:
${Criteria.VARIABLE_NAME}
For example, a query that segments by region uses ${Criteria.region}, never ${region}.
Local and global data variables
There are two layers of data variable, both resolved the same way:
- Local data variables - Declared on the audience source itself; specific to that source. Discover them with
GET /audience-source/{id}/local-data-variables. - Global data variables - Platform-level variables a source can opt into; shared across multiple audience sources. Discover them with
GET /audience-source/{id}/global-data-variables.
Each entry returns a dataVariableId (use this as the launchVariableId when overriding), a name, a type (STRING, NUMBER, BOOLEAN, FIXED_LIST, or DYNAMIC), and — for FIXED_LIST and DYNAMIC types — the set of allowed values. A global variable is re-resolved live on every read, so the same global can report differently between calls.
Set campaign-level overrides
To override a variable's default value for a specific campaign, include launchVariableValues in PUT /external/{id}/audience:
{
"audienceSource": { "audienceSourceId": 555 },
"requiredFields": ["EMAILADDRESS"],
"launchVariableValues": [
{ "launchVariableId": 41, "value": "US-WEST" }
]
}
Both launchVariableId and value are required on each entry, and value is always sent as a string — even for a NUMBER or BOOLEAN typed variable — up to 1024 characters. Overrides are validated at write time, so a bad override fails the PUT immediately with a specific error code rather than waiting until launch.
Because PUT /audience is a full replace of the audience facet, launchVariableValues is replaced wholesale. Omitting the field entirely clears all overrides, the same as sending []. To edit the audience selection while keeping existing overrides, re-send the current launchVariableValues.
Check what will run
GET /external/{id}/audience returns both the raw overrides and the resolved picture:
{
"effectiveLaunchVariables": { "region": "US-WEST", "minOrders": "3" },
"unresolvedLaunchVariables": []
}
effectiveLaunchVariables- Keyed by variable name, not id. The values that will be used at launch: your override where you supplied one, otherwise the variable's default.unresolvedLaunchVariables- A sorted list of variable names with neither an override nor a default. A non-empty list means the campaign will launch with those variables unset — resolve them before you launch.
Edit overrides without replacing the audience
To edit overrides without re-sending the audience selection, use the dedicated sub-endpoint:
PUT /messagegears-api/v1/external/{id}/audience/launch-variables
GET /messagegears-api/v1/external/{id}/audience/launch-variables
DELETE /messagegears-api/v1/external/{id}/audience/launch-variables
PUT is a full replace of the override set; an empty array clears every override and falls back to defaults. DELETE is equivalent to PUT []. Both endpoints write the same stored field the audience facet reads, so the last write wins between the two paths — there is no conflict error on transient overlap.
Destination field mappings
A destination association controls which audience columns reach the vendor, and under what name, through three sibling fields sent flat on the destination object — never nested under a wrapper:
fieldMappings- A{ audienceField: vendorField }map. Key order is significant — it selects, renames, and orders the columns delivered.excludedFieldMappings- An object map of columns to exclude, for example{ "PHONE": true }. Its keys must be disjoint fromfieldMappings, or the call fails with400 FIELD_MAPPING_OVERLAP. On read, values come back coerced to strings ({ "PHONE": "true" }).vendorSettings- Vendor-specific settings, validated against the destination'ssettingsSchema.fieldMappingsis a reserved key here — placing it insidevendorSettingsreturns400 VALIDATION_FAILED.
The reserved value DoNotShare on a fieldMappings entry suppresses that column, when the destination's doNotShareColumn names a field that supports it. Membership is matched case-insensitively on write, but if a mapping is accepted and the campaign still fails launch readiness, verify the mapped column name matches the audience's actual field name exactly.
An empty or omitted fieldMappings does not deliver all columns. It currently delivers zero columns, whether or not excludedFieldMappings is set. Always send explicit fieldMappings for every column you want delivered.
Destination semantics
A few behaviors differ from what a typical REST resource would suggest:
POSTis additive only, and re-posting is a true no-op. Re-posting adestinationIdthat's already attached returns200with the current set — thefieldMappingsandvendorSettingsyou sent are ignored, not merged. To change an existing association, usePATCH /external/{id}/destination/{destinationId}.PATCHis tri-state per attribute. An attribute you omit is left untouched. An attribute you send fully replaces the stored value — sending{}forfieldMappingsclears it, it does not reset to an all-columns default, since no such default exists. At least one attribute must be present in the body. The same full-replace rule applies tovendorSettings: to change one setting without losing the others,GETthe current object first, edit it locally, and send the whole object back onPATCH.- A
PATCHthat narrows coverage below whatrequiredFieldsneeds fails. If the resultingfieldMappingsno longer covers a required field, the call returns409 INVALID_STATEwithdetails.unmappedRequiredFields. - Non-empty
fieldMappingshas preconditions. It requires an audience already attached (409 INVALID_STATEotherwise) and a SQL-based source on a customizable vendor (422 DESTINATION_NOT_CUSTOMIZABLEotherwise).
Nesting fieldMappings, excludedFieldMappings, or vendorSettings under a legacy vendorSchema wrapper is silently accepted with 200 and writes nothing. Send the three fields as direct siblings on the destination object.
The schedule model
Scheduling is an optional facet that arms a send at a future time without a manual POST /launch. The body is a tagged union keyed on type:
type | Cadence | Required fields beyond type and timezone |
|---|---|---|
ONETIME | A single run at startDate, optionally at a specific time | startDate |
PERIODIC | Every every × unit (MINUTES or HOURS only) from startDate | startDate, every, unit |
RECURRING | Calendar recurrence defined by a dateRule and a timeRule | dateRule, timeRule |
CRON | A raw Quartz cron expression evaluated in timezone | cronExpression |
timezone is required on every variant — discover valid ids with GET /timezone.
For RECURRING, dateRule.frequency is DAILY, WEEKLY, or MONTHLY.
daysOfWeekis required whenfrequencyisWEEKLYand takes full day names (MONDAY, notMON— short forms are rejected).- There is no
dayOfMonthfield; usefrequency: MONTHLYwitheveryinstead.timeRule.modeisSIMPLE(fires once attime) orWINDOW(fires repeatedly betweenstartTimeandendTimeat anintervalin minutes).
For CRON, the expression is Quartz 7-field syntax — seconds, minutes, hours, day-of-month, month, day-of-week, and an optional year — not a Unix 5-field cron. Every weekday at 09:30 is 0 30 9 ? * MON-FRI *. Quartz short weekday forms like MON-FRI are valid here even though RecurringRule.daysOfWeek rejects them — the two fields belong to different schedule types with different validation.
The type discriminator is accepted case-insensitively on write (ONETIME, onetime, and OneTime all resolve to the same branch); responses always echo the canonical uppercase tag.
The activation gate
paused is the activation gate on every schedule variant, defaulting to false. A schedule saved with paused: true is inert — it stores the cadence without arming a send. A subsequent PUT with paused: false arms the job, which fires at the server-computed nextFireTime.
confirm: true is required only on an activating write — one that moves the schedule from unset or paused to active. Pausing a schedule, or re-saving one that's already armed without changing that state, needs no confirm.
A manual POST /launch does not disarm an armed schedule. If a campaign has an active, unpaused schedule and you also launch it manually, both will fire — this can send the same audience twice. Pause or delete the schedule first if you only want the manual launch to run.
DELETE /external/{id}/schedule cancels the pending send by removing its underlying job. It never triggers a send and never stops one already running, takes no body, and needs no confirm. To suspend the cadence without losing it, PUT with paused: true instead.
Launch and job status
POST /external/{id}/launch is asynchronous. A successful call returns 202 Accepted with a jobId:
{ "jobId": 9001, "campaignId": 42, "success": true, "message": "Launch accepted." }
Poll GET /external/job/{jobId} until the aggregate status reaches a terminal value:
status | Terminal |
|---|---|
INITIALIZING_DATA | No |
PROCESSING | No |
COLLECTING | No |
COMPLETED | Yes |
FAILED | Yes |
PARTIAL_FAILURE | Yes |
STOPPED | Yes |
A multi-destination campaign runs a single shared audience extraction and reports every destination within that one job. The response's destinations array breaks down status per vendor, so a PARTIAL_FAILURE can be traced to the specific destination that failed.
Conventions
Pagination
List endpoints return a flat envelope:
{
"items": [],
"page": 0,
"size": 50,
"totalElements": 137,
"totalPages": 3
}
page is zero-based and defaults to 0. The default size is 50; the maximum is 200. List items are skinny — to read a facet's full attributes, call its per-facet read or per-entity detail endpoint.
Retrying a call
This version does not support an Idempotency-Key header — safe retry keys are planned for a future release.
Retrying a POST, PUT, or PATCH call, including launch, re-executes it exactly as sent, so guard against duplicate calls as part of the execution flow — for example, check state with a GET before retrying a call that may have already succeeded.
launch has its own double-fire protection regardless: a 409 CONFLICT when a job is already running for the campaign — poll the existing job instead of retrying.
Errors
Every error uses the same envelope. Branch your client on code and retryable; never parse message, which is for human readers and may change:
{
"code": "CAMPAIGN_NAME_NOT_UNIQUE",
"message": "A campaign named 'Summer Retargeting' already exists.",
"retryable": false,
"requestId": "a1b2c3d4",
"field": "name",
"hint": "Choose a different name or update the existing campaign."
}
code, message, retryable, and requestId are always present. field, hint, and details appear when they add actionable information. For the full error code reference, see the External Campaign Agent API reference.
Guarded actions
THe following actions each require confirm: true in the request body.
DELETE /external/{id},PUT /external/{id}/schedulePOST /external/{id}/launch
Without it, the call returns 409 CONFIRMATION_REQUIRED and mutates nothing. Every other DELETE in the API is unguarded.
Next steps
- Set up authentication and confirm you have the entities you need. For more information, see External campaign automation prerequisites.
- Walk through the step-by-step campaign creation and launch sequence. For more information, see Launch an external campaign with automation.