---
title: Migrate from SendGrid | SendHQ Docs
description: Move SendGrid Mail Send, dynamic templates, Event Webhook, suppressions, and Inbound Parse to the SendHQ API, with a prompt your coding agent can run.
canonical: https://sendhq.cc/docs/sendgrid-migration
last-updated: 2026-09-07
updated: 2026-09-07
---
# Migrate from SendGrid

Move SendGrid Mail Send, dynamic templates, Event Webhook, suppressions, and Inbound Parse to the SendHQ API, with a prompt your coding agent can run.

## What changes

SendGrid sends through `POST https://api.sendgrid.com/v3/mail/send` with a `personalizations` array and returns `202`, with the message ID in the `X-Message-ID` response header. SendHQ takes one flat JSON message per request at `POST /emails` and returns `201` with the ID in the body. Most work is reshaping the payload and the webhook handler.

- **No SMTP relay and no SendGrid-compatible endpoint.** SendHQ is a JSON REST API at `https://sendhq.cc/api/v1`. There is no zero-code-change path: every call site moves to `POST /emails`. There is no official SendHQ SDK, so use your language's HTTP client behind one small adapter.
- **Attachments** are uploaded to a draft first and sent with `draft_id`, not inlined as base64 in the send body. There is no inline (CID) image support.
- **Tags, categories, metadata, and custom variables** have no equivalent on a send and are not returned in webhooks. Unknown JSON fields are ignored, not rejected, so remove them deliberately. Store your own correlation data against the returned `em_…` ID. `labels` file a message in the SendHQ mailbox; they are not analytics tags.
- **Scheduled sends, open tracking, and click tracking** are not available on `POST /emails`. Scheduling and open/click reports exist only for newsletter broadcasts.
- **Suppression lists cannot be imported.** SendHQ builds its list from its own bounces and complaints. Keep the exported list in your application during cutover (see below).
- **Integration trial.** Until a paid plan is active, a workspace can deliver only to the account email or a mailbox-simulator address (`…@simulator.amazonses.com`), and attachments need a paid plan.

## Concept mapping

How each SendGrid concept carries over. Where SendHQ has nothing equivalent, the row says so.

| SendGrid | SendHQ |
| --- | --- |
| API key (`Authorization: Bearer`) | Workspace API key `re_…`, same `Authorization: Bearer` header. Created under **API keys** in the dashboard and shown once. |
| SMTP relay `smtp.sendgrid.net` (user `apikey`) | **No SendHQ equivalent.** SendHQ has no SMTP relay; move SMTP senders to the HTTP API. |
| Domain authentication (`em…`, `s1._domainkey`, `s2._domainkey` CNAMEs) | Verified domain with its own DKIM CNAMEs (`POST /domains`). Both providers' records can coexist. |
| Dynamic templates (`d-…`, Handlebars) | Hosted templates with a `key`, typed flat variables, and immutable releases. No Handlebars logic. |
| Event Webhook (JSON array, ECDSA signature) | Webhooks: one JSON event per request, Standard Webhooks HMAC signature. |
| Bounces, blocks, spam reports, invalid emails | Automatic suppressions for permanent bounces and complaints (`GET /suppressions`). No import. |
| Global unsubscribes, unsubscribe groups (`asm`) | Marketing unsubscribes are recorded per workspace and sender domain when `message_class` is `marketing`. Unsubscribe groups: **No SendHQ equivalent.** |
| `categories`, `custom_args` | **No SendHQ equivalent.** Store correlation data yourself against the `em_…` ID. |
| `mail_settings.sandbox_mode` | **No SendHQ equivalent.** An unpaid workspace delivers only to the account email and `…@simulator.amazonses.com`. |
| `send_at`, `batch_id` | **No SendHQ equivalent.** Schedule in your own job queue. |
| `ip_pool_name` | **No SendHQ equivalent.** on a per-message basis. |
| Inbound Parse (MX `mx.sendgrid.net`, POST to your URL) | Inbound inboxes: mail is stored in SendHQ and announced with an `email.received` webhook. |

## Sending: endpoint and fields

One SendGrid personalization becomes one SendHQ message. A request with several personalizations becomes a `POST /emails/batch` call with one object per personalization.

