Partner API · v1
Email migrations for hosting companies
When a customer buys hosting from you, their email has to move too. With the Migrabox API you start the copy from your control panel, or you send the customer a link to a page with your logo where they type the passwords themselves. We copy every message, folder and attachment over IMAP; the old mailbox is never changed.
This page is also available in Italiano · Español · Português (Brasil) · Français · Deutsch
How it fits your business
- Same address, new server. The most common case: info@customer.com stays the same, only the hosting changes. We copy the whole history now and the last emails after the MX change, still reading the old server.
- You never touch passwords if you use hosted links: the customer types them on migrabox.com, they are encrypted and deleted 14 days after the last copy.
- One mailbox, one credit. Any size. If the first copy cannot start because a password is wrong or a server does not answer, the credit goes back to your balance.
- Test keys are free and simulate every state and webhook, so you can build the whole integration before buying anything.
Prices
Prepaid credit, valid 12 months from payment. Same figures in euro and US dollars: you choose the currency at checkout. Several packs add up; credit is taken from the pack that expires first.
| Pack | Price | Per mailbox |
|---|---|---|
| 20 mailboxes | €20.00 · $20.00 | $1.00 |
| 100 mailboxes | €80.00 · $80.00 | $0.80 |
| 500 mailboxes | €300.00 · $300.00 | $0.60 |
| 2,000 mailboxes | €1,000.00 · $1,000.00 | $0.50 |
Standard speed: up to 20 Mbps per mailbox. A faster lane through the API is planned (the speed field is already reserved).
Quickstart in 5 minutes
- Create a partner accountCompany name and country go on your receipts. You sign in with a link sent by email: no password to manage.
- Create a test keyPartner area → API keys → mode Test. It starts with
mbx_test_and is shown only once. - Call the APICreate a migration with the example below. In test mode nothing is copied: the migration goes through
queued,runningandcompletedin about ten seconds. - Add a webhookPartner area → Webhooks. Press Send test event and check your endpoint verifies the signature.
- Go liveBuy a credit pack, create a
mbx_live_key, swap it in. Same routes, same payloads.
# 1. Your test key (partner area → API keys). Test mode never copies anything.
export MIGRABOX_KEY=mbx_test_...
# 2. Check the key and your credit
curl https://migrabox.com/api/v1/account -H "Authorization: Bearer $MIGRABOX_KEY"
# 3. Create a migration (server to server)
curl https://migrabox.com/api/v1/migrations \
-H "Authorization: Bearer $MIGRABOX_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1001-mailbox-1" \
-d '{
"source": { "email": "info@customer.com", "host": "imap.oldhost.com", "password": "old-secret" },
"destination": { "email": "info@customer.com", "host": "imap.yourhosting.com", "password": "new-secret" },
"same_address": true,
"external_id": "order-1001"
}'
# 4. Follow it (or wait for the migration.completed webhook)
curl https://migrabox.com/api/v1/migrations/MIGRATION_ID -H "Authorization: Bearer $MIGRABOX_KEY"Authentication
Every request carries your key in the Authorization header: Authorization: Bearer mbx_live_…. Requests without a valid key get 401 UNAUTHORIZED.
Keys are shown once and stored by us only as a SHA-256 fingerprint. Rotate creates a new key and keeps the old one working for 24 hours; Revoke stops it at once.
A key only sees its own company’s migrations, in its own mode: a live key never sees test migrations and vice versa. Anything else answers 404 NOT_FOUND.
Use the API from your servers only. Never put a key in a web page or a mobile app.
Test mode
mbx_test_ keys use the same routes as live keys. They never connect to a mail server, never store passwords and never use credit. Probes and migrations are simulated with a fixed timeline: queued for 2 seconds, then copying for 8 seconds, then the final state, with every webhook a real migration would send (test endpoints only).
Choose the outcome with the source password:
test-auth-failed- Creation fails with
422 SOURCE_AUTH_FAILED(as destination password:DEST_AUTH_FAILED). test-connect-failed- Creation fails with
422 SOURCE_CONNECT_FAILED. A host ending in.invaliddoes the same. test-run-fails- The migration starts, then fails with
failure_code: AUTH_SOURCEand sendsmigration.failed. test-run-errors- The migration ends as
completed_with_errors(a few messages not copied).
Any other password: the migration completes.
Hosted link or server to server
Two ways to start a migration. Both end in the same migration object, the same webhooks and the same status page.
Hosted link (recommended)
POST /migration-linksreturns a URL likehttps://migrabox.com/en/email-move/#…that you send to your customer.- The page shows Migrabox in collaboration with your name and logo (upload it in the partner area).
- The customer types both passwords; you can fix the destination server (your new hosting) so they only enter the new password.
- The credit is taken when the customer submits. You get
link.opened,link.submitted, then the migration events. - Single use, valid 1 to 30 days (14 by default), can be revoked before it is used.
Server to server
POST /migrationswith both mailboxes and their passwords, when you already have them (for example a mailbox you are creating right now on your servers).- We check both logins and measure the source before taking the credit: a wrong password answers
422and costs nothing. - Passwords travel only over HTTPS, are encrypted at rest (AES-256-GCM), never appear in logs, responses or webhooks, and are deleted 14 days after the last copy.
- Up to 500 mailboxes at once with
POST /migrations/batch.
curl https://migrabox.com/api/v1/migration-links \
-H "Authorization: Bearer $MIGRABOX_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: welcome-7731" \
-d '{
"locale": "en",
"external_id": "service-7731",
"same_address": true,
"source_email": "info@customer.com",
"destination": { "host": "imap.yourhosting.com", "port": 993, "security": "ssl" }
}'
# → { "id": "…", "url": "https://migrabox.com/en/email-move/#…", "status": "open", … }Same address, new hosting
Set same_address: true when the customer keeps info@customer.com and moves it to your servers. Source and destination share the email address; the destination host is your new server.
At creation we store the addresses of the old server that answered. Every later run connects to them (with TLS verified on the old name), so after you switch the MX records to your servers, POST /migrations/{id}/delta still reads the old mailbox and fetches what arrived there in the meantime.
Recommended flow: create the migration (or the hosted link) as soon as the hosting is active → copy runs → change MX → call delta → done. Delta is free within 14 days of the last run; already copied messages are skipped, so nothing is duplicated.
Webhooks
Add endpoints in the partner area (https only, public DNS name, up to 5 per mode). We POST a JSON event to each endpoint subscribed to its type. Payloads never contain passwords or private tokens.
Answer 2xx within 10 seconds. Anything else is retried with growing delays (30 s, 2 min, 10 min, 30 min, 1 h, 2 h, 4 h, 6 h, 8 h: about 22 hours in all). The partner area shows every delivery and lets you retry it.
Events can arrive more than once and out of order: use id to process each event once and the object’s status as the truth.
Events
migration.created- A migration was created (API, batch row or hosted link).
migration.started- A run started copying (first copy or delta).
migration.completed- A run ended:
statusiscompletedorcompleted_with_errors. migration.failed- A run failed: see
failure_codeandcredit_refunded. migration.cancelled- A run was stopped with
/cancel. link.opened- Your customer opened a hosted link for the first time.
link.submitted- Your customer submitted the hosted link: the object includes the new migration.
credits.low- Live credit reached your threshold (partner area → Settings).
POST /your/webhook HTTP/1.1
Content-Type: application/json
Migrabox-Signature: t=1794470900,v1=5f2b…c9
Migrabox-Event-Id: evt_7Qm1…
{
"id": "evt_7Qm1…",
"object": "event",
"api_version": "2026-11-12",
"type": "migration.completed",
"created": 1794470900,
"livemode": true,
"data": { "object": {
"id": "92430b5b-…", "object": "migration", "status": "completed", "external_id": "order-1001",
"source": { "email": "info@customer.com", "host": "imap.oldhost.com" },
"destination": { "email": "info@customer.com", "host": "imap.yourhosting.com" },
"progress": { "run": "full", "messages_total": 18234, "messages_done": 18234, "percent": 100, … },
"credit_refunded": false, "can_delta": true, …
} }
}Verify the signature
Each delivery has the header Migrabox-Signature: t=<unix seconds>,v1=<hex>. v1 is the HMAC-SHA256 of t + "." + raw body, keyed with the endpoint’s signing secret (whsec_…, shown once when you create the endpoint).
Compute it on the raw body (before parsing JSON), compare in constant time, and refuse timestamps older than 5 minutes to block replays. During a secret rotation the header may carry more than one v1: accept the event if any of them matches.
<?php
// Verify Migrabox-Signature on the RAW body, before json_decode.
$secret = getenv('MIGRABOX_WEBHOOK_SECRET'); // whsec_...
$body = file_get_contents('php://input');
$header = $_SERVER['HTTP_MIGRABOX_SIGNATURE'] ?? '';
$parts = [];
foreach (explode(',', $header) as $p) { [$k, $v] = array_pad(explode('=', $p, 2), 2, ''); $parts[$k][] = $v; }
$t = (int) ($parts['t'][0] ?? 0);
$expected = hash_hmac('sha256', $t . '.' . $body, $secret);
$valid = abs(time() - $t) <= 300 && array_filter($parts['v1'] ?? [], fn($s) => hash_equals($expected, $s));
if (!$valid) { http_response_code(400); exit; }
$event = json_decode($body, true);
// Deliveries can repeat: use $event['id'] to process each event once.
if ($event['type'] === 'migration.completed') {
$migration = $event['data']['object']; // ['id'], ['external_id'], ['status'], ['progress'] ...
}
http_response_code(200); // answer within 10 seconds, do slow work afterwardsimport { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody: the request body exactly as received (Buffer or string), before JSON.parse.
export function verifyMigrabox(rawBody, header, secret, toleranceSeconds = 300) {
const parts = header.split(',').map((p) => p.split('='));
const t = Number(parts.find(([k]) => k === 't')?.[1]);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = Buffer.from(createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'));
return parts.filter(([k]) => k === 'v1').some(([, v]) => {
const got = Buffer.from(v ?? '');
return got.length === expected.length && timingSafeEqual(got, expected);
});
}import hashlib, hmac, time
def verify_migrabox(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = [p.split("=", 1) for p in header.split(",") if "=" in p]
t = next((v for k, v in parts if k == "t"), "")
if not t.isdigit() or abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, v) for k, v in parts if k == "v1")Errors
Errors are JSON with a stable code (use it in your code) and an English message (for your logs). The HTTP status tells you the class: 4xx your request, 5xx ours (retry with the same Idempotency-Key).
HTTP/1.1 422 Unprocessable Entity
{ "error": { "code": "SOURCE_AUTH_FAILED", "message": "Cannot sign in to the source mailbox" } }| Code | HTTP | Meaning |
|---|---|---|
INVALID_INPUT | 400 | A field is missing or invalid; the message names it (never its value). |
UNAUTHORIZED | 401 | Missing, malformed, revoked or expired key. |
INSUFFICIENT_CREDITS | 402 | No live credit left (for a batch: not enough for all rows). |
NOT_FOUND | 404 | No such object for this key and mode. |
RUN_ACTIVE | 409 | A run is already queued or copying. |
CREDENTIALS_PURGED | 409 | More than 14 days after the last run: passwords are deleted, create a new migration. |
IDEMPOTENCY_MISMATCH | 422 | This Idempotency-Key was used with a different request. |
SOURCE_AUTH_FAILED | 422 | The source server refused the login. DEST_… for the destination. |
SOURCE_CONNECT_FAILED | 422 | The source server did not answer (also TLS_FAILED, TIMEOUT, HOST_NOT_ALLOWED). |
SAME_SERVER | 422 | Both sides are the same mailbox on the same server: refused, never copied. |
RATE_LIMITED | 429 | Too many requests for this key: wait and retry. |
Limits and conventions
- Rate limits per account and mode (all your keys together), per endpoint and minute: 120 requests, 60 creations, 30 probes, 10 batches. Over the limit:
429 RATE_LIMITED. Live/probeand/discoverare available after your first credit purchase (402 CREDIT_REQUIREDbefore). - Idempotency-Key on every POST: send a unique value (your order or mailbox id). The first answer is stored for 24 hours and replayed for the same key; the header
Idempotent-Replayed: truetells you. - Pagination:
GET /migrations?limit=20returnshas_moreandnext_cursor; passcursor=for the next page. Newest first. - Server names must be DNS names: IP addresses and private networks are refused. TLS certificates are always verified.
- Dates are ISO 8601 in UTC; webhook
createdis Unix seconds. Unknown fields in our answers may be added at any time: ignore them.
Examples
Complete, dependency-free examples. Replace the key with yours.
# 1. Your test key (partner area → API keys). Test mode never copies anything.
export MIGRABOX_KEY=mbx_test_...
# 2. Check the key and your credit
curl https://migrabox.com/api/v1/account -H "Authorization: Bearer $MIGRABOX_KEY"
# 3. Create a migration (server to server)
curl https://migrabox.com/api/v1/migrations \
-H "Authorization: Bearer $MIGRABOX_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1001-mailbox-1" \
-d '{
"source": { "email": "info@customer.com", "host": "imap.oldhost.com", "password": "old-secret" },
"destination": { "email": "info@customer.com", "host": "imap.yourhosting.com", "password": "new-secret" },
"same_address": true,
"external_id": "order-1001"
}'
# 4. Follow it (or wait for the migration.completed webhook)
curl https://migrabox.com/api/v1/migrations/MIGRATION_ID -H "Authorization: Bearer $MIGRABOX_KEY"<?php
// Works on any PHP 7.4+ with the curl extension (WHMCS, cPanel, Plesk). No dependencies.
function migrabox(string $method, string $path, ?array $body = null, ?string $idempotencyKey = null): array {
$ch = curl_init('https://migrabox.com/api/v1' . $path);
$headers = ['Authorization: Bearer ' . getenv('MIGRABOX_KEY'), 'Accept: application/json'];
if ($body !== null) {
$headers[] = 'Content-Type: application/json';
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
if ($idempotencyKey !== null) $headers[] = 'Idempotency-Key: ' . $idempotencyKey;
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60,
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$json = json_decode((string) $raw, true) ?? [];
if ($status >= 400) throw new RuntimeException(($json['error']['code'] ?? 'HTTP_' . $status) . ': ' . ($json['error']['message'] ?? ''));
return $json;
}
// Hosted link: your customer types the passwords on our page, you never handle them.
$link = migrabox('POST', '/migration-links', [
'locale' => 'en',
'external_id' => 'service-' . $serviceId,
'same_address' => true,
'source_email' => $customerEmail,
'destination' => ['host' => 'imap.yourhosting.com', 'port' => 993, 'security' => 'ssl'],
], 'link-service-' . $serviceId);
echo $link['url']; // send it in your welcome email// Node 18+ (built-in fetch). No dependencies.
const BASE = 'https://migrabox.com/api/v1';
async function migrabox(method, path, body, idempotencyKey) {
const res = await fetch(BASE + path, {
method,
headers: {
Authorization: `Bearer ${process.env.MIGRABOX_KEY}`,
...(body ? { 'Content-Type': 'application/json' } : {}),
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const json = await res.json();
if (!res.ok) throw Object.assign(new Error(json.error.message), { code: json.error.code, status: res.status });
return json;
}
const migration = await migrabox('POST', '/migrations', {
source: { email: 'info@customer.com', host: 'imap.oldhost.com', password: oldPassword },
destination: { email: 'info@customer.com', host: 'imap.yourhosting.com', password: newPassword },
same_address: true,
external_id: 'order-1001',
}, 'order-1001-mailbox-1');
console.log(migration.id, migration.status); // "queued"# Python 3.8+, standard library only.
import json, os, urllib.request, urllib.error
BASE = "https://migrabox.com/api/v1"
def migrabox(method, path, body=None, idempotency_key=None):
headers = {"Authorization": f"Bearer {os.environ['MIGRABOX_KEY']}"}
data = None
if body is not None:
headers["Content-Type"] = "application/json"
data = json.dumps(body).encode()
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
req = urllib.request.Request(BASE + path, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=60) as r:
return json.load(r)
except urllib.error.HTTPError as e:
err = json.load(e)["error"]
raise RuntimeError(f"{err['code']}: {err['message']}") from None
batch = migrabox("POST", "/migrations/batch", {"migrations": [
{"source": {"email": m["email"], "host": "imap.oldhost.com", "password": m["old"]},
"destination": {"email": m["email"], "host": "imap.yourhosting.com", "password": m["new"]},
"same_address": True, "external_id": m["id"]}
for m in mailboxes
]}, idempotency_key="import-2026-11-12")
print(batch["id"], batch["status"]) # "processing": then GET /migrations/batch/{id}API reference
Base URL https://migrabox.com/api/v1. The full schemas are in the OpenAPI document.
Account
GET
/v1/accountCredit balance and lots with their expiry.
Mailboxes
POST
/v1/discoverIMAP settings of an address (same engine as the website).
Errors:
INVALID_INPUT,CREDIT_REQUIREDPOST
/v1/probeCheck a login over TLS and, with
measure, count messages and bytes.Errors:
INVALID_INPUT,CREDIT_REQUIRED,RATE_LIMITED
Migrations
POST
/v1/migrationsCreate and start a migration with both passwords.
Errors:
INVALID_INPUT,INSUFFICIENT_CREDITS,SOURCE_AUTH_FAILED,DEST_AUTH_FAILED,SOURCE_CONNECT_FAILED,DEST_CONNECT_FAILED,SOURCE_TLS_FAILED,SOURCE_HOST_NOT_ALLOWED,SAME_MAILBOX,SAME_SERVER,SAME_ADDRESS_MISMATCH,IDEMPOTENCY_MISMATCHPOST
/v1/migrations/batchUp to 500 migrations, processed in the background.
Errors:
INVALID_INPUT,INSUFFICIENT_CREDITSGET
/v1/migrations/batch/{id}Outcome of a batch, row by row.
Errors:
NOT_FOUNDGET
/v1/migrationsList with filters (status, external_id, dates) and cursor.
Errors:
INVALID_INPUT,INVALID_CURSORGET
/v1/migrations/{id}Status and progress.
Errors:
NOT_FOUNDPOST
/v1/migrations/{id}/deltaCopy what arrived since the last run (after the MX change).
Errors:
NOT_FOUND,RUN_ACTIVE,CREDENTIALS_PURGED,CREDIT_REFUNDED,NOT_READYPOST
/v1/migrations/{id}/cancelStop the queued or running copy.
Errors:
NOT_FOUND,NOT_RUNNINGGET
/v1/migrations/{id}/reportAll runs with counters and the timeline.
Errors:
NOT_FOUND
Hosted links
POST
/v1/migration-linksCreate a hosted link for your customer.
Errors:
INVALID_INPUT,DESTINATION_REQUIREDGET
/v1/migration-links/{id}Status of a hosted link.
Errors:
NOT_FOUNDPOST
/v1/migration-links/{id}/revokeCancel a link not yet used.
Errors:
NOT_FOUND,LINK_NOT_OPEN
Changelog
- v1: migrations, batch, hosted links with your logo, delta, cancel, report, discover, probe, signed webhooks, test mode.