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, orhttps://example.com/?rest_route=/bb-migrate/v1on a site without pretty permalinks. - Every route accepts
POSTonly, with a JSON body andContent-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
leafis the last segment of the route, for examplefiles-putorhandshake.bodyis 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-beginanswers 429 when a backup was started less than five minutes ago.backup-stepworks for up to 12 seconds per call. The backup's file name is not returned.import-begindrops stale temp tables and captures the preserved options.import-chunkexecutes 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'smax_request, is refused. SQL fragments are not echoed back in errors; the full message goes to the receiver's PHP error log.import-finalizeswaps the tables, appliespairs, restores preserved options.import-abortdrops 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-finalizeandcontent-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.