partner
POST /v1/partner/customers/{externalId}/site-moves
Move one of your customer's sites onto their money-site hosting, from an archive.
Authentication
Send an API key as a bearer token. The key must carry the partner.domains permission; a key without it is refused with 403, not 404.
This endpoint takes no organisation id. Your key already identifies the organisation it belongs to, and the response is scoped to it.
Try it
Replace anything in angle brackets with your own values, and the key placeholder with a key from your dashboard.
curl -X POST https://api.zinndigital.com/v1/partner/customers/{externalId}/site-moves \
-H "Authorization: Bearer zdk_live_…" \
-H "Content-Type: application/json" \
-d '{ "subscription_id": <string>, "domain": <string>, "app": <string>, "archive_url": <string>, "archive_sha256": <string> }'Signed in? The API console in your dashboard fills in your real organisation id and your own key, and runs the request against the live API so you can see the actual response. Open this endpoint in the API console
Details
Starts an asynchronous move of ONE site into the customer's hosting plan (docs/808 §12). Nothing is charged: the move spends a site slot the customer already pays for. The call is validated and answered at once (202); the work runs as a durable workflow and is read back with getPartnerSiteMove. The archive. archive_url is an https link our hosting box downloads directly (never through this API): a .tar.gz (or .zip) holding the document root under www/ and, for a site with a database, a MySQL dump named dump.sql at the top level — the V1 backup layout. The link must answer without cookies and must survive a HEAD plus up to three GETs (a retried download fetches again). archive_sha256 is checked on the box before anything is applied; a mismatch fails the move with archive_checksum_mismatch without touching the site. The site. It is created in the customer's organization on the plan named by subscription_id, on domain, with app (a key from getPartnerCapabilities site_moves.plans[].apps). Or pass replace_site_id — one of listPartnerHostingSlots replaceable_sites — to import into that empty site and move it onto domain instead of taking a new slot. domain may be the replaced site's own name (an empty_starter is built on the customer's real domain); that is not domain_in_use. A replaced empty_starter that has content on it when the move starts fails with replace_site_not_empty, and nothing on it is touched. The domain's DNS is never touched: it keeps pointing where it points until you repoint it, and it may stay in your (or your customer's) Cloudflare for good. A subdomain whose apex is somebody else's live site is fine. Refusals (422, details[0].code): no_free_slot, subscription_not_found, app_not_supported_by_plan, plan_cannot_import, domain_invalid, domain_in_use, archive_url_invalid, archive_checksum_invalid, replace_site_not_replaceable, move_already_running, idempotency_key_required, moves_not_open, no_capacity, dns_invalid, not_linked. There is no size limit (D28455): room is made for a large archive, and the move is slower, never refused. DNS (contract v2). dns says where the zone is held and who changes it: with auto_switch: true YOU repoint the record when the move reaches step: ready_to_switch (then call go-live); otherwise the customer is shown and e-mailed the exact records, the change is detected automatically, and the move goes live on its own. A retry with the same Idempotency-Key returns the same move. Requires partner.domains.
Parameters
| Name | Type | Required | What it is |
|---|---|---|---|
externalId (path) | string | Yes | YOUR id for that customer — whatever your own system calls them. It is what makes linking idempotent, and it is scoped to your partner programme: another partner's id is a 404… |
Idempotency-Key (header) | string | No | Client-generated key that makes an unsafe request replay-safe: the server stores the first response and returns it verbatim for repeats. |
Request body
| Name | Type | Required | What it is |
|---|---|---|---|
subscription_id | string | Yes | The customer's hosting subscription the site moves onto — a subscription_id from listPartnerHostingSlots. |
domain | string | Yes | The hostname the site serves on, apex or subdomain. Its DNS stays where it is. |
app | string | Yes | What the site is built with: wordpress, php (a plain PHP or static-HTML document root) or an application key the plan lists in site_moves.plans[].apps. |
php_version | string | No | Optional PHP version, e.g. 8.3. Omit for the plan's default. |
archive_url | string | Yes | The https link to the archive. Never echoed back. |
archive_sha256 | string | Yes | Lower-case hex SHA-256 of the archive file, checked before it is applied. |
archive_bytes | integer | No | The archive's size in bytes, when you know it. |
replace_site_id | object | No | Import into this empty site instead of taking a new slot — one of listPartnerHostingSlots replaceable_sites. |
source_ref | string | No | Your own id for the site being moved, echoed back on every read. |
dns | PartnerSiteMoveDns | No | Where the domain's zone is held and who changes it. Omitted, it is unknown and the customer-switch path applies (exact records shown and e-mailed, change detected). |
source | object | No | Facts about the source site the post-import fix-ups use. |
Response
| Name | Type | Required | What it is |
|---|---|---|---|
id | string | Yes | — |
status | string<queued, importing, imported, going_live, live, failed, cancelled> | Yes | imported — built and answering on preview_url; switch DNS, then call goLivePartnerSiteMove. live — the domain is served by us over HTTPS; only now delete the original. |
domain | string | Yes | — |
app | string | Yes | — |
subscription_id | string | Yes | — |
v2_site_id | object | Yes | Our site id, once the site exists. |
source_ref | string | Yes | — |
replaced_site | boolean | Yes | Whether the move imported into an existing empty site. |
preview_url | string | Yes | An https address that serves the imported site with no DNS change, or "" until imported. |
origin | object | Yes | What the domain's web record must point at. Publish an A to each a address; aaaa is empty while the box has no IPv6 — publish no AAAA then. |
tls | object | Yes | — |
checks | object | Yes | What we measured on the preview after the import. |
error_code | string | Yes | — |
error | string | Yes | — |
created_at | string | Yes | — |
updated_at | string | Yes | — |
live_at | object | Yes | — |
step | string<queued, creating_site, waiting_for_space, downloading, importing_files, importing_database, checking, ready_to_switch, waiting_for_dns, switching, securing, live, failed, cancelled> | No | Where the move is, finer than status — show it with progress and elapsed_seconds so a long import never looks crashed. There is no size limit and no clock on a move's size… |
progress | object | No | Measured on the hosting box while it applies the archive. null means not known yet — never zero. |
started_at | string | No | — |
step_started_at | object | No | — |
elapsed_seconds | integer | No | — |
retryable | boolean | No | — |
dns_case | string<partner_switch, customer_switch, unknown> | No | — |
dns_holder | string | No | — |
switch | object | No | EXACTLY what to publish for the domain to be served by us. |
rollback_advised | boolean | No | Your switch did not take within the attempt: restore the records you saved in dns.current. The original never stopped serving. |
waiting_since | object | No | — |
reminders_sent | integer | No | — |
extra_disk_mb | integer | No | Free disk space (MB) this move gave the site on top of its plan so the move could land whatever its size (D28455). 0 when the plan's own space was enough. Always present. |
extra_disk_expires_at | object | No | When extra_disk_mb ends: 7 days after live_at (D28457). null while the move is still running (the extra is open-ended until it goes live) or when there is no extra.… |
Errors this endpoint can return
401 · 403 · 404 · 422 · 429