| SendGrid | SendHQ `POST /emails` |
| --- | --- |
| `POST https://api.sendgrid.com/v3/mail/send` | `POST https://sendhq.cc/api/v1/emails` |
| `personalizations[].to[]` `{email, name}` | `to`: `["Name <email>"]` |
| `personalizations[].cc`, `.bcc` | `cc`, `bcc` (100 addresses combined with `to`) |
| Several `personalizations` | `POST /emails/batch`, one message each |
| `from` `{email, name}` | `from`: `"Name <email>"` |
| `reply_to` `{email, name}` | `reply_to`: one address string |
| `reply_to_list` | **No SendHQ equivalent.** One reply-to address only. |
| `subject`, `personalizations[].subject` | `subject` |
| `content[]` `{type: "text/plain"}` / `{type: "text/html"}` | `text` / `html` |
| `template_id` + `personalizations[].dynamic_template_data` | `template`: `{key, data}`, exactly one `to` |
| `personalizations[].substitutions` | **No SendHQ equivalent.** Render the values in your code. |
| `headers`, `personalizations[].headers` | `headers` (string values) |
| `attachments[] {content, filename, type, disposition, content_id}` | Draft upload, then `draft_id` (see Attachments). Inline `content_id`: no equivalent. |
| `categories`, `custom_args`, `send_at`, `batch_id`, `ip_pool_name`, `tracking_settings`, `mail_settings` | **No SendHQ equivalent.** Remove them. |
| `asm.group_id` | `message_class: "marketing"` adds SendHQ's own unsubscribe link and headers; groups have no equivalent. |
| No request idempotency field | `Idempotency-Key` header |
| `202`, ID in `X-Message-ID` header | `201`, `{"id": "em_…", "providerMessageId", "threadId"}` |

### SendHQ limits that affect a migration

| SendHQ rule | Value |
| --- | --- |
| Recipients per message | 100 combined `to`, `cc`, and `bcc`; each one uses one recipient credit |
| Batch | `POST /emails/batch` with `{"emails": [...]}`, up to 100 messages; `201`, or `207` when some fail. Each item returns `{index, ok, id}` or `{index, ok: false, error}` |
| Attachments | 10 files and 10 MB per message, paid plans only |
| Idempotency | `Idempotency-Key` header, at most 200 characters. Same key and identical body replays the stored response with `Idempotent-Replayed: true`; same key with a different body returns `409` |
| Daily volume for a new paid workspace | A workspace-wide daily recipient limit starts at 100 and steps up (100, 250, 500, 1,000, 2,000, 5,000, 10,000, 15,000, 25,000, 35,000, 45,000, 50,000) after each day that used at least half of it with healthy bounce and complaint rates; rising rates step it back. Each sender address also has its own daily limit. Sends past a limit return `429` |
| Domains | Every `from` address must be on a verified domain in the workspace; otherwise `403` |

## Before and after: Node.js

```javascript
// @sendgrid/mail
const sgMail = require("@sendgrid/mail");
sgMail.setApiKey(process.env.SENDGRID_API_KEY);

await sgMail.send({
  to: user.email,
  from: { email: "hello@example.com", name: "Acme" },
  subject: "Welcome to Acme",
  text: "Your account is ready.",
  html: "<p>Your account is ready.</p>",
});
```

```javascript
// No SendHQ SDK: call the REST API with fetch (Node 18+).
const res = await fetch("https://sendhq.cc/api/v1/emails", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SENDHQ_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": `welcome-${user.id}`, // one stable key per logical send
  },
  body: JSON.stringify({
    from: "Acme <hello@example.com>",
    to: [user.email],
    reply_to: "support@example.com",
    subject: "Welcome to Acme",
    html: "<p>Your account is ready.</p>",
    text: "Your account is ready.",
  }),
});
const body = await res.json();
if (!res.ok) throw new Error(`SendHQ ${res.status}: ${body.error?.message}`);
const emailId = body.id; // "em_…": store it with the event that caused the email
```

## Before and after: Python

```python
# sendgrid
import os
import sendgrid
from sendgrid.helpers.mail import Mail, Email, To, Content

sg = sendgrid.SendGridAPIClient(api_key=os.environ.get("SENDGRID_API_KEY"))
mail = Mail(Email("hello@example.com"), To(user.email), "Welcome to Acme",
            Content("text/plain", "Your account is ready."))
response = sg.client.mail.send.post(request_body=mail.get())
```

```python
# No SendHQ SDK: call the REST API with requests.
import os
import requests

res = requests.post(
    "https://sendhq.cc/api/v1/emails",
    headers={
        "Authorization": f"Bearer {os.environ['SENDHQ_API_KEY']}",
        "Idempotency-Key": f"welcome-{user.id}",  # one stable key per logical send
    },
    json={
        "from": "Acme <hello@example.com>",
        "to": [user.email],
        "reply_to": "support@example.com",
        "subject": "Welcome to Acme",
        "html": "<p>Your account is ready.</p>",
        "text": "Your account is ready.",
    },
    timeout=30,
)
if not res.ok:
    raise RuntimeError(f"SendHQ {res.status_code}: {res.json()['error']['message']}")
email_id = res.json()["id"]  # "em_…": store it with the event that caused the email
```

