Migration guide
Migrate from SendGrid to Mail.Land
Often the change is one SMTP host or one API call. This guide maps the common SendGrid fields to Mail.Land and lists the differences that need a code change.
# Before (SendGrid)
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASS=<your SendGrid API key>
# After (Mail.Land)
SMTP_HOST=smtp.mail.land
SMTP_PORT=587 # STARTTLS
SMTP_USER=apikey # any value; Mail.Land ignores it
SMTP_PASS=<your Mail.Land API key with SMTP enabled>SendGrid concepts in Mail.Land
| SendGrid | Mail.Land |
|---|---|
| API key | API key (credential), limited to the API or SMTP, to domains and to IP addresses |
| Sender authentication | Domain verification (SPF, DKIM, DMARC and return path) |
| Dynamic templates | Templates with {{name}} placeholders |
| Suppressions and unsubscribe groups | Suppressions with a scope: all mail, marketing only, or one mailing list |
| Event Webhook | Webhooks signed with HMAC-SHA256 |
| Inbound Parse | Inbound routes |
Switch SMTP: change three settings
If you send through SMTP, change the host, the port and the password. The username can stay apikey, because Mail.Land ignores it and authenticates with the key alone.
| Setting | SendGrid | Mail.Land |
|---|---|---|
| Host | smtp.sendgrid.net | smtp.mail.land |
| Port | 25, 587 or 2525 (TLS), or 465 (SSL) | 587 with STARTTLS |
| Username | apikey | Any value, for example apikey |
| Password | Your SendGrid API key | A Mail.Land API key with SMTP enabled |
Mail.Land accepts sign-in only after STARTTLS, and port 465 is not offered. If your application uses port 465, switch it to 587 with STARTTLS.
Switch the API call
The Mail.Land send call uses a flatter JSON body and the X-API-Key header. Here is the same message in both APIs.
curl -X POST https://api.sendgrid.com/v3/mail/send \
-H "Authorization: Bearer $SENDGRID_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"personalizations": [{
"to": [{ "email": "[email protected]", "name": "Jane" }],
"dynamic_template_data": { "orderNumber": "12345" }
}],
"from": { "email": "[email protected]", "name": "Your Shop" },
"reply_to": { "email": "[email protected]" },
"subject": "Order 12345 has shipped",
"content": [
{ "type": "text/plain", "value": "Your order is on its way." },
{ "type": "text/html", "value": "<p>Your order is on its way.</p>" }
],
"categories": ["order-shipped"]
}'curl -X POST https://api.mail.land/v1/transactional/send \
-H "X-API-Key: $MAILLAND_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": [{
"email": "[email protected]", "name": "Jane",
"variables": { "orderNumber": "12345" }
}],
"from": { "email": "[email protected]", "name": "Your Shop" },
"replyTo": "[email protected]",
"subject": "Order 12345 has shipped",
"textContent": "Your order is on its way.",
"htmlContent": "<p>Your order is on its way.</p>",
"tags": ["order-shipped"]
}'Field mapping for the send call
From POST /v3/mail/send to POST /v1/transactional/send.
| SendGrid | Mail.Land | Note |
|---|---|---|
Authorization: Bearer <key> | X-API-Key: <key> | A different header. |
from.email, from.name | from.email, from.name | The address must be on a verified domain. |
personalizations[].to[] | to[] | Up to 5 recipients per request with a transactional key. For bulk sending, use batch sending with a marketing key. |
personalizations[].cc, bcc | None | Not supported. Send a separate message to each recipient. |
personalizations[].dynamic_template_data | to[].variables | Per-recipient values. Use the top-level variables for values shared by every recipient. |
subject | subject | Required unless you use a template. |
content[] (text/html) | htmlContent | |
content[] (text/plain) | textContent | |
template_id | templateId | Recreate the template in Mail.Land first; the ID changes. |
reply_to.email | replyTo | A plain address string. |
categories, custom_args | tags | Tags appear in analytics and in webhook events. |
headers | customHeaders | |
attachments[].content, filename, type | attachments[].content, fileName, contentType | Base64 content. Up to 10 attachments and 10 MiB in total. |
attachments[].disposition, content_id | attachments[].inline, contentId | inline: true for an inline image. |
send_at (Unix time, up to 72 hours ahead) | scheduledAt (UTC date and time, up to 7 days ahead) | |
tracking_settings (open, click) | tracking.opens, tracking.clicks | A domain's own tracking settings take precedence for opens. |
asm.group_id | None on the send call | Marketing unsubscribes are handled by Mail.Land lists and campaigns. Import existing group unsubscribes, see below. |
No equivalent | X-Idempotency-Key header | Within the retention window, a retry with the same key returns the original response instead of sending again. |
Both APIs answer 202 Accepted. Mail.Land's response body includes a jobId, one message ID per recipient, and the lists of accepted and rejected recipients.
Import your suppression lists first
Import before your first send through Mail.Land, so that people who unsubscribed or complained stay suppressed from the start. Export each list from SendGrid as CSV, then import it with the matching type and scope, in the dashboard or with POST /v1/transactional/suppressions/import.
| SendGrid list | Mail.Land type | Scope |
|---|---|---|
| Bounces | HardBounce | Global |
| Spam reports | Complaint | Global |
| Invalid emails | Invalid | Global |
| Global unsubscribes | Unsubscribe | Global |
| Group unsubscribes | Unsubscribe | List, for the matching Mail.Land mailing list; Marketing blocks all marketing email |
SendGrid blocks are usually temporary refusals. Review them before importing rather than suppressing them all.
Point your webhooks at Mail.Land
Add your endpoint in Mail.Land, choose the events, and verify the X-MLND-Signature header with your webhook's secret. Event names differ from SendGrid's, so update your handler:
| SendGrid event | Mail.Land event |
|---|---|
delivered | message.delivered |
deferred | message.deferred |
bounce | message.bounced |
open, click | Not available yet |
spamreport | message.complained |
unsubscribe | message.unsubscribed |
Deduplicate on the event_id field: a retried delivery carries the same event ID. SendGrid's open and click events have no equivalent Mail.Land webhook today: open and click events are catalogued but not sent.
Cut over step by step
- Add and verify your sending domain in Mail.Land. Keep your SendGrid records: add include:spf.mlnd.net to your existing SPF record instead of creating a second one, and keep a single DMARC record.
- Import your suppression lists.
- Create a sandbox key set to log only, and run your integration tests against Mail.Land without delivering mail.
- Send real messages to addresses you control and check them in the email log.
- Move one application or a small share of traffic, then watch delivery, bounces and complaints.
- While both providers run, copy new unsubscribes, complaints and bounces from SendGrid to Mail.Land, and do a final sync before you switch the last traffic.
- Move the rest, and keep SendGrid active until its last scheduled and retried messages have gone.
Frequently asked questions
Can I keep sending through SendGrid while I test?
Yes. Verifying a domain in Mail.Land does not stop SendGrid from sending for it, so you can run both and move traffic gradually.
Does Mail.Land support cc and bcc?
Not on the send call. Send a separate message to each recipient instead.
Is there an official Mail.Land SDK?
Not yet. The documentation has copy-paste examples for the REST API in eight languages, and any SMTP library works with the relay.
What happens to my unsubscribe groups?
Import each group's unsubscribes with the List scope for the matching Mail.Land mailing list, and send to that list so the preference applies. The Marketing scope is the broader fallback: it blocks all marketing email to that address.
Ready to move from SendGrid?
Create a free account and send your first test today. If you want help, tell us about your setup.