Skip to content

Protocol#

This page describes what goes over the wire between two sites: the REST routes, the signature, the error shapes and the client's retry policy. It is taken from inc/class-remote.php (the receiving side) and inc/class-client.php (the calling side).

Namespace and transport#

  • Namespace: bb-migrate/v1, registered with the WordPress REST API.
  • Base URL: https://example.com/wp-json/bb-migrate/v1, or https://example.com/?rest_route=/bb-migrate/v1 on a site without pretty permalinks.
  • Every route accepts POST only, with a JSON body and Content-Type: application/json. A route without parameters is sent an empty JSON array.
  • Every route requires the signature header. There is no cookie, nonce or application password authentication.
  • Binary payloads (SQL, file contents, packages) are base64 strings inside the JSON. SQL and packages are zlib-compressed first (gzcompress).

The signature#

Each request carries one header:

X-BB-Migrate-Signature: <ts>.<nonce>.<hmac>
Part Format Meaning
ts Digits Unix time in seconds when the request was signed
nonce 16 to 64 characters of A-Z a-z 0-9 _ - Random per request (the client sends 20 characters)
hmac 64 lowercase hex characters HMAC-SHA256, keyed with the receiver's key

The HMAC is computed over four values joined by newlines:

ts \n nonce \n leaf \n body
  • leaf is the last segment of the route, for example files-put or handshake.
  • body is the raw request body, byte for byte.
$signature = hash_hmac( 'sha256', $ts . "\n" . $nonce . "\n" . $leaf . "\n" . $body, $key );
$header    = $ts . '.' . $nonce . '.' . $signature;

A signature is therefore bound to one route and one exact body.

Verification on the receiver#

In this order:

Check Failure HTTP
The header matches the format above Missing signature. 401
ts is within 300 seconds of the receiver's clock, either way Signature expired; check both clocks. 401
The HMAC matches (constant-time comparison) Bad signature: the connection key does not match (or the clocks/route differ). 403
The signature has not been seen before Replayed request. 403
The switch for the route is on This site does not accept pushes. … / This site does not allow pulls. … 403

Replay markers#

A signature is accepted once. After the HMAC has been verified, the receiver creates an empty marker file named after the HMAC in uploads/bb-migrate/nonces/. The file is created exclusively (fopen mode x), so of two identical requests arriving together only one can pass. If the marker cannot be created, because it exists or because the folder is not writable, the request is refused as a replay: the check fails closed.

Markers are kept for 15 minutes, longer than the 300 second window, and pruned by the hourly cleanup and opportunistically during requests.

Markers are files rather than transients because the plugin flushes the object cache during an import, which would wipe transients held in a persistent object cache.

The mu-plugin's own check#

The compatibility mu-plugin runs before plugins load and verifies the same header itself: format, the 300 second window, and the HMAC over the same four values. It does not consult or create replay markers and does not check switches. Its only decision is whether to load the site without other plugins and without a theme for this request.

Routes#

"Switch" is the setting that must be on on the receiver. Routes that take a scope also need allow_code_files when the scope is a theme or plugin scope.

Handshake#

Route Switch Request Response
handshake none empty home, siteurl, abspath, uploads, theme, compat, prefix, wp, php, plugin, allow_pull, allow_push, allow_code_files, max_request, local, tables

tables lists the site's prefixed tables with row estimates and sizes. compat says whether the mu-plugin handled the request. The caller compares plugin with its own version and logs a warning when they differ.

Database: pull side#

Route Switch Request Response
export-plan allow_pull export options: tables, exclude_transients, exclude_spam, exclude_revisions plan (table name, row count, primary key per table), header (the SQL header text)
export-chunk allow_pull table, cursor, head, limit (25 to 5000), opts sql (base64 of compressed SQL), cursor, rows, done, bytes

all_tables is forced off: a remote caller only ever gets prefixed tables. head asks for the table's DROP/CREATE statements before the rows.

Database: push side#

Route Switch Request Response
backup-begin allow_push empty job
backup-step allow_push job done, progress, bytes
import-begin allow_push empty job
import-chunk allow_push job, sql (base64 of compressed SQL) statements
import-finalize allow_push job, pairs, no_preserve tables, replacements
import-abort allow_push job empty
  • backup-begin answers 429 when a backup was started less than five minutes ago. backup-step works for up to 12 seconds per call. The backup's file name is not returned.
  • import-begin drops stale temp tables and captures the preserved options.
  • import-chunk executes the statements into temp tables in strict mode. A chunk that does not decompress, or that decompresses to more than the larger of 8 MB and four times the receiver's max_request, is refused. SQL fragments are not echoed back in errors; the full message goes to the receiver's PHP error log.
  • import-finalize swaps the tables, applies pairs, restores preserved options.
  • import-abort drops the temp tables.

