Migrate
Import domains, audiences, and contacts from another email provider into Sendry.
The Migrate API runs a two-phase import: first it scans the source account using a credential you provide, then — after you approve the scan results — it imports the selected resources into your Sendry organization. Credentials are sealed with AES-256-GCM (KMS-wrapped envelope encryption when MIGRATE_KEK_ARN is configured) before being persisted and are never logged.
v1 supports Resend only. sendgrid, mailgun, and postmark are reserved as future source values; sending them today returns 422 unsupported_source.
List supported sources
GET /v1/migrate/sources
Requires read_only scope. The dashboard uses this to render the connect-source form dynamically.
Response
{
"data": [
{
"id": "resend",
"label": "Resend",
"credential_field": {
"name": "credential",
"label": "Resend API key",
"placeholder": "re_…",
"help": "Generate a read-only key at https://resend.com/api-keys. We only read from your account — we never send mail or modify resources."
},
"notes": [
"We will import domains, audiences, and contacts.",
"We do NOT import API keys (security) or webhooks (you'll re-register URLs after migration).",
"Resend does not expose a public suppression-list API. Export it as CSV from your Resend dashboard and upload it under Contacts → Suppression after migration.",
"Templates: Resend templates are React Email — the same code works on Sendry, no migration needed."
]
}
]
}
Start a migration scan
POST /v1/migrate
Requires full_access scope. The credential is validated against the source provider before the scan is enqueued, so a bad token returns immediately instead of failing asynchronously.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
source | string | Yes | Source provider. Must be resend in v1. |
credential | string | Yes | Third-party API token (≥ 8 chars). Sealed before storage, used only once. |
Example request
curl -X POST https://api.sendry.online/v1/migrate \
-H "Authorization: Bearer $SENDRY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"source": "resend",
"credential": "re_xxxxxxxxxxxxxxxx"
}'
Response
{
"id": "mig_abc123",
"source": "resend",
"status": "pending",
"scan_result": null,
"import_options": null,
"import_result": null,
"error_message": null,
"created_at": "2026-05-30T14:00:00Z",
"updated_at": "2026-05-30T14:00:00Z",
"completed_at": null
}
The scan runs asynchronously. Poll GET /v1/migrate/:id (or subscribe via webhooks) until status transitions through pending → scanning → ready_for_review.
Get migration job
GET /v1/migrate/:id
Requires read_only scope.
Response (after a successful scan)
{
"id": "mig_abc123",
"source": "resend",
"status": "ready_for_review",
"scan_result": {
"domains": [
{ "name": "acme.com", "status": "verified" }
],
"audiences": [
{ "id": "aud_resend_1", "name": "Newsletter", "contact_count": 1240 }
],
"errors": []
},
"import_options": null,
"import_result": null,
"error_message": null,
"created_at": "2026-05-30T14:00:00Z",
"updated_at": "2026-05-30T14:00:12Z",
"completed_at": null
}
Status values:
| Value | Meaning |
|---|---|
pending | Scan queued |
scanning | Scan in progress |
ready_for_review | Scan complete — scan_result is populated, awaiting approval |
importing | Import in progress (the user approved) |
completed | Import finished successfully |
failed | Scan or import failed. See error_message. Retryable. |
List migration jobs
GET /v1/migrate
Requires read_only scope.
Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | number | 50 | Results per page (1–100). |
cursor | string | Migration job id from a previous response's next_cursor. |
Response
{
"data": [
{
"id": "mig_abc123",
"source": "resend",
"status": "completed",
"scan_result": { "domains": [], "audiences": [] },
"import_options": { "contacts": true },
"import_result": {
"imported": { "domains": 1, "audiences": 1, "contacts": 1240 },
"skipped": [],
"errors": []
},
"error_message": null,
"created_at": "2026-05-30T14:00:00Z",
"updated_at": "2026-05-30T14:05:33Z",
"completed_at": "2026-05-30T14:05:33Z"
}
],
"has_more": false,
"next_cursor": null
}
Approve a scanned migration
POST /v1/migrate/:id/approve
Requires full_access scope. Transitions a ready_for_review job to importing and enqueues the importer worker. Returns 409 invalid_status if the job is in any other status.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
options.domains | string[] | No | Resend domain names to import. Defaults to all from the scan result. |
options.audiences | string[] | No | Resend audience IDs to import. Defaults to all. |
options.contacts | boolean | No | Import audience contacts. Defaults to true. |
Example request
curl -X POST https://api.sendry.online/v1/migrate/mig_abc123/approve \
-H "Authorization: Bearer $SENDRY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"options": {
"domains": ["acme.com"],
"audiences": ["aud_resend_1"],
"contacts": true
}
}'
Response
Returns the updated migration job (now status: "importing").
Cancel an in-flight migration
POST /v1/migrate/:id/cancel
Requires full_access scope. Marks the job failed with error_message: "cancelled". Returns 409 invalid_status if the job is already in a terminal state (completed / failed).
Response
{
"id": "mig_abc123",
"status": "failed",
"error_message": "cancelled",
"completed_at": "2026-05-30T14:03:11Z",
"...": "..."
}
Retry a failed migration
POST /v1/migrate/:id/retry
Requires full_access scope. Only failed jobs can be retried.
- If the job failed during scan, it restarts from
pending. - If the job failed during import (after approval), it resumes from
importing. The importer is idempotent: each domain / audience / contact is checked against existing Sendry resources and skipped or linked accordingly. The persistederrorsarray is reset so deterministic per-row failures don't accumulate across retries; previously-imported counts and skipped entries are preserved.
Response
Returns the updated migration job.
Errors
| Status | Code | When |
|---|---|---|
| 401 | unauthorized | Missing or invalid API key |
| 401 | invalid_resend_credential | Resend rejected the provided credential |
| 403 | forbidden | API key lacks the required scope |
| 404 | not_found | No migration job with that id exists in your organization |
| 409 | invalid_status | Job is not in the right status for the requested action (approve / cancel / retry) |
| 422 | unsupported_source | source is reserved but not yet supported (sendgrid, mailgun, postmark) |
| 422 | validation_error | Request body failed schema validation (missing fields, credential too short, etc.) |