Skip to main content

Migration Wizard REST API

The Migration Wizard add-on registers its routes under its own namespace, benecaster-migration-wizard/v1, not Benecaster’s benecaster/v1:

https://yoursite.com/wp-json/benecaster-migration-wizard/v1/

Auth, for every route below: an administrator (manage_options) with a wp_rest nonce in the X-WP-Nonce header, and the Migration Wizard switched on for the show in the path. The add-on is bought once for the license but switched on per show, so a show it is off for answers 403 benecaster_migration_not_entitled. The dashboard routes name an import rather than a show; for those, the show is the one the import belongs to.


POST /shows/{show_id}/csv-preview

Reads an uploaded supporter export and answers what the wizard makes of it, before anybody is imported. The wizard’s upload step and its field-mapping step both run on this one response, so mapping costs no second request and no second upload.

Request: multipart, with the file under the field name file. Do not set Content-Type yourself — a multipart body needs a boundary only the browser generates, and a hand-set header arrives without one, so the route answers “no file arrived” for a request that plainly carried one.

The file is never stored. It is read out of PHP’s temporary upload and discarded when the request ends. There is no copy on the server to refer back to, so the import step uploads the file again.

Response — 200

Key What it holds
fileName The uploaded file’s name
platform The platform recognised from the headings (patreon, supercast), or null when none was — which sends the podcaster to manual column mapping
columns [ { heading, key } ] in file order: the heading as the file spelled it, and its normalised key
rowCount, truncated, rowCap How many people the file holds, and whether it ran past the row cap (50,000)
encoding The text encoding the file was read as
malformed How many rows had a different number of cells from the headings. They are still in the file, padded or cut to fit
duplicates Normalised headings that appear more than once. Where a heading repeats, the first column with it is the one read
preview The first five rows, keyed by normalised heading
fields The four fields the wizard fills, each { key, label, description, required }
mapping What the mapping screen opens with: { assignments, matchedAliases, needsManualMapping, unresolved }
distinctValues For each column with at most 50 distinct values, those values — how the tier step learns which source tiers the file contains
lapsed { count, column, checked } — how many people the file marks as having already stopped supporting, and the status column that said so

preview values and column headings have already been neutralised against spreadsheet formula injection.

lapsed.checked: false does not mean nobody has left. It means no status column was found — the platform’s export has none, this file omits it, or the file came through the any-CSV path with no platform to go on. In all three cases everybody is imported. Read checked before you read count, or a file the wizard never looked at will read as “nobody has lapsed”.

Errors

Code HTTP When
benecaster_migration_not_entitled 403 The Migration Wizard is not switched on for {show_id}
benecaster_migration_no_file 400 No file arrived under file
benecaster_migration_upload_too_large 413 PHP refused the upload; the message names the server’s own limit
benecaster_migration_csv_unreadable 422 The file could not be read as a CSV; the message is written for the podcaster

Changing the response. The benecaster_migration_csv_preview filter receives the payload and the parse. See See what the migration wizard made of an uploaded export.


GET /shows/{show_id}/email-posture

The advisory reading the wizard’s email step shows beside its sending estimate: which domain the migration emails will come from, whether a sending service is handling the site’s mail, whether that domain has SPF and DMARC records, and how far through its warm-up it is. The reading is Benecaster’s own (the same one its email settings use); the add-on does no DNS lookup of its own.

Parameters

Name In Type Description
show_id path int The show being migrated
messages query int Optional. When greater than zero, the response carries an estimate of how long sending that many messages will take

Response — 200

Key What it holds
available false on a Benecaster too old to have email warm-up; then nothing else is filled in
fromDomain The domain mail is actually sent from, after any SMTP plugin’s override
hasService, serviceName Whether a transactional email service is handling the site’s mail, and which
hasSpf, hasDmarc Whether those records were found for fromDomain
dnsChecked Whether the lookup happened at all
sendingDays Days the domain has actually sent mail on — what the warm-up steps on
daysSinceFirstSend How long ago the domain first sent — its age
isWarm Whether the warm-up is complete
estimate { days, finishDay, complete } when messages was given, otherwise null

Always 200. On an older Benecaster the answer is { "available": false }, never an error, because the panel is advice and must not take the step down with it.