## Before and after: curl

```shell
curl --request POST \
  --url https://api.sendgrid.com/v3/mail/send \
  --header "Authorization: Bearer $SENDGRID_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "personalizations": [{"to": [{"email": "customer@example.net"}]}],
    "from": {"email": "hello@example.com", "name": "Acme"},
    "reply_to": {"email": "support@example.com"},
    "subject": "Welcome to Acme",
    "content": [
      {"type": "text/plain", "value": "Your account is ready."},
      {"type": "text/html", "value": "<p>Your account is ready.</p>"}
    ]
  }'
```

```shell
curl https://sendhq.cc/api/v1/emails \
  -H "Authorization: Bearer $SENDHQ_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: welcome-user-42" \
  -d '{
    "from": "Acme <hello@example.com>",
    "to": ["customer@example.net"],
    "reply_to": "support@example.com",
    "subject": "Welcome to Acme",
    "html": "<p>Your account is ready.</p>",
    "text": "Your account is ready."
  }'
```

## Attachments

SendGrid takes attachments inline in the JSON body (`content`, `filename`, `type`, `disposition`, `content_id`). In SendHQ, upload the decoded bytes of each file to a draft and send with `draft_id`. Files with `disposition: inline` referenced by `cid:` have no SendHQ equivalent; host the image and link it with an absolute URL instead.

```shell
# 1. Create a draft (same from/to/subject as the send).
curl https://sendhq.cc/api/v1/drafts \
  -H "Authorization: Bearer $SENDHQ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"from":"billing@example.com","to":["customer@example.net"],"subject":"Invoice 1042"}'
# -> {"id":"dr_…","attachments":[]}

# 2. Upload each file as raw bytes (at most 10 files, 10 MB in total).
curl https://sendhq.cc/api/v1/drafts/dr_123/attachments \
  -H "Authorization: Bearer $SENDHQ_API_KEY" \
  -H "Content-Type: application/pdf" \
  -H "X-Filename: invoice-1042.pdf" \
  --data-binary @invoice-1042.pdf

# 3. Send with draft_id. The draft's files go with the message.
curl https://sendhq.cc/api/v1/emails \
  -H "Authorization: Bearer $SENDHQ_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: invoice-1042" \
  -d '{"from":"billing@example.com","to":["customer@example.net"],"subject":"Invoice 1042","text":"Your invoice is attached.","draft_id":"dr_123"}'
```

## Move the domain

With automated security on, SendGrid's domain authentication uses CNAMEs at `em<number>.example.com`, `s1._domainkey.example.com`, and `s2._domainkey.example.com`; with it off, an SPF include of `sendgrid.net` and a `m1._domainkey` TXT record. SendHQ's DKIM records use different selectors, so keep the SendGrid records until the cutover is finished. If your SPF record includes `sendgrid.net`, add `include:amazonses.com` to the same record rather than creating a second one, and keep your one DMARC record as it is.

### Records for SendHQ

1. Add the domain with `POST /domains` `{"name": "example.com"}` or in the dashboard. Read the exact records with `GET /domains/:id`; `GET /dns/provider?domain_id=…` also returns the relative host to type at your DNS provider.
2. Publish the three DKIM CNAME records SendHQ returns: `<token>._domainkey.example.com` pointing to `<token>.dkim.amazonses.com`. These prove ownership and sign your mail. Cloudflare users can authorize a one-time setup instead, and Domain Connect providers get a consent link.
3. SPF: keep exactly one `v=spf1` record per name. If you publish SPF for SendHQ, merge `include:amazonses.com` into the existing value; never add a second SPF record.
4. DMARC: keep exactly one record at `_dmarc.example.com`. If you have none, SendHQ recommends `v=DMARC1; p=none; adkim=r; aspf=r` while you confirm all legitimate mail passes.
5. Run `POST /domains/:id/verify` until the status is `verified`. DNS can pass through `pending`, `checking`, and `propagating` first.

## Webhooks and events

| SendGrid event | SendHQ event |
| --- | --- |
| `processed` | `email.sent` |
| `delivered` | `email.delivered` |
| `deferred` | `email.delivery_delayed` |
| `bounce` (`type: bounce` or `blocked`) | `email.bounced`; `bounce.type` is `Permanent` or `Transient` |
| `dropped` | No event: SendHQ refuses a suppressed recipient synchronously with `422`. Provider rejections arrive as `email.failed`. |
| `spamreport` | `email.complained` |
| `open`, `click` | **No SendHQ equivalent.** |
| `unsubscribe`, `group_unsubscribe`, `group_resubscribe` | **No SendHQ equivalent.** |
| Inbound Parse POST | `email.received` (then `GET /emails/:id`) |

