Sendry Docs
API Reference

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

FieldTypeRequiredDescription
sourcestringYesSource provider. Must be resend in v1.
credentialstringYesThird-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 pendingscanningready_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:

ValueMeaning
pendingScan queued
scanningScan in progress
ready_for_reviewScan complete — scan_result is populated, awaiting approval
importingImport in progress (the user approved)
completedImport finished successfully
failedScan or import failed. See error_message. Retryable.

List migration jobs

GET /v1/migrate

Requires read_only scope.

Query parameters

ParameterTypeDefaultDescription
limitnumber50Results per page (1–100).
cursorstringMigration 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

FieldTypeRequiredDescription
options.domainsstring[]NoResend domain names to import. Defaults to all from the scan result.
options.audiencesstring[]NoResend audience IDs to import. Defaults to all.
options.contactsbooleanNoImport 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 persisted errors array 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

StatusCodeWhen
401unauthorizedMissing or invalid API key
401invalid_resend_credentialResend rejected the provided credential
403forbiddenAPI key lacks the required scope
404not_foundNo migration job with that id exists in your organization
409invalid_statusJob is not in the right status for the requested action (approve / cancel / retry)
422unsupported_sourcesource is reserved but not yet supported (sendgrid, mailgun, postmark)
422validation_errorRequest body failed schema validation (missing fields, credential too short, etc.)

On this page