Reading it correctly

  • dnsChecked: false means nobody looked — a local or staging site, or a blocked resolver. hasSpf and hasDmarc are then false for want of an answer, not for want of a record. Never show that as a pass.
  • sendingDays and daysSinceFirstSend are different numbers. Quote daysSinceFirstSend for how long a domain has been sending; sendingDays counts only days that actually sent.
  • estimate.complete: false has no finish. The projection ran out of days before the queue drained, so finishDay is the last day it looked at. Show “more than days days”, never the date.
  • The estimate is a best case. Scheduled sending runs on site traffic, so a quiet site falls behind it; say “about”. It also counts what the domain has already sent today, so two requests on one day can both be right and still differ.
  • Never estimate by dividing a message count by the Emails per hour setting. That is a ceiling, and a new sending domain is held well below it.

Changing the response. The benecaster_migration_email_posture filter receives the reading. It does not run when available is false.


GET /shows/{show_id}/schema

Whether this site’s Benecaster is new enough for a migration to be written. The wizard asks once when its tab opens, and when the answer is no it shows the notice and offers no steps at all.

Response — 200

{
  "satisfied": false,
  "noticeText": "The Benecaster Migration Wizard needs … database version 1.68.0 or later. These columns are missing …",
  "minimumDbVersion": "1.68.0"
}
Key What it holds
satisfied true when every column the import writes exists
noticeText The explanation to show, naming the version needed and the missing columns. Empty when satisfied is true
minimumDbVersion The Benecaster database version the add-on needs

Always 200, whichever way it answers. “Your Benecaster is too old” is an answer, not a failed request; an error status would send a client down its generic error path instead of to the screen that can explain it.

The notice names the database version and the missing columns rather than just saying “please update”, because a site already on the newest Benecaster that is still missing a column has an upgrade that did not finish, and that needs different help.


POST /shows/{show_id}/detect-hosting

Reads the podcaster’s existing podcast feed and says where its episode audio is served from. The pre-migration checklist uses it for its Check my feed control. It is the only way the wizard can answer this: a supporter export lists people and contains no episodes or audio links.

Body

Name Type Description
feed_url string Optional. When empty, the feed address Benecaster’s setup wizard already saved for the show is used

The feed is fetched with WordPress’s safe remote request (so a form-supplied address cannot reach private or loopback addresses), with a short timeout and a response-size cap.

Response — 200

Key What it holds
verdict on_platform, independent, unrecognised or unknown — see below
hostName The host recognised, when there was one
checked How many enclosure URLs were inspected
sampleUrl One enclosure URL, so the podcaster can see what was read
reason Why the feed could not be read — set only when verdict is unknown
feedUrl The feed address that was read
verdict Means
on_platform The audio is served by a platform rather than a podcast host — one on the benecaster_platform_audio_hosts list (Patreon, Supercast, Supporting Cast, Memberful, Glow, Substack, Anchor), plus stream.mux.com, which the add-on adds for Patreon video. hostName names the platform, or the domain when it has no name for it
independent The audio is with a recognised podcast host: Libsyn, Buzzsprout, Podbean, Transistor or Captivate
unrecognised Enclosures were found, but on a host the wizard does not know
unknown The feed could not be read at all

unrecognised and unknown both mean “we do not know”, and neither is a pass. The on_platform list is the same one Benecaster’s post-import review uses, so the two screens always agree; the list of recognised podcast hosts is deliberately short, and anything not on it comes back unrecognised rather than as a guess either way. A host matches on the URL’s domain (traffic.libsyn.com matches Libsyn; notlibsyn.com does not), never on the path.

Always 200, including for a feed that could not be read — that is verdict: "unknown" with a reason.

The result is advice. The checklist tick is still what gates the migration.

Changing the response. The benecaster_migration_audio_hosting filter receives the result and the feed address. Never turn unknown or unrecognised into independent: that tells a podcaster their audio is safe on no evidence, and every subscriber they migrate gets a feed that does not play.


Running an import

Three routes run the wizard’s last step: check the plan limit, start the run, then walk it slice by slice so the screen can show a live count. The import fires the published migration actions — benecaster_migration_import_started, benecaster_migration_subscriber_imported and benecaster_migration_import_complete — and nothing else.

GET /shows/{show_id}/plan-limit?adding=N

What importing N people onto paid tiers would do against the plan’s paying-subscriber limit.

{ "known": true, "limit": 1000, "current": 640, "adding": 400, "after": 1040, "exceeds": true }
  • known: false is not “you have room”. It means the limit could not be read — an older Benecaster, or pricing data not fetched yet. Say you could not check; do not imply headroom.
  • An unlimited plan is known: true with limit: null — a different answer from known: false.
  • The limit comes from Benecaster’s own plan data. Never hard-code one.

POST /shows/{show_id}/import/start