SendGrid posts a JSON array of events signed with ECDSA (`X-Twilio-Email-Event-Webhook-Signature`, `X-Twilio-Email-Event-Webhook-Timestamp`). Deduplicate on `webhook-id` where you used `sg_event_id`, and correlate with `data.email_id` where you used `sg_message_id`.

Create an endpoint with `POST /webhooks` `{"url": "https://example.com/webhooks/sendhq", "events": [...]}` (omit `events` for all). The response includes the signing secret `whsec_…`; store it as `SENDHQ_WEBHOOK_SECRET`. Up to 10 endpoints per workspace. Each request is one JSON event, `{"type", "created_at", "data"}`, signed with the Standard Webhooks scheme (`webhook-id`, `webhook-timestamp`, `webhook-signature: v1,<base64 HMAC-SHA256 of id.timestamp.body>`). Answer 2xx within 10 seconds; failures are retried 8 times over about 27 hours with the same `webhook-id`, so deduplicate on it. Events can arrive out of order.

`data` always carries `email_id`, `from`, `to`, and `subject` (plus `cc`, `thread_id`, and `template_key` when present). Event-specific fields: `email.delivered` → `recipients`; `email.delivery_delayed` → `recipients`, `delay.type`, `delay.diagnostic`, `delay.expiration`; `email.bounced` → `recipient`, `bounce.type` (`Permanent` or `Transient`), `bounce.sub_type`, `bounce.diagnostic`, `bounce.suppressed` (one event per bounced recipient); `email.complained` → `recipient`, `complaint.feedback_type`; `email.failed` → `reason`; `email.received` → `inbox_id`, `category`. There are no open, click, or unsubscribe webhook events.

```javascript
// Standard Webhooks signature check (any Standard Webhooks library also works).
import crypto from "node:crypto";

export function verifySendHQ(rawBody, headers, secret = process.env.SENDHQ_WEBHOOK_SECRET) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = String(headers["webhook-signature"] || "").split(" ");
  if (!id || !timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return null;
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = crypto.createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();
  const ok = signatures.some((entry) => {
    const [version, value] = entry.split(",");
    const given = Buffer.from(value || "", "base64");
    return version === "v1" && given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
  return ok ? JSON.parse(rawBody) : null; // { type, created_at, data: { email_id, … } }
}
```

```python
# Standard Webhooks signature check with the standard library.
import base64, hashlib, hmac, json, os, time

def verify_sendhq(raw_body: bytes, headers) -> dict | None:
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = (headers.get("webhook-signature") or "").split(" ")
    if not msg_id or not timestamp or abs(time.time() - int(timestamp)) > 300:
        return None
    secret = os.environ["SENDHQ_WEBHOOK_SECRET"].removeprefix("whsec_")
    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(base64.b64decode(secret), signed, hashlib.sha256).digest()).decode()
    if not any(s.startswith("v1,") and hmac.compare_digest(s[3:], expected) for s in signatures):
        return None
    return json.loads(raw_body)  # {"type", "created_at", "data": {"email_id", ...}}
```

## Suppressions and bounces

Export each SendGrid list before cutover. The bounces, blocks, spam reports, invalid emails, and global unsubscribes endpoints accept `limit` (up to 500) and `offset`:

```shell
for list in bounces blocks spam_reports invalid_emails unsubscribes; do
  offset=0
  while :; do
    page=$(curl -s "https://api.sendgrid.com/v3/suppression/$list?limit=500&offset=$offset" \
      -H "Authorization: Bearer $SENDGRID_API_KEY")
    [ "$(echo "$page" | jq length)" -eq 0 ] && break
    echo "$page" | jq -r --arg list "$list" '.[] | [.email, $list] | @csv' >> sendgrid-suppressions.csv
    offset=$((offset + 500))
  done
done
# Unsubscribe groups: GET /v3/asm/suppressions returns [{email, group_id, group_name, created_at}]
```

SendHQ adds a suppression automatically for every permanent bounce and every complaint it sees, and refuses later sends to that address with `422`. Read it with `GET /suppressions` (paged with `limit` and `offset`) or `GET /blocked-recipients`. There is **no endpoint to import** another provider's suppression list into it.

- **Transactional sends:** keep the exported list in your own database and check it in your SendHQ adapter before calling `POST /emails`. Drop that check once SendHQ has run long enough to build its own list.
- **Newsletter contacts:** import the addresses with `POST /contact-imports` and a `status` column of `unsubscribed`, `bounced`, or `complained`. Those rows import as unsubscribed, so broadcasts skip them. This does not block `POST /emails`.

