Migrate from Postmark to Loops
Move Postmark email to Loops by mapping templates and API calls, preserving subscription and suppression state, and testing each send path before cutover.
Should you do this?
Postmark is transactional-first and good at that job. It is organized around Servers and Message Streams, with a transactional stream and broadcast streams. If your only requirement is a low-level transactional stream and you do not want a marketing side, staying on a transactional-focused provider is a reasonable choice.
Loops fits when you want transactional email and marketing email in one place: transactional templates for account mail, plus campaigns, contacts, mailing lists, and event-triggered Workflows for lifecycle and marketing. Inventory the sending and contact-sync behavior you need to preserve before consolidating the two.
The one structural change to plan for: in Postmark you can send raw HTML from code, or send a template with a TemplateModel. In Loops, the subject, body, and design live in a published template, and your code passes only dynamic values as dataVariables. Copy edits stop needing a code deploy. The tradeoff is that you keep the template variable names and your code in sync, because dataVariable names are case-sensitive and must match the template exactly.
Swap the packages
Install the official loops package. Keep the Postmark dependencies until no active send path or rollback plan uses them.
npm install loopsAPI mapping
Map sends, template variables, subscriptions, and delivery events separately. Loops API paths below use https://app.loops.so/api as their base URL.
Template send. Postmark POST /email/withTemplate (or client.sendEmailWithTemplate) becomes Loops POST /v1/transactional (or loops.sendTransactionalEmail). The Postmark TemplateId or TemplateAlias becomes the Loops transactionalId. The TemplateModel merge fields become Loops dataVariables. There is no HtmlBody at send time, because the content lives in the published Loops template.
Auth. Postmark authenticates each request with the X-Postmark-Server-Token header, a per-server token. Loops uses a single team API key sent as Authorization: Bearer YOUR_API_KEY. Keep the key server-side. Your application endpoint still needs access controls and input validation.
Templates. Postmark templates use Mustachio {{ }} merge fields against the TemplateModel. Recreate each template in Loops, publish it, and map the model keys to dataVariables of the same name.
Streams. Postmark separates a transactional stream from broadcast streams. Keep that separation in Loops: transactional templates for the transactional stream, and campaigns for broadcast streams.
Contacts. Postmark does not hold a marketing contact database the way an audience does. For broadcast streams it tracks subscriptions and suppressions. In Loops, recipient lists become mailing lists (GET /v1/lists), and contacts are created or updated with POST /v1/contacts/create and PUT /v1/contacts/update.
Events. Delivery, bounce, and open webhooks in Postmark map to Loops webhooks, which expose email.delivered, email.softBounced, email.hardBounced, and email.spamReported events (alongside email.opened, email.clicked, and email.unsubscribed) for monitoring.
Sending and contact-sync examples
A server-side password-reset helper. Call it with a valid, unexpired reset URL from your authentication system and a persisted key for this reset request. Publish the template with firstName and resetUrl variables before testing.
import { LoopsClient } from "loops";
const apiKey = process.env.LOOPS_API_KEY;
if (!apiKey) throw new Error("LOOPS_API_KEY is not set");
const loops = new LoopsClient(apiKey);
export async function sendPasswordReset(
email: string,
firstName: string,
resetUrl: string,
sendKey: string,
) {
const transactionalId = process.env.LOOPS_RESET_TEMPLATE_ID;
if (!transactionalId) throw new Error("LOOPS_RESET_TEMPLATE_ID is not set");
return loops.sendTransactionalEmail({
transactionalId,
email,
dataVariables: { firstName, resetUrl },
headers: { "Idempotency-Key": sendKey },
});
}The From address and the subject now live in the published Loops template, so they leave your code. The variable names (firstName, resetUrl) must match the names you define in the template, exactly, including case.
Environment
Replace the per-server token with the Loops API key.
LOOPS_API_KEY=
LOOPS_RESET_TEMPLATE_ID=Each transactionalId comes from the Loops dashboard Transactional section (the Publish page shows the id and the expected payload), or from GET /v1/transactional-emails. Store the ids you use, or read them at startup.
Dashboard setup and API-assisted migration
API key creation, sending-domain verification, mailing-list creation, and bulk CSV import still happen in the dashboard. Transactional templates and campaign drafts can be migrated with the Content API or CLI.
Create and test the API key. Generate a key under Settings -> API. Confirm it works with GET /v1/api-key, which returns { success: true, teamName }.
Verify your sending domain. Postmark authentication (DKIM, a Return-Path CNAME, and SPF) does not carry over. In Loops, add the SPF, DKIM, MX, and default DMARC records Loops shows during sending-domain setup, then verify from the domain settings page. Add the records early and wait for verification before testing sends. Propagation time depends on DNS caching. Sends only work from a verified domain.
Migrate each Postmark template with the Content API or CLI. Create the transactional email, update its draft email message with LMX and matching case-sensitive variables, then publish it. The API returns the transactionalId and revision IDs for later edits.
Create mailing lists and copy their ids. If you send broadcast streams, create the matching mailing lists in Loops and copy each list id from GET /v1/lists for use in mailingLists objects.
For contacts, there is no public bulk-contact-import API. Bulk import is done in the dashboard under Audience -> import CSV. For a programmatic move, loop over POST /v1/contacts/create or PUT /v1/contacts/update per contact, staying within the rate limit.
Cutover checklist
Export unsubscribe, complaint, invalid-address, and list-permission records before importing contacts. Reconcile them with any newer state already in Loops. Set subscribed: false for marketing opt-outs and preserve list-specific opt-outs. That flag does not block transactional mail: enforce imported delivery suppressions in your app before every send until the migration has an equivalent verified block. GET /v1/contacts/suppression inspects a Loops suppression and DELETE removes one. Neither imports a block.
Keep both providers available, with one provider owning each email type. Test content, variables, opt-outs, delivery suppressions, and duplicate prevention before moving a small cohort. Expand only after reviewing delivery events and failures. To roll back, pause the new send path and reconcile queued work before restoring the old owner.
Leave addToAudience off for account mail such as password resets. Adding a contact to the Audience does not establish marketing permission. Import and reconcile consent separately.
Keep one persisted Idempotency-Key, up to 100 characters, for each intended send or event. Reuse that key and the same payload on retries within the 24-hour window. Investigate a 409 conflict instead of generating a new key. Loops limits requests to 10 per second per team. Back off on 429. Provider idempotency does not deduplicate sends across two providers.
Keep template variables and code in sync. Because dataVariable names are case-sensitive and must match the published template, a rename in one place needs the same rename in the other. A missing required variable fails with 400.
Common questions
Can I keep sending raw HTML from code like Postmark's HtmlBody?
How long does the migration take?
Will my Postmark suppressions carry over?