Starts a run. Multipart: the export under file again (it was not kept from the upload step), plus a settings JSON string carrying platform, assignments, declined, tierMap, freeTierSlugs, graceDays, pace, sequenceEnabled, hostFirstName, reminderDaysBefore, finalWarningDaysBefore, checklist, includeLapsed and, on a show using an external membership plugin, checkoutUrls. Do not set Content-Type yourself.

checkoutUrls maps each paid Benecaster tier slug to the signup page its completion link should open — the membership plugin’s own page for that level. For each paid tier the add-on keeps the URL you send if it is on this site, otherwise the one it suggests from the plugin’s settings, otherwise none (the link then opens the show’s page). A URL on another site is dropped silently, not refused, because the redirect can only follow addresses on this site. Ignored on a show using Benecaster’s built-in membership.

Response — 201: { runId, importId, total, slices, planLimit }.

  • It refuses when the import would go over the plan’s limit, and then creates nothing — no import record, no staged rows. Migrated people count against the limit from the moment they are imported, and past it they would be served the public feed while their welcome email told them their access was live. There is no undo, so it blocks rather than warns.
  • The column mapping is checked again against the file this call carries, because nothing guarantees it is the file the podcaster confirmed the mapping against.
  • includeLapsed is off unless you send true. People the file marks as having already stopped supporting are then left out and reported with the reason lapsed_at_source. Somebody an earlier run already imported is reported as already migrated, not as lapsed. People being left out do not count toward the plan-limit check.

Where the rows wait. Between slices, the mapped rows — email, source tier, name and join date, nothing else from the file — are held in temporary site storage, 500 rows at a time. They are deleted when the run ends, expire after two hours if it is abandoned, and are removed if the add-on is uninstalled. Each run has a random id, so one administrator cannot read another’s staged import.

POST /shows/{show_id}/import/run

Body: { "run_id": "…", "slice": 0 }. Imports one slice and returns { done, processed, total, imported, skipped, notes, entries }.

  • Call slices one after another, never in parallel. They share one import and one running tally; concurrent calls interleave writes and lose counts.
  • skipped and notes are different numbers. skipped is rows that produced nobody; notes is people who were imported with something worth knowing about, such as a join date that could not be read. Adding them together overstates who was lost.
  • An expired run answers 410. A run left for more than two hours has gone. Anyone already imported stays imported, and starting again brings across only the people still missing.
  • A run belongs to the show it was started on. Sending its run_id with a different show_id gets the same 410 as an expired run, so one show’s request can never read another show’s staged rows. Start a new run on the right show.

Errors

Code HTTP When
benecaster_migration_no_file 400 import/start received no file
benecaster_migration_csv_unreadable 422 The file could not be read as a CSV
benecaster_migration_mapping_invalid 422 The column mapping does not fit this file; nothing was imported
benecaster_migration_over_plan_limit 409 The import would exceed the plan’s paying-subscriber limit; nothing was created
benecaster_migration_schema_out_of_date 409 Benecaster is too old to record a migration
benecaster_migration_batch_failed 500 The import record could not be created; nobody was imported
benecaster_migration_run_expired 410 run_id is no longer in progress, or was started on a different show
benecaster_migration_core_unavailable 503 Benecaster is not active or did not boot; nobody was imported

Where people finish the move

Two routes the wizard’s tier step and confirm step read to work out where a migrated person’s completion link should take them.

GET /shows/{show_id}/completion-readiness

Whether anybody imported into this show could finish the move.

{ "builtinMembership": false, "bridgeSlug": "memberpress" }
  • Always 200. “This show uses MemberPress” is an answer, not a failed request.
  • builtinMembership is true when the show uses Benecaster’s own membership. bridgeSlug names the membership plugin when it uses one — MemberPress, WooCommerce Subscriptions, Paid Memberships Pro or Restrict Content Pro — and is empty when no membership system is chosen at all, which is the one case the wizard warns about.
  • It reports; it never blocks an import.

GET /shows/{show_id}/bridge-levels

On a show using a membership plugin, the tiers linked to that plugin’s levels, with a suggested signup page for each paid one.