## Templates

Fetch each dynamic template with `GET /v3/templates/{template_id}`; the active version (`active: 1`) carries `subject`, `html_content`, and `plain_content`. SendGrid templates are Handlebars: `{{firstName}}` becomes a SendHQ variable such as `{{first_name}}`, while `{{#if}}`, `{{#each}}`, and unescaped `{{{ }}}` blocks must move into your code.

SendHQ hosted templates support plain variable substitution only: `{{variable}}`, where a variable key is lowercase letters, numbers, and underscores, declared with a type (`text`, `number`, `url`, or `boolean`), a required flag, and an optional fallback. Values are HTML-escaped; a `url` variable may only fill a whole `href` or `src`. There are no conditionals, loops, partials, helpers, or nested objects. Templates that use them need the logic moved into your code (render the varying part yourself and pass the result as `html`/`text`, or pass a precomputed text value).

```shell
# 1. Create the template (key: lowercase letters, numbers, hyphens).
curl https://sendhq.cc/api/v1/templates \
  -H "Authorization: Bearer $SENDHQ_API_KEY" -H "Content-Type: application/json" \
  -d '{"name":"Password reset","key":"password-reset"}'
# -> {"template":{"id":"tmpl_…","key":"password-reset"},"draft":{"revision":1,…},…}

# 2. Save the converted content and declare every variable.
curl -X PUT https://sendhq.cc/api/v1/templates/tmpl_123/draft \
  -H "Authorization: Bearer $SENDHQ_API_KEY" -H "Content-Type: application/json" \
  -d '{"revision":1,"name":"Password reset","subjectTemplate":"Reset your password, {{first_name}}",
       "htmlTemplate":"<p>Hi {{first_name}},</p><p><a href=\"{{reset_url}}\">Reset password</a></p>",
       "textTemplate":"Hi {{first_name}}, reset your password: {{reset_url}}",
       "variables":[{"key":"first_name","type":"text","required":true},{"key":"reset_url","type":"url","required":true}],
       "sampleData":{"first_name":"Asha","reset_url":"https://example.com/reset/abc"}}'

# 3. Check the exact output, then publish an immutable release.
curl -X POST https://sendhq.cc/api/v1/templates/tmpl_123/render -H "Authorization: Bearer $SENDHQ_API_KEY" \
  -H "Content-Type: application/json" -d '{"data":{"first_name":"Asha","reset_url":"https://example.com/reset/abc"}}'
curl -X POST https://sendhq.cc/api/v1/templates/tmpl_123/publish -H "Authorization: Bearer $SENDHQ_API_KEY"

# 4. Send by key: exactly one To recipient, no cc/bcc, no subject/html/text.
curl https://sendhq.cc/api/v1/emails -H "Authorization: Bearer $SENDHQ_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: reset-8f2c" \
  -d '{"from":"Acme <security@example.com>","to":["customer@example.net"],
       "template":{"key":"password-reset","data":{"first_name":"Asha","reset_url":"https://example.com/reset/abc"}}}'
```

## Inbound email

Inbound Parse points an MX record at `mx.sendgrid.net` and posts each message as multipart form data to your URL. In SendHQ the message is stored first and your webhook receives its ID.

SendHQ receives mail into inboxes it stores; it does not post parsed messages to a URL of your choice, and it has no route expressions. To move receiving:

1. Verify the domain for sending first, then call `POST /domains/:id/inbound/setup`. It returns the one MX record to publish. When the domain already has MX records that point elsewhere (your team mailboxes, or the old provider), SendHQ uses `inbound.<domain>` instead, so existing mail keeps flowing. Check it with `POST /domains/:id/inbound/verify`.
2. Create each address with `POST /inboxes` `{"domain_id": "dom_…", "local_part": "support", "name": "Support"}`.
3. Subscribe a webhook to `email.received`. Its `data` has `email_id`, `inbox_id`, `from`, `to`, `subject`, and `category`; fetch the body with `GET /emails/:id` (`html`, `text`, `attachments`) and each file with `GET /attachments/:id`. Reply in the same thread with `reply_to_email_id`.
4. To keep a copy flowing to a human mailbox, use `PUT /inboxes/:id/forwarding` `{"forward_to": "team@example.net"}`; addresses other than the account owner's must confirm first.

## Cutover plan

