Sendry Docs
API Reference

Migrate

Import domains, audiences, contacts and suppressions 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.

Supported sources: resend and sendgrid. mailgun and postmark are reserved as future source values; sending them today returns 422 unsupported_source.

What each source brings across:

SourceImportsDoes not import
resendDomains, audiences, contactsAPI keys, webhooks, suppressions (no public API), templates (React Email needs no migration)
sendgridMarketing Campaigns contacts (one Sendry audience per SendGrid list), marketing custom fields, suppressions (bounces, blocks, spam reports, global unsubscribes)API keys, webhooks, authenticated domains, templates

See Migrating from SendGrid for the end-to-end walkthrough.


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."
      ]
    },
    {
      "id": "sendgrid",
      "label": "SendGrid",
      "credential_field": {
        "name": "credential",
        "label": "SendGrid API key",
        "placeholder": "SG.…",
        "help": "Create a key at Settings → API Keys in SendGrid with Restricted Access, and grant read access to Marketing (contacts, lists, custom fields) and Suppressions. We only read from your account, we never send mail or modify resources."
      },
      "notes": ["…"]
    }
  ]
}

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: resend or sendgrid.
credentialstringYesThird-party API token (≥ 8 chars). Sealed before storage, used only once.

The credential is validated against the source before the job is created, with a single cheap authenticated call (GET /api-keys for Resend, GET /v3/scopes for SendGrid). A bad key returns 401 immediately rather than failing later inside the scan worker.

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
}

Response (after a successful SendGrid scan)

{
  "id": "mig_def456",
  "source": "sendgrid",
  "status": "ready_for_review",
  "scan_result": {
    "lists": [
      { "id": "__all__", "name": "All SendGrid contacts", "contact_count": 8210 },
      { "id": "1a2b3c4d-0000-0000-0000-000000000001", "name": "Newsletter", "contact_count": 5120 }
    ],
    "suppressions": [
      { "kind": "bounces", "count": 412, "reason": "hard_bounce" },
      { "kind": "blocks", "count": 37, "reason": "hard_bounce" },
      { "kind": "spam_reports", "count": 9, "reason": "complaint" },
      { "kind": "unsubscribes", "count": 1183, "reason": "unsubscribe" }
    ],
    "custom_fields": [
      { "id": "e1_T", "name": "plan_tier", "field_type": "Text" }
    ],
    "contact_count": 8210,
    "errors": []
  },
  "import_options": null,
  "import_result": null,
  "error_message": null,
  "created_at": "2026-09-05T14:00:00Z",
  "updated_at": "2026-09-05T14:00:41Z",
  "completed_at": null
}

lists always begins with the synthetic __all__ entry, which imports every contact in the account into one audience. It is there so accounts that keep no marketing lists still have something to select. Every other entry is a real SendGrid marketing list and becomes a Sendry audience of the same name.

A suppression count carrying "approximate": true was capped during the scan and is a lower bound; the import itself is never capped.

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

Resend options:

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.

SendGrid options:

FieldTypeRequiredDescription
options.listsstring[]NoMarketing list ids to import, one Sendry audience per list. Pass "__all__" to import every contact into a single audience. Defaults to all scanned lists.
options.suppressionsstring[]NoWhich suppression lists to import: any of bounces, blocks, spam_reports, unsubscribes. Defaults to all but blocks.
options.custom_fieldsbooleanNoCopy marketing custom fields onto each contact's metadata. Defaults to true.
options.contactsbooleanNoSet false to import suppressions only and skip contacts entirely.

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
    }
  }'

Example request (SendGrid)

