> ## Documentation Index
> Fetch the complete documentation index at: https://ahasend.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> AhaSend stores all message data in the EU. See /facts.
> The API base URL is https://api.ahasend.com. Send the API key in the Authorization: Bearer header. See /api-reference/authentication.
> Use send.ahasend.com for SMTP in the EU, or send-us.ahasend.com as a US connection point forwarding to the EU. Ports 25, 587 and 2525 use STARTTLS. Port 465 is not supported. See /smtp.
> For send-only integrations, create an API Key v2 under Credentials → Add and scope it to messages:send:{your-domain}. See /send-api/credentials and /api-reference/scopes.

# Migrate from Mailgun

> Migrate from Mailgun to AhaSend with API field and SMTP setting maps, webhook event changes, suppression import steps and a gradual traffic switch.

AhaSend migration from Mailgun means changing your send call or SMTP settings, mapping delivery events and carrying blocked recipients across before moving traffic.

## Prepare the AhaSend Account

[Verify the sending domain](/docs/domains) and create a domain-scoped [send-only v2 key](/docs/send-api/credentials) or [SMTP credential](/docs/smtp/credentials). Keep the old provider active while you test. Record your existing templates, headers, tracking choices, sender addresses, jobs and suppression rules so you can compare behavior.

## Map the API Fields

