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.

6 min readUpdated 3 August 2026api, endpoints, permissions, targets

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

List campaigns with their template. Recipient addresses are masked in demo mode
Create a campaign. Always starts in PENDING
Bulk delete. Refuses an empty id list
Bulk status change. Writes the status only — no recipient reset
One campaign in full, including its recipient list
Update name, subject, speed, template and recipients
Delete one campaign
Change status. STOPPED also resets every recipient to PENDING
Replace the recipient list. No size cap on this route
Re-queue only the failed recipients and reactivate

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:

  • name and subject must be non-blank after trimming, and at most 191 characters. A blank one returns 400 naming the field rather than a 500.
  • speed must be a whole number of at least 1. A campaign with speed 0 stays ACTIVE and sends nothing forever, so the floor is enforced at the door.
  • templateId must resolve to a template that exists. Otherwise the foreign key surfaces as a driver-level 500.
  • targets must 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

List templates
Create a template. Body optional — an empty one stores "{}"
Bulk delete templates
Id and name only, for the campaign form's dropdown
Import a template. Name, content and design are all required
One template with its full body and design
Update the body and design. Does NOT update the name
Delete one template

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

List saved blocks
Create a block. Name and design required
Bulk delete blocks
Every block with its design, in the shape the editor's Blocks panel expects
One block with its full design
Update a block's name, category and design
Delete one block

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 PENDINGSENT or PENDINGFAILED. 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.