curl -X POST https://api.sendry.online/v1/migrate/mig_def456/approve \
  -H "Authorization: Bearer $SENDRY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "options": {
      "lists": ["1a2b3c4d-0000-0000-0000-000000000001"],
      "suppressions": ["bounces", "spam_reports", "unsubscribes"],
      "custom_fields": 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.
  • For SendGrid, import_result.completed_lists records every list whose contact export already finished, so a retry resumes at the next list instead of re-running an export that already succeeded. Suppression rows are written with a conflict-do-nothing insert, so re-running them adds no duplicates and no double counts.
  • Retrying a job that stopped at limit_reached clears the flag and continues, which is what you want after upgrading your plan.

Response

Returns the updated migration job.



Import result

{
  "imported": { "domains": 0, "audiences": 2, "contacts": 8210, "suppressions": 1604 },
  "skipped": [
    { "kind": "audience", "name": "Newsletter", "reason": "matched existing audience, contacts will be added to it" }
  ],
  "errors": [],
  "limit_reached": false,
  "completed_lists": ["__all__", "1a2b3c4d-0000-0000-0000-000000000001"]
}

imported.suppressions counts rows actually written; addresses already on your suppression list are not counted again.

limit_reached is true when your plan's contact limit stopped the contact phase part-way. The job still reaches completed and everything already imported is kept: the flag is what tells you the result is partial rather than final. Upgrade your plan and call retry to bring the rest across.


SendGrid suppression mapping

Sendry's suppression list has four reasons. The four SendGrid lists map onto them like this:

SendGrid listEndpointSendry reasonSelected by default
BouncesGET /v3/suppression/bounceshard_bounceYes
BlocksGET /v3/suppression/blockshard_bounceNo
Spam reportsGET /v3/suppression/spam_reportscomplaintYes
Global unsubscribesGET /v3/suppression/unsubscribesunsubscribeYes

Blocks are excluded by default because a block is usually the receiving server refusing a message for reputation reasons, which is frequently temporary; importing them would permanently suppress addresses that are probably still reachable. Tick them if you would rather be conservative.

Sendry stores one suppression row per address, so when an address is on more than one SendGrid list the strongest reason wins: complaint over hard_bounce over unsubscribe.

Addresses on the global unsubscribe list are also flagged unsubscribed on the contact record itself, so an opt-out survives the move in both places.


SendGrid contact field mapping

SendGrid export columnSendry contact field
EMAILemail (lowercased and trimmed)
FIRST_NAMEfirst_name
LAST_NAMElast_name
CONTACT_IDmetadata.sendgrid_contact_id
PHONE_NUMBER, WHATSAPP, LINE, FACEBOOK, ALTERNATE_EMAILS, ADDRESS_LINE_1, ADDRESS_LINE_2, CITY, STATE_PROVINCE_REGION, POSTAL_CODE, COUNTRY, EXTERNAL_ID, UNIQUE_NAMEmetadata.<same name>
Marketing custom fieldsmetadata.<custom field name>, typed by the field's declared Text / Number / Date type
CREATED_AT, UPDATED_AT, ANONYMOUS_IDDropped: in Sendry these mean "created here" and would mislead

Header names are matched case-insensitively with separators folded, so EMAIL, email and First Name all resolve. Metadata values are strings (dates normalized to ISO 8601) and clamped to the contact model's limits: keys of 100 characters or fewer, values of 1000 characters or fewer.

Contacts are pulled through SendGrid's asynchronous export API: Sendry starts an export job, polls it with exponential backoff, downloads the resulting files (gzip is detected and inflated), and parses the CSV. One export runs per selected list, so each row's audience is unambiguous.


Errors

StatusCodeWhen
401unauthorizedMissing or invalid API key
401invalid_resend_credentialResend rejected the provided credential
401invalid_sendgrid_credentialSendGrid rejected the provided credential
429sendgrid_rate_limitedSendGrid rate-limited the scan or import. Retry shortly.
502sendgrid_errorSendGrid returned an error during the scan or import
502sendgrid_unreachableSendGrid could not be reached
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 (mailgun, postmark)
422validation_errorRequest body failed schema validation (missing fields, credential too short, etc.)

On this page