API reference
Every MailWizard endpoint with the permission it gates on, the recipient-list contract and its two different validation rules, the campaign and recipient status vocabularies, and the three tables behind them.
MailWizard exposes one API surface: /api/admin/mailwizard. There are no
user-facing endpoints — the addon has no customer-side pages, and nothing in it
is scoped to a caller rather than to the install.
Every route requires authentication and every route carries an explicit
permission. Errors answer with a { message, statusCode } body; read the message,
because most of them name the exact field that was refused.
List endpoints accept the platform's standard collection parameters: filter,
page, perPage, sortField, sortOrder and showDeleted. All three models
are soft-deleted, so showDeleted is meaningful on each of them.
Campaigns
Creating and updating
name, subject, speed and templateId are required on both create and
update. targets and status are optional.
{
"name": "March newsletter",
"subject": "What changed this month",
"speed": 50,
"templateId": "5f3c…",
"targets": "[{\"id\":\"a1…\",\"email\":\"jo@example.com\",\"firstName\":\"Jo\",\"status\":\"PENDING\"}]"
}targets is a JSON string, not an array — that is what the admin screens
post and what both routes expect, although an already-parsed array is accepted
too.
Four validations run before anything is written:
nameandsubjectmust be non-blank after trimming, and at most 191 characters. A blank one returns 400 naming the field rather than a 500.speedmust be a whole number of at least 1. A campaign with speed 0 staysACTIVEand sends nothing forever, so the floor is enforced at the door.templateIdmust resolve to a template that exists. Otherwise the foreign key surfaces as a driver-level 500.targetsmust parse as an array of objects, each with a usable email address.
Omitting targets on an update leaves the stored list alone. Sending an empty
list stores NULL rather than "[]", which is what makes the send job's "no
recipients" check meaningful.
The response carries the created record, so a client can navigate straight to it.
targets on POST /campaign and PUT /campaign/{id} is schema-validated at
10,000 characters. Over that, the request is refused with "Targets must be
no more than 10000 characters long."
Recipients built by the admin picker carry a full user profile, so that limit is
reached at roughly fifteen of them. PUT /campaign/{id}/target has no such
cap — it validates the shape of the list and a structural ceiling of 100,000
recipients, and nothing else. If you are driving MailWizard from a script, create
the campaign with an empty or tiny list and write the real audience through
/target.
The recipient object
{
"id": "a1b2…",
"email": "jo@example.com",
"firstName": "Jo",
"lastName": "Bloggs",
"avatar": "/uploads/…",
"status": "PENDING",
"attempts": 2,
"lastError": "550 5.1.1 no such user",
"retriedAt": "2026-08-03T09:00:00.000Z"
}Only email is genuinely required. status is normalised to PENDING if it is
missing or unrecognised — refusing a legacy value would make an old campaign
uneditable rather than merely re-sendable.
attempts, lastError and retriedAt are written by the send job and read by
the retry route. Unknown keys survive a round trip through every write path, so
anything else you attach is preserved.
Address validation is deliberately permissive: it rejects what cannot be a mailbox at all rather than second-guessing an unusual TLD.
Status transitions
{ "status": "STOPPED" }Valid values are PENDING, PAUSED, ACTIVE, STOPPED, COMPLETED and
CANCELLED.
STOPPED is special: as well as writing the status it resets every recipient
to PENDING, which is how a campaign is re-run from the beginning. The status
write is unconditional — a recipient list that cannot be read is left exactly as
stored and the campaign still stops. Anything else would leave an operator
believing they had stopped a campaign that was still sending.
Retrying failures
{ "activate": true, "force": false }Both fields are optional. activate defaults to true and is load-bearing: the
send job only selects ACTIVE campaigns, so recipients reset on a COMPLETED
campaign without reactivating it would sit PENDING forever and the retry would
look like it silently did nothing. CANCELLED is deliberately excluded from
reactivation — that is an operator saying "do not send this".
force retries addresses that have already been attempted three times. Without
it they are left alone and counted separately.
{
"id": "…",
"retried": 12,
"exhausted": 3,
"unfixable": 1,
"status": "ACTIVE",
"message": "12 failed recipient(s) queued for another attempt"
}exhausted are recipients already attempted three times; unfixable are ones
with no email address, which no retry can help. If nothing can be retried the
route answers 409 with a message saying which of those two cases applies.
Recipients whose status is SENT are never touched. That is the entire point
of this endpoint: the alternative, STOPPED, resets them too.
Templates
Create and import differ on purpose. Create takes an optional body, because
making a placeholder and designing it later is a real way to work; an omitted
body is stored as "{}". Import requires both content and design,
because importing a template without a body is meaningless.
PUT /template/{id} reads content and design and nothing else. A name in
the body is accepted by the schema and then discarded. There is no other rename
path — set the name correctly at create or import time.
content is the HTML that is actually sent. design is the visual editor's own
JSON. They are written together by the editor and can drift apart if you write
them separately; the send job only ever reads content.
Blocks
Unlike the template options endpoint, block/options does return the body —
the design is the whole point, because the editor reads that option once at
initialisation and renders the Blocks panel from it.
A block's design must parse as a JSON object. A bare string, an array or
null does not produce one broken block; it stops the editor's entire Blocks
panel from rendering, with nothing anywhere saying why. Send the full design
shape:
{
"name": "Legal footer",
"category": "Legal",
"design": "{\"body\":{\"rows\":[ … ],\"values\":{}}}"
}Conventions that catch integrators out
ids must be a non-empty array of non-blank strings. An empty array used to be
accepted and answer "0 campaigns deleted successfully" — a success message for
an action that did nothing, and exactly the shape a buggy client sending an
unpopulated selection produces.
A campaign moves through PENDING, PAUSED, ACTIVE, STOPPED,
COMPLETED, CANCELLED. A recipient only ever moves PENDING → SENT or
PENDING → FAILED. There is deliberately no COMPLETED recipient status;
counting one is how a fully delivered campaign came to display 0% progress.
It makes the campaign eligible. The send job runs hourly. Anything driving this API on a schedule needs to account for up to an hour of latency before the first email leaves.
Both the list and the detail endpoints return the full recipient list, because the
progress figures are computed from it. On a campaign written through /target
that can be a substantial payload on every page of the list.
With NEXT_PUBLIC_DEMO_STATUS=true, recipient emails are masked by both the list
and the detail endpoint. The stored rows are untouched and the recipient count is
preserved, so progress figures stay correct.
All routes log to the admin activity trail under the ADMIN_MAIL module. Reads
are logged too, so the trail records who opened a recipient list as well as who
changed one.
Tables
If you read the database directly:
| Table | Holds |
|---|---|
mailwizard_campaign |
One row per campaign. targets is a TEXT('long') JSON string holding every recipient and their delivery state. status is the six-value enum, speed a plain integer. Soft-deleted |
mailwizard_template |
content (exported HTML) and design (editor JSON), both TEXT('long') and both NOT NULL. Soft-deleted |
mailwizard_block |
design as TEXT('long'), plus an optional category used to group the editor's Blocks panel. Soft-deleted |
mailwizard_campaign.templateId is a foreign key to mailwizard_template and
cascades on delete.
Delivery state lives inside the campaign row's JSON. Nothing can join to it, group by it or aggregate over it — no report, no export, no analytics tier. Any reporting you need beyond a single campaign's own screen has to be built by reading and parsing that column yourself, or taken from your mail provider.