{
  "bridge": true,
  "siteHost": "example.com",
  "levels": [
    { "tierSlug": "supporter", "tierName": "Supporter", "levelId": "12", "levelName": "Supporter Monthly", "isFree": false, "suggestedUrl": "https://example.com/register/supporter-monthly/" }
  ]
}
  • 200 with bridge: false on a show using Benecaster’s own membership — list its tiers from Benecaster instead.
  • suggestedUrl is null for a free tier, a tier linked to no level, or a level whose plugin has no signup page set up. Suggestions are a starting point, not a guarantee — check each one opens the right signup page before you rely on it.
  • siteHost is this site’s domain. A signup page must be on it; see checkoutUrls under import/start.
  • 503 benecaster_migration_core_unavailable when Benecaster did not boot — never an empty list, which would read as “this show has no tiers”.

The migration dashboard

Four routes behind the dashboard a show’s Migration tab opens on once it has run an import. Two read, and two act on one person: resend their welcome email, or save the podcaster’s note about them.

The last three name an import_id, not a show, and the show is always taken from the import. An id from another show’s migration is refused for the show it really belongs to, never answered for the one you are signed in to.

GET /shows/{show_id}/migration

Every migration the show has run, newest first, as { "items": [ … ] }. An empty list is the ordinary answer for a show that has never migrated.

{
  "items": [
    {
      "id": 7,
      "sourcePlatform": "patreon",
      "importDate": "2026-03-15T09:00:00+00:00",
      "totalImported": 89,
      "totalCompleted": 71,
      "totalDropped": 5,
      "totalPending": 13,
      "gracePeriodDays": 30,
      "graceEndsAt": "2026-04-14T09:00:00+00:00",
      "status": "active",
      "recordsFreeTiers": true
    }
  ]
}
  • Dates carry their UTC offset. Parse them as instants, not as local times.
  • totalPending is worked out, not stored: imported, less completed, less dropped, never below zero. totalDropped counts people whose grace period ran out. On the dashboard these are Imported, Signed up, Lapsed and Still to finish.
  • recordsFreeTiers: false means nobody in that migration will ever be timed out. The import ran before the wizard recorded which tiers were free, so the daily deadline check leaves the whole migration alone rather than risk revoking a free member. Explain that; do not show it as a migration that is merely slow.

Changing the response. The benecaster_migration_dashboard_imports filter receives the list and the show ID.

GET /migration/{import_id}/patrons

One page of the people in a migration.

Name In Type Description
status query string Optional. One of imported, emailed, clicked, subscribed, grace_expired, dropped
page query int Default 1
per_page query int Default 25, at most 100

Response — 200: { items, total, page, perPage, showId }, with X-WP-Total and X-WP-TotalPages headers. Each item carries id, importId, email, displayName, sourceTier, sourceJoinedAt, mappedTierSlug, status, isShadow, tokenGenerated, hasCompletionLink, inviteSentAt, inviteResentCount, lastEmailedAt, completedAt, graceEndsAt, canReceiveEmail and notes.

  • canReceiveEmail says whether an email can reach this person at all. Benecaster’s email queue sends to whichever WordPress account holds an address, so it is false when no account holds the person’s email any more — their account was deleted, or they changed the address they log in with. email is the address you imported and is never rewritten, so it cannot tell you this. Resend is withheld while it is false.
  • No token of any kind is returned. hasCompletionLink says whether a person has a completion link; the link itself signs its holder in and never leaves the server.
  • email, displayName and sourceTier have been neutralised against spreadsheet formula injection, because they came from somebody else’s export. notes has not: it is the podcaster’s own text and is returned exactly as saved, so it can go straight back into an edit box.
  • An unrecognised status returns everybody, not nobody. A screen reading “no subscribers” for a nine-hundred-person migration looks like data loss.
  • Read isShadow, not status, to spot a scorecard row. Benecaster’s own Patreon scorecard writes rows here with a platform-specific status; those people were never migrated and hold no access. They cannot be filtered on.
  • sourceJoinedAt is a calendar day (2023-04-01), never converted. Formatting it as an instant shows the day before on every machine west of UTC.
  • notes must never reach anything a subscriber can see. It is the podcaster’s note about the person, and it is left out of that person’s own data export.

POST /migration/{import_id}/patrons/{patron_id}/resend

Queues one person’s Migration Welcome again — the dashboard’s Resend button. No body.

{ "email_sent": true, "sent_at": "2026-05-17T14:23:00+00:00", "resend_count": 2 }
  • It resends the Welcome, because that is the email carrying the feed link, the app buttons and the payment or set-password link.
  • Only for somebody still mid-move — imported, emailed or clicked — and only when canReceiveEmail is true. The Welcome says their access is active now, which is untrue for anyone whose deadline has passed or who was removed; their Access Ended email already carries a restart link.
  • It sends even when the migration’s email sequence is switched off. Clicking it is the podcaster choosing to write to one person.
  • A resend can stand in for a reminder. The reminder and final notice skip anybody already emailed in their window, and a migration sends one person at most one email a day — so a resend inside the final-notice window means that person does not get the final notice.
  • resend_count is the person’s inviteResentCount. It stays at 0 when this was their first invitation (they were imported with the sequence off), and inviteSentAt is never moved by a resend.
  • email_sent means queued, not delivered. Sending is paced, so the email may leave minutes later. benecaster_migration_invite_resent fires after it is queued.

