# Migrate from Resend

> Move from Resend to Pidgely - switch to the Pidgely SDK (same method names), move domains, webhooks, contacts and suppressions, and the differences to know.

Source: https://docs.pidgely.com/guides/migrate-from-resend/

Pidgely serves the Resend REST API at the same paths, with no version prefix. Requests, responses, status codes, the error body (`{"statusCode","name","message"}`), the `Idempotency-Key` header and cursor pagination all match. To migrate, switch to the Pidgely SDK for your language (same method names) and swap the API key; your code barely changes.

## Step by step

1. **Create an account and an API key.** Pidgely keys look like `pdg_xxxxxxxx_xxxx…`; the SDKs don't check the `re_` prefix.
2. **Add your domain** ([`POST /domains`](https://docs.pidgely.com/api-reference/domains/create/) or the dashboard) and publish the records it returns: DKIM `TXT`, the return-path `MX` + SPF `TXT`, and optionally DMARC. The return path defaults to `bounces.<domain>`; pass `"custom_return_path": "send"` to reuse the label you had on Resend. You can keep the Resend records in place during the switch: the DKIM selectors differ, so both coexist.
3. **Verify** the domain ([`POST /domains/{id}/verify`](https://docs.pidgely.com/api-reference/domains/verify/)).
4. **Switch your code** to the [Pidgely SDK](#switch-to-the-pidgely-sdk-recommended) (or, temporarily, point the Resend SDK at Pidgely) and deploy.
5. **Recreate webhooks** with [`POST /webhooks`](https://docs.pidgely.com/api-reference/webhooks/create/) and update the signing secret in your receiver. Verification code doesn't change.
6. **Import contacts and segments** if you use broadcasts ([`POST /contacts`](https://docs.pidgely.com/api-reference/contacts/create/) is an upsert, so re-running an import is safe), and copy your suppression list ([`POST /suppressions/batch/add`](https://docs.pidgely.com/api-reference/suppressions/batch-add/)) so bounced and unsubscribed addresses stay suppressed.
7. **Recreate templates** and publish them; aliases keep your send code unchanged.

## Switch to the Pidgely SDK (recommended)

The official [Pidgely SDKs](https://docs.pidgely.com/sdks/) mirror the Resend SDKs' method names, arguments and `{ data, error }` results, so switching is a change of package, import and key:

```diff
- import { Resend } from 'resend';
- const resend = new Resend(process.env.RESEND_API_KEY);
+ import { Pidgely } from 'pidgely';
+ const resend = new Pidgely(process.env.PIDGELY_API_KEY);

  const { data, error } = await resend.emails.send({ from, to, subject, html });
```

1. `npm uninstall resend && npm install pidgely` (or the [package for your language](https://docs.pidgely.com/sdks/)).
2. Rename the import and swap the `re_…` key for your `pdg_…` key.
3. If you verify webhooks with the SDK, `await` the `verify` call (it is async in the Pidgely SDK).

You also get Pidgely additions such as automatic retries, `emails.attachments`, `apiKeys.update`, `broadcasts.cancel/duplicate` and `suppressions.*`.

## Keep the Resend SDK for now (base URL per SDK)

If you can't change dependencies yet, the official Resend SDKs also work against Pidgely: set their base URL to `https://api.pidgely.com` and use a Pidgely key. Plan to switch to the Pidgely SDK afterwards.

| SDK | Override | Example |
|---|---|---|
| Node.js (`resend`) | env `RESEND_BASE_URL`, or `new Resend(key, { baseUrl })` | `RESEND_BASE_URL=https://api.pidgely.com RESEND_API_KEY=pdg_… node app.js` |
| Python (`resend`) | env `RESEND_API_URL`, or `resend.api_url = "…"` | `resend.api_url = "https://api.pidgely.com"` |
| Go (`resend-go`) | env `RESEND_BASE_URL` (read at init), or `client.BaseURL, _ = url.Parse("https://api.pidgely.com/")` | the trailing slash matters when you set `BaseURL` directly |
| PHP / Laravel (`resend-php`, `resend-laravel`) | env `RESEND_BASE_URL` (host or full URL) | `RESEND_BASE_URL=https://api.pidgely.com/` |
| Ruby / Rails (`resend`) | env `RESEND_BASE_URL` (read at load time) | `RESEND_BASE_URL=https://api.pidgely.com/` |
| Rust (`resend-rs`) | env `RESEND_BASE_URL`, or `ConfigBuilder::new(key).base_url(url)` | `.base_url("https://api.pidgely.com".parse()?)` |
| Java (`resend-java`) | `Resend.builder().apiKey(key).baseUrl("https://api.pidgely.com").build()` | |
| .NET (`Resend`) | `ResendClientOptions.ApiUrl` | `services.Configure<ResendClientOptions>(o => { o.ApiUrl = "https://api.pidgely.com"; o.ApiToken = key; });` |
| Elixir (community `resend` package) | not verified: check your package version for a base URL option, or call the API with `Req`/`Finch` | |
| cURL / raw HTTP | replace `https://api.resend.com` | `curl https://api.pidgely.com/emails -H "Authorization: Bearer pdg_…"` |

## SMTP

| Resend | Pidgely |
|---|---|
| host `smtp.resend.com` | `smtp.pidgely.com` |
| ports 465/2465 (TLS), 587/2587 (STARTTLS) | same |
| user `resend`, password = API key | user `pidgely` (`resend` also works), password = API key |
| `Resend-Idempotency-Key` header | same, or `Pidgely-Idempotency-Key` |

See [SMTP relay](https://docs.pidgely.com/guides/smtp/).

## Webhooks

Webhooks created through `POST /webhooks` use Resend's event names (`email.sent`, `email.delivered`, `email.delivery_delayed`, `email.bounced`, `email.complained`, `email.failed`, `email.suppressed`, `domain.updated`, `contact.created/updated/deleted`) and Resend's payload (`{type, created_at, data: {email_id, to[], …}}`). They are signed with the same Svix / Standard Webhooks scheme: `svix-id`, `svix-timestamp` and `svix-signature` headers, plus the `webhook-*` equivalents. `resend.webhooks.verify(…)` and the Svix libraries therefore work with your `whsec_…` secret. See [Webhooks](https://docs.pidgely.com/guides/webhooks/).

## Supported resources

| Resource | Endpoints | Notes |
|---|---|---|
| Emails | send, batch (`x-batch-validation: strict\|permissive`), retrieve, list, update (`scheduled_at`), cancel, attachments (list/retrieve) | `scheduled_at` accepts ISO 8601 and natural language (`in 1 hour`, `tomorrow at 9am`). Attachments: base64 `content` or remote `path` (fetched with SSRF protection, 40 MB budget). `react` is rendered by the Node SDK as usual. |
| Domains | create, list, retrieve, update (`tls`), delete, verify | `tls: enforced` is honoured by the MTA. `region` is informative. |
| API keys | create (`full_access`/`sending_access`, `domain_id`), list, update (name), delete | |
| Webhooks | create, list, retrieve, update, delete, rotate signing secret | |
| Suppressions | add, batch add/remove, list (`origin`), retrieve/remove by id or email | Adds the `unsubscribe` origin. |
| Contacts | create, list (`segment_id`), retrieve/update/delete by id or email, segment membership | `unsubscribed` stays in sync with the suppression list both ways. |
| Segments | create, list, retrieve, update, delete, list contacts | |
| Audiences (legacy) | create, list, retrieve, delete, audience-scoped contacts | Aliases of segments over global contacts. |
| Broadcasts | create (`send`, `scheduled_at`), list, retrieve, update (drafts), delete, send, cancel, duplicate | `{{{FIRST_NAME\|there}}}`, `{{{contact.<property>}}}`, `{{{RESEND_UNSUBSCRIBE_URL}}}` and the alias `{{{PIDGELY_UNSUBSCRIBE_URL}}}`; RFC 8058 one-click unsubscribe on every message. |
| Templates | create, list, retrieve, update, delete, publish, duplicate; `template: {id, variables}` in send | Draft plus published versions; typed variables with fallbacks. |

## Not supported yet

Each of these fails loudly rather than silently, so a migration can't misbehave unnoticed:

- **Open and click tracking.** `open_tracking`/`click_tracking` are accepted and reported as `false`. `email.opened`/`email.clicked` webhooks can be created but never fire.
- **Topics.** A non-empty `topic_id` or `topics` is rejected with 422.
- **Inbound and receiving.** Inboxes, received emails, `capabilities.receiving: enabled` (rejected) and `email.received`.
- **Other endpoints.** Automations, events, contact properties as a separate resource, contact imports, logs, OAuth, `POST /emails/{id}/share`, `GET /emails/metrics`, broadcast recipients and clicked links, domain claims, and webhook event history and replay.

## Differences worth knowing

- Delete endpoints return `200` with `{"object","id","deleted":true}`, as Resend does. The native `/v1` API returns `204`.
- Validation errors are `422 validation_error`. A From domain that is not verified, a plan limit, or a paused account returns `403 validation_error`. A `sending_access` key on a full-access endpoint returns `401 restricted_api_key`.
- `GET /emails/{id}` reads from the primary database, so it is consistent immediately after a send. List endpoints may use a read replica.
- `Idempotent-Replayed: true` marks a replayed idempotent response.
- `email.sent` means **accepted and queued**; `email.delivered` confirms the receiving server accepted it.
- Suppressions apply to every stream: an unsubscribe also stops transactional mail to that address.