Use the [AhaSend Create Message endpoint](/docs/api-reference/messages/create-message) with [v2 authentication](/docs/api-reference/authentication). This is a field mapping from [Mailgun's send API](https://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/messages/post-v3--domain-name--messages), not a drop-in change of base URL.

| Mailgun field               | AhaSend v2 field or change                                                                          |
| --------------------------- | --------------------------------------------------------------------------------------------------- |
| `from` string               | `from` object with `email` and optional `name`                                                      |
| `to` strings                | `recipients[]` address objects                                                                      |
| `subject`, `text`, `html`   | `subject`, `text_content`, `html_content`                                                           |
| `h:Reply-To`                | `reply_to` address object                                                                           |
| `o:tag`                     | `tags` array                                                                                        |
| `recipient-variables`       | Adapt to `recipients[].substitutions`; review template syntax                                       |
| `attachment` multipart data | JSON `attachments[]` with `file_name`, `content_type`, `data` and appropriate `base64`              |
| `o:deliverytime`            | `schedule.first_attempt` as RFC3339 within AhaSend's schedule window                                |
| `o:testmode`                | `sandbox: true`                                                                                     |
| `cc`, `bcc`                 | [Create Conversation](/docs/api-reference/messages/create-conversation) for visible To/CC and hidden BCC |

AhaSend Create Message returns HTTP 202 with a result for each recipient; check each `status`, including `error`, instead of treating the request as all-or-nothing. Follow [send examples](/docs/send-api/send-email) for an actual sandbox call. Keep a stable business-event [idempotency key](/docs/api-reference/idempotency) with an unchanged payload and separate keys for sandbox and live work. Review [schedule limits](/docs/send-api/send-email) before moving future jobs.

## Replace the SMTP Settings

Replace your Mailgun region's SMTP host and domain SMTP credentials with AhaSend's host and a new AhaSend SMTP credential. Do not reuse a Mailgun API key or domain password. Review region-specific source settings in [Mailgun's SMTP guide](https://documentation.mailgun.com/docs/mailgun/user-manual/sending-messages/send-smtp).

| Setting                   | AhaSend value                                                                                       |
| ------------------------- | --------------------------------------------------------------------------------------------------- |
| Host                      | `send.ahasend.com`                                                                                  |
| Port in this migration    | `587`                                                                                               |
| Encryption                | STARTTLS                                                                                            |
| Username and password     | A new AhaSend SMTP credential; use sandbox credentials for tests                                    |
| Provider-specific headers | Replace with supported [AhaSend headers](/docs/smtp/special-headers) only where they serve the same task |

See [SMTP settings](/docs/smtp) for all hosts, ports, TLS requirements and limits. Keep your existing mail library and change its transport configuration; use the [language examples](/docs/smtp) to test.

## Map Events by Meaning

These are the closest matching outcomes, not identical payloads or a guaranteed one-to-one event sequence. Keep the provider name with each saved message ID while both systems are active. See [Mailgun's event documentation](https://documentation.mailgun.com/docs/mailgun/user-manual/events/event-types) and the [AhaSend event list](/docs/api-reference/webhooks).

| Mailgun event                    | AhaSend event or application handling                                                         |
| -------------------------------- | --------------------------------------------------------------------------------------------- |
| `accepted`                       | `message.reception`                                                                           |
| `delivered`                      | `message.delivered`                                                                           |
| `failed` with temporary severity | `message.transient_error`                                                                     |
| `failed` with permanent severity | `message.bounced`, `message.failed` or `message.suppressed`, depending on the cause           |
| `opened` / `clicked`             | `message.opened` / `message.clicked` when tracking is enabled                                 |
| `complained`                     | `suppression.created` with the complaint reason                                               |
| `unsubscribed`                   | Preserve the unsubscribe in your application and the appropriate suppression                  |
| `rejected`                       | Check send-response errors and final failure events; there is no exact event-name replacement |

Create a separate AhaSend webhook configuration and replace the old verification code with [AhaSend signature checks](/docs/api-reference/webhooks/security). Parse AhaSend's payload shape, deduplicate by its delivery header, and acknowledge verified unknown event types with 2xx. Do not reuse the old provider's signing secret, retry assumptions or event IDs.

## Export the Old Recipient Blocks

Export bounces, complaints and unsubscribes for every Mailgun sending domain you are moving. Preserve Mailgun unsubscribe tags or list scopes in your app, then decide which blocks should apply to a whole AhaSend sending domain. Use the list operations in Mailgun's [Bounces API](https://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/bounces), [Complaints API](https://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/complaints) and [Unsubscribe API](https://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/unsubscribe). Follow pagination rather than assuming the first page contains the full list.

## Import Suppressions Before Switching Traffic

Export blocked addresses using the old provider's options described above. Include hard bounces, complaints, unsubscribes and application-level opt-outs; deduplicate by address and sending domain without losing the reason. Keep the original export and your application's durable preferences. A provider change is not permission to mail an opted-out address again.

Normalize the export into a CSV with `email,domain,reason,expires_at` columns. `domain` is the **AhaSend sending domain**, not the recipient's domain. This sample row is a format template; replace both placeholders. Select an expiry that preserves your block policy, and keep permanent opt-outs blocked in your own app beyond that time. See [suppression durations](/docs/bounces#suppression-duration).

```csv suppressions.csv theme={null}
email,domain,reason,expires_at
blocked@example.com,YOUR_SENDING_DOMAIN,Migrated unsubscribe,RFC3339_FUTURE_EXPIRY
```

Use Python 3.13 or newer; the script needs no third-party packages. Set `AHASEND_ACCOUNT_ID` and `AHASEND_IMPORT_KEY`, a separate [full API key](/docs/api-reference/authentication) with `suppressions:write`. This is a real management operation: sandbox mode does not apply to suppressions. First test with a unique address on your test sending domain, then verify it with [List Suppressions](/docs/api-reference/suppressions/get-suppressions).

Save the following as `import_suppressions.py`. It validates the whole CSV before making changes and stops on the first failed request. It requires an explicit domain so an empty cell cannot broaden the import to all domains.

```python import_suppressions.py theme={null}
import csv, hashlib, json, os, sys, time, urllib.error, urllib.request
from datetime import datetime, timezone
with open(sys.argv[1], newline="", encoding="utf-8-sig") as source:
    rows = list(csv.DictReader(source))
fields = ("email", "domain", "reason", "expires_at")
for number, row in enumerate(rows, 1):
    if any(not row.get(field, "").strip() for field in fields):
        raise SystemExit(f"Row {number}: fill every required column")
    expiry = datetime.fromisoformat(row["expires_at"].replace("Z", "+00:00"))
    if expiry.tzinfo is None or expiry <= datetime.now(timezone.utc):
        raise SystemExit(f"Row {number}: use a future expiry with a timezone")
url = f"https://api.ahasend.com/v2/accounts/{os.environ['AHASEND_ACCOUNT_ID']}/suppressions"
for number, row in enumerate(rows, 1):
    payload = {field: row[field].strip() for field in fields}
    encoded = json.dumps(payload, sort_keys=True).encode()
    request = urllib.request.Request(url, encoded, headers={
        "Authorization": f"Bearer {os.environ['AHASEND_IMPORT_KEY']}",
        "Content-Type": "application/json",
        "Idempotency-Key": "import-" + hashlib.sha256(encoded).hexdigest(),
    })
    try:
        with urllib.request.urlopen(request, timeout=15) as response:
            result = json.load(response)
            if response.status != 201 or not result.get("data"):
                raise ValueError("Unexpected import result")
    except (urllib.error.URLError, TimeoutError, ValueError):
        raise SystemExit(f"Stopped at row {number}; inspect it before retrying") from None
    print(f"Imported row {number}")
    time.sleep(0.05)
```

Run `python import_suppressions.py suppressions.csv`. An existing suppression can return 409: compare its domain, reason and expiry before deciding what to do. Do not delete it just to make the import pass. A stopped run may have imported earlier rows, so keep the progress output and reconcile uncertain requests. Exact retries reuse a stable [idempotency key](/docs/api-reference/idempotency); after its time limit, verify existing records before retrying. Keep imports within [API rate limits](/docs/api-reference/rate-limits).

## Switch Traffic in Small Steps

1. Verify the AhaSend domain and leave the old provider's required DNS in place while it still sends. Test the new code in [sandbox mode](/docs/send-api/sandbox), including failure and suppression paths.
2. Keep your message templates in your app or adapt them explicitly. Provider template IDs, recipient lists, request IDs and signing secrets do not transfer to AhaSend automatically.
3. Import suppressions, then update your saved recipient preferences. Repeat the export/import for changes made since the initial snapshot before moving each traffic group.
4. Route each new job to exactly one provider. Keep that choice, the saved payload and your application event ID with the job so a retry cannot silently switch providers and duplicate it.
5. Move a small group of expected mail, compare acceptance, bounces and delivery delays, and increase only when results remain stable. Follow [deliverability and warm-up guidance](/docs/deliverability).
6. Keep receiving old-provider events for in-flight messages. If you roll back, route only new unsent jobs back and preserve all new suppressions. Retire old credentials, DNS and event handlers after old work has finished.

## Related Guides

* [Domain setup](/docs/domains), [DNS providers](/docs/dns), [send-only API keys](/docs/send-api/credentials) and [SMTP credentials](/docs/smtp/credentials).
* [Send through the API](/docs/send-api/send-email), [SMTP settings](/docs/smtp), [Node.js SDK](/docs/guides/nodejs-sdk), [Go SDK](/docs/guides/go-sdk) and [CLI quickstart](/docs/cli/quickstart).
* [Webhook setup](/docs/integrations/webhooks), [signature verification](/docs/api-reference/webhooks/security), [bounces and suppression durations](/docs/bounces), [API errors](/docs/api-reference/errors) and [idempotency](/docs/api-reference/idempotency).
