Skip to content

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_import
append_customer_import_rows × N
finalize_customer_import ──manifest──▶ Customer imports → Approve / Reject
get_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.

begin_customer_import
{
"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.

append_customer_import_rows
{
"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:

finalize_customer_import
{ "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:

get_customer_import
{ "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.

cancel_customer_import
{ "customer_import": "cim_9f2Kq7LmNp3RsTuVwXyZa1Bc" }

Things agents trip on

  • declared_count is 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 (or import_key) with a different payload is idempotency_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_import again after a decision answers state_conflict; use get_customer_import.
  • Consent and exemptions cannot be imported. consent_authorised, payment_amount_limit_exempt, fee_preferences, notification_preferences, status, and test_clock are 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.