New: campaigns, automations and product emails in the same account. Meet Postal
Sign in Create account

From Loops to Postal, with the same building blocks

Loops and Postal are alike: contacts, events that start flows, transactional emails with a template, and campaigns. The call format changes, and you rebuild the flows. This guide compares them one by one and says what has no counterpart.

Both sending paths
API and SMTP
Events, flows and topics
Same design
Data format
CSV
Workflows and campaigns
Rebuild

Overview

What comes along and what you redo

Contacts come as a CSV file. You rewrite the calls, and you rebuild the flows in Postal's editor. Nothing happens on its own: you export, convert and check.

Comes as a file

Contacts, properties and who unsubscribed (Loops' Subscribed column, which becomes the inverse in Postal).

You rewrite

The transactional call, event sending, the SMTP connection and webhooks. The design is similar, the format isn't.

You rebuild

Workflows, campaigns and templates. What you take with you is the content and the idea behind each flow.

Equivalence

The same concepts, other names

Loops and Postal have almost the same design: contacts, events that start flows, and transactional emails with a template. Use this table as a dictionary.

In LoopsOn PostalHow it works on Postal
ContactContactEach email appears once per team. The external_id (alias user_id) is the person's ID in your system.
Contact properties: text, number, boolean and dateProperties: text, number, yes/no and dateThe types are the same. Create the property before using it, on both sides.
Default properties: Email, First Name, Last Name, User Id, Source, User Group, Notes and SubscribedEmail, first name, last name, external ID and unsubscribedSource, User Group and Notes become properties you create. Subscribed becomes its inverse: Unsubscribed.
Subscribed = falseUnsubscribedDoesn't receive campaigns or automation emails. Emails sent through the API still go out, as in Loops.
Suppression (bounce and complaint)Suppression listAddresses that must not receive anything. They're removed from all sending, including API sends, and never become contacts.
Mailing lists, public or privateTopics, public or privateSubjects the contact chooses to receive or not, on the preferences page that opens from the unsubscribe link.
Audience filters and saved segmentsDynamic segmentsDefined by filters and updated automatically. You redo the filters.
Events (POST /v1/events/send)Events (POST /events)Each event can have a contact and starts the automations with a trigger of that name.
Workflows (formerly called loops)AutomationsA trigger and steps, in a flow editor.
Transactional emails (transactionalId)Templates sent through POST /emailsThe template stores the HTML and, if you like, the default sender and subject.
CampaignsCampaignsSending to a segment, and, if you want, only to those subscribed to a topic.
Preference CenterPreferences pageOpens from the unsubscribe link and lists the public topics.
Email blocklist (patterns such as *@exemplo.com)No equivalentThe suppression list stores addresses, not patterns.
1

Verify the domain on Postal

In Domains, Postal shows a few records for whoever manages DNS to copy, and checks them on its own for up to 72 hours. The domain can stay verified on Loops at the same time.

Loops and Postal records side by side
In LoopsOn Postal
SubdomainThe documentation recommends a subdomain, such as mail.suaempresa.com, instead of the main domainAlso recommended: a subdomain just for email
SPF and MXOn the envelope subdomain (for example, envelope.suaempresa.com)MX and TXT on the postal subdomain
DKIMRecords from the Settings, Domain, View records screen (the names aren't in the documentation)TXT on postal._domainkey
DMARCLoops provides one record, and you can keep just one on the main domainOptional. The same _dmarc works for both
How to confirmVerify Records button. Propagation takes up to 72 hours, usually up to 24On its own, for up to 72 hours, with the status of each record
  • Going by the names, Postal's records don't collide with Loops'. Copy the values from your own Loops screen and check whether any name is the same before publishing.
  • Postal notifies you through the domain.updated webhook when the domain becomes verified. See Domains and DNS.
  • Create an API key (it starts with ps_). For systems that only send, use a send-only key restricted to the domain.
2

Rewrite transactional sending

In Loops, the call says only who to send to and which transactional email to use: the sender, subject and body live in the email registered there. In Postal the design is similar, with the template in place of the transactional email, but the call is different.

Before: Loops, POST /v1/transactional
POST https://app.loops.so/api/v1/transactional
Authorization: Bearer <Loops key>
Idempotency-Key: order-10482-shipped
Content-Type: application/json

{
  "email": "[email protected]",
  "transactionalId": "clomzp89u635xl30px7wrl0ri",
  "dataVariables": { "nome": "Ana", "pedido": "10482" }
}
200{ "success": true }
After: Postal, POST /emails
POST https://api.postal.so/emails
Authorization: Bearer ps_your_key
Idempotency-Key: order-10482-shipped
Content-Type: application/json

{
  "from": "Aurora Cosméticos <[email protected]>",
  "to": "[email protected]",
  "template": {
    "id": "order-shipped",
    "variables": { "nome": "Ana", "pedido": "10482" }
  }
}
200{ "id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b" }
POST /v1/transactional and POST /emails
In LoopsOn PostalWhat changes
POST /v1/transactionalPOST /emailsThe key goes in Authorization: Bearer on both. In Postal it starts with ps_.
email: one addressto: text or a list of up to 50A list in Postal is a single email in which everyone sees each other. For one email per person, use the batch (up to 100). You can also send user_id instead of to, and Postal uses the contact's email.
transactionalIdtemplate.id: the ID or the aliasThe Loops email must be published; so must the Postal template. Sends always use the latest published version. from is required if the template has no default sender.
dataVariables: text or numbertemplate.variables: text or numberOptional variables can be left empty in Loops. In Postal, a variable with no value and no default returns 422: declare fallback_value. Lists and repeated blocks don't go through: build the list's HTML in your system.
attachments: filename, contentType, dataattachments: filename, content_type, content (Base64) or path (URL)In Loops, support must enable attachments on your account. In Postal, they depend on the plan: up to 40 MB in total.
addToAudienceNo fieldCreate the contact separately (POST /contacts or the batch), or send an event with the contact.
Response {"success": true}Response with {"id"}Store the id: it is what appears in webhooks (data.email_id) and in GET /emails/{id}.
Errors: {success, message}Errors: {statusCode, name, message}See Errors.
Idempotency-Key: up to 100 characters; repeated within 24 hours, returns 409Idempotency-Key: 1 to 256 charactersIn Postal, repeating the key with the same body, within 24 hours, returns the first response and doesn't send again. Only a different body, or a request still in progress, gives 409.
Up to 10 requests per second per teamPer plan: Free 5, Pro 10, Scale 20 and Enterprise 50In a batch, 100 emails count as one request. See Limits.
No fields for cc, bcc, reply_to, tags or schedulingcc, bcc, reply_to, headers, tags and scheduled_atPostal additions. Scheduling goes up to 30 days and depends on the plan.
  • In Loops, people who unsubscribed still receive transactional emails. It's the same in Postal: only the suppression list blocks an API send.
  • Prefer an SDK? Postal's (Node.js, PHP and Laravel) use this same API. See SDKs and CLI and the emails reference.
3

If you send over SMTP, the email becomes a real one

Over SMTP, Loops doesn't receive an ordinary email: the message body is a JSON with the transactionalId, the recipient's address and the template data (in the documentation's Laravel example, the address from Mail::to() isn't used for delivery). Postal's SMTP delivers the message as it arrives, with the subject, the HTML and the email's own recipients.

The body Loops expects over SMTP
{
  "transactionalId": "clomzp89u635xl30px7wrl0ri",
  "email": "[email protected]",
  "dataVariables": { "loginUrl": "https://exemplo.com.br/entrar?code=482913" }
}
Laravel .env
- MAIL_HOST=smtp.loops.so- MAIL_PORT=587- MAIL_USERNAME=loops- MAIL_PASSWORD=<chave de API do Loops>- MAIL_ENCRYPTION=tls+ MAIL_SCHEME=smtps+ MAIL_HOST=smtp.postal.so+ MAIL_PORT=465+ MAIL_USERNAME=postal+ MAIL_PASSWORD=ps_sua_chave
SMTP configuration on both sides
Loops SMTPPostal SMTP
Server and portsmtp.loops.so, port 587smtp.postal.so, port 465 (direct TLS, recommended) or 587 (STARTTLS). If your network blocks them, 2465 or 2587
Username and passwordloops and a Loops API keypostal and an API key (ps_…). A send-only key is enough
Message bodyThe JSON above, with the transactionalIdThe real email, in HTML and text
RecipientThe JSON's email fieldThe envelope recipients, up to 50 per message
TemplateThe transactional email chosen by the transactionalIdPostal's SMTP has no templates: send the finished HTML, or use the API
  • In Laravel, this means replacing the text view that built the JSON with a view holding the email's HTML, and passing the subject in the Envelope. The address from Mail::to() now takes effect.
  • In Postal, authentication (AUTH PLAIN or AUTH LOGIN) always comes after TLS. SMTP has no tags or scheduling.
  • SMTP sends follow the same rules as the API (verified domain, quotas and suppression) and show up in Emails and Logs. See the SMTP guide.
4

Switch event sending and contact creation

Events play the same role in both: they tell the platform what happened in your product and start flows. In Postal, a single POST /events also registers or updates the person before starting the automations.

Before: Loops, POST /v1/events/send
POST https://app.loops.so/api/v1/events/send
Authorization: Bearer <Loops key>
Idempotency-Key: order-10482-paid
Content-Type: application/json

{
  "email": "[email protected]",
  "eventName": "order_paid",
  "eventProperties": { "total": 289.9 },
  "plano": "pro"
}
200{ "success": true }
After: Postal, POST /events
POST https://api.postal.so/events
Authorization: Bearer ps_your_key
Idempotency-Key: order-10482-paid
Content-Type: application/json

{
  "name": "order_paid",
  "contact": { "email": "[email protected]" },
  "properties": { "total": 289.9 },
  "contact_properties": { "plano": "pro" }
}
Events and contacts through the API
In LoopsOn PostalWhat changes
eventNamenameUp to 100 characters: letters, numbers, ., _, : and -.
email or userIdcontact: email, ID or external_id:<id>, or an objectPostal looks the contact up by ID, then by external_id, then by email. A new email creates the contact.
eventPropertiespropertiesUp to 16 KB. They're available in automation filters and conditions and as variables in emails ({{event.plano}}).
Contact properties loose at the root of the bodycontact_propertiesThe property must exist, on both sides.
mailingLists: {id: true} or falseNo fieldPostal's event doesn't touch topics. Use PATCH /contacts/{id}/topics or an automation's update contact step.
POST /v1/contacts/create (409 if it already exists)POST /contacts (422 if already registered)The fields change to first_name, last_name, external_id and properties.
PUT /v1/contacts/update: creates it if it doesn't existPOST /contacts/batch: creates or updates, up to 10,000 per callLooks the contact up by ID, by external_id and by email. For a single existing contact, PATCH /contacts/{id}.
subscribedunsubscribedThe inverse: subscribed: false becomes unsubscribed: true.
  • An event without contact is recorded but doesn't start automations. With a new email, Postal creates the contact, notifies the contact.created webhook and starts the contact-created automations.
  • An address on the suppression list never becomes a contact: the response is 422 email_suppressed.
  • The Node.js and PHP SDKs (events.send) and the CLI (postal events send) send events. See the events reference.
5

Rebuild workflows and campaigns

Flows don't carry over on their own. The good news is that the blocks are almost the same, so the conversion is just renaming: open each Loops workflow and build the equivalent in Automations.

signUp eventWait 3 daysCondition: paid = noSend email
Triggers and nodes
In LoopsOn PostalNote
Contact added triggercontact.createdContacts from a CSV import only trigger automations when the import asks for it (trigger_automations). In Loops you have to ask for it too.
Contact updated triggercontact.property_changedWith the property and, if you like, the previous value (from) and the new one (to).
Contact added to list triggertopic.subscribed or segment.addedDepends on whether the list became a topic or a segment.
Event received triggereventWith up to 10 filters on the event's properties.
Email nodesend_email stepSends a published template. With detect_replies, Postal detects replies.
Timer nodewait stepWaits from minutes to weeks, or until a given day and time, with time zone.
Audience filter nodecondition stepThe condition has two paths, Yes and No. Leave No empty to end the run for anyone who doesn't pass the filter.
Branch nodecondition stepPostal splits into Yes and No; for more paths, chain conditions.
Experiment nodeexperiment stepA/B test between 2 and 4 variants, on the plans that include it.
No equivalent in Loopswait_for, update_contact and webhookSteps that Loops' workflow page doesn't list: waiting for the contact to open, click or reply, changing the contact and notifying your system.
  • Each contact enters once by default (reentry: "once"). To enter on every trigger, use every_time.
  • Campaigns and saved segments (saved segments) are also rebuilt: campaigns in Campaigns and segments as dynamic segments. Campaigns that were already sent aren't imported.
  • Imports don't start automations unless you turn that option on. Review who would enter before you activate it. See Automations.
6

Rebuild the emails and variables

Postal doesn't convert emails from another platform. Paste the HTML into the editor or send it through POST /templates. What changes is how you write the variables.

Create the template in Postal
POST https://api.postal.so/templates
Authorization: Bearer ps_your_key
Content-Type: application/json

{
  "name": "Access code",
  "alias": "access-code",
  "subject": "Your access code",
  "html": "<p>To sign in, <a href=\"{{{loginUrl}}}\">click here</a>.</p>",
  "variables": [{ "key": "loginUrl" }]
}
201Publish with POST /templates/{id}/publish
Email variables
In LoopsOn PostalNote
{DATA_VARIABLE:loginUrl}{{{loginUrl}}}The value comes in template.variables. In the HTML, values are escaped.
Contact properties in campaign and flow emails{{{FIRST_NAME}}}, {{{EMAIL}}} or {{{contact.chave}}}Only in campaigns and automations. The property must exist.
Event data{{event.chave}}In emails from automations started by an event.
Repeated blocks (arrays), up to 100 itemsNo equivalentEach Postal variable is text or a number. Build the list's HTML in your system.
{unsubscribe_link} (MJML emails){{{POSTAL_UNSUBSCRIBE_URL}}}Without the variable, Postal adds an unsubscribe footer to campaigns.
Email written in MJMLHTMLCompile the MJML to HTML before pasting.
  • Variable names accept letters, numbers and _, up to 50 characters. Declare in variables the ones that have a default value.
  • The template also stores the default sender and subject (from, subject), which apply when the send doesn't provide its own.
  • See the templates reference; the plan sets how many templates the team has.
7

Recreate the webhook

Signature verification is the same on both: the documentation for each describes the headers webhook-id, webhook-timestamp and webhook-signature and an HMAC-SHA256, in Base64, of id.timestamp.corpo, with the whsec_… secret. Your verification code should keep working, just with the new endpoint's secret. What changes are the event names and bodies.

Events: what maps to what
Loops eventPostal eventNote
email.deliveredemail.delivered
email.softBouncedemail.bounceddata.bounce.type is Transient.
email.hardBouncedemail.bounceddata.bounce.type is Permanent, and the address goes on the suppression list (suppression.added event). In Loops, a hard bounce also triggers contact.unsubscribed.
email.spamReportedemail.complainedThe address goes on the suppression list.
email.opened, email.clickedemail.opened, email.clickedOnly with tracking turned on for the domain. In Loops, they don't exist for transactional emails.
email.unsubscribed, email.resubscribedcontact.updatedA marketing contact's unsubscribe generates contact.updated.
contact.created, contact.deletedcontact.created, contact.deletedThey come with the contact and the origin of the change (source).
contact.unsubscribedcontact.updatedWith unsubscribed true on the contact.
contact.mailingList.subscribed, .unsubscribedNo documented event of its ownPostal topics don't have an event with that name.
campaign.email.sent, loop.email.sent, transactional.email.sentemail.sentApproximate: one per email. If it came from a campaign, data.broadcast_id says which one.
testing.testEventDashboard test eventSend one before switching your traffic.
Webhooks: format, signature and retries
In LoopsOn PostalNote
One endpoint per accountSeveral endpoints, depending on the planEach endpoint chooses its own events, and the URL must be HTTPS and public.
eventName, eventTime (Unix), webhookSchemaVersion and blocks such as email and contactIdentity{type, created_at, data}created_at comes in ISO 8601. The email is in data, with email_id, to, subject and tags.
Webhook-Id, Webhook-Timestamp and Webhook-Signature headersThe same ones, plus the equivalent svix-*Header names are case-insensitive. Reject timestamps more than 5 minutes off, as on both.
Up to 8 attempts over about 28 hours, 2xx response within 15 secondsUp to 8 attempts over about 27 hours, 2xx response within 15 secondsAll with the same webhook-id: use it to discard duplicates. Resend from the dashboard or via POST /webhooks/{id}/events/{event_id}/replay.
Disables the endpoint if all attempts fail for 5 daysDisables the endpoint after consecutive failures (disabled_reason: "failing")Check the webhook status over the first few days.
30-day history30-day historyDashboard and GET /webhooks/{id}/events.
  • Create the endpoint in Webhooks (dashboard or POST /webhooks), choose the events and store the signing_secret. See the signature verification.
  • Remove the Loops webhook only after switching all your traffic: emails that still go out through Loops keep generating events.
8

Export your Loops audience and import it into Postal

In Loops' Audience, open the ••• menu at the corner of the filter box and choose Download. To export just one group, apply the filters first. The CSV has one column per contact property, the default ones and yours. In Postal, the import is under Audience, Import CSV.

SubscribedImport

Subscribed equal to true.

Import as contacts.

UnsubscribedImport with “sim” (yes)

Subscribed equal to false. In Loops, a hard bounce also unsubscribes.

Import as unsubscribed. First, set aside the suppressed ones.

SuppressedSuppress

Anyone who hard bounced or complained: Loops blocks sending.

Put them on the suppression list.

Blocked by defaultNo equivalent

Addresses and domains that Loops rejects on entry (blocklist).

No equivalent: reject them at the source.

  • Subscribed is the inverse of Unsubscribed. Create an Unsubscribed column with sim where Subscribed is false and não where it is true. Or download two files, filtering by Subscribed, and import the unsubscribed one with sim on every row.
  • Suppressed: Loops' documentation only describes looking up one address at a time (GET /v1/contacts/suppression?email=). To separate the suppressed from those who unsubscribed by choice, query each address in the unsubscribed file (the limit is 10 requests per second) and put the suppressed ones on the suppression list with POST /suppressions/batch/add, 100 per request, before importing contacts.
  • Lists: Loops doesn't document whether the CSV includes list membership. If it doesn't, export one list per file. Lists become topics (public or private, as there): import each file selecting the topic in Subscribe to topics. That field subscribes every contact in the file.
  • Properties: the types are the same (text, number, yes/no and date). Source, User Group and Notes become properties you create beforehand, because the import doesn't create new properties. Dates in the 2026-10-28 format work.
  • Plan limit: in Loops, unsubscribed contacts don't count toward your plan. In Postal, unsubscribed contacts take up a slot in the contact limit (1,000 on Free; 5,000 on Pro; 50,000 on Scale). Addresses on the suppression list don't. See Pricing.
  • Leave the Trigger automations option off. Contacts that already exist are updated by email, empty cells don't erase values, and each file can be up to 20 MB.
  • Before sending, run the list through Email validation.
9

Check what has no equivalent and switch your traffic

Switch one flow at a time, with Loops still on. Start with the test addresses and with yourself, compare deliveries and bounces over the first few days, and only then move the rest.

Domain verifiedTest at [email protected]One flow on PostalAll trafficTurn off Loops
What changes shape, and how to work around it
In LoopsWhat to do in Postal
Blocklist of addresses and domains (*@exemplo.com)No equivalent. In forms, the email validation option rejects disposable addresses and domains that don't receive email.
Repeated blocks in dataVariablesBuild the list's HTML in your system.
addToAudience on sendCreate the contact separately or send an event with the contact.
Attachments enabled by supportThey depend on the plan: up to 40 MB in total.
MJML emailsCompile to HTML.
A single webhook endpointIn Postal there can be several, depending on the plan.

Before you flip the key

  • The domain is verified in Postal and the API key is created (send-only and restricted to the domain, for systems that only send).
  • Transactional sending was rewritten and tested with a test address, and your code stores the id of each email.
  • The transactional emails became published templates, with variables in the {{{nome}}} format.
  • Anyone who sends over SMTP now sends the real email, not Loops' JSON.
  • Events go to POST /events and the automations with an event trigger are active.
  • The webhook was created, the signature is checked with the new secret and a test event arrived.
  • The Subscribed column was inverted into Unsubscribed, and the suppressed addresses are on the suppression list before the import.
  • The import was left without triggering automations, and the flows were reviewed before activating.
  • Your code records which provider each email went out through, and the bounces were checked over the first few days.

Frequently asked questions

Can I keep using the Loops SDK?

Not for sending through Postal: Postal doesn't accept Loops' format. Replace the calls with the Postal SDK (Node.js, PHP and Laravel) or with HTTP, to POST /emails and POST /events.

Do my workflows migrate on their own?

No. The triggers and nodes have a counterpart in Postal, but each flow is built again in the Automations editor. Use the chance to review what still makes sense.

Does the code that verifies webhook signatures change?

Usually just the secret. Both documentations describe the same headers and the same HMAC-SHA256 of id.timestamp.corpo. Test with the dashboard's test event before switching your traffic. What changes are the event names and bodies.

Can I keep the domain on both at the same time?

Yes. Loops uses the envelope subdomain for SPF and MX, and Postal uses the postal subdomain and the postal selector for DKIM. Check Loops' records screen to see whether any name on your account is the same before publishing.

Want to try Postal?

Create your account and start on the Free plan.