1. **Prepare.** Verify every From domain in SendHQ while SendGrid keeps sending. DKIM records for different providers live under different selectors, so both can stay published for the whole cutover. Activate a paid plan before real traffic; the integration trial only delivers to your own address.
2. **Build the switch.** Route every send through one adapter with a provider setting per stream, for example `EMAIL_PROVIDER_PASSWORD_RESET=sendhq`, plus an optional percentage (hash the recipient or user ID so one person always gets the same provider). Keep the SendGrid code path intact.
3. **Parallel run.** Keep both webhook handlers live. Store which provider sent each message next to its ID, so events, retries, and support lookups go to the right place.
4. **Ramp.** Start with one low-volume stream (password resets, receipts) to an internal list, then real users. Raise the SendHQ share as the workspace daily limit rises; the first day allows 100 recipients, and anything over the limit returns `429`, which your adapter should send back to SendGrid during the ramp instead of dropping.
5. **Monitor.** Watch `GET /deliverability/stats`, `GET /deliverability/reputation`, webhook `email.bounced` and `email.complained` rates, and your own 4xx/5xx counts per provider. A `423` means one From address is paused for bounce or complaint rates: stop that stream and clean its list.
6. **Roll back** by flipping the stream setting back to SendGrid. Nothing in DNS has to change for a rollback while both providers' records stay published.
7. **Finish.** When every stream has run cleanly on SendHQ, remove the SendGrid SDK, credentials, webhook endpoint, and DNS records that only it used (its DKIM selector, tracking or return-path CNAMEs, and its SPF include).

## Hand this to your coding agent

Paste this into Claude Code, Cursor, Codex, or another coding agent at the root of your repository. It is self-contained: it lists the SendHQ endpoints, fields, and limits so the agent does not have to guess, and it asks for a report of anything it could not map. Review the diff like any other change, and register the webhook URL and DNS records yourself.