Files#

Route Switch Request Response
files-list allow_pull scope, paths files: relative path → s (size), m (modification time)
files-get allow_pull scope, path, offset, length (1 KB to 8 MB) data (base64), size, mtime, eof
files-get-many allow_pull scope, paths files: list of path, data, mtime, size; stops at the receiver's max_request
files-put allow_push scope, path, offset, data, final, mtime empty
files-put-many allow_push scope, files: list of path, data, mtime written: the paths written
files-delete allow_push scope, paths deleted: the paths deleted

files-put-many and files-delete report what they did rather than failing for a file they refuse; the caller compares the answer with what it sent.

Page packages#

Route Switch Request Response
content-list allow_pull search, type posts: up to 40 rows of ID, title, slug, type, status, modified, url
content-export allow_pull items, opts package (base64 of compressed JSON), posts, attachments, files
content-import allow_push package, opts report: posts, attachments, warnings, map, and snippets when snippets arrived

Package files travel separately through the file routes in the uploads scope, before content-import on a push and after content-export on a pull.

Site actions#

Route Switch Request Response
site-plugins allow_push; allow_code_files unless action is list action (list, activate, deactivate), slugs plugins, or results (slug → outcome)
site-cache allow_push action (get, set, purge), values settings; settings and changed; or purged
site-snippets allow_push ids files
site-meta allow_push post, type, items: list of key, value, ref, files post, url, report

Which command calls what#

Command Routes
Test connection handshake
pull handshake, export-plan, export-chunk
push handshake, backup-begin, backup-step, import-begin, import-chunk, import-finalize (import-abort on failure)
files pull files-list, files-get-many, files-get
files push files-list, files-put-many, files-put; files-delete with --delete; site-cache with --purge
pages pull content-export, files-get-many, files-get
pages push handshake, files-put-many, files-put, content-import
pages list content-list
site … site-plugins, site-cache, site-snippets
meta push site-meta

Response and error shapes#

There are two shapes, depending on where a request stops.

Refused before the route runs (signature or switch). The standard WordPress REST error:

{
  "code": "bb_migrate_auth",
  "message": "Signature expired; check both clocks.",
  "data": { "status": 401 }
}

code is bb_migrate_auth for signature failures (401 or 403) and bb_migrate_denied for a switch that is off (403).

Answered by the route. Always an object with ok:

{ "ok": true, "written": ["assets/dist/css/core.css"] }
{ "ok": false, "error": "Unknown scope." }
HTTP status Used for
200 Success (ok: true)
400 A bad request: unknown table, unknown job, corrupt package or chunk, file not readable, write refused
403 An unknown scope, or a theme or plugin scope (or plugin change) without allow_code_files
429 backup-begin inside the five minute lock
500 An exception while importing, finalizing or running a site action; the message is in error

The client treats a reply as an error when it has no truthy ok, and reports error, else message, else Error <status> from <route>.

Not JSON at all. When the body does not decode to a JSON object or array, the client reports:

Unexpected response (<status>) from <route>: (an HTML page, not the plugin: wrong URL, a firewall, or the plugin is inactive there)

or, when the body contains no <, its first 120 characters.

Client behaviour#

For every request the client:

  • sends to <base URL>/<leaf>,
  • follows no redirects. A redirect would carry the signed body to another host,
  • verifies TLS certificates unless "Verify SSL certificates" is off on the sending site,
  • refuses private and loopback targets (WordPress's reject_unsafe_urls) unless the sending site is itself local,
  • uses a timeout suited to the route: 30 seconds for the handshake, 120 by default, 300 for file batches and listings, SQL chunks, package exports and site actions, 600 for import-finalize and content-import.

Client retry policy#

Idempotent routes are tried up to three times. All others are sent once.

Retried (up to 3 attempts) Never retried
handshake import-begin, import-chunk, import-finalize, import-abort
files-list, files-get, files-get-many, files-put, files-put-many, files-delete backup-begin
export-plan, export-chunk content-import
content-list, content-export site-plugins, site-cache, site-snippets, site-meta
backup-step

A retry happens only when the failure looks like transport, not like an answer from the plugin:

  • the HTTP request itself failed with a cURL error (a dropped connection, a TLS read error, a timeout), or
  • the reply was not JSON and its status was 5xx or 429 (an error page from a proxy or the web server).

A JSON reply from the plugin is final, whatever its status. The client waits 2 seconds before the second attempt and 4 before the third. Each attempt is signed again with a new timestamp and nonce, so a retry is not a replay.

Imports and site actions are never retried because a reply lost after the receiver acted would run the action twice.