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 Loops | On Postal | How it works on Postal |
|---|---|---|
| Contact | Contact | Each 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 date | Properties: text, number, yes/no and date | The 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 Subscribed | Email, first name, last name, external ID and unsubscribed | Source, User Group and Notes become properties you create. Subscribed becomes its inverse: Unsubscribed. |
| Subscribed = false | Unsubscribed | Doesn't receive campaigns or automation emails. Emails sent through the API still go out, as in Loops. |
| Suppression (bounce and complaint) | Suppression list | Addresses that must not receive anything. They're removed from all sending, including API sends, and never become contacts. |
| Mailing lists, public or private | Topics, public or private | Subjects the contact chooses to receive or not, on the preferences page that opens from the unsubscribe link. |
| Audience filters and saved segments | Dynamic segments | Defined 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) | Automations | A trigger and steps, in a flow editor. |
Transactional emails (transactionalId) | Templates sent through POST /emails | The template stores the HTML and, if you like, the default sender and subject. |
| Campaigns | Campaigns | Sending to a segment, and, if you want, only to those subscribed to a topic. |
| Preference Center | Preferences page | Opens from the unsubscribe link and lists the public topics. |
Email blocklist (patterns such as *@exemplo.com) | No equivalent | The suppression list stores addresses, not patterns. |
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.
| In Loops | On Postal | |
|---|---|---|
| Subdomain | The documentation recommends a subdomain, such as mail.suaempresa.com, instead of the main domain | Also recommended: a subdomain just for email |
| SPF and MX | On the envelope subdomain (for example, envelope.suaempresa.com) | MX and TXT on the postal subdomain |
| DKIM | Records from the Settings, Domain, View records screen (the names aren't in the documentation) | TXT on postal._domainkey |
| DMARC | Loops provides one record, and you can keep just one on the main domain | Optional. The same _dmarc works for both |
| How to confirm | Verify Records button. Propagation takes up to 72 hours, usually up to 24 | On 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.updatedwebhook when the domain becomesverified. 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.
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.
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" }
}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" }
}
}| In Loops | On Postal | What changes |
|---|---|---|
POST /v1/transactional | POST /emails | The key goes in Authorization: Bearer on both. In Postal it starts with ps_. |
email: one address | to: text or a list of up to 50 | A 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. |
transactionalId | template.id: the ID or the alias | The 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 number | template.variables: text or number | Optional 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, data | attachments: 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. |
addToAudience | No field | Create 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 409 | Idempotency-Key: 1 to 256 characters | In 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 team | Per plan: Free 5, Pro 10, Scale 20 and Enterprise 50 | In a batch, 100 emails count as one request. See Limits. |
No fields for cc, bcc, reply_to, tags or scheduling | cc, bcc, reply_to, headers, tags and scheduled_at | Postal 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.
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.
{
"transactionalId": "clomzp89u635xl30px7wrl0ri",
"email": "[email protected]",
"dataVariables": { "loginUrl": "https://exemplo.com.br/entrar?code=482913" }
}- 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| Loops SMTP | Postal SMTP | |
|---|---|---|
| Server and port | smtp.loops.so, port 587 | smtp.postal.so, port 465 (direct TLS, recommended) or 587 (STARTTLS). If your network blocks them, 2465 or 2587 |
| Username and password | loops and a Loops API key | postal and an API key (ps_…). A send-only key is enough |
| Message body | The JSON above, with the transactionalId | The real email, in HTML and text |
| Recipient | The JSON's email field | The envelope recipients, up to 50 per message |
| Template | The transactional email chosen by the transactionalId | Postal'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 fromMail::to()now takes effect. - In Postal, authentication (
AUTH PLAINorAUTH 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.
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.
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"
}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" }
}| In Loops | On Postal | What changes |
|---|---|---|
eventName | name | Up to 100 characters: letters, numbers, ., _, : and -. |
email or userId | contact: email, ID or external_id:<id>, or an object | Postal looks the contact up by ID, then by external_id, then by email. A new email creates the contact. |
eventProperties | properties | Up 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 body | contact_properties | The property must exist, on both sides. |
mailingLists: {id: true} or false | No field | Postal'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 exist | POST /contacts/batch: creates or updates, up to 10,000 per call | Looks the contact up by ID, by external_id and by email. For a single existing contact, PATCH /contacts/{id}. |
subscribed | unsubscribed | The inverse: subscribed: false becomes unsubscribed: true. |
- An event without
contactis recorded but doesn't start automations. With a new email, Postal creates the contact, notifies thecontact.createdwebhook 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.
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.
| In Loops | On Postal | Note |
|---|---|---|
| Contact added trigger | contact.created | Contacts 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 trigger | contact.property_changed | With the property and, if you like, the previous value (from) and the new one (to). |
| Contact added to list trigger | topic.subscribed or segment.added | Depends on whether the list became a topic or a segment. |
| Event received trigger | event | With up to 10 filters on the event's properties. |
| Email node | send_email step | Sends a published template. With detect_replies, Postal detects replies. |
| Timer node | wait step | Waits from minutes to weeks, or until a given day and time, with time zone. |
| Audience filter node | condition step | The condition has two paths, Yes and No. Leave No empty to end the run for anyone who doesn't pass the filter. |
| Branch node | condition step | Postal splits into Yes and No; for more paths, chain conditions. |
| Experiment node | experiment step | A/B test between 2 and 4 variants, on the plans that include it. |
| No equivalent in Loops | wait_for, update_contact and webhook | Steps 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, useevery_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.
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.
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" }]
}| In Loops | On Postal | Note |
|---|---|---|
{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 items | No equivalent | Each 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 MJML | HTML | Compile the MJML to HTML before pasting. |
- Variable names accept letters, numbers and
_, up to 50 characters. Declare invariablesthe 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.
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.
| Loops event | Postal event | Note |
|---|---|---|
email.delivered | email.delivered | |
email.softBounced | email.bounced | data.bounce.type is Transient. |
email.hardBounced | email.bounced | data.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.spamReported | email.complained | The address goes on the suppression list. |
email.opened, email.clicked | email.opened, email.clicked | Only with tracking turned on for the domain. In Loops, they don't exist for transactional emails. |
email.unsubscribed, email.resubscribed | contact.updated | A marketing contact's unsubscribe generates contact.updated. |
contact.created, contact.deleted | contact.created, contact.deleted | They come with the contact and the origin of the change (source). |
contact.unsubscribed | contact.updated | With unsubscribed true on the contact. |
contact.mailingList.subscribed, .unsubscribed | No documented event of its own | Postal topics don't have an event with that name. |
campaign.email.sent, loop.email.sent, transactional.email.sent | email.sent | Approximate: one per email. If it came from a campaign, data.broadcast_id says which one. |
testing.testEvent | Dashboard test event | Send one before switching your traffic. |
| In Loops | On Postal | Note |
|---|---|---|
| One endpoint per account | Several endpoints, depending on the plan | Each 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 headers | The 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 seconds | Up to 8 attempts over about 27 hours, 2xx response within 15 seconds | All 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 days | Disables the endpoint after consecutive failures (disabled_reason: "failing") | Check the webhook status over the first few days. |
| 30-day history | 30-day history | Dashboard and GET /webhooks/{id}/events. |
- Create the endpoint in Webhooks (dashboard or
POST /webhooks), choose the events and store thesigning_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.
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.
Subscribed equal to true.
Import as contacts.
Subscribed equal to false. In Loops, a hard bounce also unsubscribes.
Import as unsubscribed. First, set aside the suppressed ones.
Anyone who hard bounced or complained: Loops blocks sending.
Put them on the suppression list.
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
simwhere Subscribed is false andnãowhere it is true. Or download two files, filtering by Subscribed, and import the unsubscribed one withsimon 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 withPOST /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-28format 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.
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.
| In Loops | What 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 dataVariables | Build the list's HTML in your system. |
addToAudience on send | Create the contact separately or send an event with the contact. |
| Attachments enabled by support | They depend on the plan: up to 40 MB in total. |
| MJML emails | Compile to HTML. |
| A single webhook endpoint | In 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
idof 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 /eventsand 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.