Migrate Customers with an Agent
Moving a customer base off another platform is the job an agent is best placed to do and the one a merchant is least willing to let it do unsupervised. The customer-import tools split the difference: the agent prepares the complete, validated dataset; the merchant approves an immutable manifest of exactly that dataset; what was approved is what runs; and only the merchant can stop it or roll it back.
Sandbox-only today. The five tools exist on the sandbox server and are
refused for anything but a sandbox OAuth connection —
the merchant’s approval binds to the connected application, and a raw
sk_sandbox_ key identifies no application. Live enablement is gated on a
separate release; a non-sandbox account answers sandbox_only. Parameters,
states, limits, and error codes are in the
tool reference.
The shape of it
agent merchant (dashboard)───── ────────────────────begin_customer_importappend_customer_import_rows × Nfinalize_customer_import ──manifest──▶ Customer imports → Approve / Rejectget_customer_import (poll) ◀──────── (Stop while running) ◀──────── (Roll back afterwards)Approval is not an API call, and there is no tool for it. The agent’s
last write is finalize_customer_import; from there it can only poll, or
withdraw the proposal before a decision is made.
1. Begin
Declare the exact number of rows you will stage and where they came from.
declared_count is a budget, not a ceiling: finalisation refuses unless the
staged total matches it exactly, so count the source first.
{ "import_key": "crm-export-2026-09-21", "source_reference": "Q3 CRM export (customers.csv)", "declared_count": 1000, "field_mapping": { "name": "full_name", "email": "email_address" }}field_mapping is display provenance — it tells the merchant which source
column each field came from. It does not transform anything: rows must
already be in the target shape when you append them.
The response gives the cim_… reference, the limits in force, and
staging_expires_at (7 days — a staging import left untouched that long is
cancelled and purged):
{ "customer_import": "cim_9f2Kq7LmNp3RsTuVwXyZa1Bc", "status": "staging", "declared_count": 1000, "limits": { "max_rows_per_chunk": 500, "max_declared_count": 10000 }, "staging_expires_at": "2026-09-28T04:10:00Z", "replayed": false}2. Stage rows in chunks
Append up to 500 rows per call, each chunk under its own chunk_key. Only
the allow-listed fields are accepted;
source_row_key is your stable id for the row and is how every error and
failure is reported back to you.
{ "customer_import": "cim_9f2Kq7LmNp3RsTuVwXyZa1Bc", "chunk_key": "c0", "rows": [ { "source_row_key": "crm-000983", "name": "Aroha Ngata", "email": "aroha.ngata@example.com", "reference": "CRM-983", "business_name": "Ngata Landscaping Ltd", "currency": "NZD", "timezone": "Pacific/Auckland", "sms_phone_raw": "+64211234567", "sms_phone_country": "NZ", "billing_address": { "line1": "12 Karangahape Rd", "city_or_suburb": "Auckland", "postal_code": "1010", "country": "NZ" } }, { "source_row_key": "crm-000984", "name": "Sam Tui", "email": "sam.tui@example.com" } ]}Carry your source system’s customer id as reference. It is a
candidate key
— unique per account, matched without regard to case or surrounding
whitespace — and it is how the payment-plan import will name each plan’s
customer (customer_reference), so the two exports of a migration join on
the id they already share and you never need the cus_… ids of the
customers you just created. A row whose reference is already taken is
reported as invalid rather than creating a duplicate.
Each response reports accepted for the chunk and the running
staged_total. Chunks are atomic and idempotent: if a call times out,
resend it unchanged under the same chunk_key and you get the original
response back with replayed: true. If you need to correct rows, use a
new chunk_key — reusing one with different rows is refused with
idempotency_conflict, so a replay can never quietly mean “your corrections
were staged” when they were not.
Three things are rejected at this step, before anything travels further:
unknown row fields, a source_row_key already staged in this import
(duplicate_source_row_keys, with the keys in the error’s details), and a
chunk that would push the total past declared_count (limit_exceeded).
3. Finalise
Once staged_total equals declared_count, freeze the dataset:
{ "customer_import": "cim_9f2Kq7LmNp3RsTuVwXyZa1Bc" }This validates the complete dataset, computes a content hash over the sorted rows, and proposes the result to the merchant:
{ "customer_import": "cim_9f2Kq7LmNp3RsTuVwXyZa1Bc", "status": "requires_approval", "action": "aoa_7Hj2kLm9NpQr4StUvWxYz3Ab", "dataset_hash": "sha256:0123…cdef", "row_count": 1000, "projection": { "creates": 982, "invalid": 18, "fields_set": ["name", "email", "reference", "business_name", "currency", "timezone", "sms_phone_raw", "sms_phone_country", "billing_address"], "errors_sample": [ { "source_row_key": "crm-001201", "errors": ["Email is invalid"] } ] }, "decide_by": "2026-09-24T00:00:00Z", "replayed": false}Read the projection before you tell your human it is ready. creates is
how many customers approval will create; invalid rows passed shape checks
but fail validation (an email the API rejects, a reference already in use)
and are reported row by row in errors_sample. They stay in the frozen
dataset — the merchant sees them too — and are skipped at execution. If
invalid is not what you expect, cancel_customer_import and begin again
with corrected rows: after finalisation the rows are frozen, and any append
answers post_finalisation_mutation.
Finalisation refuses outright if the staged count does not match
declared_count (declared_count_mismatch) or if no row at all would
create a customer (zero_valid_rows).
4. The merchant approves — in the dashboard
Hand over the import reference and the dataset_hash. In the dashboard,
under Customer imports for the account, the merchant opens the import
and sees the source, the connected application, the dataset fingerprint,
the field sources, the decision deadline, and What approving does: the
number of customers it creates, the fields it sets, and a sample of the
invalid rows. Approve import asks them to confirm creating that many
customers from that source; Reject ends it.
The agent cannot approve, and nothing an agent sends can move the import
past requires_approval. If nobody decides before decide_by (72 hours
from finalisation by default; you may request up to 7 days with
requested_decide_by), the import becomes expired. Silence never
approves.
If the connection is disconnected while the import is awaiting approval,
the proposal is withdrawn (cancelled), and the approval screen refuses to
approve an import whose connection is dead.
5. Poll
Execution starts automatically on approval — there is no separate “run” step and no approved-but-dormant window. Poll until the status is terminal:
{ "customer_import": "cim_9f2Kq7LmNp3RsTuVwXyZa1Bc" }While running the body carries progress:
{ "status": "executing", "progress": { "created": 612, "failed": 3, "remaining": 385 }, "outcome": null, "rollback_action": null}and once finished it carries outcome instead, with up to 20 failures by
source_row_key:
{ "status": "partially_completed", "progress": null, "outcome": { "created": 979, "failed": 21, "failures_sample": [ { "source_row_key": "crm-001201", "errors": ["Email is invalid"] } ] }, "rollback_action": null}completed means every row created a customer; partially_completed and
failed mean some or all did not. A row can be valid at finalisation and
still fail at execution — a reference taken in the meantime, for example —
so the outcome, not the projection, is the record of what happened. Per-row
failures are outcome data: the import still ran, and the customers it did
create exist.
Revoking the connection after approval does not stop execution. The authority is the merchant’s approval, and the merchant holds the stop control; the agent merely loses polling.
What stop and rollback look like from the agent’s side
Both are merchant controls in the dashboard. The agent has no tool for
either and sees each only through get_customer_import.
Stop. While the import is approved or executing, the merchant can
click Stop import. Rows not yet claimed by the execution job never run;
customers already created remain. The next poll answers status: "stopped"
with an outcome whose created + failed falls short of the row count by
exactly the rows that never ran. This is deliberately distinct from
partially_completed: “some rows failed” and “the merchant stopped it” are
different facts, and a stop can land before a single row has run.
Rollback. From completed, partially_completed, failed, or
stopped, the merchant can click Roll back import. This archives the
customers the import created — but only those with no subsequent payments,
mandates, or plans and no merchant edits; every skipped customer is reported
to the merchant with its reason. The next poll answers
status: "rolled_back" with rollback_action naming the compensating
action:
{ "status": "rolled_back", "outcome": { "created": 979, "failed": 21 }, "rollback_action": "aoa_5Fg8hJk2LmNp6QrStUvWxYz1"}One rollback per import; a repeated request replays the existing one. Agents can neither initiate nor propose a rollback today.
Withdrawing
cancel_customer_import withdraws an import while it is staging or
requires_approval — the wrong file, a bad mapping, an invalid count you
are not happy with — instead of leaving a proposal to expire in the
merchant’s queue. Once approved it answers state_conflict: an approval,
once given, is never unwound by the agent that requested it.
{ "customer_import": "cim_9f2Kq7LmNp3RsTuVwXyZa1Bc" }Things agents trip on
declared_countis exact. Count the rows before you begin. Under- or over-staging is refused; there is no “finalise what I have”.- Corrections need new keys. A
chunk_key(orimport_key) with a different payload isidempotency_conflict, never a silent replace. - The dataset is frozen at finalise. To change a single row, cancel and begin a new import.
- The merchant does the approving. Tell your human the import reference
and the fingerprint, then poll. Polling
finalize_customer_importagain after a decision answersstate_conflict; useget_customer_import. - Consent and exemptions cannot be imported.
consent_authorised,payment_amount_limit_exempt,fee_preferences,notification_preferences,status, andtest_clockare rejected as unknown fields. An import creates customers; it asserts nothing on their behalf.
Next: import their plans
With the customers in place and their source ids carried as reference,
the second export of the migration — the recurring schedules — goes through
the same envelope: see
Migrate payment plans with an agent.
Each plan row names its customer by customer_reference, resolved at
finalisation, and every plan is created as a draft that bills nothing until
the customer adds a payment method.