Migrate from SendGrid to Loops

Move SendGrid sends and contact lists to Loops. Map templates and variables, preserve subscription state, and verify one email type before switching the next.

Should you do this?

Loops can combine transactional sends with marketing campaigns and workflows, using templates your team can edit without a code deploy. If you only need SMTP delivery, compare infrastructure providers on cost and the sending operations your team is prepared to own.

If your SendGrid integration sends HTML in each request, move that content into a published Loops template and pass the dynamic values as dataVariables. SendGrid Dynamic Templates already separate content from code: migrate those templates and map their variable names to the Loops template. In both cases, keep the names in the published template and sending code in sync.

Give this to your agent

Migrate this codebase from SendGrid to Loops (loops.so). Read
https://loops.so/docs/sdks/javascript and
https://app.loops.so/openapi.json before changing API calls.

Inventory send functions, templates, variables, and contact-list sync.
Use the loops npm package for transactional sends and contact updates.
Preserve existing function signatures where their behavior maps to Loops.
Report any behavior that needs a different implementation.

Carry over unsubscribe, suppression, and list-permission state. Assign
one sending provider to each message type so cutover cannot double-send.
Persist one idempotency key per intended send and reuse it on retries.
Check template rendering, variables, failure handling, and delivery
events with test recipients before moving traffic. Keep a rollback
path, and list the dashboard setup and acceptance checks still needed.

Swap the packages

npm install loops

# After cutover and the rollback period:
npm uninstall @sendgrid/mail @sendgrid/client

Install the official loops package. Keep the SendGrid dependencies until no remaining send or rollback path uses them. In the examples below, the password-reset caller supplies the account email, a generated reset URL, and the persisted key for that intended send. The contact-sync caller supplies reconciled permission values from your records, preserving any newer unsubscribe in Loops.

API mapping

  • sgMail.send({ to, from, subject, html }) becomes loops.sendTransactionalEmail({ transactionalId, email, dataVariables }). Content lives in a published dashboard template. Dynamic values go in dataVariables.

  • Per-send from addresses become your verified sending domain plus per-template From fields. Data variables work inside From and Subject fields.

  • PUT /v3/marketing/contacts becomes loops.updateContact({ email, properties, mailingLists }). Both upsert. Setting a list id to false removes the contact from that list.

  • Error handling needs a provider-specific review. Loops exposes APIError with statusCode and json. Handle rate limits and network failures separately from invalid requests, and update any code that reads SendGrid-specific error fields.

Sending and contact-sync examples

import { LoopsClient } from "loops";

const apiKey = process.env.LOOPS_API_KEY;
if (!apiKey) throw new Error("Missing LOOPS_API_KEY");
const loops = new LoopsClient(apiKey);

export async function sendPasswordReset(
  to: string,
  resetUrl: string,
  sendKey: string,
) {
  const templateId = process.env.LOOPS_TRANSACTIONAL_ID_PASSWORD_RESET;
  if (!templateId) throw new Error("Missing password-reset template ID");
  return loops.sendTransactionalEmail({
    transactionalId: templateId,
    email: to,
    dataVariables: { resetUrl },
    headers: { "Idempotency-Key": sendKey },
  });
}
import { LoopsClient } from "loops";

const apiKey = process.env.LOOPS_API_KEY;
if (!apiKey) throw new Error("Missing LOOPS_API_KEY");
const loops = new LoopsClient(apiKey);

export async function syncMarketingContact(
  email: string,
  firstName: string,
  subscribed: boolean,
  productUpdatesPermission: boolean,
) {
  const listId = process.env.LOOPS_PRODUCT_UPDATES_LIST_ID;
  if (!listId) throw new Error("Missing product-updates list ID");
  return loops.updateContact({
    email,
    properties: { firstName, subscribed },
    mailingLists: { [listId]: subscribed && productUpdatesPermission },
  });
}

Environment and caller requirements

# Before
SENDGRID_API_KEY=
SENDGRID_PRODUCT_UPDATES_LIST_ID=

# After
LOOPS_API_KEY=                          # Settings -> API
LOOPS_TRANSACTIONAL_ID_WELCOME=         # each published template's Publish page
LOOPS_TRANSACTIONAL_ID_PASSWORD_RESET=
LOOPS_TRANSACTIONAL_ID_PAYMENT_FAILED=
LOOPS_PRODUCT_UPDATES_LIST_ID=          # Audience -> Lists

Dashboard setup and API-assisted migration

  1. Generate an API key under Settings, then API. Sanity check it with GET https://app.loops.so/api/v1/api-key.

  2. Verify your sending domain: add the SPF, DKIM, and MX records, then verify. Add the records early. Propagation time depends on DNS caching, so wait for domain verification before testing sends.

  3. Migrate each SendGrid template with the Content API or CLI. Create the transactional email, update its draft email message with LMX and the same case-sensitive variables, then publish it. The API returns the transactionalId and revision IDs for later edits.

  4. Create your mailing lists and copy their IDs.

Cutover checklist

  • Export SendGrid unsubscribe, suppression, and list-permission state before importing contacts. Preserve negative states, including on contacts that already exist in Loops.

  • Keep both providers available, with one owner per email type. Verify rendered content, variables, suppression behavior, and delivery events with test recipients. Move a small production cohort next, then compare failures and duplicate sends before expanding. Roll back by pausing the new send path and restoring the old owner.

  • Transactional sends don’t add recipients to your audience unless you pass addToAudience: true. Leave it off for password resets.

  • The rate limit is 10 requests per second per team. Back off on 429. Persist an Idempotency-Key for each intended send and reuse it on retries within 24 hours. A reused key returns 409. Reconcile uncertain sends before retrying outside that window or through SendGrid, which does not share Loops keys.

  • Keep template variable names and code in sync. A send missing a required variable fails with a 400 unless you mark the variable optional in the editor.

Common questions

Can I keep sending HTML defined in my code?

How long does the migration take?

Will my unsubscribes carry over?

Implementation guides

For a new application, start with transactional email from Next.js. For an agent-assisted setup, see send email from AI agents, or read how transactional email works on Loops.