```text
Migrate this codebase from SendGrid to SendHQ. Keep user-visible behaviour identical. Do not send real email, change DNS, or call production APIs while working.

STEP 1: INVENTORY
Search the whole repository (application code, workers, scripts, infrastructure as code, CI config, .env examples, docs) for every use of SendGrid, including: the @sendgrid/mail and @sendgrid/client npm packages, the sendgrid Python package (SendGridAPIClient, sendgrid.helpers.mail), any other SendGrid SDK, raw HTTP calls to api.sendgrid.com or api.eu.sendgrid.com, SMTP transports (nodemailer, smtplib, framework mailers) pointed at smtp.sendgrid.net with username "apikey", SENDGRID_* env vars, template IDs starting with "d-", and Event Webhook or Inbound Parse handlers (X-Twilio-Email-Event-Webhook-Signature header).
List each call site with: what it sends (transactional, marketing, inbound handling), the SendGrid features it uses (personalizations, dynamic templates, attachments, categories, custom_args, send_at, asm groups, sandbox mode, tracking settings, Event Webhook, Inbound Parse), and the env vars it reads.

STEP 2: SENDHQ API FACTS (use exactly these; do not guess or invent other endpoints)
- Base URL: https://sendhq.cc/api/v1
- Auth: header "Authorization: Bearer <SENDHQ_API_KEY>". Keys start with re_. Server side only, never in browser or mobile code.
- There is no SendHQ SDK, no SMTP relay, and no SendGrid-compatible endpoint. Call the REST API with the project's existing HTTP client (fetch, axios, requests, httpx, net/http, ...).
- Send one: POST /emails, Content-Type: application/json. Fields:
  from (string, "Name <addr@verified-domain>", required; the domain must be verified in SendHQ)
  to (string or array of strings, required), cc, bcc (string or array). At most 100 addresses across to+cc+bcc.
  subject (string), html (string), text (string). One of html, text, or template is required.
  reply_to (ONE address string, not an array)
  headers (object of string to string; not allowed: From, To, Cc, Bcc, Subject, Date, Message-ID, In-Reply-To, References, Content-Type, MIME-Version)
  message_class ("transactional" default, or "marketing", which adds SendHQ's unsubscribe link and headers unless you supply your own one-click List-Unsubscribe)
  template ({"key": "...", "data": {...}} or {"id": "tmpl_..."}; not combinable with subject/html/text; exactly one to and no cc/bcc)
  draft_id (attachments, see below), reply_to_email_id (thread a reply), labels (array of up to 10 mailbox label names)
  Response 201: {"id": "em_...", "providerMessageId": "...", "threadId": "..."}. Persist id.
- Unknown fields (tags, metadata, categories, custom variables, scheduled times, tracking flags) are ignored. Do not send them.
- Idempotency: header "Idempotency-Key" (max 200 chars), one stable key per logical send. Retrying the identical body with the same key replays the stored response (response header Idempotent-Replayed: true). Same key with a different body returns 409.
- Batch: POST /emails/batch {"emails": [ ...up to 100 send objects ]} returns 201 or 207 with {"data": [{"index", "ok", "id"} or {"index", "ok": false, "error": {"message", "status"}}], "count", "successful", "failed"}.
- Attachments: POST /drafts {"from", "to", "subject"} returns {"id": "dr_..."}; then for each file POST /drafts/{id}/attachments with the raw bytes as the body, header Content-Type: <mime type> and X-Filename: <name>; then POST /emails with "draft_id": "dr_...". Max 10 files and 10 MB per message. No inline/CID images.
- Errors: JSON {"error": {"message", "status", "code"?}}. 400 invalid input, 401 bad key, 402 trial or plan limit, 403 domain not verified or not owned, 404 missing resource, 409 idempotency conflict, 413 attachments too large, 422 recipient suppressed or unsubscribed, 423 this From address is paused, 429 usage or daily limit, 502/503 transient. Retry only 429/502/503, with backoff and the same Idempotency-Key and identical body.
- Webhooks: create with POST /webhooks {"url": "https://...", "events": [...]}; the response has "secret": "whsec_...". Event types: email.sent, email.delivered, email.delivery_delayed, email.bounced, email.complained, email.failed, email.received. One JSON event per request: {"type", "created_at", "data"}; data has email_id, from, to, subject, and per type: delivered -> recipients; delivery_delayed -> recipients, delay {type, diagnostic, expiration}; bounced -> recipient, bounce {type "Permanent"|"Transient", sub_type, diagnostic, suppressed}; complained -> recipient, complaint {feedback_type}; failed -> reason; received -> inbox_id, category. No open, click, or unsubscribe events.
  Verify Standard Webhooks signatures on the RAW body: headers webhook-id, webhook-timestamp, webhook-signature ("v1,<base64>", space separated if several). Expected = base64(HMAC-SHA256(key = base64-decode(secret without "whsec_"), message = "{webhook-id}.{webhook-timestamp}.{raw body}")). Constant-time compare, reject timestamps older than 5 minutes, dedupe on webhook-id, return 2xx within 10 seconds.
- Suppressions: SendHQ suppresses permanent bounces and complaints itself (GET /suppressions, GET /blocked-recipients). There is no import endpoint.
- Templates: POST /templates {"name", "key"}; PUT /templates/{id}/draft {"revision", "name", "subjectTemplate", "htmlTemplate", "textTemplate", "variables": [{"key", "type": "text"|"number"|"url"|"boolean", "required"}], "sampleData"}; POST /templates/{id}/render {"data"}; POST /templates/{id}/publish. Only {{variable}} substitution with lowercase_snake_case keys: no conditionals, loops, partials, or nested objects.
- Inbound: POST /domains/{id}/inbound/setup returns the MX record to publish; POST /inboxes {"domain_id", "local_part", "name"} creates an address; an email.received webhook carries data.email_id and data.inbox_id; GET /emails/{id} returns from, to, subject, html, text, and attachments (download with GET /attachments/{id}). Mail is stored, not forwarded to your URL.
- Full contract: https://sendhq.cc/openapi.json and https://sendhq.cc/docs/sendgrid-migration

STEP 3: FIELD MAPPING FROM SENDGRID
- One personalization = one SendHQ message. Multiple personalizations -> POST /emails/batch.
- personalizations[].to/cc/bcc [{email, name}] -> to/cc/bcc as "Name <email>" strings.
- from {email, name} -> from "Name <email>". reply_to {email} -> reply_to "email". reply_to_list -> not supported, keep the first address and report it.
- content[] text/plain -> text, text/html -> html. subject -> subject. headers -> headers.
- template_id + dynamic_template_data -> template {"key": "<new SendHQ key>", "data": {...}}; create the template first (see templates) and keep a map of old d- IDs to new keys.
- attachments[] (base64 content) -> decode, upload to a draft, send with draft_id. disposition "inline"/content_id -> not supported, report it.
- categories, custom_args, send_at, batch_id, asm, ip_pool_name, mail_settings, tracking_settings, substitutions -> not supported; remove them and report each use.
- 202 + X-Message-ID header -> 201 + JSON body id ("em_...").
- Event Webhook (JSON array, ECDSA): processed -> email.sent; delivered -> email.delivered; deferred -> email.delivery_delayed; bounce -> email.bounced; spamreport -> email.complained; dropped -> handle the synchronous 422 at send time instead; open, click, unsubscribe, group_* -> no SendHQ event, report the handlers that depend on them.
- Inbound Parse multipart POST -> email.received webhook + GET /emails/{id}.

STEP 4: IMPLEMENT
- Create one SendHQ email adapter module with the same function signatures the app already uses, so call sites change as little as possible. Read the key from SENDHQ_API_KEY.
- Keep the SendGrid implementation behind a switch (for example EMAIL_PROVIDER=sendhq|sendgrid, optionally per stream) so the team can run both and roll back. Default it to sendgrid.
- Env vars: add SENDHQ_API_KEY and SENDHQ_WEBHOOK_SECRET to every .env example, config schema, secret manifest, and deployment config where SENDGRID_API_KEY (and any SendGrid template ID or webhook key variables) appear. Do not delete the SendGrid variables yet.
- Generate one Idempotency-Key per logical send from data you already have (for example "<event>-<record id>") and reuse it on retries.
- Webhooks: add a SendHQ webhook route next to the existing SendGrid handler. Map SendHQ events onto the same internal handlers (for example delivered, bounced, complained). Correlate by the em_ id you stored at send time, not by SendGrid message IDs.
- Suppressions: keep honouring the existing SendGrid suppression data (export it to the app's database if the app does not already store it) and check it in the adapter before sending.
- SMTP senders: replace each SMTP transport that targets smtp.sendgrid.net with the HTTP adapter; SendHQ has no SMTP relay.

STEP 5: TESTS
- Unit-test the adapter with a mocked HTTP client: exact URL, method, headers (Authorization, Content-Type, Idempotency-Key) and JSON body for a plain send, cc/bcc, reply_to, custom headers, a template send, a batch, and an attachment send (draft, upload, send with draft_id).
- Test error handling for 401, 409, 422, 423, 429 and 503, and that only 429/502/503 are retried with the same key.
- Test the webhook route: a valid signature is accepted, a tampered body, an old timestamp, and a wrong secret are rejected, a repeated webhook-id is processed once, and each event type reaches the right internal handler.
- Run the existing test suite and keep it green.

STEP 6: REPORT
Finish with a list of everything you could not map one-to-one, with file paths, for example: categories and custom_args used for analytics, unsubscribe groups, scheduled sends, open/click handlers, inline images, sandbox-mode tests, SMTP senders that cannot switch to HTTP. Also list the env vars added, the webhook URL to register in SendHQ, and the DNS records the team must add by hand. Do not remove SendGrid code or credentials; the team will do that after cutover.
```