PATCH /migration/{import_id}/patrons/{patron_id}

Saves the podcaster’s outreach note on one person — the dashboard’s Your notes box.

Body: { "notes": "Spoke on Discord, moving this week" }. null or "" clears the note.

Response — 200: the person, exactly as GET …/patrons returns them.

  • notes is the only field the body may carry. Anything else is refused whole, not half-applied — so sending back the object you read from GET …/patrons fails.
  • A note replaces the one before it. It is not a log; read the current note first if you mean to add to it.
  • Plain text, up to 2,000 characters after cleaning.
  • benecaster_migration_patron_note_saved fires after the save.

GET /migration/{import_id}/failed-sends

Every migration email in the import that could not be sent, across all five types — the dashboard’s Emails that did not send panel.

Response — 200: { items, total, shown, truncated, byType, maxPerType }. Each item carries queueId, userId, displayName, email, emailType, attempts, lastError, sentAt, createdAt, patronId, patronStatus and retryable.

  • At most 100 of each email type, oldest first. total is the true number and can be larger than shown; truncated says when it is.
  • createdAt is a date as stored, with no time zone. Show it as a date and do not convert it. sentAt is a UTC time.
  • retryable is the server’s answer. Only a failed welcome to somebody still mid-move, whose WordPress account still exists, is retryable. It is not the same check as canReceiveEmail: somebody who changed their login email can be unreachable for Resend and still retryable here, because the failed email is already addressed to their account. Do not work it out yourself, because half the rule is where the person stands now.
  • displayName, email and lastError have been neutralised against spreadsheet formula injection. A deleted account leaves the first two empty.
  • benecaster_migration_failed_sends filters the whole response.

POST /migration/{import_id}/failed-sends/{queue_id}/retry

Sends one failed welcome again, exactly as it was first written — the panel’s Send again button. No body.

{ "queued": true, "queueId": 4182 }
  • Welcome only, and only for somebody still mid-move. Every migration email is stored as it was written on the day it was queued, so a late reminder would show a passed deadline, and a welcome to somebody who has finished or lapsed would be untrue.
  • It is not a resend. Nothing is recorded against the person, so their next reminder still goes out. …/resend writes a new welcome and counts as chasing them.
  • There is no way to retry everything at once.
  • benecaster_migration_failed_send_retried fires once it is back in the queue, not once it is delivered.

Errors

Nothing is sent or saved on any of these.

Code HTTP When
benecaster_migration_import_not_found 404 No such import
benecaster_migration_patron_not_found 404 No such person, or they belong to a different import
benecaster_migration_patron_completed 409 Resend or retry: they have already finished their move
benecaster_migration_patron_not_resendable 409 Resend or retry: their deadline has passed, they were removed, or they are a scorecard row
benecaster_migration_patron_has_no_email 409 Resend: no address on the record
benecaster_migration_patron_unreachable 409 Resend: no WordPress account holds this address any more; checked before the status refusals
benecaster_migration_note_unexpected_fields 400 Note: the body carries a field other than notes; the message names it
benecaster_migration_note_missing 400 Note: no notes key, or a value that is not text — never a silent success
benecaster_migration_note_too_long 422 Note: over 2,000 characters
benecaster_migration_schema_out_of_date 409 Resend or note: Benecaster is too old to record it; checked before anything is sent or written
benecaster_migration_core_unavailable 503 Resend: Benecaster is not reachable
benecaster_migration_email_not_queued 503 Resend: the email could not be queued
benecaster_migration_note_not_saved 500 Note: the save failed
benecaster_migration_send_not_retryable 409 Retry: not one of this import’s failed welcomes, or already retried or sent
benecaster_migration_send_recipient_address_missing 409 Retry: the person’s WordPress account was deleted, so the email has no address; checked before the status refusals
benecaster_migration_send_recipient_unknown 409 Retry: the email’s recipient is not in this import
benecaster_migration_queue_unavailable 503 Failed sends or retry: Benecaster’s email queue is not reachable

See Also

Need this built rather than just documented? See our services →