## Provider references

Provider details on this page were checked against these official pages on November 7, 2026. Check them again before you migrate; provider APIs change.

- [Mail Send API](https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send)
- [Mail Send limitations](https://www.twilio.com/docs/sendgrid/api-reference/mail-send/limitations)
- [X-Message-ID](https://www.twilio.com/docs/sendgrid/glossary/x-message-id)
- [Node.js quickstart](https://www.twilio.com/docs/sendgrid/for-developers/sending-email/quickstart-nodejs)
- [Python quickstart](https://www.twilio.com/docs/sendgrid/for-developers/sending-email/quickstart-python)
- [SMTP integration](https://www.twilio.com/docs/sendgrid/for-developers/sending-email/integrating-with-the-smtp-api)
- [Event Webhook reference](https://www.twilio.com/docs/sendgrid/for-developers/tracking-events/event)
- [Event Webhook security](https://www.twilio.com/docs/sendgrid/for-developers/tracking-events/getting-started-event-webhook-security-features)
- [Retrieve all bounces](https://www.twilio.com/docs/sendgrid/api-reference/bounces-api/retrieve-all-bounces)
- [Retrieve all blocks](https://www.twilio.com/docs/sendgrid/api-reference/blocks-api/retrieve-all-blocks)
- [Retrieve all spam reports](https://www.twilio.com/docs/sendgrid/api-reference/spam-reports-api/retrieve-all-spam-reports)
- [Retrieve all invalid emails](https://www.twilio.com/docs/sendgrid/api-reference/invalid-emails-api/retrieve-all-invalid-emails)
- [Retrieve all global suppressions](https://www.twilio.com/docs/sendgrid/api-reference/suppressions-global-suppressions/retrieve-all-global-suppressions)
- [Retrieve all group suppressions](https://www.twilio.com/docs/sendgrid/api-reference/suppressions-suppressions/retrieve-all-suppressions)
- [Using Handlebars](https://www.twilio.com/docs/sendgrid/for-developers/sending-email/using-handlebars)
- [Retrieve a single transactional template](https://www.twilio.com/docs/sendgrid/api-reference/transactional-templates/retrieve-a-single-transactional-template)
- [Domain authentication](https://www.twilio.com/docs/sendgrid/ui/account-and-settings/how-to-set-up-domain-authentication)
- [Inbound Parse webhook](https://www.twilio.com/docs/sendgrid/for-developers/parsing-email/setting-up-the-inbound-parse-webhook)
