# Billing
Source: https://loops.so/docs/account/billing
How Loops billing works, including plan tiers, contact limits, and how to manage your subscription.
## How billing works
Loops billing is based on the number of contacts stored in your account. A contact is an email address that you send marketing emails to. Transactional recipients do not count as contacts (unless they are also sent marketing emails).
Your current billing plan is displayed in Stripe, which can be accessed by going to your [Billing page](https://app.loops.so/settings?page=billing) and clicking **Start/Manage subscription**.
When you are billed, your charge is always calculated based on the number of contacts you have at that exact time. If your contact list grows between billing cycles, you'll be notified via email and moved to the appropriate plan tier at your next billing date. If your contact list shrinks, you'll automatically be moved to a lower plan tier to match your new contact count.
It can take up to 24 hours for contact additions or removals to be reflected in your billing tier.
Billing works as a monthly subscription paid in advance. If your contact count moves you into a higher plan tier, your service will never be interrupted. You'll simply be billed at the new rate when your next billing date arrives.
## Free plan
The free plan is available to anyone with fewer than 1,000 contacts. On the free plan:
* You can send up to 4,000 emails every 30 days (marketing and transactional combined).
* Loops branding is placed at the bottom of your emails.
* You are limited to joining a maximum of 3 free teams. To join more, upgrade one of your teams to a paid plan, or [leave](/docs/account/team-members) or delete a team to free up space.
Once you exceed 1,000 contacts, you'll be prompted to upgrade to a paid plan. You can also upgrade at any time by clicking **Start subscription** at the top of your [Billing page](https://app.loops.so/settings?page=billing).
## Paid plans
Paid plans are tiered by contact count. Unlike the free plan, paid plans have no limit on the number of emails you can send.
Paid plan pricing is available on the [Loops pricing page](https://loops.so/pricing).
## Sending rate limits
Free accounts can send up to 10 emails per second. Paid teams can send up to 1,000 emails per second. Emails over the limit stay queued until they can be sent.
## Billing receipt emails
You can choose which email addresses receive receipts and invoices from Loops.
To add, remove or change these emails, go to your [Billing page](https://app.loops.so/settings?page=billing) and enter or remove addresses in the **Billing receipt emails** section.
## Canceling your subscription
You can cancel your subscription at any time by going to your [Billing page](https://app.loops.so/settings?page=billing) and clicking **Manage subscription**.
For further questions about billing, go to your [Support page](https://app.loops.so/settings?page=support), or contact [help@loops.so](mailto:help@loops.so).
# Change your login email
Source: https://loops.so/docs/account/changing-your-email
How to update the email address you use to log in to Loops.
You will need the account owner role to change your email address.
1. Ensure you have access to your new email address.
2. Go to [Team settings](https://app.loops.so/settings?page=team).
3. Invite your new email address to your team.
4. Accept the invite from an incognito window or a different browser.
5. Once the new email address has joined the team, promote it to an owner role.
6. Remove your old email address from the team.
# Delete your account
Source: https://loops.so/docs/account/delete-account
How to permanently delete your Loops account.
You will need the account owner role to delete your account.
Deleting your account is permanent. All emails, contacts, audience data and team settings will be removed and cannot be recovered.
1. Go to [Account settings](https://app.loops.so/settings?page=account).
2. Scroll to the **Danger Zone**.
3. Click **Delete this team...**.
4. In the confirmation modal, review the list of sending domains that will be deleted and the actions that will happen immediately.
5. Type **permanently delete team** in the text field to confirm.
6. Click **Permanently delete**.
If you have an active paid subscription, cancel it from the [Billing settings](https://app.loops.so/settings?page=billing) before deleting your account.
# Notifications
Source: https://loops.so/docs/account/notifications
Learn how to receive notifications about new Loops contacts in external tools.
You will receive a notification when a contact is added to your audience through the API, integrations or form submission. You will not receive notifications for manually added users via CSV or other methods.
You can manage your notifications in the [Notifications](https://app.loops.so/settings?page=notifications) page.
## Email notifications
By default, you will receive an email notification from Loopsbot when a contact is added to your audience. You can disable this notification by toggling the **Notifications enabled** switch in the [Notifications section](https://app.loops.so/settings?page=notifications) of your Settings. If you'd like to receive email notifications again, you can toggle the switch back on. This setting is individual to your user account, and won't propagate to the rest of your team.
## Slack notifications
You can receive notifications in Slack for new contacts. Please note, this is a global setting and will apply to all additional seats in your account.
### Set up Slack notifications
1. Go to [Settings -> Notifications](https://app.loops.so/settings?page=notifications).
2. Click the link to be redirected to Slack for authorization.
3. Select the channel you want to add the bot to.
The bot must be invited to private channels before they become visible in the dropdown.
4. Send a test message to verify the integration works.
That's it! All new contacts added to your audience will now send a notification to the selected Slack channel.
### Other options
Here are a few other ways to get Slack notifications for new contacts:
1. Install an email add-on to send individual emails from [Gmail](https://slack.com/apps/AEFLFJR9Q-slack-for-gmail) or [Outlook](https://slack.com/apps/AFS3736H3-slack-for-outlook) to channels or DMs (free).
2. Create a forwarding address to send individual emails to your DM with Slackbot (free).
[View the full documentation from Slack](https://slack.com/help/articles/206819278-Send-emails-to-Slack) for more information.
# Team members
Source: https://loops.so/docs/account/team-members
How to add and manage team members in your Loops account.
It is free to add additional team members to your account.
Here's how to add team members to your team:
1. Go to [Settings -> Team](https://app.loops.so/settings?page=team).
2. Enter the new team member's name and email address and click **Invite**.
## Owner role
You can promote members to become an Owner of your team.
Owners have special permissions:
* Owners can invite and remove team members.
* Owners can promote members to become owners.
## Managing members
You can add unlimited members to each team, for free.
Only owners can invite new members. Owners can also remove members and other owners. If you remove someone from your team, they will be established with a new Loops team of their own.
# Team switcher
Source: https://loops.so/docs/account/team-switcher
Learn how to create and connect multiple Loops accounts with one email address.
You may want to have multiple Loops accounts for different products, domains or teams.
You can create new teams from a single Loops account.
You can also combine existing Loops teams under a single email address so you can easily switch between them instead of having to log out every time.
The existing [team member features](/docs/account/team-members) allow you to invite and remove users to and from individual teams.
Free users can be in a maximum of 3 teams. As soon as a user joins a paid team, they can be added to unlimited teams.
## Create a new team
If you want to create a new team, click on the team switcher in the top left of your Loops account, then select **+ New team**.
This will create a new team and ask you to set up a new sending domain.
## Connect existing accounts
If you have existing accounts that you want to merge under one email address, there are a few steps to complete.
In this example **Team A** is the main account you want to log in with, and **Team B** is the account you want to connect.
Make sure you're signed in to the Loops account you want to be your only account (Team A).
**In a different browser** (or incognito window) log in to Team B (the account you want to connect) and invite your selected Team A email address from the [Team settings page](https://app.loops.so/settings?page=team).
An invitation email will be sent to your Team A email address. Since you are already logged in to Team A, you can ignore the email.
Refresh the Team settings page and you will see an invite alert appear in the bottom right corner. Click **Accept**.
Accepting the invite will automatically switch you to the Team A account.
If you want to become the [owner](/docs/account/team-members#owner-role) of Team B (giving you full permissions), go back to Team B in the other browser window and refresh the Team page. Give the **Owner** role to your Team A email address by clicking the `•••` menu button.
Becoming owner is not required but is necessary if you are the only user in the team.
You can sign out of Team B (or close your incognito window), because you can now access it through your Team A account.
If you want, you can safely remove the previous email address you used to sign in to Team B from the [Team settings page](https://app.loops.so/settings?page=team).
## Invite members to teams
If you want your team members to access multiple teams, simply invite the same email address to different teams. Once they accept the invitation, they will be able to switch between the teams they have access to.
## Billing within teams
Billing is handled individually per team. When you switch between teams you will be able to handle billing separately for each.
# CSV Upload
Source: https://loops.so/docs/add-users/csv-upload
Easily add contacts to Loops by uploading a CSV file.
Please read through the [contact properties](/docs/contacts/properties) information to understand how to format data in your CSV file.
## Add new contacts via CSV
1. Select **Import** in the top right of the [Audience](https://app.loops.so/audience) page.
2. Hover on **CSV** and click **Upload CSV**. Download the [example formatted CSV](https://app.loops.so/loops_sample.csv) to get an idea of the columns we use. By default, we recommend using at least `Email`, `First Name`, `Last Name`, `User Group` and `Source` columns.
3. After uploading the CSV you'll have a chance to review any duplicates or missing information before finishing.
4. When the upload is finished, all the uploaded contacts can be viewed in the Audience page. That's it! 🎊
### Some important notes
* All new contacts uploaded will be automatically marked as subscribed. If you want to mark imported contacts as unsubscribed add a `Subscribed` column into your CSV file and use "false" as the column value.
* The default `Source` value for each uploaded contact will be "CSV Upload". You can change this by adding a `Source` column to your CSV file and specifying a custom value.
* Dates in your CSV file must be in one of the accepted formats listed on the [contact properties](/docs/contacts/properties#dates) page (timestamps are not supported).
## Update contacts via CSV
You can also upload a CSV file to update your existing contacts in bulk.
You can either download a CSV from your Audience page in Loops, edit the data and re-upload it, or start with a new CSV file and just include the contacts and columns you want to update.
When updating contacts, Loops will first look for a matching contact using the value in the `User ID` column, then the `Email` column.
If a contact is found, Loops will update the contact using the data provided in the CSV file. If a contact is not found with either `User ID` or `Email` values, a new contact will be created using the data provided.
### Some important notes
* The importer does not re-subscribe contacts who are marked as unsubscribed in your Audience, so updating the `Subscribed` column to `true` will not re-subscribe the contact.
* Cells that are empty in the CSV file will not overwrite existing data in Loops. If you want to clear a field, you will need to provide a value of `null` in the CSV file (4 characters, all lowercase). Note, this only works for `string` type fields.
## Trigger workflows via CSV
You can use CSV uploads as a way to trigger workflows on a group of new or existing contacts.
If you want to trigger workflows for existing contacts, download a CSV from your [Audience page](https://app.loops.so/audience) then re-upload the file. Only include the contacts you want to trigger workflows for in the CSV file.
After uploading the CSV, on the Review page select the **Trigger workflows** option. This will trigger all applicable workflows you have set up with **Contact created** and **Contact updated** triggers.
If you select to add contacts to mailing lists on this same page as well as toggle **Trigger workflows** ON, you will also trigger **Contact added to list** workflows.
## View previous CSV uploads
After uploading CSV files you can view a history of your uploads plus details for each file's rows.
On the [Audience page](https://app.loops.so/audience) click **Import contacts**, hover on **CSV** and click **Upload CSV**. On the next page click on **View imports**, which will show you a list of all of your past uploads.
Clicking on one of your uploads will let you view lists of all new, updated or duplicated contacts plus a list of any errors from the import.
# Add contacts via integrations
Source: https://loops.so/docs/add-users/integrations
Connect Loops to external platforms to automatically add contacts to your audience.
We have a range of integrations with other platforms, which allow syncing of data between services you use.
For example, you can add users from thousands of apps through [Segment](/docs/integrations/segment) or [Zapier](/docs/integrations/zapier), or use forms on platforms like [Framer](/docs/integrations/framer) or [Webflow](/docs/integrations/webflow) to let contacts sign up to your mailing list.
[Go to the Integrations page](/docs/integrations) to view the full list.
# Add and update contacts with the Loops API
Source: https://loops.so/docs/add-users/loops-api
Loops provides a REST API to manage your contacts.
With [the Loops API](/docs/api-reference/intro), you can easily manage contacts directly from your application or service.
For example, creating a new contact is as easy as sending a `POST` request to `https://app.loops.so/api/v1/contacts/create`.
```json theme={"dark"}
{
"email": "test@example.com",
"firstName": "Adam",
"lastName": "Kaczmarek",
"favoriteColor": "blue",
"userGroup": "Founders",
"source": "Signup form Service"
}
```
We also offer endpoints for finding, updating and deleting contacts (plus some other features like sending transactional email and sending events).
## Learn more
Find out how to send events using our API.
# Add a branch
Source: https://loops.so/docs/api-reference/add-workflow-branch
https://app.loops.so/openapi.json post /v1/workflows/{workflowId}/nodes/{nodeId}/add-branch
Add a branch and a child node under an existing Branch or Experiment node.
Adding a branch to a `BranchNode` creates one `AudienceFilter` child node.
Adding a branch to an `ExperimentBranchNode` creates one `VariantNode` child node.
This endpoint does not accept node configuration fields; [update the child node](/docs/api-reference/update-workflow-node) after creation. Public workflows are limited to 300 nodes, and this endpoint adds 1 node.
# API key
Source: https://loops.so/docs/api-reference/api-key
https://app.loops.so/openapi.json get /v1/api-key
Test your API key and get team context.
# Change workflow mailing list
Source: https://loops.so/docs/api-reference/change-workflow-mailing-list
https://app.loops.so/openapi.json post /v1/workflows/{workflowId}/mailing-list
Change a workflow's mailing list.
Use this endpoint to change a workflow's mailing list.
Make a dry run request using `dryRun: true`.
If queued contacts would be removed from the workflow due to the change of mailing list, Loops returns with `"status": "queuedContactsFound"` instead of applying the change. Retry with `queuedContactPolicy: "discard"` to apply the change and discard those contacts.
Confirmed update responses include the simplified workflow after the mailing list changes.
# API Changelog
Source: https://loops.so/docs/api-reference/changelog
Release notes for the Loops REST API and webhooks, covering new endpoints, fields, breaking changes, and deprecations across OpenAPI versions.
New:
* [Create audience segment](/docs/api-reference/create-audience-segment) endpoint.
* Endpoints for creating and editing [workflows](/docs/api-reference/intro#workflows) and [workflow nodes](/docs/api-reference/intro#workflow-nodes).
* Endpoints for reading [event patterns](/docs/api-reference/intro#event-patterns).
Updated: removed `queuedContactLimitReached` workflow node delete and mailing list change responses.
Updated: `rootNodeId` is now always a non-null string in [workflow](/docs/api-reference/get-workflow) responses.
New: endpoints for creating and updating themes and components.
New: [Guardian](/docs/api-reference/run-guardian-checks) endpoint for validating email message content via the API.
New: a `transactionalName` field in the [transactional.email.sent](/docs/webhooks#transactional-email-sent) webhook event.
New: endpoints for viewing [workflows](/docs/api-reference/list-workflows) and [workflow nodes](/docs/api-reference/get-workflow-node). These endpoints are currently in open alpha.
New: endpoints for [campaign groups](/docs/api-reference/list-campaign-groups), [transactional groups](/docs/api-reference/list-transactional-groups), [audience segments](/docs/api-reference/list-audience-segments), and [email message previews](/docs/api-reference/preview-email-message).
Updated:
* [Campaign](/docs/api-reference/create-campaign) endpoints now support audience targeting (mailing list, segment, or filter), groups, and scheduling.
* [Email message](/docs/api-reference/update-email-message) endpoints now support CC/BCC, language, email format, and property fallbacks.
* [Transactional email](/docs/api-reference/create-transactional-email) endpoints now support groups.
New: endpoints for creating and editing transactional emails. These endpoints are currently in an open alpha.
New: [Upload](/docs/api-reference/create-upload) endpoints for uploading images to use in emails.
New: endpoints for creating and editing campaigns. These endpoints are currently in an open alpha.
New: API endpoints to [check](/docs/api-reference/check-contact-suppression) and [remove](/docs/api-reference/remove-contact-suppression) suppression for contacts.
New: a `campaignName` field in the [campaign.email.sent](/docs/webhooks#campaign-email-sent) webhook event.
New: an [`email.resubscribed`](/docs/webhooks#email-resubscribed) webhook event for
when contacts resubscribe to your marketing email from an email's
"Unsubscribe" link.
New: an `optInStatus` field from [double opt-in](/docs/contacts/double-opt-in) in
the [Find contact](/docs/api-reference/find-contact) endpoint and in
`contact.created` [webhook events](/docs/webhooks).
New: [webhooks](/docs/webhooks) are now available to all users.
Improvement: all endpoints now return a `success` and `message` field in the error response bodies.
`error` and `path` top-level keys are now deprecated but will continue to be supported for some time for backward compatibility.
The docs have been updated to reflect that the `email` parameter is optional
in the [Update contact](/docs/api-reference/update-contact) endpoint if a `userId`
is provided.
Improvement: data variables are now optional in the [Send transactional
email](/docs/api-reference/send-transactional-email) endpoint. [Read
more](/docs/transactional#optional-data-variables)
New: an `Idempotency-Key` header when [sending
events](/docs/api-reference/send-event).
New: an `Idempotency-Key` header when [sending transactional
emails](/docs/api-reference/send-transactional-email).
New: an endpoint for [listing transactional
emails](/docs/api-reference/list-transactional-emails) and their data variables.
New: [webhooks](/docs/webhooks) are in Beta.
New: new endpoints for [creating contact properties](/docs/api-reference/create-contact-property) and [listing contact properties](/docs/api-reference/list-contact-properties).
Deprecation: the [List custom fields](/docs/api-reference/list-custom-fields) endpoint is now deprecated in favor of the new [List contact properties](/docs/api-reference/list-contact-properties) endpoint.
New: we added `description` to mailing list objects in the [List mailing
lists](/docs/api-reference/list-mailing-lists) endpoint.
Improvement: the maximum payload size for the transactional endpoint has been
increased from 1MB to 4MB, allowing for more or larger attachments.
New: our new [Nuxt module](/docs/sdks/nuxt) is now out!
New: our new [Ruby SDK](/docs/sdks/ruby) is available!
New: we added an `isPublic` attribute to mailing list objects in the [List
mailing lists](/docs/api-reference/list-mailing-lists) endpoint.
New: there's a new `addToAudience` parameter in the [Send transactional
email](/docs/api-reference/send-transactional-email) endpoint that can add contacts to
your audience.
New: support for our new [mailing lists](/docs/contacts/mailing-lists) feature.
You can now add contacts to and remove contacts from mailing lists in the [Create contact](/docs/api-reference/create-contact), [Update contact](/docs/api-reference/update-contact) and [Send event](/docs/api-reference/send-event) endpoints.
There is also a [new endpoint](/docs/api-reference/list-mailing-lists) for retrieving your mailing lists.
New: you can now [Find contacts](/docs/api-reference/find-contact) by `userId`.
New: a new endpoint for testing your integration and/or API key: [API
key](/docs/api-reference/api-key).
New: you can now include [event properties](/docs/events/properties) in requests to
the [Send event](/docs/api-reference/send-event) endpoint.
Improvement: we removed behavior that returned a `400 Bad Request` response if
an unrecognized property was added to Contact endpoints.
Improvement: sending in a payload that contains an unrecognized property will
now return a `400 Bad Request` response.
Clarification in the API docs that `userId` can be used in a [Send
event](/docs/api-reference/send-event) request.
New: we added a new endpoint for [querying custom contact
properties](/docs/api-reference/list-custom-fields).
Our [official JavaScript/TypeScript SDK](/docs/sdks/javascript) is now available!
New: we now accept dates as a custom [contact property](/docs/contacts/properties)
type. [View the available formats](/docs/contacts/properties#dates).
# Check contact suppression status
Source: https://loops.so/docs/api-reference/check-contact-suppression
https://app.loops.so/openapi.json get /v1/contacts/suppression
Check if a contact is suppressed by email address or user ID.
# Complete an upload
Source: https://loops.so/docs/api-reference/complete-upload
https://app.loops.so/openapi.json post /v1/uploads/{emailAssetId}/complete
Finalize an image upload after the file has been uploaded to the pre-signed URL.
# Create an audience segment
Source: https://loops.so/docs/api-reference/create-audience-segment
https://app.loops.so/openapi.json post /v1/audience-segments
Create a new audience segment.
# Create a campaign
Source: https://loops.so/docs/api-reference/create-campaign
https://app.loops.so/openapi.json post /v1/campaigns
Create a draft marketing campaign and its associated email message.
This endpoint creates a draft campaign and an empty email message in one step. Use the returned `emailMessageId` with [Update an email message](/docs/api-reference/update-email-message) to set subject, sender, preview text, and LMX content.
The audience (mailing list, segment, or filter), group, and scheduling can be set on create or later via [Update a campaign](/docs/api-reference/update-campaign).
To send a test preview of a campaign, use the [Preview email message](/docs/api-reference/preview-email-message) endpoint.
# Create a campaign group
Source: https://loops.so/docs/api-reference/create-campaign-group
https://app.loops.so/openapi.json post /v1/campaign-groups
Create a new campaign group.
# Create a component
Source: https://loops.so/docs/api-reference/create-component
https://app.loops.so/openapi.json post /v1/components
Create a new email component from an LMX body.
# Create a contact
Source: https://loops.so/docs/api-reference/create-contact
https://app.loops.so/openapi.json post /v1/contacts/create
Add a contact to your audience.
If you want to "update or create" contacts, consider using the [Update a contact](https://loops.so/docs/api-reference/update-contact) endpoint instead.
# Create a contact property
Source: https://loops.so/docs/api-reference/create-contact-property
https://app.loops.so/openapi.json post /v1/contacts/properties
Create a custom contact property.
# Create a theme
Source: https://loops.so/docs/api-reference/create-theme
https://app.loops.so/openapi.json post /v1/themes
Create a new email theme for use across Loops emails.
# Create a transactional email
Source: https://loops.so/docs/api-reference/create-transactional-email
https://app.loops.so/openapi.json post /v1/transactional-emails
Create a new transactional email and its draft email message, ready for editing, previewing, and publishing through the Loops API.
This endpoint creates a transactional email and an empty draft email message in one step.
Use the returned `draftEmailMessageId` and `draftEmailMessageContentRevisionId` when calling the [Update email message](/docs/api-reference/update-email-message) endpoint to set subject, sender, preview text, and LMX content.
Call [Publish transactional email draft](/docs/api-reference/publish-transactional-email) to publish.
# Create a transactional group
Source: https://loops.so/docs/api-reference/create-transactional-group
https://app.loops.so/openapi.json post /v1/transactional-groups
Create a new transactional group.
# Create an upload
Source: https://loops.so/docs/api-reference/create-upload
https://app.loops.so/openapi.json post /v1/uploads
Request a pre-signed URL to upload an image asset.
Request a pre-signed URL to upload an image asset. Upload the file with an HTTP `PUT` to the returned `presignedUrl` (sending the same `Content-Type` and `Content-Length`), then call [Complete an upload](/docs/api-reference/complete-upload) to finalize the asset.
# Create a workflow
Source: https://loops.so/docs/api-reference/create-workflow
https://app.loops.so/openapi.json post /v1/workflows
Create a draft workflow with a blank trigger and exit node.
# Create a workflow node
Source: https://loops.so/docs/api-reference/create-workflow-node
https://app.loops.so/openapi.json post /v1/workflows/{workflowId}/nodes
Create a new default workflow node and return it with the latest workflow
Choose where the node goes with `insertMode`:
* `between` places it between an existing `fromNodeId` -> `toNodeId` connection
* `before` places it before `toNodeId`
* `after` places it after `fromNodeId` when that node has exactly one outgoing connection.
Use `after` for simple linear paths when you do not want to fetch and pass the current child node ID. `after` is invalid when `fromNodeId` has no outgoing nodes, multiple outgoing nodes, or is an exit node. When the source has multiple outgoing nodes, use `between` with the exact `toNodeId` instead. For `before`, deprecated `beforeNodeId` requests are still accepted for compatibility, but new callers should use `toNodeId`.
New nodes start with default settings; update the node after creation to configure it. Branch nodes create their default paths too: `BranchNode` creates two `AudienceFilter` children, and `ExperimentBranchNode` creates two regular `VariantNode` children plus one control `VariantNode`.
Public workflows can have up to 300 nodes. Generated children count toward that limit, so a normal create adds 1 node, `BranchNode` adds 3, and `ExperimentBranchNode` adds 4. To add a sibling child path to a branch or experiment branch, use the `POST /v1/workflows/{workflowId}/nodes/{nodeId}/add-branch` endpoint. Branch paths can be edited with create-node, but a workflow cannot be started unless each direct `BranchNode` child is an `AudienceFilter`. For experiments, use create-node only to insert a missing `VariantNode` before non-variant content; use add-branch for another variant path.
# List dedicated sending IP addresses
Source: https://loops.so/docs/api-reference/dedicated-sending-ips
https://app.loops.so/openapi.json get /v1/dedicated-sending-ips
Retrieve a list of Loops' dedicated sending IP addresses.
This endpoint is provided for the rare instances where you may need to whitelist our sending IPs. Please note that this list is subject to change and will not include shared IPs used for sending mail. Unless you are sure you need this and are comfortable watching for changes, we strongly recommend you *do not* whitelist these IPs.
# Delete a contact
Source: https://loops.so/docs/api-reference/delete-contact
https://app.loops.so/openapi.json post /v1/contacts/delete
Delete a contact by email address or user ID.
Delete a contact by `email` or `userId`.
# Delete a workflow node
Source: https://loops.so/docs/api-reference/delete-workflow-node
https://app.loops.so/openapi.json delete /v1/workflows/{workflowId}/nodes/{nodeId}
Delete a single workflow node.
Delete a node from a workflow.
Make a dry run request using `dryRun: true`.
If contacts are queued at the node, Loops returns with `"status": "queuedContactsFound"` instead of deleting. Retry with `queuedContactPolicy: "discard"` to delete the node and discard those queued contacts.
Confirmed deletion responses include the simplified workflow after the node is removed.
# Delete workflow nodes recursively
Source: https://loops.so/docs/api-reference/delete-workflow-nodes
https://app.loops.so/openapi.json delete /v1/workflows/{workflowId}/nodes/{nodeId}/recursive
Delete a node and its downstream subtree.
Delete a node and its downstream subtree.
Make a dry run request using `dryRun: true`.
If contacts are queued at any node that would be deleted, Loops returns with `"status": "queuedContactsFound"` instead of deleting. Retry with `queuedContactPolicy: "discard"` to delete the nodes and discard those queued contacts.
Confirmed deletion responses include the simplified workflow after the nodes are removed.
# Ensure a transactional email has a draft
Source: https://loops.so/docs/api-reference/ensure-transactional-draft
https://app.loops.so/openapi.json post /v1/transactional-emails/{transactionalId}/draft
Ensure a transactional email has a draft email message ready for editing, creating one from the published version if no draft currently exists.
If a draft email message already exists, it is returned unchanged. Otherwise a new empty draft is created (seeded from the most recent published version when present).
Use the returned `draftEmailMessageId` and `draftEmailMessageContentRevisionId` when calling [Update an email message](/docs/api-reference/update-email-message) to edit the draft's content.
# API Examples
Source: https://loops.so/docs/api-reference/examples
Browse copy/paste code examples for the Loops API and SDKs covering contacts, events, campaigns, transactional emails, and content revisions.
Code examples for managing contacts.
Code examples for creating campaigns, targeting audiences, sending previews, and editing email content with LMX.
Code examples for accessing workflows and their nodes.
Code examples for sending events.
Code examples for creating, previewing, publishing, and sending transactional emails.
# Campaigns API examples
Source: https://loops.so/docs/api-reference/examples/campaigns
Code examples for creating campaigns, targeting audiences, sending previews, and updating email messages with content revisions via the Loops API, SDKs, and CLI.
## Create a campaign
This creates a draft campaign and a related email message in one request.
Only a `name` value is required.
Save the returned `emailMessageContentRevisionId`. Pass it as
`expectedRevisionId` when updating an email message to avoid `409 Conflict` errors caused by stale revisions.
[API reference](/docs/api-reference/create-campaign)\
[CLI reference](/docs/cli/campaigns#create)
```bash CLI theme={"dark"}
loops campaigns create --name "Spring product announcement" -o json
```
```js JavaScript theme={"dark"}
const response = await fetch("https://app.loops.so/api/v1/campaigns", {
method: "POST",
headers: {
"Authorization": "Bearer ",
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Spring product announcement",
}),
});
const data = await response.json();
const campaignId = data.id;
const emailMessageId = data.emailMessageId;
const emailMessageContentRevisionId = data.emailMessageContentRevisionId;
```
```js JavaScript SDK theme={"dark"}
import { LoopsClient } from "loops";
const loops = new LoopsClient("");
const data = await loops.createCampaign({
name: "Spring product announcement",
});
const campaignId = data.id;
const emailMessageId = data.emailMessageId;
const emailMessageContentRevisionId = data.emailMessageContentRevisionId;
```
```php PHP SDK theme={"dark"}
use Loops\LoopsClient;
$loops = new LoopsClient('');
$result = $loops->campaigns->create(name: 'Spring product announcement');
$campaign_id = $result['id'];
$email_message_id = $result['emailMessageId'];
$content_revision_id = $result['emailMessageContentRevisionId'];
```
```ruby Ruby SDK theme={"dark"}
response = LoopsSdk::Campaigns.create(
name: "Spring product announcement",
)
campaign_id = response["id"]
email_message_id = response["emailMessageId"]
content_revision_id = response["emailMessageContentRevisionId"]
```
```python Python theme={"dark"}
import requests
response = requests.post(
"https://app.loops.so/api/v1/campaigns",
headers={
"Authorization": "Bearer ",
"Content-Type": "application/json",
},
json={
"name": "Spring product announcement",
},
)
data = response.json()
campaign_id = data["id"]
email_message_id = data["emailMessageId"]
content_revision_id = data["emailMessageContentRevisionId"]
```
## Query themes and components for your LMX
You can fetch your available themes and reusable components before building
the `lmx` payload.
List themes [API reference](/docs/api-reference/list-themes) [CLI reference](/docs/cli/themes#list)\
List components [API reference](/docs/api-reference/list-components) [CLI reference](/docs/cli/components#list)
```bash CLI theme={"dark"}
loops themes list -o json
loops components list -o json
```
```js JavaScript theme={"dark"}
const [themesResponse, componentsResponse] = await Promise.all([
fetch("https://app.loops.so/api/v1/themes?perPage=20", {
method: "GET",
headers: {
"Authorization": "Bearer ",
},
}),
fetch("https://app.loops.so/api/v1/components?perPage=20", {
method: "GET",
headers: {
"Authorization": "Bearer ",
},
}),
]);
const themes = await themesResponse.json();
const components = await componentsResponse.json();
```
```js JavaScript SDK theme={"dark"}
import { LoopsClient } from "loops";
const loops = new LoopsClient("");
const [themes, components] = await Promise.all([
loops.listThemes({ perPage: 20 }),
loops.listComponents({ perPage: 20 }),
]);
```
```php PHP SDK theme={"dark"}
use Loops\LoopsClient;
$loops = new LoopsClient('');
$themes = $loops->themes->list(per_page: 20);
$components = $loops->components->list(per_page: 20);
```
```ruby Ruby SDK theme={"dark"}
themes = LoopsSdk::Themes.list(perPage: 20)
components = LoopsSdk::Components.list(perPage: 20)
```
```python Python theme={"dark"}
import requests
themes_response = requests.get(
"https://app.loops.so/api/v1/themes",
headers={
"Authorization": "Bearer ",
},
params={"perPage": 20},
)
components_response = requests.get(
"https://app.loops.so/api/v1/components",
headers={
"Authorization": "Bearer ",
},
params={"perPage": 20},
)
themes = themes_response.json()
components = components_response.json()
```
## Update the related email message with `contentRevisionId`
Use `emailMessageId` from when you created the campaign as the path parameter, and pass the `emailMessageContentRevisionId` as `expectedRevisionId`.
Apply styles or a theme in ``, and create an email using LMX elements.
Themes and components you queried in step 2 can be referenced by their IDs.
Save the returned `contentRevisionId` after each update. Pass it as
`expectedRevisionId` on the next update to avoid `409 Conflict` errors caused
by stale revisions.
[API reference](/docs/api-reference/update-email-message)\
[CLI reference](/docs/cli/email-messages#update)
```bash CLI theme={"dark"}
loops email-messages update $emailMessageId \
--expected-revision-id $emailMessageContentRevisionId \
--subject "Big spring updates" \
--preview-text "A quick look at what's new" \
--from-name "Loops" \
--from-email hello \
--reply-to support@example.com \
--lmx-file ./email.lmx
```
```js JavaScript theme={"dark"}
const lmxContent = `
Hey there, here is what's new.Read the full changelog in your dashboard.`;
const response = await fetch(
`https://app.loops.so/api/v1/email-messages/${emailMessageId}`,
{
method: "POST",
headers: {
"Authorization": "Bearer ",
"Content-Type": "application/json",
},
body: JSON.stringify({
expectedRevisionId: emailMessageContentRevisionId,
subject: "Big spring updates",
previewText: "A quick look at what's new",
fromName: "Loops",
fromEmail: "hello",
replyToEmail: "support@example.com",
lmx: lmxContent,
}),
},
);
const updated = await response.json();
const nextContentRevisionId = updated.contentRevisionId;
```
```js JavaScript SDK theme={"dark"}
import { LoopsClient } from "loops";
const loops = new LoopsClient("");
const lmxContent = `
Hey there, here is what's new.Read the full changelog in your dashboard.`;
const updated = await loops.updateEmailMessage(emailMessageId, {
expectedRevisionId: emailMessageContentRevisionId,
subject: "Big spring updates",
previewText: "A quick look at what's new",
fromName: "Loops",
fromEmail: "hello",
replyToEmail: "support@example.com",
lmx: lmxContent,
});
const nextContentRevisionId = updated.contentRevisionId;
```
```php PHP SDK theme={"dark"}
use Loops\LoopsClient;
$loops = new LoopsClient('');
$lmx_content = <<Hey there, here is what's new.Read the full changelog in your dashboard.
LMX;
$updated = $loops->emailMessages->update(
email_message_id: $email_message_id,
expected_revision_id: $content_revision_id,
subject: 'Big spring updates',
preview_text: 'A quick look at what\'s new',
from_name: 'Loops',
from_email: 'hello',
reply_to_email: 'support@example.com',
lmx: $lmx_content,
);
$next_content_revision_id = $updated['contentRevisionId'];
```
```ruby Ruby SDK theme={"dark"}
lmx_content = <<~LMX
Hey there, here is what's new.Read the full changelog in your dashboard.
LMX
updated = LoopsSdk::EmailMessages.update(
email_message_id: email_message_id,
expected_revision_id: content_revision_id,
subject: "Big spring updates",
preview_text: "A quick look at what's new",
from_name: "Loops",
from_email: "hello",
reply_to_email: "support@example.com",
lmx: lmx_content,
)
next_content_revision_id = updated["contentRevisionId"]
```
```python Python theme={"dark"}
import requests
response = requests.post(
f"https://app.loops.so/api/v1/email-messages/{email_message_id}",
headers={
"Authorization": "Bearer ",
"Content-Type": "application/json",
},
json={
"expectedRevisionId": content_revision_id,
"subject": "Big spring updates",
"previewText": "A quick look at what's new",
"fromName": "Loops",
"fromEmail": "hello",
"replyToEmail": "support@example.com",
"lmx": "Hey there, here is what's new.",
},
)
updated = response.json()
next_content_revision_id = updated["contentRevisionId"]
```
## Upload an image asset
If your LMX includes `` tags, upload image files with the Upload API
and use the returned `finalUrl` as the image `src`.
[Create upload API reference](/docs/api-reference/create-upload)\
[Complete upload API reference](/docs/api-reference/complete-upload)\
[Upload CLI reference](/docs/cli/uploads#create)
```bash CLI theme={"dark"}
loops uploads create ./header.png -o json
```
```js JavaScript theme={"dark"}
const createResponse = await fetch("https://app.loops.so/api/v1/uploads", {
method: "POST",
headers: {
"Authorization": "Bearer ",
"Content-Type": "application/json",
},
body: JSON.stringify({
contentType: "image/png",
contentLength: imageBuffer.byteLength,
}),
});
const { emailAssetId, presignedUrl } = await createResponse.json();
await fetch(presignedUrl, {
method: "PUT",
headers: {
"Content-Type": "image/png",
"Content-Length": String(imageBuffer.byteLength),
},
body: imageBuffer,
});
const completeResponse = await fetch(
`https://app.loops.so/api/v1/uploads/${emailAssetId}/complete`,
{
method: "POST",
headers: {
"Authorization": "Bearer ",
},
},
);
const { finalUrl } = await completeResponse.json();
```
```js JavaScript SDK theme={"dark"}
import { LoopsClient } from "loops";
const loops = new LoopsClient("");
const { emailAssetId, presignedUrl } = await loops.createUpload({
contentType: "image/png",
contentLength: imageBuffer.byteLength,
});
await fetch(presignedUrl, {
method: "PUT",
headers: {
"Content-Type": "image/png",
"Content-Length": String(imageBuffer.byteLength),
},
body: imageBuffer,
});
const { finalUrl } = await loops.completeUpload(emailAssetId);
```
```php PHP SDK theme={"dark"}
use Loops\LoopsClient;
$loops = new LoopsClient('');
$result = $loops->uploads->upload(path: './header.png');
$final_url = $result['finalUrl'];
```
```ruby Ruby SDK theme={"dark"}
response = LoopsSdk::Uploads.upload(path: "./header.png")
final_url = response["finalUrl"]
```
```python Python theme={"dark"}
import requests
create_response = requests.post(
"https://app.loops.so/api/v1/uploads",
headers={
"Authorization": "Bearer ",
"Content-Type": "application/json",
},
json={
"contentType": "image/png",
"contentLength": len(image_bytes),
},
)
create_data = create_response.json()
email_asset_id = create_data["emailAssetId"]
presigned_url = create_data["presignedUrl"]
requests.put(
presigned_url,
headers={
"Content-Type": "image/png",
"Content-Length": str(len(image_bytes)),
},
data=image_bytes,
)
complete_response = requests.post(
f"https://app.loops.so/api/v1/uploads/{email_asset_id}/complete",
headers={
"Authorization": "Bearer ",
},
)
final_url = complete_response.json()["finalUrl"]
```
## Target an audience segment
List your [audience segments](/docs/api-reference/list-audience-segments) to find a
segment ID, then apply it to a draft campaign with
[Update a campaign](/docs/api-reference/update-campaign).
Setting `audienceSegmentId` clears any existing `audienceFilter` on the campaign.
[API reference](/docs/api-reference/update-campaign)\
[CLI reference](/docs/cli/campaigns#update)
```js JavaScript theme={"dark"}
const segmentsResponse = await fetch(
"https://app.loops.so/api/v1/audience-segments",
{
method: "GET",
headers: {
"Authorization": "Bearer ",
},
},
);
const { data: segments } = await segmentsResponse.json();
const audienceSegmentId = segments[0].id;
const updateResponse = await fetch(
`https://app.loops.so/api/v1/campaigns/${campaignId}`,
{
method: "POST",
headers: {
"Authorization": "Bearer ",
"Content-Type": "application/json",
},
body: JSON.stringify({
audienceSegmentId,
}),
},
);
const updatedCampaign = await updateResponse.json();
```
```js JavaScript SDK theme={"dark"}
import { LoopsClient } from "loops";
const loops = new LoopsClient("");
const { data: segments } = await loops.listAudienceSegments();
const audienceSegmentId = segments[0].id;
const updatedCampaign = await loops.updateCampaign(campaignId, {
audienceSegmentId,
});
```
```php PHP SDK theme={"dark"}
use Loops\LoopsClient;
$loops = new LoopsClient('');
$segments = $loops->audienceSegments->list();
$audience_segment_id = $segments['data'][0]['id'];
$updated_campaign = $loops->campaigns->update(
campaign_id: $campaign_id,
audience_segment_id: $audience_segment_id,
);
```
```ruby Ruby SDK theme={"dark"}
segments = LoopsSdk::AudienceSegments.list
audience_segment_id = segments["data"][0]["id"]
updated_campaign = LoopsSdk::Campaigns.update(
campaign_id: campaign_id,
audience_segment_id: audience_segment_id,
)
```
```python Python theme={"dark"}
import requests
segments_response = requests.get(
"https://app.loops.so/api/v1/audience-segments",
headers={
"Authorization": "Bearer ",
},
)
segments = segments_response.json()["data"]
audience_segment_id = segments[0]["id"]
update_response = requests.post(
f"https://app.loops.so/api/v1/campaigns/{campaign_id}",
headers={
"Authorization": "Bearer ",
"Content-Type": "application/json",
},
json={
"audienceSegmentId": audience_segment_id,
},
)
updated_campaign = update_response.json()
```
You can also set the segment when creating the campaign:
```js JavaScript theme={"dark"}
const response = await fetch("https://app.loops.so/api/v1/campaigns", {
method: "POST",
headers: {
"Authorization": "Bearer ",
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Spring product announcement",
audienceSegmentId: "clr4c8t0v008yl70x3y4z5a6b",
}),
});
```
```js JavaScript SDK theme={"dark"}
import { LoopsClient } from "loops";
const loops = new LoopsClient("");
const data = await loops.createCampaign({
name: "Spring product announcement",
audienceSegmentId: "clr4c8t0v008yl70x3y4z5a6b",
});
```
```php PHP SDK theme={"dark"}
use Loops\LoopsClient;
$loops = new LoopsClient('');
$result = $loops->campaigns->create(
name: 'Spring product announcement',
audience_segment_id: 'clr4c8t0v008yl70x3y4z5a6b',
);
```
```ruby Ruby SDK theme={"dark"}
response = LoopsSdk::Campaigns.create(
name: "Spring product announcement",
audience_segment_id: "clr4c8t0v008yl70x3y4z5a6b",
)
```
```python Python theme={"dark"}
import requests
response = requests.post(
"https://app.loops.so/api/v1/campaigns",
headers={
"Authorization": "Bearer ",
"Content-Type": "application/json",
},
json={
"name": "Spring product announcement",
"audienceSegmentId": "clr4c8t0v008yl70x3y4z5a6b",
},
)
```
## Send a campaign preview
After updating the campaign's email message, send a test preview to one or more
addresses. Campaign previews accept `contactProperties` for personalization.
Use `emailMessageId` from when you created the campaign as the path parameter.
[API reference](/docs/api-reference/preview-email-message)
```js JavaScript theme={"dark"}
const previewResponse = await fetch(
`https://app.loops.so/api/v1/email-messages/${emailMessageId}/preview`,
{
method: "POST",
headers: {
"Authorization": "Bearer ",
"Content-Type": "application/json",
},
body: JSON.stringify({
emails: ["you@example.com"],
contactProperties: {
firstName: "Alex",
plan: "Pro",
},
}),
},
);
const preview = await previewResponse.json();
```
```js JavaScript SDK theme={"dark"}
import { LoopsClient } from "loops";
const loops = new LoopsClient("");
const preview = await loops.sendEmailMessagePreview(emailMessageId, {
emails: ["you@example.com"],
contactProperties: {
firstName: "Alex",
plan: "Pro",
},
});
```
```php PHP SDK theme={"dark"}
use Loops\LoopsClient;
$loops = new LoopsClient('');
$preview = $loops->emailMessages->preview(
email_message_id: $email_message_id,
emails: ['you@example.com'],
contact_properties: [
'firstName' => 'Alex',
'plan' => 'Pro',
],
);
```
```ruby Ruby SDK theme={"dark"}
preview = LoopsSdk::EmailMessages.preview(
email_message_id: email_message_id,
emails: ["you@example.com"],
contact_properties: {
firstName: "Alex",
plan: "Pro",
},
)
```
```python Python theme={"dark"}
import requests
preview_response = requests.post(
f"https://app.loops.so/api/v1/email-messages/{email_message_id}/preview",
headers={
"Authorization": "Bearer ",
"Content-Type": "application/json",
},
json={
"emails": ["you@example.com"],
"contactProperties": {
"firstName": "Alex",
"plan": "Pro",
},
},
)
preview = preview_response.json()
```
# Contacts API examples
Source: https://loops.so/docs/api-reference/examples/contacts
Copy/paste code examples for managing contacts with the Loops API, CLI, and SDKs, including create, update, find, and delete requests with sample payloads.
## Create a contact
[API reference](/docs/api-reference/create-contact)\
[CLI reference](/docs/cli/contacts#create)
```bash CLI theme={"dark"}
loops contacts create \
--email test@example.com \
--first-name John \
--last-name Doe
```
```js JavaScript theme={"dark"}
const response = await fetch("https://app.loops.so/api/v1/contacts/create", {
method: "POST",
headers: {
"Authorization": "Bearer ",
"Content-Type": "application/json",
},
body: JSON.stringify({
email: "test@example.com",
firstName: "John",
lastName: "Doe",
}),
});
const data = await response.json();
```
```js JavaScript SDK theme={"dark"}
import { LoopsClient } from "loops";
const loops = new LoopsClient("");
const response = await loops.createContact(
"test@example.com",
{
firstName: "John",
lastName: "Doe"
},
);
```
```php PHP SDK theme={"dark"}
use Loops\LoopsClient;
$loops = new LoopsClient("");
$result = $loops->contacts->create(
email: 'test@example.com',
properties: [
'firstName' => 'John',
'lastName' => 'Doe',
],
);
```
```ruby Ruby SDK theme={"dark"}
response = LoopsSdk::Contacts.create(
email: "test@example.com",
properties: {
firstName: "John",
lastName: "Doe",
},
)
```
```python Python theme={"dark"}
import requests
response = requests.post(
"https://app.loops.so/api/v1/contacts/create",
headers={
"Authorization": "Bearer ",
"Content-Type": "application/json"
},
json={
"email": "test@example.com",
"firstName": "John",
"lastName": "Doe"
}
)
```
## Create a contact and add them to a mailing list
[API reference](/docs/api-reference/create-contact)\
[CLI reference](/docs/cli/contacts#create)
```bash CLI {5} theme={"dark"}
loops contacts create \
--email test@example.com \
--first-name John \
--last-name Doe \
--list "=true"
```
```js JavaScript {11-13} theme={"dark"}
const response = await fetch("https://app.loops.so/api/v1/contacts/create", {
method: "POST",
headers: {
"Authorization": "Bearer ",
"Content-Type": "application/json",
},
body: JSON.stringify({
email: "test@example.com",
firstName: "John",
lastName: "Doe",
mailingLists: {
"": true
},
}),
});
const data = await response.json();
```
```js JavaScript SDK {10-12,17} theme={"dark"}
import { LoopsClient } from "loops";
const loops = new LoopsClient("");
const properties = {
firstName: "John",
lastName: "Doe",
};
const mailingLists = {
"": true,
};
const response = await loops.createContact(
"test@example.com",
properties,
mailingLists,
);
```
```php PHP SDK {11-13} theme={"dark"}
use Loops\LoopsClient;
$loops = new LoopsClient("");
$result = $loops->contacts->create(
email: 'test@example.com',
properties: [
'firstName' => 'John',
'lastName' => 'Doe',
],
mailing_lists: [
'' => TRUE,
],
);
```
```ruby Ruby SDK {7-9} theme={"dark"}
response = LoopsSdk::Contacts.create(
email: "test@example.com",
properties: {
firstName: "John",
lastName: "Doe",
},
mailing_lists: {
"": true,
},
)
```
```python Python {13-15} theme={"dark"}
import requests
response = requests.post(
"https://app.loops.so/api/v1/contacts/create",
headers={
"Authorization": "Bearer ",
"Content-Type": "application/json"
},
json={
"email": "test@example.com",
"firstName": "John",
"lastName": "Doe",
"mailingLists": {
"": True
}
}
)
```
## Update a contact
When updating a contact you must provide an `email` or `userId` value to identify the contact.
You can use the "update" endpoint to update or create contacts. If the provided email or user ID does not exist, a new contact will be created.
[API reference](/docs/api-reference/update-contact)\
[CLI reference](/docs/cli/contacts#update)
```bash CLI theme={"dark"}
loops contacts update \
--email test@example.com \
--prop planName=Pro
```
```js JavaScript theme={"dark"}
const response = await fetch("https://app.loops.so/api/v1/contacts/update", {
method: "PUT",
headers: {
"Authorization": "Bearer ",
"Content-Type": "application/json"
},
body: JSON.stringify({
email: "test@example.com",
planName: "Pro",
}),
});
const data = await response.json();
```
```js JavaScript SDK theme={"dark"}
import { LoopsClient } from "loops";
const loops = new LoopsClient("");
const response = await loops.updateContact(
"test@example.com",
{
planName: "Pro",
},
);
```
```php PHP SDK theme={"dark"}
use Loops\LoopsClient;
$loops = new LoopsClient("");
$result = $loops->contacts->update(
email: 'test@example.com',
properties: [
'planName' => 'Pro',
],
);
```
```ruby Ruby SDK theme={"dark"}
response = LoopsSdk::Contacts.update(
email: "test@example.com",
properties: {
planName: "Pro",
},
)
```
```python Python theme={"dark"}
import requests
response = requests.put(
"https://app.loops.so/api/v1/contacts/update",
headers={
"Authorization": "Bearer ",
"Content-Type": "application/json"
},
json={
"email": "test@example.com",
"planName": "Pro"
}
)
```
## Update a contact's email address
For this the contact will need to already have a `userId` value set.
[API reference](/docs/api-reference/update-contact)\
[CLI reference](/docs/cli/contacts#update)
```bash CLI theme={"dark"}
loops contacts update \
--user-id 12345 \
--email test@example.com
```
```js JavaScript theme={"dark"}
const response = await fetch("https://app.loops.so/api/v1/contacts/update", {
method: "PUT",
headers: {
"Authorization": "Bearer ",
"Content-Type": "application/json"
},
body: JSON.stringify({
userId: "12345",
email: "test@example.com",
}),
});
const data = await response.json();
```
```js JavaScript SDK theme={"dark"}
import { LoopsClient } from "loops";
const loops = new LoopsClient("");
const response = await loops.updateContact(
"test@example.com",
{
userId: "12345",
},
);
```
```php PHP SDK theme={"dark"}
use Loops\LoopsClient;
$loops = new LoopsClient("");
$result = $loops->contacts->update(
email: 'test@example.com',
properties: [
'userId' => '12345',
],
);
```
```ruby Ruby SDK theme={"dark"}
response = LoopsSdk::Contacts.update(
email: "test@example.com",
properties: {
userId: "12345",
},
)
```
```python Python theme={"dark"}
import requests
response = requests.put(
"https://app.loops.so/api/v1/contacts/update",
headers={
"Authorization": "Bearer ",
"Content-Type": "application/json"
},
json={
"email": "test@example.com",
"userId": "12345"
}
)
```
## Subscribe a contact to a mailing list
[API reference](/docs/api-reference/update-contact)\
[CLI reference](/docs/cli/contacts#update)
```bash CLI theme={"dark"}
loops contacts update \
--email test@example.com \
--list "=true"
```
```js JavaScript theme={"dark"}
const response = await fetch("https://app.loops.so/api/v1/contacts/update", {
method: "PUT",
headers: {
"Authorization": "Bearer ",
"Content-Type": "application/json"
},
body: JSON.stringify({
email: "test@example.com",
mailingLists: {
"" => true
},
}),
});
const data = await response.json();
```
```js JavaScript SDK theme={"dark"}
import { LoopsClient } from "loops";
const loops = new LoopsClient("");
const response = await loops.updateContact(
"test@example.com",
{},
{
"": true,
},
);
```
```php PHP SDK theme={"dark"}
use Loops\LoopsClient;
$loops = new LoopsClient("");
$result = $loops->contacts->update(
email: 'test@example.com',
mailing_lists: [
'' => TRUE,
],
);
```
```ruby Ruby SDK theme={"dark"}
response = LoopsSdk::Contacts.update(
email: "test@example.com",
mailing_lists: {
"" => true,
},
)
```
```python Python theme={"dark"}
import requests
response = requests.put(
"https://app.loops.so/api/v1/contacts/update",
headers={
"Authorization": "Bearer ",
"Content-Type": "application/json"
},
json={
"email": "test@example.com",
"mailingLists": {
"": True
}
}
)
```
## Unsubscribe a contact from a mailing list
This removes a contact from a specific mailing list. [See below](#unsubscribe-a-contact) to see how to fully unsubscribe a contact.
Use `false` to unsubscribe a contact from a mailing list.
[API reference](/docs/api-reference/update-contact)\
[CLI reference](/docs/cli/contacts#update)
```bash CLI {4} theme={"dark"}
loops contacts update \
--email test@example.com \
--list "=false"
```
```js JavaScript {9-11} theme={"dark"}
const response = await fetch("https://app.loops.so/api/v1/contacts/update", {
method: "PUT",
headers: {
"Authorization": "Bearer ",
"Content-Type": "application/json"
},
body: JSON.stringify({
email: "test@example.com",
mailingLists: {
"" => false
},
}),
});
const data = await response.json();
```
```js JavaScript SDK {8-10} theme={"dark"}
import { LoopsClient } from "loops";
const loops = new LoopsClient("");
const response = await loops.updateContact(
"test@example.com",
{},
{
"" => false,
},
);
```
```php PHP SDK {7-9} theme={"dark"}
use Loops\LoopsClient;
$loops = new LoopsClient("");
$result = $loops->contacts->update(
email: 'test@example.com',
mailing_lists: [
'' => FALSE,
],
);
```
```ruby Ruby SDK {3-5} theme={"dark"}
response = LoopsSdk::Contacts.update(
email: "test@example.com",
mailing_lists: {
"" => false,
},
)
```
```python Python {11-13} theme={"dark"}
import requests
response = requests.put(
"https://app.loops.so/api/v1/contacts/update",
headers={
"Authorization": "Bearer ",
"Content-Type": "application/json"
},
json={
"email": "test@example.com",
"mailingLists": {
"": False
}
}
)
```
## Unsubscribe a contact
Set `subscribed` to `false` to unsubscribe a contact. The contact will no longer receive campaign or workflow emails, but will remain listed in your audience.
[API reference](/docs/api-reference/update-contact)\
[CLI reference](/docs/cli/contacts#update)
```bash CLI {3} theme={"dark"}
loops contacts update \
--email test@example.com \
--subscribed false
```
```js JavaScript {9} theme={"dark"}
const response = await fetch("https://app.loops.so/api/v1/contacts/update", {
method: "PUT",
headers: {
"Authorization": "Bearer ",
"Content-Type": "application/json"
},
body: JSON.stringify({
email: "test@example.com",
subscribed: false,
}),
});
const data = await response.json();
```
```js JavaScript SDK {7-9} theme={"dark"}
import { LoopsClient } from "loops";
const loops = new LoopsClient("");
const response = await loops.updateContact(
"test@example.com",
{
subscribed: false,
},
);
```
```php PHP SDK {7} theme={"dark"}
use Loops\LoopsClient;
$loops = new LoopsClient("");
$result = $loops->contacts->update(
email: 'test@example.com',
subscribed: false,
);
```
```ruby Ruby SDK {3} theme={"dark"}
response = LoopsSdk::Contacts.update(
email: "test@example.com",
subscribed: false,
)
```
```python Python {11} theme={"dark"}
import requests
response = requests.put(
"https://app.loops.so/api/v1/contacts/update",
headers={
"Authorization": "Bearer ",
"Content-Type": "application/json"
},
json={
"email": "test@example.com",
"subscribed": False
}
)
```
## Delete a contact
You can delete contacts by email or user ID.
[API reference](/docs/api-reference/delete-contact)\
[CLI reference](/docs/cli/contacts#delete)
```bash CLI theme={"dark"}
loops contacts delete --email test@example.com
```
```js JavaScript theme={"dark"}
const response = await fetch("https://app.loops.so/api/v1/contacts/delete", {
method: "POST",
headers: {
"Authorization": "Bearer ",
"Content-Type": "application/json"
},
body: JSON.stringify({
email: "test@example.com",
}),
});
const data = await response.json();
```
```js JavaScript SDK theme={"dark"}
import { LoopsClient } from "loops";
const loops = new LoopsClient("");
const response = await loops.deleteContact({
email: "test@example.com",
});
```
```php PHP SDK theme={"dark"}
use Loops\LoopsClient;
$loops = new LoopsClient("");
$result = $loops->contacts->delete(
email: 'test@example.com',
);
```
```ruby Ruby SDK theme={"dark"}
response = LoopsSdk::Contacts.delete(
email: "test@example.com",
)
```
```python Python theme={"dark"}
import requests
response = requests.post(
"https://app.loops.so/api/v1/contacts/delete",
headers={
"Authorization": "Bearer ",
"Content-Type": "application/json"
},
json={
"email": "test@example.com",
}
)
```
## Check contact suppression status
[API reference](/docs/api-reference/check-contact-suppression)\
[CLI reference](/docs/cli/contacts#suppression-check)
```bash CLI theme={"dark"}
loops contacts suppression check --email test@example.com
```
```js JavaScript theme={"dark"}
const params = new URLSearchParams({ email: "test@example.com" });
const response = await fetch(
`https://app.loops.so/api/v1/contacts/suppression?${params.toString()}`,
{
method: "GET",
headers: {
"Authorization": "Bearer ",
},
},
);
const data = await response.json();
```
```python Python theme={"dark"}
import requests
response = requests.get(
"https://app.loops.so/api/v1/contacts/suppression",
headers={
"Authorization": "Bearer ",
},
params={
"email": "test@example.com",
},
)
data = response.json()
```
## Remove suppression for a contact
[API reference](/docs/api-reference/remove-contact-suppression)\
[CLI reference](/docs/cli/contacts#suppression-remove)
```bash CLI theme={"dark"}
loops contacts suppression remove --email test@example.com
```
```js JavaScript theme={"dark"}
const params = new URLSearchParams({ email: "test@example.com" });
const response = await fetch(
`https://app.loops.so/api/v1/contacts/suppression?${params.toString()}`,
{
method: "DELETE",
headers: {
"Authorization": "Bearer ",
},
},
);
const data = await response.json();
```
```python Python theme={"dark"}
import requests
response = requests.delete(
"https://app.loops.so/api/v1/contacts/suppression",
headers={
"Authorization": "Bearer ",
},
params={
"email": "test@example.com",
},
)
data = response.json()
```
# Events API examples
Source: https://loops.so/docs/api-reference/examples/events
Copy/paste code examples for sending events to Loops via the API, CLI, and SDKs to trigger workflows and attach event properties, with sample payloads.
## Send an event
[API reference](/docs/api-reference/send-event)\
[CLI reference](/docs/cli/events#send)
```bash CLI theme={"dark"}
loops events send testEvent --email test@example.com
```
```js JavaScript theme={"dark"}
await fetch("https://app.loops.so/api/v1/events/send", {
method: "POST",
headers: {
"Authorization": "Bearer ",
"Content-Type": "application/json"
},
body: JSON.stringify({
email: "test@example.com",
eventName: "testEvent",
}),
});
```
```js JavaScript SDK theme={"dark"}
import { LoopsClient } from "loops";
const loops = new LoopsClient("");
const response = await loops.sendEvent({
email: "test@example.com",
eventName: "testEvent",
});
```
```php PHP SDK theme={"dark"}
use Loops\LoopsClient;
$loops = new LoopsClient("");
$result = $loops->events->send(
email: 'test@example.com',
event_name: 'testEvent',
);
```
```ruby Ruby SDK theme={"dark"}
response = LoopsSdk::Events.send(
email: "test@example.com",
event_name: "testEvent",
)
```
```python Python theme={"dark"}
import requests
response = requests.post(
"https://app.loops.so/api/v1/events/send",
headers={
"Authorization": "Bearer ",
"Content-Type": "application/json"
},
json={
"email": "test@example.com",
"eventName": "testEvent",
}
)
```
## Send an event with event properties
Include data that can be used in your workflow emails triggered by the event.
[API reference](/docs/api-reference/send-event)\
[CLI reference](/docs/cli/events#send)
```bash CLI {3} theme={"dark"}
loops events send testEvent \
--email test@example.com \
--props ./event-props.json
```
```js JavaScript {10-12} theme={"dark"}
await fetch("https://app.loops.so/api/v1/events/send", {
method: "POST",
headers: {
"Authorization": "Bearer ",
"Content-Type": "application/json"
},
body: JSON.stringify({
email: "test@example.com",
eventName: "testEvent",
eventProperties: {
"testProperty": "testValue",
},
}),
});
```
```js JavaScript SDK {8-10} theme={"dark"}
import { LoopsClient } from "loops";
const loops = new LoopsClient("");
const response = await loops.sendEvent({
email: "test@example.com",
eventName: "testEvent",
eventProperties: {
"testProperty": "testValue",
},
});
```
```php PHP SDK {8-10} theme={"dark"}
use Loops\LoopsClient;
$loops = new LoopsClient("");
$result = $loops->events->send(
email: 'test@example.com',
event_name: 'testEvent',
event_properties: [
'testProperty' => 'testValue',
],
);
```
```ruby Ruby SDK {4-6} theme={"dark"}
response = LoopsSdk::Events.send(
email: "test@example.com",
event_name: "testEvent",
event_properties: {
testProperty: "testValue",
},
)
```
```python Python {12-14} theme={"dark"}
import requests
response = requests.post(
"https://app.loops.so/api/v1/events/send",
headers={
"Authorization": "Bearer ",
"Content-Type": "application/json"
},
json={
"email": "test@example.com",
"eventName": "testEvent",
"eventProperties": {
"testProperty": "testValue",
},
}
)
```
## Send an event and update the contact
Include contact properties to update the contact as the event is sent.
[API reference](/docs/api-reference/send-event)\
[CLI reference](/docs/cli/events#send)
```bash CLI {3} theme={"dark"}
loops events send testEvent \
--email test@example.com \
--contact-props ./contact-props.json
```
```js JavaScript {10} theme={"dark"}
await fetch("https://app.loops.so/api/v1/events/send", {
method: "POST",
headers: {
"Authorization": "Bearer ",
"Content-Type": "application/json"
},
body: JSON.stringify({
email: "test@example.com",
eventName: "testEvent",
planName: "Pro", // Contact property
}),
});
```
```js JavaScript SDK {8-10} theme={"dark"}
import { LoopsClient } from "loops";
const loops = new LoopsClient("");
const response = await loops.sendEvent({
email: "test@example.com",
eventName: "testEvent",
contactProperties: {
planName: "Pro",
},
});
```
```php PHP SDK {8-10} theme={"dark"}
use Loops\LoopsClient;
$loops = new LoopsClient("");
$result = $loops->events->send(
email: 'test@example.com',
event_name: 'testEvent',
contact_properties: [
'planName' => 'Pro',
],
);
```
```ruby Ruby SDK {4-6} theme={"dark"}
response = LoopsSdk::Events.send(
email: "test@example.com",
event_name: "testEvent",
contact_properties: {
planName: "Pro",
},
)
```
```python Python {12-14} theme={"dark"}
import requests
response = requests.post(
"https://app.loops.so/api/v1/events/send",
headers={
"Authorization": "Bearer ",
"Content-Type": "application/json"
},
json={
"email": "test@example.com",
"eventName": "testEvent",
"contactProperties": {
"planName": "Pro",
},
}
)
```
## Send an event with an idempotency key
Add an `Idempotency-Key` header to the request to prevent duplicate requests.
[API reference](/docs/api-reference/send-event#param-idempotency-key)\
[CLI reference](/docs/cli/events#send)
```bash CLI {3} theme={"dark"}
loops events send testEvent \
--email test@example.com \
--idempotency-key 550e8400-e29b-41d4-a716-446655440000
```
```js JavaScript {6} theme={"dark"}
await fetch("https://app.loops.so/api/v1/events/send", {
method: "POST",
headers: {
"Authorization": "Bearer ",
"Content-Type": "application/json"
"Idempotency-Key": "550e8400-e29b-41d4-a716-446655440000",
},
body: JSON.stringify({
email: "test@example.com",
eventName: "testEvent",
}),
});
```
```js JavaScript SDK {8-10} theme={"dark"}
import { LoopsClient } from "loops";
const loops = new LoopsClient("");
const response = await loops.sendEvent({
email: "test@example.com",
eventName: "testEvent",
headers: {
"Idempotency-Key": "550e8400-e29b-41d4-a716-446655440000",
},
});
```
```php PHP SDK {8-10} theme={"dark"}
use Loops\LoopsClient;
$loops = new LoopsClient("");
$result = $loops->events->send(
email: 'test@example.com',
event_name: 'testEvent',
headers: [
'Idempotency-Key' => '550e8400-e29b-41d4-a716-446655440000',
],
);
```
```ruby Ruby SDK {4-6} theme={"dark"}
response = LoopsSdk::Events.send(
email: "test@example.com",
event_name: "testEvent",
headers: {
"Idempotency-Key" => "550e8400-e29b-41d4-a716-446655440000",
},
)
```
```python Python {8} theme={"dark"}
import requests
response = requests.post(
"https://app.loops.so/api/v1/events/send",
headers={
"Authorization": "Bearer ",
"Content-Type": "application/json"
"Idempotency-Key": "550e8400-e29b-41d4-a716-446655440000",
},
json={
"email": "test@example.com",
"eventName": "testEvent",
}
)
```
# Transactional email API examples
Source: https://loops.so/docs/api-reference/examples/transactional-emails
Code examples for creating transactional emails, updating drafts with content revisions, previewing, publishing, and sending via the Loops API, CLI, and SDKs.
## Create a transactional email
This creates a transactional email and a related draft email message in one request.
Only a `name` value is required.
Save the returned `draftEmailMessageContentRevisionId`. Pass it as
`expectedRevisionId` when updating the draft email message to avoid `409 Conflict` errors caused by stale revisions.
[API reference](/docs/api-reference/create-transactional-email)
There is no CLI command for creating transactional emails yet — use the API below.
```js JavaScript theme={"dark"}
const response = await fetch("https://app.loops.so/api/v1/transactional-emails", {
method: "POST",
headers: {
"Authorization": "Bearer ",
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Password reset",
}),
});
const data = await response.json();
const transactionalId = data.id;
const draftEmailMessageId = data.draftEmailMessageId;
const draftEmailMessageContentRevisionId =
data.draftEmailMessageContentRevisionId;
```
```js JavaScript SDK theme={"dark"}
import { LoopsClient } from "loops";
const loops = new LoopsClient("");
const data = await loops.createTransactionalEmail({
name: "Password reset",
});
const transactionalId = data.id;
const draftEmailMessageId = data.draftEmailMessageId;
const draftEmailMessageContentRevisionId =
data.draftEmailMessageContentRevisionId;
```
```php PHP SDK theme={"dark"}
use Loops\LoopsClient;
$loops = new LoopsClient('');
$result = $loops->transactional->create(name: 'Password reset');
$transactional_id = $result['id'];
$draft_email_message_id = $result['draftEmailMessageId'];
$draft_email_message_content_revision_id =
$result['draftEmailMessageContentRevisionId'];
```
```ruby Ruby SDK theme={"dark"}
response = LoopsSdk::Transactional.create(
name: "Password reset",
)
transactional_id = response["id"]
draft_email_message_id = response["draftEmailMessageId"]
draft_email_message_content_revision_id =
response["draftEmailMessageContentRevisionId"]
```
```python Python theme={"dark"}
import requests
response = requests.post(
"https://app.loops.so/api/v1/transactional-emails",
headers={
"Authorization": "Bearer ",
"Content-Type": "application/json",
},
json={
"name": "Password reset",
},
)
data = response.json()
transactional_id = data["id"]
draft_email_message_id = data["draftEmailMessageId"]
draft_email_message_content_revision_id = data[
"draftEmailMessageContentRevisionId"
]
```
## Query themes and components for your LMX
You can fetch your available themes and reusable components before building
the `lmx` payload.
List themes [API reference](/docs/api-reference/list-themes) [CLI reference](/docs/cli/themes#list)\
List components [API reference](/docs/api-reference/list-components) [CLI reference](/docs/cli/components#list)
```bash CLI theme={"dark"}
loops themes list -o json
loops components list -o json
```
```js JavaScript theme={"dark"}
const [themesResponse, componentsResponse] = await Promise.all([
fetch("https://app.loops.so/api/v1/themes?perPage=20", {
method: "GET",
headers: {
"Authorization": "Bearer ",
},
}),
fetch("https://app.loops.so/api/v1/components?perPage=20", {
method: "GET",
headers: {
"Authorization": "Bearer ",
},
}),
]);
const themes = await themesResponse.json();
const components = await componentsResponse.json();
```
```js JavaScript SDK theme={"dark"}
import { LoopsClient } from "loops";
const loops = new LoopsClient("");
const [themes, components] = await Promise.all([
loops.listThemes({ perPage: 20 }),
loops.listComponents({ perPage: 20 }),
]);
```
```php PHP SDK theme={"dark"}
use Loops\LoopsClient;
$loops = new LoopsClient('');
$themes = $loops->themes->list(per_page: 20);
$components = $loops->components->list(per_page: 20);
```
```ruby Ruby SDK theme={"dark"}
themes = LoopsSdk::Themes.list(perPage: 20)
components = LoopsSdk::Components.list(perPage: 20)
```
```python Python theme={"dark"}
import requests
themes_response = requests.get(
"https://app.loops.so/api/v1/themes",
headers={
"Authorization": "Bearer ",
},
params={"perPage": 20},
)
components_response = requests.get(
"https://app.loops.so/api/v1/components",
headers={
"Authorization": "Bearer ",
},
params={"perPage": 20},
)
themes = themes_response.json()
components = components_response.json()
```
## Update the draft email message with `contentRevisionId`
Use `draftEmailMessageId` from when you created the transactional as the path parameter, and pass `draftEmailMessageContentRevisionId` as `expectedRevisionId`.
Apply styles or a theme in ``, and build the email using LMX elements.
Themes and components you queried in the previous step can be referenced by their IDs.
Save the returned `contentRevisionId` after each update. Pass it as
`expectedRevisionId` on the next update to avoid `409 Conflict` errors caused
by stale revisions.
[API reference](/docs/api-reference/update-email-message)\
[CLI reference](/docs/cli/email-messages#update)
```bash CLI theme={"dark"}
loops email-messages update $draftEmailMessageId \
--expected-revision-id $draftEmailMessageContentRevisionId \
--subject "Reset your password" \
--preview-text "Your password reset link" \
--from-name "Loops" \
--from-email hello \
--reply-to support@example.com \
--lmx-file ./email.lmx
```
```js JavaScript theme={"dark"}
const lmxContent = `
Click the link below to reset your password.
...
`;
const response = await fetch(
`https://app.loops.so/api/v1/email-messages/${draftEmailMessageId}`,
{
method: "POST",
headers: {
"Authorization": "Bearer ",
"Content-Type": "application/json",
},
body: JSON.stringify({
expectedRevisionId: draftEmailMessageContentRevisionId,
subject: "Reset your password",
previewText: "Your password reset link",
fromName: "Loops",
fromEmail: "hello",
replyToEmail: "support@example.com",
lmx: lmxContent,
}),
},
);
const updated = await response.json();
const nextContentRevisionId = updated.contentRevisionId;
```
```js JavaScript SDK theme={"dark"}
import { LoopsClient } from "loops";
const loops = new LoopsClient("");
const lmxContent = `
Click the link below to reset your password.
...
`;
const updated = await loops.updateEmailMessage(draftEmailMessageId, {
expectedRevisionId: draftEmailMessageContentRevisionId,
subject: "Reset your password",
previewText: "Your password reset link",
fromName: "Loops",
fromEmail: "hello",
replyToEmail: "support@example.com",
lmx: lmxContent,
});
const nextContentRevisionId = updated.contentRevisionId;
```
```php PHP SDK theme={"dark"}
use Loops\LoopsClient;
$loops = new LoopsClient('');
$lmx_content = <<Click the link below to reset your password.
...
LMX;
$updated = $loops->emailMessages->update(
email_message_id: $draft_email_message_id,
expected_revision_id: $draft_email_message_content_revision_id,
subject: 'Reset your password',
preview_text: 'Your password reset link',
from_name: 'Loops',
from_email: 'hello',
reply_to_email: 'support@example.com',
lmx: $lmx_content,
);
$next_content_revision_id = $updated['contentRevisionId'];
```
```ruby Ruby SDK theme={"dark"}
lmx_content = <<~LMX
Click the link below to reset your password.
...
LMX
updated = LoopsSdk::EmailMessages.update(
email_message_id: draft_email_message_id,
expected_revision_id: draft_email_message_content_revision_id,
subject: "Reset your password",
preview_text: "Your password reset link",
from_name: "Loops",
from_email: "hello",
reply_to_email: "support@example.com",
lmx: lmx_content,
)
next_content_revision_id = updated["contentRevisionId"]
```
```python Python theme={"dark"}
import requests
response = requests.post(
f"https://app.loops.so/api/v1/email-messages/{draft_email_message_id}",
headers={
"Authorization": "Bearer ",
"Content-Type": "application/json",
},
json={
"expectedRevisionId": draft_email_message_content_revision_id,
"subject": "Reset your password",
"previewText": "Your password reset link",
"fromName": "Loops",
"fromEmail": "hello",
"replyToEmail": "support@example.com",
"lmx": "Click the link below to reset your password.",
},
)
updated = response.json()
next_content_revision_id = updated["contentRevisionId"]
```
## Send a transactional preview
After updating the draft email message, send a test preview to one or more
addresses before publishing. Transactional previews accept `dataVariables` for
personalization.
Use `draftEmailMessageId` from when you created the transactional as the path
parameter.
There is no CLI command for previews yet — use the API below.
[API reference](/docs/api-reference/preview-email-message)
```js JavaScript theme={"dark"}
const previewResponse = await fetch(
`https://app.loops.so/api/v1/email-messages/${draftEmailMessageId}/preview`,
{
method: "POST",
headers: {
"Authorization": "Bearer ",
"Content-Type": "application/json",
},
body: JSON.stringify({
emails: ["you@example.com"],
dataVariables: {
loginUrl: "https://example.com/reset?token=abc123",
firstName: "Alex",
},
}),
},
);
const preview = await previewResponse.json();
```
```js JavaScript SDK theme={"dark"}
import { LoopsClient } from "loops";
const loops = new LoopsClient("");
const preview = await loops.sendEmailMessagePreview(draftEmailMessageId, {
emails: ["you@example.com"],
dataVariables: {
loginUrl: "https://example.com/reset?token=abc123",
firstName: "Alex",
},
});
```
```php PHP SDK theme={"dark"}
use Loops\LoopsClient;
$loops = new LoopsClient('');
$preview = $loops->emailMessages->preview(
email_message_id: $draft_email_message_id,
emails: ['you@example.com'],
data_variables: [
'loginUrl' => 'https://example.com/reset?token=abc123',
'firstName' => 'Alex',
],
);
```
```ruby Ruby SDK theme={"dark"}
preview = LoopsSdk::EmailMessages.preview(
email_message_id: draft_email_message_id,
emails: ["you@example.com"],
data_variables: {
loginUrl: "https://example.com/reset?token=abc123",
firstName: "Alex",
},
)
```
```python Python theme={"dark"}
import requests
preview_response = requests.post(
f"https://app.loops.so/api/v1/email-messages/{draft_email_message_id}/preview",
headers={
"Authorization": "Bearer ",
"Content-Type": "application/json",
},
json={
"emails": ["you@example.com"],
"dataVariables": {
"loginUrl": "https://example.com/reset?token=abc123",
"firstName": "Alex",
},
},
)
preview = preview_response.json()
```
## Upload an image asset
If your LMX includes `` tags, upload image files with the Upload API
and use the returned `finalUrl` as the image `src`.
[Create upload API reference](/docs/api-reference/create-upload)\
[Complete upload API reference](/docs/api-reference/complete-upload)
[Upload CLI reference](/docs/cli/uploads#create)
```bash CLI theme={"dark"}
loops uploads create ./header.png -o json
```
```js JavaScript theme={"dark"}
const createResponse = await fetch("https://app.loops.so/api/v1/uploads", {
method: "POST",
headers: {
"Authorization": "Bearer ",
"Content-Type": "application/json",
},
body: JSON.stringify({
contentType: "image/png",
contentLength: imageBuffer.byteLength,
}),
});
const { emailAssetId, presignedUrl } = await createResponse.json();
await fetch(presignedUrl, {
method: "PUT",
headers: {
"Content-Type": "image/png",
"Content-Length": String(imageBuffer.byteLength),
},
body: imageBuffer,
});
const completeResponse = await fetch(
`https://app.loops.so/api/v1/uploads/${emailAssetId}/complete`,
{
method: "POST",
headers: {
"Authorization": "Bearer ",
},
},
);
const { finalUrl } = await completeResponse.json();
```
```js JavaScript SDK theme={"dark"}
import { LoopsClient } from "loops";
const loops = new LoopsClient("");
const { emailAssetId, presignedUrl } = await loops.createUpload({
contentType: "image/png",
contentLength: imageBuffer.byteLength,
});
await fetch(presignedUrl, {
method: "PUT",
headers: {
"Content-Type": "image/png",
"Content-Length": String(imageBuffer.byteLength),
},
body: imageBuffer,
});
const { finalUrl } = await loops.completeUpload(emailAssetId);
```
```php PHP SDK theme={"dark"}
use Loops\LoopsClient;
$loops = new LoopsClient('');
$result = $loops->uploads->upload(path: './header.png');
$final_url = $result['finalUrl'];
```
```ruby Ruby SDK theme={"dark"}
response = LoopsSdk::Uploads.upload(path: "./header.png")
final_url = response["finalUrl"]
```
```python Python theme={"dark"}
import requests
create_response = requests.post(
"https://app.loops.so/api/v1/uploads",
headers={
"Authorization": "Bearer ",
"Content-Type": "application/json",
},
json={
"contentType": "image/png",
"contentLength": len(image_bytes),
},
)
create_data = create_response.json()
email_asset_id = create_data["emailAssetId"]
presigned_url = create_data["presignedUrl"]
requests.put(
presigned_url,
headers={
"Content-Type": "image/png",
"Content-Length": str(len(image_bytes)),
},
data=image_bytes,
)
complete_response = requests.post(
f"https://app.loops.so/api/v1/uploads/{email_asset_id}/complete",
headers={
"Authorization": "Bearer ",
},
)
final_url = complete_response.json()["finalUrl"]
```
## Publish the transactional email
Publish the draft email message so it can be sent with the transactional send endpoint.
There is no CLI command for publishing transactional emails yet — use the API below.
[API reference](/docs/api-reference/publish-transactional-email)
```js JavaScript theme={"dark"}
const publishResponse = await fetch(
`https://app.loops.so/api/v1/transactional-emails/${id}/publish`,
{
method: "POST",
headers: {
"Authorization": "Bearer ",
},
},
);
const published = await publishResponse.json();
```
```js JavaScript SDK theme={"dark"}
import { LoopsClient } from "loops";
const loops = new LoopsClient("");
const published = await loops.publishTransactionalEmail(transactionalId);
```
```php PHP SDK theme={"dark"}
use Loops\LoopsClient;
$loops = new LoopsClient('');
$published = $loops->transactional->publish(transactional_id: $transactional_id);
```
```ruby Ruby SDK theme={"dark"}
published = LoopsSdk::Transactional.publish(
transactional_id: transactional_id,
)
```
```python Python theme={"dark"}
import requests
publish_response = requests.post(
f"https://app.loops.so/api/v1/transactional-emails/{id}/publish",
headers={
"Authorization": "Bearer ",
},
)
published = publish_response.json()
```
## Send a transactional email
Use the transactional `id` from create (or publish) as `transactionalId` in the send request.
[API reference](/docs/api-reference/send-transactional-email)\
[CLI reference](/docs/cli/transactional#send)
```bash CLI theme={"dark"}
loops transactional send \
--email test@example.com \
--var loginUrl=https://example.com/login
```
```js JavaScript theme={"dark"}
await fetch("https://app.loops.so/api/v1/transactional", {
method: "POST",
headers: {
"Authorization": "Bearer ",
"Content-Type": "application/json",
},
body: JSON.stringify({
email: "test@example.com",
transactionalId: id,
dataVariables: {
loginUrl: "https://example.com/login",
},
}),
});
```
```js JavaScript SDK theme={"dark"}
import { LoopsClient } from "loops";
const loops = new LoopsClient("");
const response = await loops.sendTransactionalEmail({
email: "test@example.com",
transactionalId: "",
dataVariables: {
loginUrl: "https://example.com/login",
},
});
```
```php PHP SDK theme={"dark"}
use Loops\LoopsClient;
$loops = new LoopsClient("");
$result = $loops->transactional->send(
email: 'test@example.com',
transactional_id: '',
data_variables: [
'loginUrl' => 'https://example.com/login',
],
);
```
```ruby Ruby SDK theme={"dark"}
response = LoopsSdk::Transactional.send(
email: "test@example.com",
transactional_id: "",
data_variables: {
loginUrl: "https://example.com/login",
},
)
```
```python Python theme={"dark"}
import requests
response = requests.post(
"https://app.loops.so/api/v1/transactional",
headers={
"Authorization": "Bearer ",
"Content-Type": "application/json",
},
json={
"email": "test@example.com",
"transactionalId": id,
"dataVariables": {
"loginUrl": "https://example.com/login",
},
},
)
```
## Send a transactional email with an array data variable
Learn more about [arrays](/docs/creating-emails/editor#arrays).
[API reference](/docs/api-reference/send-transactional-email#param-data-variables)\
[CLI reference](/docs/cli/transactional#send)
```bash CLI theme={"dark"}
loops transactional send \
--email test@example.com \
--json-vars ./vars.json
```
```js JavaScript {11-14} theme={"dark"}
await fetch("https://app.loops.so/api/v1/transactional", {
method: "POST",
headers: {
"Authorization": "Bearer ",
"Content-Type": "application/json",
},
body: JSON.stringify({
email: "test@example.com",
transactionalId: "",
dataVariables: {
items: [
{ name: "Item 1", description: "Description of Item 1" },
{ name: "Item 2", description: "Description of Item 2" },
],
},
}),
});
```
```js JavaScript SDK {9-12} theme={"dark"}
import { LoopsClient } from "loops";
const loops = new LoopsClient("");
const response = await loops.sendTransactionalEmail({
email: "test@example.com",
transactionalId: "",
dataVariables: {
items: [
{ name: "Item 1", description: "Description of Item 1" },
{ name: "Item 2", description: "Description of Item 2" },
],
},
});
```
```php PHP SDK {9-18} theme={"dark"}
use Loops\LoopsClient;
$loops = new LoopsClient("");
$result = $loops->transactional->send(
email: 'test@example.com',
transactional_id: '',
data_variables: [
'items' => [
[
'name' => 'Item 1',
'description' => 'Description of Item 1',
],
[
'name' => 'Item 2',
'description' => 'Description of Item 2',
],
],
],
);
```
```ruby Ruby SDK {5-8} theme={"dark"}
response = LoopsSdk::Transactional.send(
email: "test@example.com",
transactional_id: "",
data_variables: {
items: [
{ name: "Item 1", description: "Description of Item 1" },
{ name: "Item 2", description: "Description of Item 2" },
],
},
)
```
```python Python {13-16} theme={"dark"}
import requests
response = requests.post(
"https://app.loops.so/api/v1/transactional",
headers={
"Authorization": "Bearer ",
"Content-Type": "application/json",
},
json={
"email": "test@example.com",
"transactionalId": "",
"dataVariables": {
"items": [
{ "name": "Item 1", "description": "Description of Item 1" },
{ "name": "Item 2", "description": "Description of Item 2" },
],
},
},
)
```
## Send a transactional email with attachments
You must request attachments to be enabled in your account before you can send emails with them.
[API reference](/docs/api-reference/send-transactional-email#param-attachments)\
[CLI reference](/docs/cli/transactional#send)
```bash CLI theme={"dark"}
loops transactional send \
--email test@example.com \
--var loginUrl=https://example.com/login \
--attachment ./example.pdf
```
```js JavaScript {13-19} theme={"dark"}
await fetch("https://app.loops.so/api/v1/transactional", {
method: "POST",
headers: {
"Authorization": "Bearer ",
"Content-Type": "application/json",
},
body: JSON.stringify({
email: "test@example.com",
transactionalId: "",
dataVariables: {
loginUrl: "https://example.com/login",
},
attachments: [
{
filename: "example.pdf",
contentType: "application/pdf",
data: "",
},
],
}),
});
```
```js JavaScript SDK {11-17} theme={"dark"}
import { LoopsClient } from "loops";
const loops = new LoopsClient("");
const response = await loops.sendTransactionalEmail({
email: "test@example.com",
transactionalId: "",
dataVariables: {
loginUrl: "https://example.com/login",
},
attachments: [
{
filename: "example.pdf",
contentType: "application/pdf",
data: "",
},
],
});
```
```php PHP SDK {11-17} theme={"dark"}
use Loops\LoopsClient;
$loops = new LoopsClient("");
$result = $loops->transactional->send(
email: 'test@example.com',
transactional_id: '',
data_variables: [
'loginUrl' => 'https://example.com/login',
],
attachments: [
[
'filename' => 'example.pdf',
'content_type' => 'application/pdf',
'data' => base64_encode(file_get_contents('path/to/example.pdf')),
],
],
);
```
```ruby Ruby SDK {7-13} theme={"dark"}
response = LoopsSdk::Transactional.send(
email: "test@example.com",
transactional_id: "",
data_variables: {
loginUrl: "https://example.com/login",
},
attachments: [
{
filename: 'example.pdf',
content_type: 'application/pdf',
data: '',
},
],
)
```
```python Python {15-21} theme={"dark"}
import requests
response = requests.post(
"https://app.loops.so/api/v1/transactional",
headers={
"Authorization": "Bearer ",
"Content-Type": "application/json",
},
json={
"email": "test@example.com",
"transactionalId": "",
"dataVariables": {
"loginUrl": "https://example.com/login",
},
"attachments": [
{
"filename": "example.pdf",
"contentType": "application/pdf",
"data": "",
},
],
},
)
```
## Send a transactional email with an idempotency key
Add an `Idempotency-Key` header to the request to prevent duplicate requests.
[API reference](/docs/api-reference/send-transactional-email#param-idempotency-key)\
[CLI reference](/docs/cli/transactional#send)
```bash CLI theme={"dark"}
loops transactional send \
--email test@example.com \
--var loginUrl=https://example.com/login \
--idempotency-key 550e8400-e29b-41d4-a716-446655440000
```
```js JavaScript {6} theme={"dark"}
await fetch("https://app.loops.so/api/v1/transactional", {
method: "POST",
headers: {
"Authorization": "Bearer ",
"Content-Type": "application/json",
"Idempotency-Key": "550e8400-e29b-41d4-a716-446655440000",
},
body: JSON.stringify({
email: "test@example.com",
transactionalId: "",
dataVariables: {
loginUrl: "https://example.com/login",
},
}),
});
```
```js JavaScript SDK {11-13} theme={"dark"}
import { LoopsClient } from "loops";
const loops = new LoopsClient("");
const response = await loops.sendTransactionalEmail({
email: "test@example.com",
transactionalId: "",
dataVariables: {
loginUrl: "https://example.com/login",
},
headers: {
"Idempotency-Key": "550e8400-e29b-41d4-a716-446655440000",
},
});
```
```php PHP SDK {11-13} theme={"dark"}
use Loops\LoopsClient;
$loops = new LoopsClient("");
$result = $loops->transactional->send(
email: 'test@example.com',
transactional_id: '',
data_variables: [
'loginUrl' => 'https://example.com/login',
],
headers: [
'Idempotency-Key' => '550e8400-e29b-41d4-a716-446655440000',
],
);
```
```ruby Ruby SDK {7-9} theme={"dark"}
response = LoopsSdk::Transactional.send(
email: "test@example.com",
transactional_id: "",
data_variables: {
loginUrl: "https://example.com/login",
},
headers: {
'Idempotency-Key' => '550e8400-e29b-41d4-a716-446655440000',
},
)
```
```python Python {8} theme={"dark"}
import requests
response = requests.post(
"https://app.loops.so/api/v1/transactional",
headers={
"Authorization": "Bearer ",
"Content-Type": "application/json",
"Idempotency-Key": "550e8400-e29b-41d4-a716-446655440000",
},
json={
"email": "test@example.com",
"transactionalId": "",
"dataVariables": {
"loginUrl": "https://example.com/login",
},
},
)
```
## List transactional emails
[API reference](/docs/api-reference/list-transactional-emails)\
[CLI reference](/docs/cli/transactional#list)
```bash CLI theme={"dark"}
loops transactional list -o json
```
```js JavaScript theme={"dark"}
await fetch("https://app.loops.so/api/v1/transactional-emails", {
method: "GET",
headers: {
"Authorization": "Bearer ",
},
});
```
```js JavaScript SDK theme={"dark"}
import { LoopsClient } from "loops";
const loops = new LoopsClient("");
const response = await loops.listTransactionalEmails();
```
```php PHP SDK theme={"dark"}
use Loops\LoopsClient;
$loops = new LoopsClient("");
$result = $loops->transactional->list();
```
```ruby Ruby SDK theme={"dark"}
response = LoopsSdk::Transactional.list
```
```python Python theme={"dark"}
import requests
response = requests.get(
"https://app.loops.so/api/v1/transactional-emails",
headers={
"Authorization": "Bearer ",
},
)
```
# Workflows API examples
Source: https://loops.so/docs/api-reference/examples/workflows
Copy/paste code examples for listing workflows, retrieving workflow graphs, and fetching detailed workflow node configurations via the Loops API, SDKs, and CLI.
## List workflows
Retrieve a paginated list of workflows, most recently updated first.
[API reference](/docs/api-reference/list-workflows)\
[CLI reference](/docs/cli/workflows#list)
```bash CLI theme={"dark"}
loops workflows list -o json
```
```js JavaScript theme={"dark"}
const response = await fetch("https://app.loops.so/api/v1/workflows?perPage=20", {
method: "GET",
headers: {
"Authorization": "Bearer ",
},
});
const { data, pagination } = await response.json();
const workflowId = data[0]?.id;
const nextCursor = pagination.nextCursor;
```
```js JavaScript SDK theme={"dark"}
import { LoopsClient } from "loops";
const loops = new LoopsClient("");
const { data, pagination } = await loops.listWorkflows({ perPage: 20 });
const workflowId = data[0]?.id;
const nextCursor = pagination.nextCursor;
```
```php PHP SDK theme={"dark"}
use Loops\LoopsClient;
$loops = new LoopsClient('');
$result = $loops->workflows->list(per_page: 20);
$workflow_id = $result['data'][0]['id'] ?? null;
$next_cursor = $result['pagination']['nextCursor'];
```
```ruby Ruby SDK theme={"dark"}
response = LoopsSdk::Workflows.list(perPage: 20)
workflow_id = response["data"][0]&.dig("id")
next_cursor = response["pagination"]["nextCursor"]
```
```python Python theme={"dark"}
import requests
response = requests.get(
"https://app.loops.so/api/v1/workflows",
headers={
"Authorization": "Bearer ",
},
params={"perPage": 20},
)
body = response.json()
workflows = body["data"]
workflow_id = workflows[0]["id"] if workflows else None
next_cursor = body["pagination"]["nextCursor"]
```
## Get a workflow
Fetch a simplified workflow graph with node types, connections, and selected
display fields.
Save `rootNodeId` and the keys in `nodes`. Use them to request full node
details with [Get workflow node](/docs/api-reference/get-workflow-node).
[API reference](/docs/api-reference/get-workflow)\
[CLI reference](/docs/cli/workflows#get)
```bash CLI theme={"dark"}
loops workflows get $workflowId -o json
```
```js JavaScript theme={"dark"}
const response = await fetch(
`https://app.loops.so/api/v1/workflows/${workflowId}`,
{
method: "GET",
headers: {
"Authorization": "Bearer ",
},
},
);
const workflow = await response.json();
const rootNodeId = workflow.rootNodeId;
const nodes = workflow.nodes;
const firstNode = nodes[rootNodeId];
const nextNodeIds = firstNode?.nextNodeIds ?? [];
```
```js JavaScript SDK theme={"dark"}
import { LoopsClient } from "loops";
const loops = new LoopsClient("");
const workflow = await loops.getWorkflow(workflowId);
const rootNodeId = workflow.rootNodeId;
const nodes = workflow.nodes;
const firstNode = nodes[rootNodeId];
const nextNodeIds = firstNode?.nextNodeIds ?? [];
```
```php PHP SDK theme={"dark"}
use Loops\LoopsClient;
$loops = new LoopsClient('');
$workflow = $loops->workflows->get(workflow_id: $workflow_id);
$root_node_id = $workflow['rootNodeId'];
$nodes = $workflow['nodes'];
$first_node = $nodes[$root_node_id];
$next_node_ids = $first_node['nextNodeIds'] ?? [];
```
```ruby Ruby SDK theme={"dark"}
workflow = LoopsSdk::Workflows.get(workflow_id: workflow_id)
root_node_id = workflow["rootNodeId"]
nodes = workflow["nodes"]
first_node = nodes[root_node_id]
next_node_ids = first_node&.dig("nextNodeIds") || []
```
```python Python theme={"dark"}
response = requests.get(
f"https://app.loops.so/api/v1/workflows/{workflow_id}",
headers={
"Authorization": "Bearer ",
},
)
workflow = response.json()
root_node_id = workflow["rootNodeId"]
nodes = workflow["nodes"]
first_node = nodes.get(root_node_id)
next_node_ids = first_node.get("nextNodeIds", []) if first_node else []
```
## Get workflow node details
Use the simplified workflow graph to pick a `nodeId`, then fetch the full node
configuration.
[API reference](/docs/api-reference/get-workflow-node)\
[CLI reference](/docs/cli/workflows#nodes-get)
```bash CLI theme={"dark"}
loops workflows nodes get $workflowId $nodeId -o json
```
```js JavaScript theme={"dark"}
const nodeId = rootNodeId;
const response = await fetch(
`https://app.loops.so/api/v1/workflows/${workflowId}/nodes/${nodeId}`,
{
method: "GET",
headers: {
"Authorization": "Bearer ",
},
},
);
const node = await response.json();
if (node.typeName === "EventTrigger") {
console.log(node.eventName, node.reEligible);
}
if (node.typeName === "SendEmailAction") {
// Use emailMessageId to read or edit the email's content (see below).
console.log(node.emailMessageId, node.subject);
}
```
```js JavaScript SDK theme={"dark"}
import { LoopsClient } from "loops";
const loops = new LoopsClient("");
const nodeId = rootNodeId;
const node = await loops.getWorkflowNode(workflowId, nodeId);
if (node.typeName === "EventTrigger") {
console.log(node.eventName, node.reEligible);
}
if (node.typeName === "SendEmailAction") {
console.log(node.subject);
}
```
```php PHP SDK theme={"dark"}
use Loops\LoopsClient;
$loops = new LoopsClient('');
$node_id = $root_node_id;
$node = $loops->workflows->getNode(
workflow_id: $workflow_id,
node_id: $node_id,
);
if ($node['typeName'] === 'EventTrigger') {
echo $node['eventName'], $node['reEligible'];
}
if ($node['typeName'] === 'SendEmailAction') {
echo $node['emailMessageId'], $node['subject'];
}
```
```ruby Ruby SDK theme={"dark"}
node_id = root_node_id
node = LoopsSdk::Workflows.get_node(
workflow_id: workflow_id,
node_id: node_id,
)
if node["typeName"] == "EventTrigger"
puts node["eventName"], node["reEligible"]
end
if node["typeName"] == "SendEmailAction"
puts node["subject"]
end
```
```python Python theme={"dark"}
node_id = root_node_id
response = requests.get(
f"https://app.loops.so/api/v1/workflows/{workflow_id}/nodes/{node_id}",
headers={
"Authorization": "Bearer ",
},
)
node = response.json()
if node["typeName"] == "EventTrigger":
print(node.get("eventName"), node.get("reEligible"))
if node["typeName"] == "SendEmailAction":
# Use emailMessageId to read or edit the email's content (see below).
print(node.get("emailMessageId"), node.get("subject"))
```
## Walk connected nodes
After fetching a node, follow `nextNodeIds` to load the next steps in the
workflow.
```bash CLI theme={"dark"}
for nodeId in $nextNodeIds; do
loops workflows nodes get $workflowId $nodeId -o json
done
```
```js JavaScript theme={"dark"}
async function getWorkflowNodes(workflowId, nodeIds) {
const nodes = await Promise.all(
nodeIds.map(async (nodeId) => {
const response = await fetch(
`https://app.loops.so/api/v1/workflows/${workflowId}/nodes/${nodeId}`,
{
method: "GET",
headers: {
"Authorization": "Bearer ",
},
},
);
return response.json();
}),
);
return nodes;
}
const connectedNodes = await getWorkflowNodes(workflowId, nextNodeIds);
```
```js JavaScript SDK theme={"dark"}
import { LoopsClient } from "loops";
const loops = new LoopsClient("");
async function getWorkflowNodes(workflowId, nodeIds) {
return Promise.all(
nodeIds.map((nodeId) => loops.getWorkflowNode(workflowId, nodeId)),
);
}
const connectedNodes = await getWorkflowNodes(workflowId, nextNodeIds);
```
```php PHP SDK theme={"dark"}
use Loops\LoopsClient;
$loops = new LoopsClient('');
function get_workflow_nodes(LoopsClient $loops, string $workflow_id, array $node_ids): array {
return array_map(
fn (string $node_id) => $loops->workflows->getNode(
workflow_id: $workflow_id,
node_id: $node_id,
),
$node_ids,
);
}
$connected_nodes = get_workflow_nodes($loops, $workflow_id, $next_node_ids);
```
```ruby Ruby SDK theme={"dark"}
def get_workflow_nodes(workflow_id, node_ids)
node_ids.map do |node_id|
LoopsSdk::Workflows.get_node(
workflow_id: workflow_id,
node_id: node_id,
)
end
end
connected_nodes = get_workflow_nodes(workflow_id, next_node_ids)
```
```python Python theme={"dark"}
def get_workflow_nodes(workflow_id, node_ids):
nodes = []
for node_id in node_ids:
response = requests.get(
f"https://app.loops.so/api/v1/workflows/{workflow_id}/nodes/{node_id}",
headers={
"Authorization": "Bearer ",
},
)
nodes.append(response.json())
return nodes
connected_nodes = get_workflow_nodes(workflow_id, next_node_ids)
```
## Edit a workflow email's content
Each `SendEmailAction` node includes an `emailMessageId`. Pass that ID to the
email message content APIs to read or edit the email's subject, LMX body,
sender, and more.
First fetch the current content to get its `contentRevisionId`, then send an
update.
Save the returned `contentRevisionId` after each update and pass it as
`expectedRevisionId` on the next update to avoid `409 Conflict` errors caused
by stale revisions.
Get email message [API reference](/docs/api-reference/get-email-message) [CLI reference](/docs/cli/email-messages#get)\
Update email message [API reference](/docs/api-reference/update-email-message) [CLI reference](/docs/cli/email-messages#update)
```bash CLI theme={"dark"}
loops email-messages get $emailMessageId -o json
loops email-messages update $emailMessageId \
--expected-revision-id $contentRevisionId \
--subject "Welcome to Loops 👋" \
--lmx 'Thanks for signing up!'
```
```js JavaScript theme={"dark"}
// emailMessageId comes from a SendEmailAction node (see above).
const emailMessageId = node.emailMessageId;
// Fetch the current content to get the latest contentRevisionId.
const current = await fetch(
`https://app.loops.so/api/v1/email-messages/${emailMessageId}`,
{
method: "GET",
headers: {
"Authorization": "Bearer ",
},
},
).then((response) => response.json());
const response = await fetch(
`https://app.loops.so/api/v1/email-messages/${emailMessageId}`,
{
method: "POST",
headers: {
"Authorization": "Bearer ",
"Content-Type": "application/json",
},
body: JSON.stringify({
expectedRevisionId: current.contentRevisionId,
subject: "Welcome to Loops 👋",
lmx: "Thanks for signing up!",
}),
},
);
const updated = await response.json();
const nextContentRevisionId = updated.contentRevisionId;
```
```js JavaScript SDK theme={"dark"}
import { LoopsClient } from "loops";
const loops = new LoopsClient("");
const emailMessageId = node.emailMessageId;
const current = await loops.getEmailMessage(emailMessageId);
const updated = await loops.updateEmailMessage(emailMessageId, {
expectedRevisionId: current.contentRevisionId,
subject: "Welcome to Loops 👋",
lmx: "Thanks for signing up!",
});
const nextContentRevisionId = updated.contentRevisionId;
```
```php PHP SDK theme={"dark"}
use Loops\LoopsClient;
$loops = new LoopsClient('');
$email_message_id = $node['emailMessageId'];
$current = $loops->emailMessages->get(email_message_id: $email_message_id);
$updated = $loops->emailMessages->update(
email_message_id: $email_message_id,
expected_revision_id: $current['contentRevisionId'],
subject: 'Welcome to Loops 👋',
lmx: 'Thanks for signing up!',
);
$next_content_revision_id = $updated['contentRevisionId'];
```
```ruby Ruby SDK theme={"dark"}
email_message_id = node["emailMessageId"]
current = LoopsSdk::EmailMessages.get(email_message_id: email_message_id)
updated = LoopsSdk::EmailMessages.update(
email_message_id: email_message_id,
expected_revision_id: current["contentRevisionId"],
subject: "Welcome to Loops 👋",
lmx: "Thanks for signing up!",
)
next_content_revision_id = updated["contentRevisionId"]
```
```python Python theme={"dark"}
# email_message_id comes from a SendEmailAction node (see above).
email_message_id = node["emailMessageId"]
# Fetch the current content to get the latest contentRevisionId.
current = requests.get(
f"https://app.loops.so/api/v1/email-messages/{email_message_id}",
headers={
"Authorization": "Bearer ",
},
).json()
response = requests.post(
f"https://app.loops.so/api/v1/email-messages/{email_message_id}",
headers={
"Authorization": "Bearer ",
"Content-Type": "application/json",
},
json={
"expectedRevisionId": current["contentRevisionId"],
"subject": "Welcome to Loops 👋",
"lmx": "Thanks for signing up!",
},
)
updated = response.json()
next_content_revision_id = updated["contentRevisionId"]
```
# Find a contact
Source: https://loops.so/docs/api-reference/find-contact
https://app.loops.so/openapi.json get /v1/contacts/find
Find a contact by email address or user ID.
Search for a contact by `email` or `userId`. Only one parameter is allowed.
# Get an audience segment
Source: https://loops.so/docs/api-reference/get-audience-segment
https://app.loops.so/openapi.json get /v1/audience-segments/{audienceSegmentId}
Retrieve a single audience segment by ID.
# Get a campaign
Source: https://loops.so/docs/api-reference/get-campaign
https://app.loops.so/openapi.json get /v1/campaigns/{campaignId}
Retrieve a single campaign by ID.
# Get a campaign group
Source: https://loops.so/docs/api-reference/get-campaign-group
https://app.loops.so/openapi.json get /v1/campaign-groups/{campaignGroupId}
Retrieve a single campaign group by ID.
# Get a component
Source: https://loops.so/docs/api-reference/get-component
https://app.loops.so/openapi.json get /v1/components/{componentId}
Retrieve a single component by ID.
# Get an email message
Source: https://loops.so/docs/api-reference/get-email-message
https://app.loops.so/openapi.json get /v1/email-messages/{emailMessageId}
Retrieve an email message, including its compiled LMX content.
# Get an event pattern by ID
Source: https://loops.so/docs/api-reference/get-event-pattern
https://app.loops.so/openapi.json get /v1/event-patterns/{eventPatternId}
Retrieve event pattern details for a workflow event trigger.
# Get an event pattern by name
Source: https://loops.so/docs/api-reference/get-event-pattern-by-name
https://app.loops.so/openapi.json get /v1/event-patterns/by-name/{eventName}
Retrieve event pattern details by event name for a workflow event trigger. Event names are case-sensitive, so `PaymentReceived` and `paymentReceived` are different events.
# Get a theme
Source: https://loops.so/docs/api-reference/get-theme
https://app.loops.so/openapi.json get /v1/themes/{themeId}
Retrieve a single theme by ID.
# Get a transactional email
Source: https://loops.so/docs/api-reference/get-transactional-email
https://app.loops.so/openapi.json get /v1/transactional-emails/{transactionalId}
Retrieve a single transactional email by ID.
# Get a transactional group
Source: https://loops.so/docs/api-reference/get-transactional-group
https://app.loops.so/openapi.json get /v1/transactional-groups/{transactionalGroupId}
Retrieve a single transactional group by ID.
# Get a workflow
Source: https://loops.so/docs/api-reference/get-workflow
https://app.loops.so/openapi.json get /v1/workflows/{workflowId}
Retrieve a workflow graph with node type names, connections, and selected display fields.
Returns a simplified view of the workflow graph. Each node includes its `typeName`, outgoing connections in `nextNodeIds`, and selected display fields. Use [Get a workflow node](/docs/api-reference/get-workflow-node) to retrieve full details for a single node.
# Get a workflow node
Source: https://loops.so/docs/api-reference/get-workflow-node
https://app.loops.so/openapi.json get /v1/workflows/{workflowId}/nodes/{nodeId}
Retrieve detailed data for a single workflow node.
# API Introduction
Source: https://loops.so/docs/api-reference/intro
Use the Loops REST API to manage contacts, send events, and send transactional email. Learn authentication, rate limits, and request/response examples.
You can use the Loops API to add contacts to your Loops audience,
update their attributes, and send events to Loops.
Prefer a no-code or low-code setup? Use our [integrations](/docs/integrations) (like [Zapier](/docs/integrations/zapier) or [Make](/docs/integrations/make)), or trigger workflows using [incoming webhooks](/docs/integrations/incoming-webhooks).
## Authentication
Your Loops API key should never be used client side or exposed to your end
users.
To get started, you'll need an API key. Go to [Settings -> API](https://app.loops.so/settings?page=api) in Loops and click **Generate key**.
This creates an API key. You can assign it a human-readable name:
We suggest using a different API key for different purposes. You can revoke an API key at any time with the trash icon.
When making an API call, add an Authorization header and set the API key as a Bearer token:
You can test your API key by making a `GET` request to
```
https://app.loops.so/api/v1/api-key
```
If successful, you will receive the following response:
```
{
"success": true,
"teamName": "Company name"
}
```
Here's an example Curl request (replace `d2d561f5ff80136f69b4b5a31b9fb3c9` with your own API key):
```bash theme={"dark"}
curl https://app.loops.so/api/v1/api-key -H "Accept: application/json" -H "Authorization: Bearer d2d561f5ff80136f69b4b5a31b9fb3c9"
```
## API Reference
The base URL for the API is `https://app.loops.so/api`
### API key
Test your API key.
### Audience segments
View audience segments for campaign targeting.
### Campaign groups
Organize campaigns into groups.
### Campaigns
Create and manage campaigns.
### Configuration
View configuration settings.
### Contact properties
Manage contact properties.
### Contacts
Manage contacts.
### Components
Reusable components for your emails.
### Email messages
Manage the content of campaigns and transactional emails.
### Event patterns
View workflow event patterns.
### Events
Send events to trigger workflows.
### Mailing lists
View your mailing lists.
### Themes
Retrieve email themes.
### Transactional emails
Create, manage and send transactional emails.
### Transactional groups
Organize transactional emails into groups.
### Uploads
Upload images for email messages.
### Workflow nodes
View and mutate workflow nodes.
### Workflows
View and manage workflows.
## Rate Limiting
To ensure the quality of service for all users, our API is rate limited. This means there's a limit to the number of requests your application can make to our API in a certain time frame.
The baseline rate limit is **10 requests per second per team**.
For the content API, the baseline rate limit is **60 requests per 60 seconds per team**. This applies to `/v1/campaigns/*`, `/v1/campaign-groups/*`, `/v1/components/*`, `/v1/email-messages/*`, `/v1/themes/*`, `/v1/transactional-emails/*`, `/v1/transactional-groups/*`, `/v1/uploads/*`, and `/v1/workflows/*` endpoints.
To see your current usage, we provide the following response headers in every API response:
* `x-ratelimit-limit`: The maximum number of requests you can make per second.
* `x-ratelimit-remaining`: The number of requests remaining in the current rate limit window.
Here is an example of a successful response with rate limit headers:
If you exceed this limit, you'll receive a response with HTTP status `429 Too Many Requests`. Here is an example of a failed response with rate limit headers:
It's important to handle these 429 responses in your application. We recommend implementing retries with an exponential backoff strategy.
If your use case requires a higher limit, please [contact support](https://app.loops.so/settings?page=support) and we'll be happy to discuss your needs.
## Debugging
Sometimes things go wrong. Here are some tips to help you debug your API requests.
If you are having trouble with the API, we recommend using a tool like [Postman](https://www.postman.com/) to test your requests.
### Handling CORS errors
The Loops API does not support cross-origin requests made from client-side JavaScript. To avoid CORS errors, make sure to issue your requests from a server-side application.
### Dealing with `401 Unauthorized` "Invalid API key" errors
Make sure you have generated an API key from [Settings -> API](https://app.loops.so/settings?page=api) and that you are including it in your requests.
Your API key should be included in the "Authorization" header of your request, following the format `Authorization: Bearer YOUR_API_KEY`.
### Handling rate limiting (`429` Responses)
The Loops API allows a maximum of 10 requests per second per team. If you receive a `429 Too Many Requests` response, this means you have exceeded this limit.
The `x-ratelimit-limit` and `x-ratelimit-remaining` headers in the response can provide information about your current rate limit usage.
### Handling other `400`-level Responses
`400`-level responses typically indicate that there was a problem with the request. The response body will contain more information about what went wrong, so be sure to check it for details.
Check on your request type (GET, POST, PUT, DELETE) and ensure that you are using the correct one for the endpoint you are trying to access.
### "Some body key or value is longer than allowable"
If you receive this error, it means that a value in the request body is too long. We support a maximum of 500 characters for each value, including the opening and closing quotes. Please reduce the length of the values in your request and try again.
***
If you have followed these steps and are still experiencing issues, don't hesitate to [reach out to the Loops team](https://app.loops.so/settings?page=support) for further assistance.
## OpenAPI spec
Get started quickly with the Loops API using our OpenAPI documents.
You can import these documents into an API client like Postman or Insomnia to see and use all of our endpoints, with example requests and expected responses.
* **YAML:** [app.loops.so/openapi.yaml](https://app.loops.so/openapi.yaml)
* **JSON:** [app.loops.so/openapi.json](https://app.loops.so/openapi.json)
## SDKs
SDKs are software packages built on top of the API, making it easier to integrate into your project.
The official Go SDK for Loops.
The official JavaScript/TypeScript SDK for Loops.
} href="/sdks/nuxt">
The official Nuxt module for Loops.
The official PHP SDK for Loops.
} href="/sdks/ruby">
The official Ruby SDK for Loops.
### Unofficial SDKs
The following SDKs are community-submitted and have not been officially reviewed or endorsed by Loops. We recommend thoroughly testing and reviewing the code before integrating it into your project.
* [Laravel](https://github.com/plutolinks/laravel-loops) by PlutoLinks
* [PHP](https://github.com/plutolinks/loops-php) by PlutoLinks
* [Ruby on Rails](https://github.com/danielfriis/loops_rails) by Daniel Friis
[Submit an SDK](mailto:dan@loops.so)
# List audience segments
Source: https://loops.so/docs/api-reference/list-audience-segments
https://app.loops.so/openapi.json get /v1/audience-segments
Retrieve a paginated list of audience segments, most recently created first.
# List campaign groups
Source: https://loops.so/docs/api-reference/list-campaign-groups
https://app.loops.so/openapi.json get /v1/campaign-groups
Retrieve a paginated list of campaign groups, most recently created first.
# List campaigns
Source: https://loops.so/docs/api-reference/list-campaigns
https://app.loops.so/openapi.json get /v1/campaigns
Retrieve a paginated list of campaigns.
# List components
Source: https://loops.so/docs/api-reference/list-components
https://app.loops.so/openapi.json get /v1/components
Retrieve a paginated list of email components.
# List contact properties
Source: https://loops.so/docs/api-reference/list-contact-properties
https://app.loops.so/openapi.json get /v1/contacts/properties
Retrieve a list of your account's contact properties.
# List event patterns
Source: https://loops.so/docs/api-reference/list-event-patterns
https://app.loops.so/openapi.json get /v1/event-patterns
Retrieve a paginated list of event patterns available to workflow event trigger nodes.
# List mailing lists
Source: https://loops.so/docs/api-reference/list-mailing-lists
https://app.loops.so/openapi.json get /v1/lists
Retrieve a list of your account's mailing lists.
# List themes
Source: https://loops.so/docs/api-reference/list-themes
https://app.loops.so/openapi.json get /v1/themes
Retrieve a paginated list of email themes, most recently created first.
# List transactional emails
Source: https://loops.so/docs/api-reference/list-transactional-emails
https://app.loops.so/openapi.json get /v1/transactional-emails
Retrieve a paginated list of transactional emails, most recently created first.
# List transactional emails
Source: https://loops.so/docs/api-reference/list-transactional-emails-v1
https://app.loops.so/openapi.json get /v1/transactional
Get a list of published transactional emails.
This endpoint is deprecated. Use [List transactional emails](/docs/api-reference/list-transactional-emails) instead.
# List transactional groups
Source: https://loops.so/docs/api-reference/list-transactional-groups
https://app.loops.so/openapi.json get /v1/transactional-groups
Retrieve a paginated list of transactional groups, most recently created first.
# List workflows
Source: https://loops.so/docs/api-reference/list-workflows
https://app.loops.so/openapi.json get /v1/workflows
Retrieve a paginated list of workflows.
# Send a preview of an email message
Source: https://loops.so/docs/api-reference/preview-email-message
https://app.loops.so/openapi.json post /v1/email-messages/{emailMessageId}/preview
Send a test preview of an email message to one or more addresses.
Send a test email message to one or more addresses. Accepted variable fields depend on the parent's type:
* **Campaign** accepts `contactProperties`.
* **Workflow** accepts `contactProperties` and `eventProperties`.
* **Transactional** accepts `dataVariables`.
Supplying a field the parent cannot reference results in a `400 Bad Request` error.
Each team can send up to 100 preview emails per day.
# Publish a transactional email
Source: https://loops.so/docs/api-reference/publish-transactional-email
https://app.loops.so/openapi.json post /v1/transactional-emails/{transactionalId}/publish
Publish the transactional email's current draft email message. The draft becomes the published version and the draft is cleared.
The transactional email's current draft email message is published. The draft becomes the published version and the draft is cleared.
After publishing, use the returned `id` as `transactionalId` with [Send a transactional email](/docs/api-reference/send-transactional-email) to send the email.
# Remove contact from suppression
Source: https://loops.so/docs/api-reference/remove-contact-suppression
https://app.loops.so/openapi.json delete /v1/contacts/suppression
Remove suppression for a contact by email address or user ID.
# Reroute a node connection
Source: https://loops.so/docs/api-reference/reroute-node-connection
https://app.loops.so/openapi.json post /v1/workflows/{workflowId}/nodes/{nodeId}/reroute
Reroute one existing node connection to another valid target node.
Use this endpoint to modify an existing node connection to another target. Send the source node in the URL path and the new target in the request body.
The source node must have exactly one outgoing connection. Branch and experiment branch nodes cannot be rerouted with this endpoint because they have multiple branch-specific outputs. The current target must still have another incoming connection after the reroute, and the new target must be valid for the source node. The workflow cannot be edited while in a `SENDING` state.
# Run Guardian checks on an email message
Source: https://loops.so/docs/api-reference/run-guardian-checks
https://app.loops.so/openapi.json get /v1/email-messages/{emailMessageId}/guardian
Run Guardian content validation on an email message and return errors and warnings before you publish or send.
Run the same [Guardian](/docs/creating-emails/guardian) checks that run in the Loops editor on a saved email message. Use this to validate campaign, workflow, and transactional email content before publishing or sending.
Checks depend on the email message's parent type:
* **Campaign** validates contact properties and rendered links and buttons.
* **Workflow** validates contact properties, event properties from the workflow's event trigger, and rendered links and buttons.
* **Transactional** validates data variables and rendered links and buttons.
* **System transactional** validates required data variables and rendered links and buttons.
This will not work on MJML email messages, as they are not supported in the API.
# Send an event
Source: https://loops.so/docs/api-reference/send-event
https://app.loops.so/openapi.json post /v1/events/send
Send events to trigger workflows.
Provide either an `email` or `userId` value or both to identify the contact. If both are provided, the system will look for a contact with either a matching `email` or `userId` value. If a contact is found for one of the values (e.g. `email`), the other value (e.g. `userId`) will be updated. If a contact is not found, a new contact will be created using both `email` and `userId` values.
# Send a transactional email
Source: https://loops.so/docs/api-reference/send-transactional-email
https://app.loops.so/openapi.json post /v1/transactional
Send a transactional email.
Send a transactional email, including optional data variables, to a single recipient.
To set dynamic Subject, From, Reply to, CC, BCC email header fields, add data variables to those fields in the editor, then include data for each variable in the API request. Read our [transactional email guide](/docs/transactional) for more details.
To send a test preview of a transactional email, use the [Preview email message](/docs/api-reference/preview-email-message) endpoint.
Please [email us](mailto:help@loops.so) to enable attachments on your account before using them with the API.
# Update a campaign
Source: https://loops.so/docs/api-reference/update-campaign
https://app.loops.so/openapi.json post /v1/campaigns/{campaignId}
Update a draft campaign's name, group, audience (mailing list, segment, or filter), or scheduling.
At least one field must be provided. Draft campaigns support every field below. Once a campaign has been sent, only `name` and `campaignGroupId` can be updated.
# Update a campaign group
Source: https://loops.so/docs/api-reference/update-campaign-group
https://app.loops.so/openapi.json post /v1/campaign-groups/{campaignGroupId}
Update a campaign group's name or description.
At least one field must be provided. The reserved "Unsorted" group cannot be edited.
# Update a component
Source: https://loops.so/docs/api-reference/update-component
https://app.loops.so/openapi.json post /v1/components/{componentId}
Update a component's name and/or LMX body. Body changes cascade to every email using the component.
When the `lmx` body changes, the update cascades to every email using this component, and `affectedEmailCount` reports how many were affected. A change that would introduce a dynamic variable an email using the component cannot use is rejected.
# Update a contact
Source: https://loops.so/docs/api-reference/update-contact
https://app.loops.so/openapi.json put /v1/contacts/update
Update or create a contact.
Update a contact by `email` or `userId`. You must provide one of these parameters.
If you want to update a contact's email address, the contact will first need a `userId` value. You can then make a request containing the `userId` field along with an updated email address.
This endpoint will create a contact if a matching contact does not already exist in your audience.
Provide either `email` or `userId` to identify the contact you want to update. If both are provided, the system will look for a contact with either a matching `email` or `userId` value. If a contact is found for one of the values (e.g. `email`), the other value (e.g. `userId`) will be updated. If a contact is not found, a new contact will be created using both `email` and `userId` values.
# Update an email message
Source: https://loops.so/docs/api-reference/update-email-message
https://app.loops.so/openapi.json post /v1/email-messages/{emailMessageId}
Update an email message's subject, preview text, sender, or LMX content.
Supply `expectedRevisionId` matching the current `contentRevisionId`.
The campaign or transactional email that owns this email message must be in `Draft` status.
# Update a theme
Source: https://loops.so/docs/api-reference/update-theme
https://app.loops.so/openapi.json post /v1/themes/{themeId}
Update a theme's name and/or styles. Style changes cascade to every email using the theme.
When `styles` change, the update cascades to every email using this theme, and `affectedEmailCount` in the response reports how many emails were affected.
Manual style edits made on individual emails are preserved: the cascade only changes properties an email has not overridden. A per-email override is removed only when it becomes identical to the theme's new value, after which that email follows the theme for that property.
# Update a transactional email
Source: https://loops.so/docs/api-reference/update-transactional-email
https://app.loops.so/openapi.json post /v1/transactional-emails/{transactionalId}
Update a transactional email by ID.
# Update a transactional group
Source: https://loops.so/docs/api-reference/update-transactional-group
https://app.loops.so/openapi.json post /v1/transactional-groups/{transactionalGroupId}
Update a transactional group's name or description. At least one field must be provided. The reserved "Unsorted" group cannot be edited.
At least one field must be provided. The reserved "Unsorted" group cannot be edited.
# Update a workflow
Source: https://loops.so/docs/api-reference/update-workflow
https://app.loops.so/openapi.json post /v1/workflows/{workflowId}
Update a workflow's display properties.
To change a workflow's mailing list, use the [Change workflow mailing list](/docs/api-reference/change-workflow-mailing-list) endpoint instead. This is because changing mailing lists may remove queued contacts from the workflow.
# Update a workflow node
Source: https://loops.so/docs/api-reference/update-workflow-node
https://app.loops.so/openapi.json post /v1/workflows/{workflowId}/nodes/{nodeId}
Update workflow-node-owned fields for a single node.
Shared resources such as email messages and audience segments should be updated through their own APIs.
# Loops CLI
Source: https://loops.so/docs/cli
The official Loops command-line interface. Manage contacts, send events, workflows, and deliver transactional emails directly from your terminal.
## Installation
### One-line installer
```bash theme={"dark"}
curl -fsSL https://install.loops.so/cli | sh
```
To install a specific version or to a custom path, add `-s -- ` after `sh` in the command above. The default installation path is `~/.local/bin`.
Using a coding agent? Install the Loops CLI and agent skills together with
`curl -fsSL https://install.loops.so/wizard | sh`.
### Homebrew
```bash theme={"dark"}
brew install loops-so/tap/loops
```
### Script for Windows PowerShell
```powershell theme={"dark"}
irm https://raw.githubusercontent.com/loops-so/cli/main/install.ps1 | iex
```
### Go install
```bash theme={"dark"}
go install github.com/loops-so/cli/cmd/loops@latest
```
### Verify
```bash theme={"dark"}
loops --version
```
## Authentication
The CLI requires a Loops API key. Get one from [Settings > API](https://app.loops.so/settings?page=api) in Loops.
There are two ways to authenticate the CLI.
### Keyring storage
Store a key locally with `loops auth login`:
```bash theme={"dark"}
loops auth login my-team
```
You can store keys for multiple teams and switch between them:
* `loops auth use ` — set a stored key as the default
* `loops auth list` — list stored keys and see which is the default
* `loops auth get ` — print a stored API key
* `--team ` on any command — use a specific stored key for that command
See [Auth commands](/docs/cli/auth) for the full reference.
### Environment variable
Set `LOOPS_API_KEY` to use a key directly — useful for CI or when keyring storage isn't available.
```bash theme={"dark"}
export LOOPS_API_KEY=your_api_key
```
### Precedence
When multiple keys are available, the CLI resolves them in this order:
1. `LOOPS_API_KEY` environment variable
2. `--team` flag
3. The current default (set via `loops auth use`)
## Global flags
These flags can be used with any command.
| Flag | Short | Description |
| ---------- | ----- | ----------------------------------------- |
| `--debug` | | Print API request details before sending |
| `--output` | `-o` | Output format: `text` (default) or `json` |
| `--team` | `-t` | Team key name to use |
| `--help` | `-h` | Show help for any command |
## Commands
Log in, switch teams, and manage stored API keys.
Create, target, schedule, and update draft campaigns.
Read and create audience segments.
Organize campaigns into groups.
Create and list contact properties.
Create, update, find, and delete contacts.
Set up shell autocompletion.
View, create, and update email components.
Get, preview, and update draft email messages.
Send events to trigger automations.
Read event patterns.
View your mailing lists.
View, create, and update email themes.
Send and manage transactional emails.
Organize transactional emails into groups.
Upload files as email assets.
List, create, and edit workflows and their nodes.
## Utility commands
### `loops api-key`
Validate your current API key.
```bash theme={"dark"}
loops api-key
```
### `loops dedicated-sending-ips`
List the dedicated sending IP addresses assigned to your team.
```bash theme={"dark"}
loops dedicated-sending-ips
```
### `loops skill install`
Install the [Loops CLI skill](/docs/skills) so AI coding agents can discover and use the CLI. Pass `--all` to install every Loops skill, `--global` to install it globally, and `-y` to skip confirmation prompts.
```bash theme={"dark"}
loops skill install
```
### `loops --version`
Print the CLI version.
```bash theme={"dark"}
loops --version
```
### `loops agent-context`
Print a JSON description of every command, subcommand, and flag in the CLI. Designed for AI coding agents (and other tools) that need to discover what the CLI can do without scraping `--help` output.
```bash theme={"dark"}
loops agent-context
```
# Audience segments
Source: https://loops.so/docs/cli/audience-segments
Read and create audience segments.
The `audience-segments` command reads and creates audience segments, which are used to target [campaigns](/docs/cli/campaigns) and for filtering inside [workflows](/docs/cli/workflows).
## `create`
Create a new audience segment. Provide the audience filter inline with `--filter` or from a JSON file with `--filter-file`.
Set the filter inline with `--filter`:
```bash theme={"dark"}
loops audience-segments create \
--name "Pro plan contacts" \
--filter '{"match":"all","conditions":[{"type":"property","key":"plan","operator":"equals","value":"pro"}]}'
```
Alternatively, load the filter from a JSON file with `--filter-file`:
```json filter.json theme={"dark"}
{
"match": "all",
"conditions": [
{ "type": "property", "key": "plan", "operator": "equals", "value": "pro" }
]
}
```
```bash theme={"dark"}
loops audience-segments create \
--name "Pro plan contacts" \
--filter-file ./filter.json
```
The full audience filter schema is documented in the [API reference](/docs/api-reference/create-audience-segment).
### Flags
| Flag | Short | Description |
| --------------- | ----- | -------------------------------------------- |
| `--name` | `-n` | Segment name Required |
| `--description` | | Segment description |
| `--filter` | | Audience filter as an inline JSON string |
| `--filter-file` | | Path to a JSON file with the audience filter |
## `get`
Get an audience segment by ID.
```bash theme={"dark"}
loops audience-segments get cln0y4p6r003yl70i1j2k3l4m
```
## `list`
List audience segments.
```bash theme={"dark"}
loops audience-segments list
```
Pass `--pick` to filter the list interactively with [fzf](https://github.com/junegunn/fzf) and copy the selected audience segment ID to your clipboard. Requires `fzf` to be installed and on your `PATH`.
```bash theme={"dark"}
loops audience-segments list --pick
```
### Flags
| Flag | Description |
| ------------ | ------------------------------------------------------------------------------------------------------------------------- |
| `--pick` | Interactively pick an audience segment and copy its ID to the clipboard. Requires [fzf](https://github.com/junegunn/fzf). |
| `--per-page` | Results per page (10–50, default 20) |
| `--cursor` | Pagination cursor for a specific page |
# Auth
Source: https://loops.so/docs/cli/auth
Log in, switch teams, and manage stored API keys.
The `auth` command manages your CLI authentication. API keys are stored locally so you can switch between teams without re-entering credentials.
## `login`
Authenticate with your Loops API key. Pass a name of your choosing — you'll use it later with `auth use`, `auth logout`, and the `--team` flag to refer to this key. You'll be prompted to paste the key interactively.
```
loops auth login [--flags]
```
### Flags
| Flag | Description |
| --------------- | ------------------------------------- |
| `--skip-verify` | Save the API key without verifying it |
## `list`
List all stored API keys.
```bash theme={"dark"}
loops auth list
```
Pass `--pick` to filter the list interactively with [fzf](https://github.com/junegunn/fzf) and switch to the selected team (equivalent to `loops auth use `). Requires `fzf` to be installed and on your `PATH`.
```bash theme={"dark"}
loops auth list --pick
```
### Flags
| Flag | Description |
| -------- | ---------------------------------------------------------------------------------------- |
| `--pick` | Interactively pick a team to switch to. Requires [fzf](https://github.com/junegunn/fzf). |
## `get`
Print a stored API key by name. In text output, only the key is printed — useful for piping into scripts or other tools.
```bash theme={"dark"}
loops auth get my-team
```
Use `--output json` to get the key name and value together:
```bash theme={"dark"}
loops auth get my-team --output json
```
This command prints your API key to stdout. Be careful when using it in shared terminals or scripts that log output.
## `status`
Print the resolved configuration, including which API key is currently active.
```bash theme={"dark"}
loops auth status
```
## `use`
Set or clear the active API key. Pass the name of a stored key to switch to it.
```
loops auth use [--flags]
```
To clear the active team selection:
```bash theme={"dark"}
loops auth use --clear
```
### Flags
| Flag | Description |
| --------- | --------------------- |
| `--clear` | Clear the active team |
## `logout`
Remove a stored API key.
```
loops auth logout
```
# Campaign groups
Source: https://loops.so/docs/cli/campaign-groups
Create, list, and update campaign groups.
The `campaign-groups` command manages campaign groups, which are used to organize [campaigns](/docs/cli/campaigns).
## `create`
Create a new campaign group.
```bash theme={"dark"}
loops campaign-groups create --name "Product updates"
```
### Flags
| Flag | Short | Description |
| --------------- | ----- | ------------------------------------------------------------------------------------ |
| `--name` | `-n` | Campaign group name. Cannot be the reserved name "Unsorted". Required |
| `--description` | `-d` | Campaign group description |
## `get`
Get a campaign group by ID.
```bash theme={"dark"}
loops campaign-groups get cln0y4p6r003yl70i1j2k3l4m
```
## `list`
List campaign groups.
```bash theme={"dark"}
loops campaign-groups list
```
Pass `--pick` to filter the list interactively with [fzf](https://github.com/junegunn/fzf) and copy the selected campaign group ID to your clipboard. Requires `fzf` to be installed and on your `PATH`.
```bash theme={"dark"}
loops campaign-groups list --pick
```
### Flags
| Flag | Description |
| ------------ | ---------------------------------------------------------------------------------------------------------------------- |
| `--pick` | Interactively pick a campaign group and copy its ID to the clipboard. Requires [fzf](https://github.com/junegunn/fzf). |
| `--per-page` | Results per page (10–50, default 20) |
| `--cursor` | Pagination cursor for a specific page |
## `update`
Update a campaign group.
```bash theme={"dark"}
loops campaign-groups update cln0y4p6r003yl70i1j2k3l4m --name "Weekly digest"
```
### Flags
| Flag | Short | Description |
| --------------- | ----- | ------------------------------------------------- |
| `--name` | `-n` | New name. Cannot be the reserved name "Unsorted". |
| `--description` | `-d` | New description |
# Campaigns
Source: https://loops.so/docs/cli/campaigns
Create, list, and update draft campaigns.
The `campaigns` command lets you manage [campaigns](/docs/api-reference/create-campaign) in your Loops account.
## `create`
Create a new draft campaign. This also creates an empty email message — use [`email-messages update`](/docs/cli/email-messages#update) to set its subject, sender, preview text, and LMX content.
```bash theme={"dark"}
loops campaigns create --name "March product update"
```
Target the campaign at an [audience segment](/docs/cli/audience-segments) and schedule it to send at a specific time:
```bash theme={"dark"}
loops campaigns create \
--name "March product update" \
--audience-segment-id cln0y4p6r003yl70i1j2k3l4m \
--schedule-at 2026-07-01T12:00:00Z
```
### Flags
| Flag | Short | Description |
| ------------------------ | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--name` | `-n` | Campaign name Required |
| `--audience-segment-id` | | [Audience segment](/docs/cli/audience-segments) ID to target. Pass `"null"` to clear. |
| `--campaign-group-id` | | [Campaign group](/docs/cli/campaign-groups) ID |
| `--mailing-list-id` | | Mailing list ID to target. Pass `"null"` to clear. |
| `--audience-filter-file` | | Path to a JSON file with an ad-hoc audience filter. When an audience segment is also set, this filter is applied on top of the segment's own filter — contacts must match both. Pass `"null"` to clear. |
| `--schedule-at` | | Send at the given ISO 8601 timestamp (e.g. `2026-07-01T12:00:00Z`) |
| `--schedule-now` | | Send immediately when published |
## `get`
Get a campaign by ID.
```bash theme={"dark"}
loops campaigns get cln0y4p6r003yl70i1j2k3l4m
```
## `list`
List campaigns.
```bash theme={"dark"}
loops campaigns list
```
Pass `--pick` to filter the list interactively with [fzf](https://github.com/junegunn/fzf) and copy the selected campaign ID to your clipboard. Requires `fzf` to be installed and on your `PATH`.
```bash theme={"dark"}
loops campaigns list --pick
```
### Flags
| Flag | Description |
| ------------ | ---------------------------------------------------------------------------------------------------------------- |
| `--pick` | Interactively pick a campaign and copy its ID to the clipboard. Requires [fzf](https://github.com/junegunn/fzf). |
| `--per-page` | Results per page (10–50, default 20) |
| `--cursor` | Pagination cursor for a specific page |
## `update`
Update a draft campaign.
```bash theme={"dark"}
loops campaigns update cln0y4p6r003yl70i1j2k3l4m --name "April product update"
```
Retarget an existing campaign at a [campaign group](/docs/cli/campaign-groups) and send it immediately when published:
```bash theme={"dark"}
loops campaigns update cln0y4p6r003yl70i1j2k3l4m \
--campaign-group-id clr4c8t0v008yl70x3y4z5a6b \
--schedule-now
```
### Flags
| Flag | Short | Description |
| ------------------------ | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--name` | `-n` | Campaign name |
| `--audience-segment-id` | | [Audience segment](/docs/cli/audience-segments) ID to target. Pass `"null"` to clear. |
| `--campaign-group-id` | | [Campaign group](/docs/cli/campaign-groups) ID |
| `--mailing-list-id` | | Mailing list ID to target. Pass `"null"` to clear. |
| `--audience-filter-file` | | Path to a JSON file with an ad-hoc audience filter. When an audience segment is also set, this filter is applied on top of the segment's own filter — contacts must match both. Pass `"null"` to clear. |
| `--schedule-at` | | Send at the given ISO 8601 timestamp (e.g. `2026-07-01T12:00:00Z`) |
| `--schedule-now` | | Send immediately when published |
# Completion
Source: https://loops.so/docs/cli/completion
Set up shell autocompletion for the Loops CLI.
The `completion` command generates autocompletion scripts for your shell.
## Bash
Requires the `bash-completion` package.
Load for the current session:
```bash theme={"dark"}
source <(loops completion bash)
```
Load for every new session:
```bash theme={"dark"}
# Linux
loops completion bash > /etc/bash_completion.d/loops
# macOS
loops completion bash > $(brew --prefix)/etc/bash_completion.d/loops
```
## Zsh
If shell completion is not already enabled, run once:
```bash theme={"dark"}
echo "autoload -U compinit; compinit" >> ~/.zshrc
```
Load for the current session:
```bash theme={"dark"}
source <(loops completion zsh)
```
Load for every new session:
```bash theme={"dark"}
# Linux
loops completion zsh > "${fpath[1]}/_loops"
# macOS
loops completion zsh > $(brew --prefix)/share/zsh/site-functions/_loops
```
## Fish
Load for the current session:
```bash theme={"dark"}
loops completion fish | source
```
Load for every new session:
```bash theme={"dark"}
loops completion fish > ~/.config/fish/completions/loops.fish
```
## PowerShell
Load for the current session:
```powershell theme={"dark"}
loops completion powershell | Out-String | Invoke-Expression
```
To load for every new session, add the output of the above command to your PowerShell profile.
# Components
Source: https://loops.so/docs/cli/components
View, create, and update email components.
The `components` command lets you view, create, and update [components](/docs/creating-emails/components) saved in your Loops account.
## `create`
Create a new component. Pass [LMX](/docs/creating-emails/lmx) inline with `--lmx` or from a file with `--lmx-file`:
```bash theme={"dark"}
loops components create --name "Header" --lmx "Hello"
```
```bash theme={"dark"}
loops components create --name "Header" --lmx-file ./header.lmx
```
### Flags
| Flag | Description |
| ------------ | ---------------------------------------------------------------------------------------------- |
| `--name` | Component name Required |
| `--lmx` | [LMX](/docs/creating-emails/lmx) markup (inline). Mutually exclusive with `--lmx-file`. |
| `--lmx-file` | Path to a file containing [LMX](/docs/creating-emails/lmx) markup. Mutually exclusive with `--lmx`. |
## `get`
Get a component by ID.
```bash theme={"dark"}
loops components get cmn5ziajr01oztzlifwarda8m
```
## `list`
List components.
```bash theme={"dark"}
loops components list
```
Pass `--pick` to filter the list interactively with [fzf](https://github.com/junegunn/fzf) and copy the selected component ID to your clipboard. Requires `fzf` to be installed and on your `PATH`.
```bash theme={"dark"}
loops components list --pick
```
### Flags
| Flag | Description |
| ------------ | ----------------------------------------------------------------------------------------------------------------- |
| `--pick` | Interactively pick a component and copy its ID to the clipboard. Requires [fzf](https://github.com/junegunn/fzf). |
| `--per-page` | Results per page (10–50, default 20) |
| `--cursor` | Pagination cursor for a specific page |
## `update`
Update a component.
```bash theme={"dark"}
loops components update cmn5ziajr01oztzlifwarda8m --name "Header" --lmx-file ./header.lmx
```
### Flags
| Flag | Description |
| ------------ | ---------------------------------------------------------------------------------------------- |
| `--name` | Component name |
| `--lmx` | [LMX](/docs/creating-emails/lmx) markup (inline). Mutually exclusive with `--lmx-file`. |
| `--lmx-file` | Path to a file containing [LMX](/docs/creating-emails/lmx) markup. Mutually exclusive with `--lmx`. |
# Contact properties
Source: https://loops.so/docs/cli/contact-properties
Create and list contact properties.
The `contact-properties` command lets you manage contact properties in your Loops account.
## `create`
Create a new contact property.
```bash theme={"dark"}
loops contact-properties create \
--name planName \
--type string
```
### Flags
| Flag | Description |
| -------- | ------------------------------------------------------------------------------- |
| `--name` | Property name (camelCase, e.g. `planName`) Required |
| `--type` | Property type (`string`, `number`, `boolean` or `date`) Required |
There are a few [reserved names](/docs/contacts/properties#reserved-names) that you
cannot use for contact properties.
## `list`
List all contact properties.
```bash theme={"dark"}
loops contact-properties list
```
To show only custom properties:
```bash theme={"dark"}
loops contact-properties list --custom
```
Pass `--pick` to filter the list interactively with [fzf](https://github.com/junegunn/fzf) and copy the selected property to your clipboard. Requires `fzf` to be installed and on your `PATH`.
```bash theme={"dark"}
loops contact-properties list --pick
```
### Flags
| Flag | Description |
| ---------- | ------------------------------------------------------------------------------------------------------------ |
| `--custom` | Only list custom properties |
| `--pick` | Interactively pick a property and copy it to the clipboard. Requires [fzf](https://github.com/junegunn/fzf). |
# Contacts
Source: https://loops.so/docs/cli/contacts
Create, update, find, and delete contacts, and manage suppression.
The `contacts` command lets you manage contacts in your Loops audience.
## `create`
Create a new contact.
```bash theme={"dark"}
loops contacts create \
--email test@example.com \
--first-name Alice \
--list "cm06f5v0e45nf0ml5754o9cix=true"
```
### Flags
| Flag | Short | Description |
| ----------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--email` | `-e` | Contact email address Required |
| `--user-id` | `-u` | User ID |
| `--first-name` | | First name |
| `--last-name` | | Last name |
| `--subscribed` | `-s` | [Subscribed status](/docs/contacts/properties#subscribed) (`true` or `false`). We recommend leaving this field out unless you specifically want to unsubscribe or re-subscribe a contact. All new contacts are subscribed by default. |
| `--user-group` | | User group |
| `--source` | | Source ("API" by default) |
| `--list` | | Mailing list subscription as `id=true\|false` (repeatable) |
| `--prop` | | Contact property as `KEY=value` (repeatable) |
| `--contact-props` | | Path to a JSON file of [contact properties](/docs/contacts/properties) |
### Contact properties
Use `--prop` to set [contact properties](/docs/contacts/properties) inline:
```bash theme={"dark"}
loops contacts create \
--email test@example.com \
--first-name Alice \
--prop "planName=Pro" \
--prop "signupDate=2025-01-15"
```
Alternatively, use `--contact-props` to set properties from a JSON file:
```json props.json theme={"dark"}
{
"firstName": "Alice",
"planName": "Pro",
"signupDate": "2025-01-15"
}
```
```bash theme={"dark"}
loops contacts create \
--email test@example.com \
--contact-props ./props.json
```
The flags for default properties (i.e. `--first-name` or `--subscribed`) take precedence over the `--prop` flag.
## `update`
Update an existing contact. Identify the contact by email or user ID.
```bash theme={"dark"}
loops contacts update \
--email test@example.com \
--last-name Smith \
--list "cm06f5v0e45nf0ml5754o9cix=true"
```
### Flags
| Flag | Short | Description |
| ----------------- | ----- | ------------------------------------------------------------------------ |
| `--email` | `-e` | Contact email address |
| `--user-id` | `-u` | User ID |
| `--first-name` | | First name |
| `--last-name` | | Last name |
| `--subscribed` | `-s` | [Subscribed status](/docs/contacts/properties#subscribed) (`true` or `false`) |
| `--user-group` | | User group |
| `--list` | | Mailing list subscription as `id=true\|false` (repeatable) |
| `--prop` | | Contact property as `KEY=value` (repeatable) |
| `--contact-props` | | Path to a JSON file of contact properties |
### Contact properties
Use `--prop` to set [contact properties](/docs/contacts/properties) inline:
```bash theme={"dark"}
loops contacts update \
--email test@example.com \
--last-name Smith \
--prop "planName=Unlimited"
```
Alternatively, use `--contact-props` to set properties from a JSON file:
```json props.json theme={"dark"}
{
"lastName": "Smith",
"planName": "Unlimited",
}
```
```bash theme={"dark"}
loops contacts update \
--email test@example.com \
--contact-props ./props.json
```
The flags for default properties (i.e. `--first-name` or `--subscribed`) take precedence over the `--prop` flag.
## `find`
Find a contact by email or user ID.
```bash theme={"dark"}
loops contacts find --email test@example.com
```
### Flags
| Flag | Short | Description |
| ----------- | ----- | --------------------- |
| `--email` | `-e` | Contact email address |
| `--user-id` | `-u` | Contact user ID |
## `delete`
Delete a contact by email or user ID.
```bash theme={"dark"}
loops contacts delete --email test@example.com
```
### Flags
| Flag | Short | Description |
| ----------- | ----- | --------------------- |
| `--email` | `-e` | Contact email address |
| `--user-id` | `-u` | Contact user ID |
## `suppression check`
Check whether a contact is on the suppression list. Identify the contact by email or user ID.
```bash theme={"dark"}
loops contacts suppression check --email test@example.com
```
### Flags
| Flag | Short | Description |
| ----------- | ----- | --------------------- |
| `--email` | `-e` | Contact email address |
| `--user-id` | `-u` | Contact user ID |
## `suppression remove`
Remove a contact from the suppression list. Identify the contact by email or user ID.
```bash theme={"dark"}
loops contacts suppression remove --email test@example.com
```
### Flags
| Flag | Short | Description |
| ----------- | ----- | --------------------- |
| `--email` | `-e` | Contact email address |
| `--user-id` | `-u` | Contact user ID |
# Email messages
Source: https://loops.so/docs/cli/email-messages
Get and update draft email messages.
The `email-messages` command lets you view and edit the email message attached to a campaign. Email messages are created automatically when you [create a campaign](/docs/cli/campaigns#create).
Use this command when you want to keep email content in Git as `.lmx` files,
review changes locally, and push updates to a draft email message with revision
protection.
## `get`
Get an email message by ID, including its current content and `contentRevisionId`.
```bash theme={"dark"}
loops email-messages get cmn5zia4i0017tzli8ric8giv
```
## `guardian`
Run [Guardian](/docs/creating-emails/guardian) content validation on an email message. See [Run Guardian checks](/docs/api-reference/run-guardian-checks) for the checks performed and the response format.
```bash theme={"dark"}
loops email-messages guardian cmn5zia4i0017tzli8ric8giv
```
## `preview`
Send a test preview of an email message to the addresses passed in `--email`. The variable fields accepted depend on the parent type:
* Campaign previews accept `--contact-prop`.
* Workflow previews accept `--contact-prop` and `--event-prop`.
* Transactional previews accept `--var` and `--json-vars`.
```bash theme={"dark"}
loops email-messages preview cmn5zia4i0017tzli8ric8giv \
--email test@example.com \
--email another@example.com
```
### Flags
| Flag | Short | Description |
| ---------------- | ----- | ----------------------------------------------------------------------------------- |
| `--email` | | Recipient email address (repeatable; at least one required) Required |
| `--contact-prop` | | Contact property as `KEY=value` (repeatable) |
| `--event-prop` | | Event property as `KEY=value` (repeatable) |
| `--var` | `-v` | Data variable as `KEY=value` (repeatable; transactional previews only) |
| `--json-vars` | `-j` | Path to a JSON file of data variables |
## `update`
Update fields on a draft email message. The owning campaign must be in `Draft` status.
```bash theme={"dark"}
loops email-messages update cmn5zia4i0017tzli8ric8giv \
--expected-revision-id clv8g2x4z012yl70n5o6p7q8r \
--subject "Welcome to Acme" \
--preview-text "Here's how to get started" \
--lmx-file ./email.lmx
```
### Concurrency control
Every update must include either `--expected-revision-id` or `--force`:
* `--expected-revision-id` — pass the `contentRevisionId` from a prior `email-messages get`. The request is rejected with a conflict if the server's revision has advanced, so you don't overwrite a concurrent edit.
* `--force` — fetch the current revision automatically and use it. This overwrites any concurrent edits.
### Setting content
Pass [LMX](/docs/creating-emails/lmx) inline with `--lmx` or from a file with `--lmx-file`:
```bash theme={"dark"}
loops email-messages update cmn5zia4i0017tzli8ric8giv \
--force \
--lmx-file ./email.lmx
```
### Git workflow
For a safer file-based workflow:
1. Run `loops email-messages get ` and save the current LMX
in your repository.
2. Commit the `.lmx` file or review it in a branch.
3. Update the draft email with `--lmx-file` and the latest
`--expected-revision-id`.
4. Save the returned `contentRevisionId` before making another update.
Use `--force` only when you intentionally want to overwrite the latest draft
content.
### Flags
| Flag | Short | Description |
| ------------------------ | ----- | ---------------------------------------------------------------------------------------------------------------------- |
| `--expected-revision-id` | `-r` | Last-seen `contentRevisionId` from `email-messages get`. Mutually exclusive with `--force`. |
| `--force` | `-f` | Fetch the current revision and use it (overwrites concurrent edits). Mutually exclusive with `--expected-revision-id`. |
| `--subject` | | Email subject |
| `--preview-text` | | Email preview text |
| `--from-name` | | Sender display name |
| `--from-email` | | Sender username (the part before `@` — the team's sending domain is appended automatically) |
| `--reply-to` | | Reply-to email address |
| `--cc` | | CC email address |
| `--bcc` | | BCC email address |
| `--email-format` | | Email format (`styled` or `plain`) |
| `--language-code` | | Language code, [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) (e.g. `en`, `fr`) |
| `--lmx` | | [LMX](/docs/creating-emails/lmx) markup (inline). Mutually exclusive with `--lmx-file`. |
| `--lmx-file` | | Path to a file containing [LMX](/docs/creating-emails/lmx) markup. Mutually exclusive with `--lmx`. |
| `--contact-fallback` | | Contact property fallback as `KEY=value` (repeatable). Replaces all server-side contact fallbacks. |
| `--data-fallback` | | Transactional data-variable fallback as `KEY=value` (repeatable). Replaces all server-side data fallbacks. |
| `--event-fallback` | | Event property fallback as `KEY=value` (repeatable). Replaces all server-side event fallbacks. |
At least one content flag (`--subject`, `--preview-text`, `--from-name`, `--from-email`, `--reply-to`, `--lmx`, or `--lmx-file`) must be provided.
# Event patterns
Source: https://loops.so/docs/cli/event-patterns
Read event patterns detected in your account.
The `event-patterns` command reads event patterns detected in your account from the [events](/docs/cli/events) you send (v0.9.0).
## `list`
List event patterns.
```bash theme={"dark"}
loops event-patterns list
```
### Flags
| Flag | Description |
| ------------ | ------------------------------------- |
| `--per-page` | Results per page (10–50, default 20) |
| `--cursor` | Pagination cursor for a specific page |
## `get`
Get an event pattern by ID or name.
```bash theme={"dark"}
loops event-patterns get --name signup_completed
```
```bash theme={"dark"}
loops event-patterns get --id cmnx1d95z001llilji4vapgqs
```
### Flags
| Flag | Description |
| -------- | ---------------- |
| `--id` | Event pattern ID |
| `--name` | Event name |
# Events
Source: https://loops.so/docs/cli/events
Send events to trigger automations.
The `events` command lets you send events to Loops to trigger automations.
## `send`
Send an event for a contact identified by email or user ID.
```
loops events send [--flags]
```
Example:
```bash theme={"dark"}
loops events send signup \
--email test@example.com
```
### Flags
| Flag | Description |
| ------------------- | ----------------------------------------------------------------- |
| `--email` | Contact email address (required if `--user-id` is not provided) |
| `--user-id` | Contact user ID (required if `--email` is not provided) |
| `--props` | Path to a JSON file of [event properties](/docs/events/properties) |
| `--contact-props` | Path to a JSON file of [contact properties](/docs/contacts/properties) |
| `--list` | Mailing list subscription as `id=true\|false` (repeatable) |
| `--idempotency-key` | Idempotency key to prevent duplicate sends |
### Event properties file
Use `--props` to attach [event properties](/docs/events/properties) to the event:
```json event-props.json theme={"dark"}
{
"plan": "Pro",
"trialDays": 14
}
```
```bash theme={"dark"}
loops events send signup \
--email test@example.com \
--props ./event-props.json
```
# Lists
Source: https://loops.so/docs/cli/lists
View your mailing lists.
The `lists` command lets you view mailing lists in your Loops account.
## `list`
List all mailing lists.
```bash theme={"dark"}
loops lists list
```
Pass `--pick` to filter the list interactively with [fzf](https://github.com/junegunn/fzf) and copy the selected mailing list ID to your clipboard. Requires `fzf` to be installed and on your `PATH`.
```bash theme={"dark"}
loops lists list --pick
```
### Flags
| Flag | Description |
| -------- | -------------------------------------------------------------------------------------------------------------------- |
| `--pick` | Interactively pick a mailing list and copy its ID to the clipboard. Requires [fzf](https://github.com/junegunn/fzf). |
# Themes
Source: https://loops.so/docs/cli/themes
View, create, and update email themes.
The `themes` command lets you view, create, and update [themes](/docs/creating-emails/styles) saved in your Loops account.
## `create`
Create a new theme. Pass theme styles as a JSON file with `--styles-file`.
```json styles.json theme={"dark"}
{
"backgroundColor": "#ffffff",
"bodyColor": "#f5f5f5",
"textBaseColor": "#1a1a1a",
"textLinkColor": "#2563eb",
"buttonBodyColor": "#2563eb",
"buttonTextColor": "#ffffff",
"borderRadius": 8
}
```
```bash theme={"dark"}
loops themes create --name "Newsletter" --styles-file ./styles.json
```
### Flags
| Flag | Short | Description |
| --------------- | ----- | ------------------------------------- |
| `--name` | `-n` | Theme name Required |
| `--styles-file` | | Path to a JSON file with theme styles |
## `get`
Get a theme by ID.
```bash theme={"dark"}
loops themes get clo1z5q7s004yl70y3z4a5b6c
```
## `list`
List themes.
```bash theme={"dark"}
loops themes list
```
Pass `--pick` to filter the list interactively with [fzf](https://github.com/junegunn/fzf) and copy the selected theme ID to your clipboard. Requires `fzf` to be installed and on your `PATH`.
```bash theme={"dark"}
loops themes list --pick
```
### Flags
| Flag | Description |
| ------------ | ------------------------------------------------------------------------------------------------------------- |
| `--pick` | Interactively pick a theme and copy its ID to the clipboard. Requires [fzf](https://github.com/junegunn/fzf). |
| `--per-page` | Results per page (10–50, default 20) |
| `--cursor` | Pagination cursor for a specific page |
## `update`
Update a theme.
```bash theme={"dark"}
loops themes update clo1z5q7s004yl70y3z4a5b6c --name "Newsletter (dark)" --styles-file ./styles.json
```
### Flags
| Flag | Short | Description |
| --------------- | ----- | ------------------------------------- |
| `--name` | `-n` | Theme name |
| `--styles-file` | | Path to a JSON file with theme styles |
# Transactional
Source: https://loops.so/docs/cli/transactional
Send and manage transactional emails.
The `transactional` command lets you send and manage transactional emails. There is also a top-level `loops send` shortcut for quickly sending a transactional email.
## `send`
Send a transactional email.
```
loops transactional send [--flags]
```
Example:
```bash theme={"dark"}
loops transactional send cmnx1d95z001llilji4vapgqs \
--email test@example.com
```
Pass [data variables](/docs/transactional#data-variables) inline with `--var` or from a JSON file with `--json-vars`:
```bash theme={"dark"}
loops transactional send cmnx1d95z001llilji4vapgqs \
--email test@example.com \
--var name=Alice \
--var company=Acme
```
```bash theme={"dark"}
loops transactional send cmnx1d95z001llilji4vapgqs \
--email test@example.com \
--json-vars ./vars.json
```
Add attachments with `--attachment`:
```bash theme={"dark"}
loops transactional send cmnx1d95z001llilji4vapgqs \
--email test@example.com \
--attachment ./attachment.pdf
```
### Flags
| Flag | Short | Description |
| ------------------- | ----- | ------------------------------------------------------ |
| `--email` | | Recipient email address Required |
| `--var` | `-v` | Data variable as `KEY=value` (repeatable) |
| `--json-vars` | `-j` | Path to a JSON file of data variables |
| `--attachment` | `-A` | Path to a file to attach (repeatable) |
| `--add-to-audience` | `-a` | Create a contact in your audience if one doesn't exist |
| `--idempotency-key` | | Idempotency key to prevent duplicate sends |
## `create`
Create a transactional email with an empty draft.
```bash theme={"dark"}
loops transactional create --name "Password reset"
```
Assign the email to a [transactional group](/docs/cli/transactional-groups) with `--transactional-group-id`:
```bash theme={"dark"}
loops transactional create \
--name "Password reset" \
--transactional-group-id cmqz4t8b0002mkabc9wxyz123
```
### Flags
| Flag | Short | Description |
| -------------------------- | ----- | ------------------------------------------------ |
| `--name` | `-n` | Transactional email name Required |
| `--transactional-group-id` | | Transactional group ID to assign this email to |
## `get`
Get a transactional email.
```bash theme={"dark"}
loops transactional get cmnx1d95z001llilji4vapgqs
```
## `draft`
Ensure a draft exists for a transactional email. If a draft already exists it is returned unchanged; otherwise a new empty draft is created (seeded from the most recent published version when present).
```bash theme={"dark"}
loops transactional draft cmnx1d95z001llilji4vapgqs
```
## `publish`
Publish the current draft of a transactional email.
```bash theme={"dark"}
loops transactional publish cmnx1d95z001llilji4vapgqs
```
## `update`
Update a transactional email's name or group.
To update the email's content, edit its related [email message](/docs/cli/email-messages).
```bash theme={"dark"}
loops transactional update cmnx1d95z001llilji4vapgqs \
--name "Password reset email"
```
Move the email to another [transactional group](/docs/cli/transactional-groups) with `--transactional-group-id`:
```bash theme={"dark"}
loops transactional update cmnx1d95z001llilji4vapgqs \
--transactional-group-id cmqz4t8b0002mkabc9wxyz123
```
### Flags
| Flag | Short | Description |
| -------------------------- | ----- | ------------------------------------------------ |
| `--name` | `-n` | Transactional email name Required |
| `--transactional-group-id` | | Move the email to this transactional group |
## `list`
List published transactional emails.
```bash theme={"dark"}
loops transactional list
```
Pass `--pick` to filter the list interactively with [fzf](https://github.com/junegunn/fzf) and copy the selected transactional ID to your clipboard. Requires `fzf` to be installed and on your `PATH`.
```bash theme={"dark"}
loops transactional list --pick
```
### Flags
| Flag | Description |
| ------------ | --------------------------------------------------------------------------------------------------------------------------- |
| `--pick` | Interactively pick a transactional email and copy its ID to the clipboard. Requires [fzf](https://github.com/junegunn/fzf). |
| `--per-page` | Results per page (10–50, default 20) |
| `--cursor` | Pagination cursor for a specific page |
## `loops send` shortcut
`loops send` is a top-level shortcut for `loops transactional send`. It accepts the same flags.
```
loops send [--flags]
```
Example:
```bash theme={"dark"}
loops send cmnx1d95z001llilji4vapgqs \
--email test@example.com \
--var name=Alice
```
# Transactional groups
Source: https://loops.so/docs/cli/transactional-groups
Create, list, and update transactional groups.
The `transactional-groups` command lets you manage transactional groups, which are used to organize your [transactional emails](/docs/cli/transactional).
## `create`
Create a transactional group.
```bash theme={"dark"}
loops transactional-groups create --name "Account emails"
```
Add a description with `--description`:
```bash theme={"dark"}
loops transactional-groups create \
--name "Account emails" \
--description "Password resets, email verification, and login alerts"
```
### Flags
| Flag | Short | Description |
| --------------- | ----- | ------------------------------------------------ |
| `--name` | `-n` | Transactional group name Required |
| `--description` | `-d` | Transactional group description |
## `get`
Get a transactional group by ID.
```bash theme={"dark"}
loops transactional-groups get cmqz4t8b0002mkabc9wxyz123
```
## `list`
List transactional groups.
```bash theme={"dark"}
loops transactional-groups list
```
Pass `--pick` to filter the list interactively with [fzf](https://github.com/junegunn/fzf) and copy the selected transactional group ID to your clipboard. Requires `fzf` to be installed and on your `PATH`.
```bash theme={"dark"}
loops transactional-groups list --pick
```
### Flags
| Flag | Description |
| ------------ | --------------------------------------------------------------------------------------------------------------------------- |
| `--pick` | Interactively pick a transactional group and copy its ID to the clipboard. Requires [fzf](https://github.com/junegunn/fzf). |
| `--per-page` | Results per page (10–50, default 20) |
| `--cursor` | Pagination cursor for a specific page |
## `update`
Update a transactional group.
```bash theme={"dark"}
loops transactional-groups update cmqz4t8b0002mkabc9wxyz123 \
--name "Transactional emails"
```
### Flags
| Flag | Short | Description |
| --------------- | ----- | --------------- |
| `--name` | `-n` | New name |
| `--description` | `-d` | New description |
# Uploads
Source: https://loops.so/docs/cli/uploads
Upload files as email assets.
The `uploads` command lets you upload files as email assets that can be referenced in [LMX](/docs/creating-emails/lmx) email content.
## `create`
Upload a file as an email asset.
```bash theme={"dark"}
loops uploads create ./header.png
```
Use the returned `finalUrl` in [LMX `` elements](/docs/creating-emails/lmx#images).
By default the MIME type is sniffed from the file contents. Override it with `--content-type` if needed:
```bash theme={"dark"}
loops uploads create ./header.png --content-type image/png
```
### Flags
| Flag | Description |
| ---------------- | --------------------------------------------------------------------- |
| `--content-type` | MIME type to use for the upload (default: sniffed from file contents) |
# Workflows
Source: https://loops.so/docs/cli/workflows
List, inspect, create, and update workflows and their nodes.
Workflow write commands are now available, but full support for the [Workflow API](/docs/api-reference/examples/workflows) is still evolving. Endpoints are in alpha and are subject to change.
The `workflows` command lets you list, inspect, create, and update workflows and their nodes in your Loops account.
## `list`
List workflows.
```bash theme={"dark"}
loops workflows list
```
Pass `--pick` to filter the list interactively with [fzf](https://github.com/junegunn/fzf) and copy the selected workflow ID to your clipboard. Requires `fzf` to be installed and on your `PATH`.
```bash theme={"dark"}
loops workflows list --pick
```
### Flags
| Flag | Description |
| ------------ | ---------------------------------------------------------------------------------------------------------------- |
| `--pick` | Interactively pick a workflow and copy its ID to the clipboard. Requires [fzf](https://github.com/junegunn/fzf). |
| `--per-page` | Results per page (10–50, default 20) |
| `--cursor` | Pagination cursor for a specific page |
## `get`
Get a workflow by ID. Returns workflow metadata and a summary table of its nodes. For full node configuration, use [`workflows nodes get`](#nodes-get).
```bash theme={"dark"}
loops workflows get cls5d9u1w009yl70c7d8e9f0g
```
## Optimistic concurrency
Workflow write commands that modify an existing workflow accept an `--expected-revision-id` flag to guard against overwriting concurrent changes. Pass the revision ID from a prior [`workflows get`](#get); if the workflow has changed since then, the request is rejected with a conflict.
## `create`
Create a workflow.
```
loops workflows create --name [--flags]
```
```bash theme={"dark"}
loops workflows create \
--name "Welcome series" \
--description "Onboarding emails for new signups"
```
### Flags
| Flag | Short | Description |
| ------------------- | ----- | ---------------------------------------- |
| `--name` | `-n` | Workflow name Required |
| `--description` | `-d` | Workflow description |
| `--mailing-list-id` | | Mailing list ID to send the workflow to. |
## `update`
Update a workflow's name and/or description.
```
loops workflows update [--flags]
```
```bash theme={"dark"}
loops workflows update cls5d9u1w009yl70c7d8e9f0g \
--name "Welcome series v2" \
--expected-revision-id clv8g2x4z012yl70n5o6p7q8r
```
### Flags
| Flag | Short | Description |
| ------------------------ | ----- | ------------------------------------------------------------------------------------ |
| `--name` | `-n` | Workflow name |
| `--description` | `-d` | Workflow description |
| `--expected-revision-id` | | Expected workflow revision ID for [optimistic concurrency](#optimistic-concurrency). |
## `change-mailing-list`
Change a workflow's mailing list.
```
loops workflows change-mailing-list --mailing-list-id [--flags]
```
```bash theme={"dark"}
loops workflows change-mailing-list cls5d9u1w009yl70c7d8e9f0g \
--mailing-list-id clw9h3y5a013yl70o6p7q8r9s
```
Preview the impact on queued contacts before applying the change with `--dry-run`.
### Flags
| Flag | Description |
| ------------------------- | ------------------------------------------------------------------------------------ |
| `--mailing-list-id` | Mailing list ID. Pass `"null"` to clear. Required |
| `--dry-run` | Report queued-contact impact without applying the change. |
| `--queued-contact-policy` | How to treat queued contacts: `fail` or `discard`. |
| `--expected-revision-id` | Expected workflow revision ID for [optimistic concurrency](#optimistic-concurrency). |
## `nodes get`
Get full details for a single workflow node. See [Get workflow node](/docs/api-reference/get-workflow-node) for the full response schema.
```bash theme={"dark"}
loops workflows nodes get
loops workflows nodes get cls5d9u1w009yl70c7d8e9f0g clt6e0v2x010yl70h1i2j3k4l
```
For complex node configuration (such as audience filters or event property queries), use `--output json`.
## `nodes create`
Create a workflow node.
```
loops workflows nodes create --insert-mode --node-type [--flags]
```
```bash theme={"dark"}
loops workflows nodes create cls5d9u1w009yl70c7d8e9f0g \
--insert-mode before \
--node-type SendEmailAction \
--before-node-id clt6e0v2x010yl70h1i2j3k4l
```
Use `--insert-mode between` with `--from-node-id` and `--to-node-id` to insert a node between two existing nodes, or `--insert-mode before` with `--before-node-id` to insert ahead of a node.
### Flags
| Flag | Description |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `--insert-mode` | Insert mode: `between` or `before`. Required |
| `--node-type` | Node type: `AudienceFilter`, `BranchNode`, `ExperimentBranchNode`, `TimerAction`, `SendEmailAction`, or `VariantNode`. Required |
| `--from-node-id` | Source node ID (insert-mode `between`). |
| `--to-node-id` | Target node ID (insert-mode `between`). |
| `--before-node-id` | Node ID to insert before (insert-mode `before`). |
| `--expected-revision-id` | Expected workflow revision ID for [optimistic concurrency](#optimistic-concurrency). |
## `nodes update`
Update a workflow node.
```
loops workflows nodes update --payload-file [--flags]
```
```bash theme={"dark"}
loops workflows nodes update cls5d9u1w009yl70c7d8e9f0g clt6e0v2x010yl70h1i2j3k4l \
--payload-file ./node.json \
--expected-revision-id clv8g2x4z012yl70n5o6p7q8r
```
The payload file must contain a `typeName` field identifying the node type:
```json theme={"dark"}
{
"typeName": "TimerAction"
}
```
See [Get workflow node](/docs/api-reference/get-workflow-node) for the full node schema.
### Flags
| Flag | Description |
| ------------------------ | ---------------------------------------------------------------------------------------------------- |
| `--payload-file` | Path to a JSON file with the node payload (must include a `typeName` field). Required |
| `--expected-revision-id` | Expected workflow revision ID for [optimistic concurrency](#optimistic-concurrency). |
## `nodes delete`
Delete a workflow node.
```
loops workflows nodes delete [--flags]
```
```bash theme={"dark"}
loops workflows nodes delete cls5d9u1w009yl70c7d8e9f0g clt6e0v2x010yl70h1i2j3k4l \
--recursive
```
Preview the impact on queued contacts before applying the deletion with `--dry-run`.
### Flags
| Flag | Description |
| ------------------------- | ------------------------------------------------------------------------------------ |
| `--recursive` | Also delete downstream nodes. |
| `--dry-run` | Report queued-contact impact without applying the deletion. |
| `--queued-contact-policy` | How to treat queued contacts: `fail` or `discard`. |
| `--expected-revision-id` | Expected workflow revision ID for [optimistic concurrency](#optimistic-concurrency). |
## `nodes add-branch`
Add a branch to a branch node.
```
loops workflows nodes add-branch [--flags]
```
```bash theme={"dark"}
loops workflows nodes add-branch cls5d9u1w009yl70c7d8e9f0g clt6e0v2x010yl70h1i2j3k4l
```
### Flags
| Flag | Description |
| ------------------------ | ------------------------------------------------------------------------------------ |
| `--expected-revision-id` | Expected workflow revision ID for [optimistic concurrency](#optimistic-concurrency). |
# Contact activity timeline
Source: https://loops.so/docs/contacts/contact-activity
The contact activity timeline is a great way to see all the activity for a specific contact.
The contact activity timeline provides a chronological view of all the activity for a specific contact. To access the contact activity timeline, click on a contact from inside an audience.
Email properties that can show on the timeline include:
* Sent
* Opened
* Clicked
* Soft bounced
* Hard bounced
* Marked as spam
* Unsubscribed
Additionally, we also show the following properties:
* Added to audience
* Event fired
# Delete contacts
Source: https://loops.so/docs/contacts/delete-contacts
Remove contacts from your audience.
## Delete single contacts
You can delete contacts from your audience on the [Audience page](https://app.loops.so/audience).
Click the `•••` menu icon on the contact you want to delete and select **Delete**.
## Delete groups of contacts
To delete contacts in bulk, use the filters on the [Audience page](https://app.loops.so/audience) to narrow down the selection of contacts you want to delete.
Then click the `•••` menu icon in the filter box at the top of the Audience page and select **Delete contacts**.
This button will delete all contacts listed in the table below, based on the filter(s) you set up.
## Delete contacts with the API
You can delete single contacts using the API's [Delete contact](/docs/api-reference/delete-contact) endpoint, by email address or `userId` value.
```json theme={"dark"}
POST https://app.loops.so/api/v1/contacts/delete
{
"email": "test@example.com"
}
```
# Double opt-in
Source: https://loops.so/docs/contacts/double-opt-in
Require and manage subscription confirmations for new contacts.
Double opt-in requires new contacts to confirm their subscription before you can send them marketing emails. This improves list quality and deliverability.
## Good to know
This feature only applies to [marketing sends](/docs/types-of-emails) (campaigns and workflows). Transactional emails are never restricted by double opt-in.
Double opt-in is currently only enabled on [Form endpoints](/docs/forms/simple-form). API endpoints like [Create contact](/docs/api-reference/create-contact) and [Update contact](/docs/api-reference/update-contact) are not yet gated. Coverage will expand to these endpoints soon.
## The double opt-in flow
When double opt-in is enabled:
1. Contact submits a form and receives a confirmation email with a link to a branded confirmation page.
2. The confirmation page displays **Confirm subscription** and **No thanks** buttons.
3. Contacts appear in your audience as "Pending" in the **Double opt-in** column.
4. Contact confirms or rejects the subscription:
1. If they click **Confirm subscription**, they're subscribed to your audience and any selected mailing lists. This also triggers any applicable workflows.
2. If they click **No thanks**, they remain in your audience but are marked as [unsubscribed](/docs/contacts/properties#subscribed).\
They can request a new confirmation email from the same page if they clicked **No thanks** by accident.
Unsubscribed contacts do not count toward [your plan's
limits](https://loops.so/pricing).
3. If they don't respond, the contact continues to appear as "Pending". Pending contacts are not automatically removed. You can manually delete them if needed.
## Enable double opt-in
To enable double opt-in, go to [Settings -> Sending](https://app.loops.so/settings?page=sending) and scroll to the **Double opt-in** section. Turn the **Double opt-in** setting on.
This creates a special transactional email that is used to confirm the subscription, which you can customize in the [Transactional](https://app.loops.so/transactional) page in the Loops dashboard.
To disable double opt-in, turn the **Double opt-in** setting off.
## Customize the confirmation email
The opt-in email is a special [transactional email](/docs/transactional) that is created automatically when you first turn on double opt-in in your account.
You are free to customize the email as you like but it has specific requirements:
* Keep the email short, clearly branded, and focused on the confirmation action.
* Include the required data variable `optInUrl` (added automatically when the email is created).
* Other data variables are not allowed.
Click **Edit Draft** to [edit the email](/docs/transactional#editing-the-email). Make sure to click **Publish** after editing to make your changes live.
Learn how to create and send transactional email with Loops.
## Manually re-send a confirmation email
You can re-send a confirmation email to a contact from their profile page. This is only available for contacts who are currently "Pending".
On a contact's profile page, click the `•••` menu icon and choose **Request opt-in**.
## Webhooks and the API
When double opt-in is enabled, contact webhooks don't fire until the contact is confirmed. Specifically:
* The [`contact.created` event](/docs/webhooks#event-types) will only be sent for contacts created via forms once the contact has confirmed their subscription.
* Other contact-related webhooks (such as `contact.mailingList.subscribed`) will also only fire after confirmation.
* Contacts remain in a "Pending" state in your audience until they confirm, and no webhook events are triggered during this pending state.
You can read a contact's opt-in status from the API by looking at the `optInStatus` field in the [Find contact](/docs/api-reference/find-contact) endpoint.
# Email blocklist
Source: https://loops.so/docs/contacts/email-blocklist
Stop certain email addresses or domains from being added to your audience.
You can stop certain emails from being added to your audience by using Loops' Email Blocklist feature.
Go to [Settings -> Account](https://app.loops.so/settings?page=account) and click **Add pattern**.
In the input, enter an email pattern to block.
For example, you can input `*@example.com` to block all email addresses ending with `@example.com`, or `*@*.edu` to block all `.edu` email addresses.
To block an individual email address, simply insert the full address like `test@example.com`.
The blocklist feature works across all methods that allow signups to your audience: manually adding in Loops, CSV uploads, forms, the API and all integrations.
# Export contacts
Source: https://loops.so/docs/contacts/export-contacts
Download CSVs of your contact data.
At any point you can download a CSV file of your audience. This can be your whole audience, or a specific subset of contacts defined by [filters or segments](/docs/contacts/filters-segments).
## Export your audience
To download a CSV of your audience, click the `•••` menu icon in the top right of the filter box and select **Download**.
The CSV file will contain columns for each property of your contacts, including [default properties](/docs/contacts/properties#default-contact-properties) plus any [custom properties](/docs/contacts/properties#custom-contact-properties) you've added.
## Using filters and segments
If you want to only export a particular list of contacts or subset of your audience, you can use filters and segments.
Some example exports you can create:
* contacts who have opened a specific campaign
* contacts who were not sent one of your campaigns
* contacts who have clicked a link in one of your workflow emails
* contacts who subscribed after a certain date
* contacts who have a certain domain name in their email address
You can also combine filters to create more complex exports.
Here's an example of a filter you can use to export all contacts who have opened a specific campaign:
If you want to save your filters for the future, you can create a [segment](/docs/contacts/filters-segments#audience-segments) by clicking **Save segment**.
For reusable groups like "opened this campaign" or "not sent this campaign",
save the filtered audience as a segment instead of exporting a one-off CSV.
# Filters and Segments
Source: https://loops.so/docs/contacts/filters-segments
How to send emails to specific groups of contacts and save segments for future use.
To manage Loops with an AI agent, [connect Loops to Claude](/docs/mcp-server), or
install the [Loops CLI and agent skills](/docs/quickstart-agents) for Claude Code,
Codex, or Cursor.
After you have added contacts to your audience in Loops, you may want to create workflows or send campaigns to certain groups of contacts.
You can do this with filters and segments.
## Audience filters
When sending [campaigns](/docs/types-of-emails#campaigns) or emails inside [workflows](/docs/workflows), you can send to specific groups of contacts in your audience.
Choosing these contacts is done using filters, which are based on two sets of data:
* [contact properties](/docs/contacts/properties) (either default properties or custom properties you've added to your audience)
* **contact activity** from your campaigns and workflows (sends, not sent
status, opens and clicks)
For example, you can use filters to build segments like:
* contacts with a specific domain in their email address
* contacts who were added via a specific source
* contacts who were sent a specific campaign
* contacts who were not sent a specific campaign
* contacts who opened or clicked a specific campaign or workflow email
* contacts who didn't open a specific email within one of your workflows
You can add multiple filters at once, and you can also choose to apply *all filters* ("All of these are true") or *any filter* ("Any of these are true").
Audience filters are automatically updated as contact properties are changed. This means that your emails will only send to contacts that match the filter at the time the email is sent.
## Activity filters
Activity filters let you segment contacts based on campaign and workflow email
activity:
* **Sent**: contacts who were sent a campaign or workflow email.
* **Not sent**: contacts who were not sent a selected campaign or workflow
email.
* **Opened**: contacts who opened a campaign or workflow email.
* **Clicked**: contacts who clicked a tracked link in a campaign or workflow
email.
For example, you can use **Not sent** to build a segment of contacts who have
not received a selected campaign yet, then save that filtered audience as a
segment.
If you want to measure whether a campaign drove a business outcome, use
[goals](/docs/goals/overview) instead of treating opens or clicks as the primary
engagement signal.
Transactional emails do not track opens or link clicks. Activity filters for
opens and clicks apply to campaign and workflow emails.
## Audience segments
Segments are just filters that are saved in your Loops account for the future. This makes them easily reusable between different workflows and campaigns.
You can create segments while sending campaigns or workflows, or directly on the [Audience page](https://app.loops.so/audience).
To create a segment, first filter the audience table and then click **Save segment**.
You will be prompted to add a name for your segment, then click **Finish**.
Your new segment will then be available in a dropdown whenever you filter your audience for campaigns or workflows.
Just like filters, segments are automatically updated when contact data is changed, meaning emails always send to the correct contacts at the time the email is sent.
# List management
Source: https://loops.so/docs/contacts/mailing-lists
Organize contacts and offer a subscriber preference center with mailing lists.
To manage Loops with an AI agent, [connect Loops to Claude](/docs/mcp-server), or
install the [Loops CLI and agent skills](/docs/quickstart-agents) for Claude Code,
Codex, or Cursor.
Mailing lists are useful when you want to organize contacts in more public groups and when you want contacts to be able to opt in and out of specific lists.
Using Lists will automatically generate a branded Preference Center for contacts in your audience, allowing them to easily manage their subscription preferences.
Lists can be public or private and contacts can belong to many, one, or no lists.
For organizing contacts for your own use, we suggest using [filters and segments](/docs/contacts/filters-segments) instead.
Lists are an optional feature. You can use Loops without lists but if you'd
like finer-grained control over your contacts and the types of communication
they receive, lists are available on any plan tier.
## List types
Each list you create can be **Public** or **Private**.
By default lists are private, meaning they are only shown to their subscribed contacts (non-subscribers won't be able to see or subscribe to private lists in the Preference Center).
If you want to allow general opt-in to a list, you can set the list visibility to `Public`. Public lists will be shown to all contacts in the Preference Center.
You can also sign up new subscribers to public lists with [Forms](/docs/forms/simple-form).
Both private and public lists are visible within your Loops admin and can be
used for filtering contacts when sending campaigns and workflows.
## Preference Center
The Preference Center allows your contacts to manage their own subscription preferences.
A link to the contact-specific Preference Center is automatically added to each marketing email sent from Loops. You can link to the Preference Center in MJML emails by using the `{unsubscribe_link}` [dynamic tag](/docs/creating-emails/personalizing-emails#dynamic-tag-syntax).
You can upload a company icon to brand your Preference Center. This option is shown just below your mailing lists in the [Lists settings page](https://app.loops.so/settings?page=lists).
You can brand your unsubscribe page with a company icon even if you do not use
the lists features.
Within the Preference Center, contacts will see:
* your company icon (if uploaded)
* the names and descriptions of all public lists
* the names and descriptions of all private lists they are subscribed to
* the option to unsubscribe from each list they are subscribed to
## Email footers
When sending campaigns and workflows to specific lists, the [email footer](/docs/creating-emails/editor#footer-content) will include the name of the list the email was sent to. This is useful for contacts to see which list the email was sent to, as well as unsubscribe from a certain list.
## Create a list
Go to [Settings -> Lists](https://app.loops.so/settings?page=lists).
Click on the **Create a list** button.
A new mailing list will appear. Enter a name for your list and optionally, a description.
You can also choose a color to easily identify the list inside your Loops account.
Choose between `Private` or `Public` ([see above](#list-types)).
Click **Save changes** to finalize the creation of the list.
## Edit a list
To edit an existing list, go to [Settings -> Lists](https://app.loops.so/settings?page=lists).
Edit the name, description, visibility and color.
Click **Save changes** to apply the changes.
After saving your changes, the updated list data will be instantly available
to your contacts in their Preference Centers.
## Delete a list
You can delete lists by clicking on a list's `•••` menu icon and selecting **Delete**.
You cannot delete a list that:
* has been [sent a campaign](#send-campaigns-to-a-list), or has a sending or draft campaign
* is selected in the "Contact added to list" trigger [in a workflow](#trigger-a-workflow-when-a-contact-is-added-to-a-list)
* has been [applied to a workflow](#send-workflows-to-a-list)
* is being used in a [form](/docs/forms/simple-form)
Lists that cannot be deleted will show an "In use" badge. Hover over the badge to see a list of the campaigns, workflows and forms that are stopping the list from being deleted.
## Utilizing lists
Here are a few ways you can use lists to send emails and organize contacts.
### Send campaigns to a list
Instead of sending campaigns to your whole audience, you can send emails to a specific list.
[Create a campaign](https://app.loops.so/campaigns) or edit an existing one.
On the [Audience page](https://app.loops.so/audience), select the list you want to send to.
Users not subscribed to the selected list will not receive the campaign.
Optionally, you can apply additional [filters or segments](/docs/contacts/filters-segments) to further refine your audience.
### Send workflows to a list
You can configure workflows to only send to contacts in a specific list (this applies to all triggers).
[Create a workflow](/docs/workflows) or edit an existing one.
Select the list you want to trigger the workflow.
Start the workflow. Only contacts from the selected list will be entered into the workflow.
### Trigger a workflow when a contact is added to a list
This example is a typical use case of sending an email sequence to new contacts when they are added to a specific list.
[Create a workflow](/docs/workflows) or edit an existing one.
Set the workflow trigger to "Contact added to list".
Select the list you want to trigger the workflow.
Start the workflow. When a contact is added to the selected list, the workflow will be triggered.
### Manually add contacts to lists
How to add existing contacts to your different mailing lists within Loops.
You cannot add contacts to mailing lists if they are unsubscribed from your audience.\
Likewise, if a contact unsubscribes from a list via the
Preference Center, they cannot be resubscribed by your team.
#### Individual contacts
Go to your [Audience page](https://app.loops.so/audience).
Click on the contact you want to manage.
In the contact details page, click on **Subscribed** to reveal the mailing
list dropdown.
Toggle each list on or off as needed. Click **Save changes** in the top
right to apply the changes.
#### Bulk contacts
You can easily add any filtered group of contacts to a specific list on the Audience page.
Go to your [Audience page](https://app.loops.so/audience).
Add filters to segment your audience into the group of contacts you'd like
to add to a list.
Click the `•••` menu icon on the far right-hand side of the audience
filters, select **Add to mailing list** and then select the list(s) you want
to add the contacts to.
### Upload a CSV to a list
If you want to import contacts to a list in bulk you can use our [CSV importer](/docs/add-users/csv-upload).
In the final stage of the form you can select a list, which will add all contacts (new or existing) in the CSV file to that list.
### Add contacts to lists with the API
Utilizing the [Loops API](/docs/api-reference/intro) you can programmatically add and remove contacts to and from Lists.
You cannot add contacts to mailing lists if they are unsubscribed from your audience.\
Likewise, if a contact unsubscribes from a list via the
Preference Center, they cannot be resubscribed by your team.
When [creating a contact](/docs/api-reference/create-contact), [updating a contact](/docs/api-reference/update-contact), or [sending an event](/docs/api-reference/send-event) with the API, you can include a `mailingLists` object in the payload.
This `mailingLists` object is a key-value pair of list IDs and a subscription status. The subscription status can be `true` or `false`.
```json theme={"dark"}
{
"email": "test@example.com",
"mailingLists": {
"cm06f5v0e45nf0ml5754o9cix": true,
"cm16k73gq014h0mmj5b6jdi9r": false
}
}
```
In this example, the contact would be subscribed to `cm06f5v0e45nf0ml5754o9cix` and unsubscribed from `cm16k73gq014h0mmj5b6jdi9r`.
Mailing list IDs can be found [in the app](https://app.loops.so/settings?page=lists) (click the ID to add it to your clipboard) or by using the [API](/docs/api-reference/list-mailing-lists).
### Add contacts to lists with forms
If you use a [form](/docs/forms/simple-form) on your website you can subscribe contacts to specific lists.
When exporting HTML from the [Forms page](https://app.loops.so/forms) in Loops, choose a list from the **Settings** tab.
Adding contacts to a list via a form only works with public lists. The option
to select a list will only appear in the form settings if you have at least
one public list.
If you already have a form in place or are using a [custom form](/docs/forms/custom-form) you can add a `mailingLists` parameter to the form body with the value a comma-separated list of mailing list IDs.
```html HTML example {3} theme={"dark"}
```
```javascript JavaScript example {4} theme={"dark"}
fetch(formEndpointUrl, {
method: "POST",
body:
"mailingLists=cm06f5v0e45nf0ml5754o9cix,cm16k73gq014h0mmj5b6jdi9r" +
"&email=" +
encodeURIComponent(emailInput.value),
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
```
# Contact properties
Source: https://loops.so/docs/contacts/properties
How to add, edit and delete contact properties.
To manage Loops with an AI agent, [connect Loops to Claude](/docs/mcp-server), or
install the [Loops CLI and agent skills](/docs/quickstart-agents) for Claude Code,
Codex, or Cursor.
Contact properties are fields you store on each contact in Loops (like name, plan, or signup source). You can use them to personalize emails, segment your audience, and trigger workflows.
Loops includes a set of default contact properties and lets you create custom properties for your product.
## Editing contacts
You can edit a contact's properties in the [Audience](https://app.loops.so/audience) page by clicking on a contact. Edit the contact's information in the form and click **Save changes**. If you want your changes to the contact to trigger any related workflows, select **Save and trigger** instead.
You can also edit a contact's properties via [the API](/docs/api-reference/update-contact) or one of our [integrations](/docs/integrations).
[CSV uploads](/docs/add-users/csv-upload#update-contacts-via-csv) are a good way to edit multiple contacts in bulk.
## Default contact properties
These are the default properties for every contact on Loops. They cannot be deleted.
| Contact Property | Example | Email Tag\* | API Name\*\* |
| ---------------- | --------------------------------------------- | ------------- | ------------ |
| Email | *[test@example.com](mailto:test@example.com)* | `{email}` | `email` |
| First Name | *Chris* | `{firstName}` | `firstName` |
| Last Name | *Frantz* | `{lastName}` | `lastName` |
| Notes | *Favorite color is blue.* | `{notes}` | N/A |
| Source | *API* | `{source}` | `source` |
| Subscribed | `true` | N/A | `subscribed` |
| User Group | *Investors* | `{userGroup}` | `userGroup` |
| User Id | *ask523236* | `{userId}` | `userId` |
\* Used to [add personalization to emails](/docs/creating-emails/personalizing-emails#dynamic-tag-syntax).\
\*\* Used in [API requests](/docs/api-reference/intro).
### Source
"Source" describes where the contact originated from.
By default, this value will be "Form" for contacts added [via a form](/docs/forms/simple-form), or "API" for contacts added [via the API](/docs/api-reference/intro). You can specify custom "Source" values when adding contacts via forms and the API.
### Subscribed
The "Subscribed" value determines whether a contact is able to receive **workflow emails and campaigns**. Unsubscribed contacts *will continue to receive all transactional emails*.
Contacts can unsubscribe from your emails using an [Unsubscribe link](/docs/creating-emails/editor#footer-content) automatically added to your campaigns and workflows.
#### Using `subscribed` in API requests
When you include a `subscribed` value in your API requests, you will manually change the contact's subscribed status. For example, if you send `subscribed: true` when updating a contact, previously unsubscribed contacts will be re-subscribed.
We recommend leaving `subscribed` out of your API requests unless you
specifically want to unsubscribe or re-subscribe a contact. All new contacts
are subscribed by default (`subscribed: true`).
### Some important notes:
* We do not charge for unsubscribed contacts.
* We suggest you keep unsubscribed contacts in your audience. If you delete and then re-add them in the future somehow, they may end up being "subscribed" even though they have been unsubscribed.
* You can re-subscribe unsubscribed contacts [with the API](/docs/api-reference/update-contact) and with some of [our integrations](/docs/integrations#manage-contacts). You cannot re-subscribe contacts via a [CSV upload](/docs/add-users/csv-upload) or from the Audience page in Loops. Unsubscribes from [mailing lists](/docs/contacts/mailing-lists) work differently. You cannot re-subscribe contacts to lists via any method.
### User Group
"User Group" is a useful optional property that you can use to segment contacts. It is a free text field that allows you to easily divide contacts into groups like "Users", "VIPs", "Investors" or "Customers".
Contacts can currently only have one user group value.
### User Id
"User Id" is a unique external ID you can assign to each contact in your audience. For example, this could be a customer ID from your store or a user ID from your SaaS.
This field is optional but is very useful if you are working with our API. For example, you need a user ID to be able to [change a contact's email address](/docs/api-reference/update-contact).
### Mailing lists
Read about [how to use mailing lists](/docs/contacts/mailing-lists) in Loops.
## Custom contact properties
Custom contact properties are additional fields that you can create to store information about contacts.
### Types of property
Custom contact properties can be one of four different types:
* String
* Number
* Boolean
* Date (see below)
You can specify a property type when creating new properties in Loops.
#### Dates
When sending dates with the [API](/docs/api-reference/intro) or via one of our [integrations](/docs/integrations), you can use either a Unix timestamp (*in milliseconds*) or an [ECMA-262 date-time string](https://tc39.es/ecma262/multipage/numbers-and-dates.html#sec-date-time-string-format).
Timestamps must be in milliseconds and can be sent as either an integer or string.
* `1705486871000` (if the Unix timestamp is `1705486871`)
Supported date formats are shown below. These must be sent as a string.
Adding a time offset at the end (e.g. `+02:00` or `-07:00`) is optional (if omitted, the date will default to UTC).
* `YYYY-MM-DDTHH:mm:ss.sss`
* `YYYY-MM-DDTHH:mm:ss`
* `YYYY-MM-DDTHH:mm`
* `YYYY-MM-DD HH:mm:ss.sss`
* `YYYY-MM-DD HH:mm:ss`
* `YYYY-MM-DD HH:mm`
* `YYYY-MM-DD`
[CSV uploads](/docs/add-users/csv-upload) accept dates in the formats listed above, but do not support timestamps.
### Reserved names
Note that Loops does not allow the creation of properties with the following reserved names:
* `id`
* `listId`
* `softDeleteAt`
* `teamId`
* `updatedAt`
### Add a property
One way to create custom contact properties is to go to [your Audience](https://app.loops.so/audience) and click on any of the column headers, then select **Add property**.
Alternatively you can scroll to the end of the Audience table and click the `+` button at the end of the column headers.
A third way is to go to your [API Settings](https://app.loops.so/settings?page=api) page, scroll down to the Contact properties section and click **Add property**.
In each of these cases, you'll see a form asking you for a property name and type.
It's also possible to [create contact properties with the API](/docs/api-reference/create-contact-property).
```json theme={"dark"}
POST https://app.loops.so/api/v1/contacts/properties
{
"name": "myCustomProperty",
"type": "string"
}
```
### Editing contact properties
You cannot edit a contact property after it has been created. If you need to change the name or type of a property, you will need to create a new one.
### Deleting contact properties
You can delete properties on the Audience page by clicking on column headers.
You can also delete properties from your [API Settings](https://app.loops.so/settings?page=api) page by clicking the trashcan icons.
It is not possible to delete default contact properties.
Once a contact property is deleted, all associated data will also be deleted
and cannot be recovered. It's important to be sure that you won't need the
information stored in a property before deleting it.
#### Property in use warning
If you receive a “Property in use” warning modal while deleting a contact property, there are a few things you can check before you're able to delete the property.
* If the listed email is a Campaign:
* Check if the property is in use as [dynamic content](/docs/creating-emails/personalizing-emails) inside the email editor
* Ensure this property is not being actively used in the Audience filter
* If the listed email is inside a workflow:
* Make sure a draft or running workflow is not using it as part of the Audience filter or as a Trigger
# Contact suppression
Source: https://loops.so/docs/contacts/suppression
Check suppression status and remove suppressions with the API.
When an email to a contact results in a hard bounce or a complaint, Loops suppresses sending email to the contact to protect your sending reputation. Attempted sends to this contact will appear as hard bounces.
Suppressed contacts won't receive further emails sent via Loops. In order for a contact to receive emails again, you need to remove the suppression.
You should typically only remove suppression if you're confident the address is now deliverable (for example, the mailbox was restored).
## Check if a contact is suppressed
You can use the [Check suppression](/docs/api-reference/check-contact-suppression) endpoint to check if a contact is suppressed and to view your current suppression removal quota.
```
GET https://app.loops.so/api/v1/contacts/suppression?email=test@example.com
```
## Remove suppression for a contact
Free Loops accounts can only request suppression removal via our [support channels](https://app.loops.so/settings?page=support).
Paid accounts can use the [Remove suppression](/docs/api-reference/remove-contact-suppression) endpoint to remove a suppression.
```
DELETE https://app.loops.so/api/v1/contacts/suppression?email=test@example.com
```
## Suppression removal quota
Paid accounts can request up to 100 suppression removals in a rolling 30 day period.
You can view your current suppression removal quota by using the [Check suppression](/docs/api-reference/check-contact-suppression) endpoint.
# Components
Source: https://loops.so/docs/creating-emails/components
Create reusable Loops components for your emails.
Components are reusable elements for emails. They help you create frequently-repeated sections of emails once, which you can then easily drop into new emails.
Changes made to components can be synced to all other instances. You can also choose to make local edits to a single component instance without updating others.
Components can be created from and added to all emails created in Loops (campaign, workflow and transactional emails). They also work in both Plain and Styled emails.
## Example components
Some useful examples of components are logos and social icons. These elements are typically the same across multiple emails; using components will make sure they are the same everywhere.
Most of the time you will want your logo to have the same alignment, spacing and size in your emails. Similarly, you will want the same set of social icons readily available to drop into every new email you create.
## Create a component
To create a component, click on an element in your email and then the **Create component** icon in the editor panel.
You can also create and manage components [via the API](/docs/api-reference/create-component).
A modal will appear where you can name your component. Use a descriptive name so you can easily find your component in the future.
Click **Create**. You will see your new component appear in the Components list on the left of the editor.
All components you create are available in all of your emails, i.e. a
component created in a campaign email is also available to insert into
transactional emails.
## Insert a component
Click on **Components** at the top of the left-hand panel to reveal your components list, then simply click on a component to insert it.
If you want to change the location of a component in your email, you can drag and drop it within the editor just like other blocks, by using the six dot menu icon.
If you already have a component in your email, clicking **Duplicate** in the **Block styles** editor panel will add a copy of that component into the email, including any local edits made.
## Edit a component
You can tell if an element in your email is a component by looking for a
purple outline around the block in the editor.
To edit a component, edit its content as you would any other part of your email.
This will create local edits to that single instance; these edits are not synced to other components in use elsewhere. This means you can make tweaks and changes to a single instance of a component without updating all other instances.
If a component has local edits, you will notice the **Push changes** and **Reset changes** icons are now active.
If you want to save your changes to all instances of the component, click on the **Push changes to main component** button. Note that any local edits made to the component in other emails will be preserved.
If you make local edits to a component that you want to revert, click the **Reset component changes** option.
## Rename a component
In the Components list, find the component you want to rename, then click the `•••` menu icon. Click on **Rename** to show the rename modal.
Enter a new name and click **Rename**.
## Delete a component
Deleting a component only deletes the component; it does not remove the
component from emails. A deleted component's contents will be retained by any
email it was added to.
In the Components list, find the component you want to delete, then click the `•••` menu icon. Click on **Delete** to show the confirmation modal.
Click **Delete** to delete the component.
# Duplicating emails
Source: https://loops.so/docs/creating-emails/duplicating-emails
Reuse basic elements of an email or create email templates.
Duplicating emails is a great way to save time crafting similar emails.
You can easily create emails that share many of the same elements (investor updates, product updates, waitlist invites, etc).
## Campaigns and transactional emails
To duplicate an email:
1. Go into **Campaigns** or **Transactional**.
2. Click the `•••` menu icon and select **Duplicate**.
3. Edit the new email's subject line, preview text and content as needed.
## Workflows
With workflows, you can duplicate a full workflow (which may contain many emails) using the method above (click a `•••` menu icon within the **Workflows** list page).
However, you may also duplicate emails within a workflow by clicking the **Copy existing email** button in the inspector panel on the right-hand side after creating a new email node. This will let you copy and insert any email from any of your existing workflows, including the currently opened workflow.
# The editor
Source: https://loops.so/docs/creating-emails/editor
Loops' email editor has writing mode, themes, dynamic personalization, reusable content blocks, keyboard/Markdown shortcuts and more.
To manage Loops with an AI agent, [connect Loops to Claude](/docs/mcp-server), or
install the [Loops CLI and agent skills](/docs/quickstart-agents) for Claude Code,
Codex, or Cursor.
We have built a powerful editor for creating emails. Create any email with our flexible design tools, and write distraction-free with Writing mode.
Here are a few of the editor's stand-out features:
* Customizable custom themes to keep your designs consistent
* Writing mode, distraction-free editing which hides the editor interface
* Auto-saves as you type
* Mobile-friendly so you can write and design emails on any device
* Support for images, buttons, social icons, dividers and columns
* The same interface for marketing and transactional emails
* Personalize emails with contact and event properties
* Helpful keyboard and Markdown shortcuts for adding content and formatting text
If you would rather upload emails into Loops, you can [import
MJML](/docs/creating-emails/uploading-custom-email) instead of using the editor.
## Formatting text
Once you have written content in your email, you will see formatting options appear in the right-hand panel. These options will change depending on the element you have selected in the email.
You can choose between different headings, style text **bold**, *italic*, underline as well as add links. You can also change text size and color.
Text can also be easily changed into lists, quotes and [code blocks](/docs/creating-emails/editor/code-blocks).
You can also use [keyboard shortcuts](#keyboard-shortcuts) and [Markdown formatting](/docs/creating-emails/editor/markdown) to format your text.
## Writing mode
To focus on writing your email, you can enter Writing mode.
To toggle writing mode, use the switch just above the editor or use the `⌘+/` (Mac) or `Ctrl+/` (Windows) keyboard shortcut.
When in Writing mode, you can edit styles by clicking the **Show styles** button that appears above selected elements. This will open a slide-in editor panel on the right.
## Adding elements
Emails can be more than just text. You can add the following elements to your emails:
* images
* links
* buttons
* dividers
* social icons
* columns
* sections
* arrays
You can add elements from the toolbar above the editor, or by hovering over an existing element in your email and clicking the `+` plus icon on the left.
You can also add elements by typing `/` in the editor:
If you continue typing, you can narrow down the selections. You can use your arrow keys to select items and pressing Enter will confirm your selection.
When adding dynamic content like contact properties to [personalize emails](/docs/creating-emails/personalizing-emails), you can type "\{" to open the dynamic content menu.
You can also use [keyboard shortcuts](#keyboard-shortcuts) and [Markdown formatting](#markdown-formatting) to add certain elements to your email.
## Reordering content
If you hover over a block in your email, you'll see a drag-and-drop icon appear on the left. You can drag this icon to re-order elements in your email.
## Footer content
We add an automatic footer into campaign and workflow emails, which includes your company name, address and an unsubscribe link.
Unsubscribe links help prevent your messages from being marked as spam and ensure that your messages are willingly being received by an engaged audience, helping your deliverability.
The unsubscribe link leads to a [Preference Center](/docs/contacts/mailing-lists#preference-center), where your contacts can manage their own subscriptions.
You can edit your company address from [Settings -> Domain](https://app.loops.so/settings?page=domain). The address shown there is what appears in every email footer and can't be fully removed. A physical address and unsubscribe link are required for deliverability and anti-spam compliance.
If your email was sent to a [mailing list](/docs/contacts/mailing-lists), the footer will also [include the name of the list](/docs/contacts/mailing-lists#email-footers) the email was sent to.
### Editing footer content
The automatic footer can't be edited directly, but we have a manual system for creating custom footers on paid plans. [Contact support](mailto:help@loops.so) for more information.
For [custom MJML emails](/docs/creating-emails/uploading-custom-email), design your footer however you like and include the `{unsubscribe_link}` tag on your unsubscribe link.
## Keyboard shortcuts
The following keyboard shortcuts are available in the editor.
You can also use [Markdown formatting](/docs/creating-emails/editor/markdown) to format your text.
The editor automatically saves your work as you type so there is no need to
save manually.
| Shortcut | Action |
| :------------ | :------------------------------- |
| `⌘` `/` | Toggle writing mode |
| `/` | Insert a block (open slash menu) |
| `⌘` `D` | Duplicate a block |
| `⌘` `K` | Insert a link |
| `⌘` `B` | Bold text |
| `⌘` `I` | Italicize text |
| `⌘` `U` | Underline text |
| `Tab` | Indent a list item |
| `Shift` `Tab` | Unindent a list item |
You can also use the following default keyboard shortcuts:
| Shortcut | Action |
| :-------------- | :--------- |
| `⌘` `A` | Select all |
| `⌘` `C` | Copy |
| `⌘` `V` | Paste |
| `⌘` `X` | Cut |
| `⌘` `Z` | Undo |
| `⌘` `Shift` `Z` | Redo |
| Shortcut | Action |
| :------------ | :------------------------------- |
| `Ctrl` `/` | Toggle writing mode |
| `/` | Insert a block (open slash menu) |
| `Ctrl` `D` | Duplicate a block |
| `Ctrl` `K` | Insert a link |
| `Ctrl` `B` | Bold text |
| `Ctrl` `I` | Italicize text |
| `Ctrl` `U` | Underline text |
| `Tab` | Indent a list item |
| `Shift` `Tab` | Unindent a list item |
You can also use the following default keyboard shortcuts:
| Shortcut | Action |
| :----------------- | :--------- |
| `Ctrl` `A` | Select all |
| `Ctrl` `C` | Copy |
| `Ctrl` `V` | Paste |
| `Ctrl` `X` | Cut |
| `Ctrl` `Z` | Undo |
| `Ctrl` `Shift` `Z` | Redo |
# Arrays
Source: https://loops.so/docs/creating-emails/editor/arrays
Arrays let you add repeatable content blocks to transactional emails, populated at send time via data variables.
Arrays are a way to add repeatable sections to an email allowing you to add a dynamic list of items, like products.
You can display up to 100 items in each array block.
Arrays are currently supported in transactional emails.
## Adding arrays
You can add array blocks to your email by using the button in the editor toolbar or typing `/` and selecting **Array**.
Arrays are assigned a data variable name (default is `items`) which can be changed in the editor panel in the **Array** section.
## Adding content to arrays
Once the array block is in your email, you can add content inside, which will be repeated for each item in the array.
For example, if you have an array of products and you want to show a name and a description for each product, you can add a heading and a text block inside the array block.
## Adding dynamic content
You can add dynamic content to array blocks by inserting [data variables](/docs/transactional#add-data-variables). These data variables can then be populated with data when you [send your email](/docs/transactional#send-your-email).
You can add data variables by using the button above the editor, typing `{`, or typing `/` and selecting **Data variable**.
By default, data variables you add will be available within the array block only. You can change variable tags inside array blocks to be "global" by changing the **Array item** option in the right sidebar to **No**.
## Previewing arrays
You can preview how multiple items will look in your email by using the `+` and `-` buttons in the **Preview** section of the editor sidebar. These buttons allow you to see how the array will look in your email with different numbers of items.
# Buttons
Source: https://loops.so/docs/creating-emails/editor/buttons
Add styled, clickable buttons to your Loops emails.
You can add buttons to your email to allow recipients to take action.
Buttons are styled using the **Block styles** editor panel and can contain text.
You can add links to buttons by clicking the `+` icon in the **Link** editor panel.
Add buttons:
* by using the button icon in the editor toolbar
* by typing `/` in the editor and selecting **Button**
* by hovering over an existing element in your email and clicking the `+` plus icon on the left and selecting **Button**
## Dynamic content
To add [dynamic content](/docs/creating-emails/personalizing-emails#dynamic-tag-syntax) to button text, you need to manually type the correct tag syntax (e.g. `{firstName}`, `{EVENT_PROPERTY:firstName}`, `{DATA_VARIABLE:firstName}`) directly into the button field.
Adding dynamic content to buttons through the UI is not yet supported.
Read about adding dynamic content in emails using the tag syntax.
# Code blocks
Source: https://loops.so/docs/creating-emails/editor/code-blocks
Add code blocks to your email
## Code blocks
Code blocks are a way to display code in your email.
They are displayed in a monospace font with a gray background. You can edit the background color in the **Block styles** editor panel, along with padding and corner border radius.
Note that no syntax highlighting is added to code blocks because of email client compatibility.
You can add code blocks to your email by typing three backticks ` ``` `.
Alternatively, you can use the `/` slash menu or the `+` plus menu on the left of the editor, and then select **Code Block**.
You can also select **Code Block** from the **Text** panel in the right editor sidebar.
## Inline code
Add inline code to your email (like `this inline code`) by typing a single backtick `` ` ``.
# Columns
Source: https://loops.so/docs/creating-emails/editor/columns
Arrange email content side-by-side with layouts of 2 to 4 columns.
Columns allow you to arrange content side by side, creating dynamic layouts for your emails. You can use columns to place elements like images, text, buttons, and more next to each other.
You can use layouts of 2 to 4 columns. Nesting columns within columns is not
yet supported.
## Adding columns
You can add columns to your email in several ways:
* **Using the editor toolbar**: Click on the columns icon to insert a column layout.
* **Using the `/` slash menu in the editor**: Type `/` to open the slash menu then start typing "columns" or click on the **Columns** option.
* **Using the `+` icon**: hover over an existing element in your email and click the `+` plus icon on the left, then select **Columns**.
* **Dragging two blocks together**: Click and hold the block settings icon (six dots) to the left of a block. Drag the block next to another one until you see a vertical gray line on the right, which indicates a new column will be created.
## Customizing columns
Once you've added columns, you can customize their appearance and behavior to suit your design needs.
### Number of columns
You can choose the number of columns you want to use by clicking on the number of columns you want to use in the **Block styles** editor panel.
If you decrease the number of columns, the content of the farthest right column is moved to the bottom of the new right-most column.
### Width and spacing
You can adjust the width of a column by clicking and dragging the gray bar between the columns.
You can also adjust how much space appears between columns by dragging the green bar between the columns.
### Stacking columns on mobile
When emails are viewed on a mobile device (when there is less horizontal space available) you can choose if your columns should stack vertically for better readability or stay as columns.
In the **Block styles** editor panel you'll find the option to stack columns vertically on mobile (when the viewport is 479px or smaller).
### Adding and moving content
You can drag elements like text blocks, images, and buttons in and out of your columns.
To do this, hold down the `⌘` key (Mac) or `Ctrl` key (Windows) while hovering over the element you want to move. Then click and drag the block settings icon (six dots) to move the block to its new position.
## Tips
* **No nested columns**: Keep in mind that nesting columns within columns is not supported. If you need a complex layout, consider adjusting your design to fit within the 2 to 4 column limit.
* **Consistent styling**: Use the block settings to ensure that padding and alignment are consistent across your columns for a polished look.
# Dividers
Source: https://loops.so/docs/creating-emails/editor/dividers
Separate or group content in your Loops emails with dividers.
You can add dividers to your email to separate or group content.
Add dividers:
* by using the divider icon in the editor toolbar
* by typing `/` in the editor and selecting **Divider**
* by hovering over an existing element in your email and clicking the `+` plus icon on the left and selecting **Divider**
* by typing `---` and hitting the space bar
# Images
Source: https://loops.so/docs/creating-emails/editor/images
Insert and link images in Loops emails, including dynamic images sourced from contact properties, event properties, or data variables.
Add images:
* by using the image button in the editor toolbar
* by dragging or pasting images into the editor
* by typing `/` in the editor and selecting **Image**
* by hovering over an existing element in your email and clicking the `+` plus icon on the left and selecting **Image**
Images must be under 4 MB in size.
Add a link to an image by selecting the element and then clicking the `+` button in the **Link** editor panel on the right.
## Dynamic images
You can also insert images hosted outside of Loops into your emails.
First, add a placeholder image to your email (which you can use to specify the location and size of the image) and then click `+` in the **Dynamic source** section in the right-hand panel.
Keep in mind that any images you insert must be publicly accessible and the
URL must end with a supported image file extension for email (like `.jpg` or
`.png`). Additionally, these images should be hosted for a period of time that
is relevant to the email's lifecycle as they will not be stored in Loops.
Paste in an image URL or create dynamic URLs by clicking the icon on the right.
For example, you could insert an `imageUrl` data variable in a transactional email, or use an `avatarUrl` custom contact property in a campaign.
You can also create dynamic URLs by combining text and dynamic content inside the image source field.
The placeholder image in the editor will not update when you add a dynamic
source. In order to test dynamic images, you can send [preview
emails](/docs/sending-first-email#preview-your-email).
Note that, just as with other dynamic content, if the contact or event
property used for your dynamic image is missing, the email will not be sent.
# Links
Source: https://loops.so/docs/creating-emails/editor/links
Add static and dynamic links to text, images, and buttons in Loops emails, append UTM parameters, and control per-link click tracking.
You can add links to text, images and buttons. First, select the text or element and click the `+` icon in the **Link** editor panel. You can also use the `⌘+K` keyboard shortcut.
Enter your URL into the field then hit Enter. Helpfully, you can omit the `https://` protocol as it is automatically added to each link.
## Dynamic links
You can create dynamic links using contact and event properties or data variables.
When editing a link, click the icon—which will be different depending on the type of email you are creating—on the right of the input, then add the dynamic content you want to use.
## UTM parameters
Loops does not automatically add UTM parameters to links in your emails. To attribute traffic and conversions in tools like Google Analytics, append UTM parameters directly to your link URLs when you add them:
```
https://example.com/pricing?utm_source=loops&utm_medium=email&utm_campaign=launch
```
You can combine UTM parameters with [dynamic links](#dynamic-links) to vary values per contact or per send—for example, using a data variable for `utm_campaign`.
## Link tracking
By default marketing emails (campaigns and workflows) have opens and link tracking enabled. This can be disabled for all emails in [Settings -> Sending](https://app.loops.so/settings?page=sending).
Links and opens in transactional emails are never tracked in order to improve
deliverability.
If you want to disable click tracking on individual links in your emails, click the icon in the editor panel.
We do not support deeplinks prepended with `ms-word:` as they are blocked by Gmail and other inbox providers. We recommend linking to a non-deeplink landing page instead. This may also be the case with other deeplinks in certain situations—you'll know if that's the case as the link will seem unclickable.
# Markdown
Source: https://loops.so/docs/creating-emails/editor/markdown
Write and format Loops email content with Markdown shortcuts for headings, lists, quotes, bold, and links.
The editor has full support for Markdown, allowing you to write and format content quickly using familiar syntax.
## Markdown formatting
You can use Markdown to add content to your emails. For example, you can add a heading by typing `#` followed by a space.
It's also possible to format text with Markdown, like adding bold text using `**`, or inserting links using `[text](url)`.
Add different content blocks using the Markdown formatting below followed by a space.
| Syntax | Action |
| :------------- | :------------------ |
| `#` | Add a heading 1 |
| `##` | Add a heading 2 |
| `###` | Add a heading 3 |
| `>` | Add a quote |
| `1.` | Add a numbered list |
| `-` or `*` | Add a bulleted list |
| ` ```code``` ` | Add a code block |
You can format text with the following shortcuts:
| Syntax | Action |
| :-------------------------- | :------------------ |
| `**text**` or `__text__` | Bold text |
| `*text*` or `_text_` | Italicize text |
| `~~text~~` | Strike through text |
| `[Loops](https://loops.so)` | Add a link |
| `` `code` `` | Inline code |
## Pasting markdown
The editor supports pasting Markdown content, making it seamless to bring content from other platforms. Paste Markdown with `⌘+Shift+V` (Mac) or `Ctrl+Shift+V` (Windows) and it will be converted into native editor blocks.
## Custom markdown tags
When pasting markdown, you can use custom Loops tags to add buttons and columns directly in your content:
**Columns**
```html theme={"dark"}
### Column 1 heading
Column *1* text
### Column 2 heading
Column *2* text
```
**Buttons**
```html theme={"dark"}
Subscribe
```
These custom tags can be mixed with regular markdown formatting.
# Sections
Source: https://loops.so/docs/creating-emails/editor/sections
Group blocks into reusable sections to structure your email layout.
Sections allow you to group blocks together and add styling options to the whole section at once. This makes card-style layouts much easier to build.
## Adding sections
Add sections:
* by using the section button in the editor toolbar
* by typing `/` in the editor and selecting **Section**
* by hovering over an existing element in your email and clicking the `+` plus icon on the left and selecting **Section**
Classic Outlook may render extra padding around sections due to its legacy rendering engine.
## Conditional sections
Sections can be shown or hidden for each recipient based on a [contact property](/docs/contacts/properties), [event property](/docs/events/properties), or [data variable](/docs/transactional#add-data-variables) value. This is useful for content that only applies to some people, like a plan-specific callout or a promo banner.
First, add a section to your email, then open the **Condition** panel in the editor sidebar.
Set **Show when** to build a condition from up to three parts:
1. **Variable** — the field to check:
* In campaigns and workflows, choose a [contact property](/docs/contacts/properties).
* In workflows triggered by events, you can also choose an [event property](/docs/events/properties).
* In [transactional emails](/docs/transactional), type the name of a [data variable](/docs/transactional#add-data-variables).
2. **Operator** — how to compare it (see below).
3. **Value** — what to compare against. Only shown for operators that need one, such as **Equals** or **Contains**.
For example, `email` / **Does not contain** / `loops.so` shows the section only for recipients whose email address does not contain `loops.so`.
Leave **Show when** blank to show the section to every recipient.
In transactional emails, you can create a new variable just for conditional sections, like `showPromo`. The data variable used does not need to be in the email content itself. Any condition-only data variables are accepted in the [Send transactional API call](/docs/api-reference/send-transactional-email) like regular data variables.
### Operators
The available operators depend on the variable's type. Data variables are always treated as text.
| Type | Operators |
| ------- | -------------------------------------------------------------------------- |
| Text | Is not empty, Is empty, Equals, Does not equal, Contains, Does not contain |
| Number | Is not empty, Is empty, Equals, Does not equal, Greater than, Less than |
| Boolean | Is not empty, Is empty, Is true, Is false |
A missing or empty value never fails the send; it just hides the section, so condition variables are optional at send time.
Everything inside a hidden section is removed from the email, including any variables, arrays or links it contains.
Conditions can also be authored in [LMX](/docs/creating-emails/lmx#conditional-sections) with the `if`, `ifOperation`, and `ifValue` attributes.
## Clickable sections
Sections become clickable when you add a link in the **Link** editor panel, which is useful for:
* product cards
* account-summary callouts
* any layout where a section should link somewhere
# Social icons
Source: https://loops.so/docs/creating-emails/editor/social-icons
Add a row of social platform icons to your Loops email footer.
You can add a row of social icons to your emails. There are hundreds to choose from.
## Adding icons
Add a social icon block:
* by selecting the globe icon in the editor toolbar
* by typing `/` in the editor and selecting **Icons**
* by hovering over an existing element in your email and clicking the `+` plus icon on the left and selecting **Icons**
You have options for icon size, gap and color (black, gray or white). You can add more icons by clicking the `+` icon in the editor panel.
To rearrange icons, you can drag them around in the editor or in the Links list in the editor panel.
You can also change padding, alignment and more using the **Block styles**.
## Available icons
| Company | Icon name |
| ------------------------------- | -------------------------------- |
| 42 Group | `42-group` |
| 500px | `500px` |
| Accessible Icon | `accessible-icon` |
| Accusoft | `accusoft` |
| Adn | `adn` |
| Adversal | `adversal` |
| Affiliatetheme | `affiliatetheme` |
| Airbnb | `airbnb` |
| Algolia | `algolia` |
| Alipay | `alipay` |
| Amazon | `amazon` |
| Amazon Pay | `amazon-pay` |
| Amilia | `amilia` |
| Android | `android` |
| AngelList | `angellist` |
| Angrycreative | `angrycreative` |
| Angular | `angular` |
| App Store | `app-store` |
| App Store iOS | `app-store-ios` |
| Apper | `apper` |
| Apple | `apple` |
| Apple Pay | `apple-pay` |
| Apple Podcast | `apple-podcast` |
| Arch Linux | `arch-linux` |
| Artstation | `artstation` |
| Asymmetrik | `asymmetrik` |
| Atlassian | `atlassian` |
| Audible | `audible` |
| Autoprefixer | `autoprefixer` |
| Avianex | `avianex` |
| Aviato | `aviato` |
| AWS | `aws` |
| Bandcamp | `bandcamp` |
| Battle Net | `battle-net` |
| Behance | `behance` |
| Behance (square) | `square-behance` |
| Bilibili | `bilibili` |
| Bimobject | `bimobject` |
| Bitbucket | `bitbucket` |
| Bitcoin | `bitcoin` |
| Bity | `bity` |
| Black Tie | `black-tie` |
| Blackberry | `blackberry` |
| Blogger | `blogger` |
| Blogger B | `blogger-b` |
| Bluesky | `bluesky` |
| Bluesky (square) | `square-bluesky` |
| Bluetooth | `bluetooth` |
| Bluetooth B | `bluetooth-b` |
| Board Game Geek | `board-game-geek` |
| Bootstrap | `bootstrap` |
| Bots | `bots` |
| Brave | `brave` |
| Brave Reverse | `brave-reverse` |
| Btc | `btc` |
| Buffer | `buffer` |
| Buromobelexperte | `buromobelexperte` |
| Buy N Large | `buy-n-large` |
| BuySellAds | `buysellads` |
| Canadian Maple Leaf | `canadian-maple-leaf` |
| Cash App | `cash-app` |
| Card - Amazon Pay | `cc-amazon-pay` |
| Card - Amex | `cc-amex` |
| Card - Apple Pay | `cc-apple-pay` |
| Card - Diners Club | `cc-diners-club` |
| Card - Discover | `cc-discover` |
| Card - JCB | `cc-jcb` |
| Card - Mastercard | `cc-mastercard` |
| Card - PayPal | `cc-paypal` |
| Card - Stripe | `cc-stripe` |
| Card - Visa | `cc-visa` |
| Centercode | `centercode` |
| CentOS | `centos` |
| Chrome | `chrome` |
| Chromecast | `chromecast` |
| Circle Zulip | `circle-zulip` |
| Claude | `claude` |
| Cloudflare | `cloudflare` |
| Cloudscale | `cloudscale` |
| Cloudsmith | `cloudsmith` |
| Cloudversify | `cloudversify` |
| Cmplid | `cmplid` |
| Codepen | `codepen` |
| Codiepie | `codiepie` |
| Confluence | `confluence` |
| Connect Develop | `connectdevelop` |
| Contao | `contao` |
| Cosmos | `cosmos` |
| Cotton Bureau | `cotton-bureau` |
| Cpanel | `cpanel` |
| Creative Commons | `creative-commons` |
| Creative Commons By | `creative-commons-by` |
| Creative Commons NC | `creative-commons-nc` |
| Creative Commons NC EU | `creative-commons-nc-eu` |
| Creative Commons NC JP | `creative-commons-nc-jp` |
| Creative Commons ND | `creative-commons-nd` |
| Creative Commons PD | `creative-commons-pd` |
| Creative Commons Pd Alternative | `creative-commons-pd-alt` |
| Creative Commons Remix | `creative-commons-remix` |
| Creative Commons SA | `creative-commons-sa` |
| Creative Commons Sampling | `creative-commons-sampling` |
| Creative Commons Sampling Plus | `creative-commons-sampling-plus` |
| Creative Commons Share | `creative-commons-share` |
| Creative Commons Zero | `creative-commons-zero` |
| Critical Role | `critical-role` |
| CSS | `css` |
| CSS3 | `css3` |
| CSS3 Alternative | `css3-alt` |
| Cuttlefish | `cuttlefish` |
| D And D | `d-and-d` |
| D And D Beyond | `d-and-d-beyond` |
| Dailymotion | `dailymotion` |
| Dart Lang | `dart-lang` |
| Dashcube | `dashcube` |
| Debian | `debian` |
| Deezer | `deezer` |
| Delicious | `delicious` |
| Deploydog | `deploydog` |
| Deskpro | `deskpro` |
| Deskpro (square) | `square-deskpro` |
| Dev | `dev` |
| Deviantart | `deviantart` |
| DHL | `dhl` |
| Diaspora | `diaspora` |
| Digg | `digg` |
| Digital Ocean | `digital-ocean` |
| Discord | `discord` |
| Discourse | `discourse` |
| Disqus | `disqus` |
| Dochub | `dochub` |
| Docker | `docker` |
| Draft2digital | `draft2digital` |
| Dribbble | `dribbble` |
| Dribbble (square) | `square-dribbble` |
| Dropbox | `dropbox` |
| Drupal | `drupal` |
| Duolingo | `duolingo` |
| Dyalog | `dyalog` |
| Earlybirds | `earlybirds` |
| eBay | `ebay` |
| Edge | `edge` |
| Edge Legacy | `edge-legacy` |
| Elementor | `elementor` |
| Eleventy | `eleventy` |
| Ello | `ello` |
| Ember | `ember` |
| Empire | `empire` |
| Envelope | `envelope` |
| Envelope (square) | `square-envelope` |
| Envira | `envira` |
| Erlang | `erlang` |
| Ethereum | `ethereum` |
| Etsy | `etsy` |
| Evernote | `evernote` |
| Expeditedssl | `expeditedssl` |
| Facebook | `facebook` |
| Facebook (square) | `square-facebook` |
| Facebook F | `facebook-f` |
| Facebook Messenger | `facebook-messenger` |
| Fantasy Flight Games | `fantasy-flight-games` |
| FedEx | `fedex` |
| Fediverse | `fediverse` |
| Fedora | `fedora` |
| Figma | `figma` |
| Figma (square) | `square-figma` |
| Files Pinwheel | `files-pinwheel` |
| Firefox | `firefox` |
| Firefox Browser | `firefox-browser` |
| First Order | `first-order` |
| First Order Alternative | `first-order-alt` |
| Firstdraft | `firstdraft` |
| Flickr | `flickr` |
| Flipboard | `flipboard` |
| Flutter | `flutter` |
| Fly | `fly` |
| Font Awesome | `font-awesome` |
| Font Awesome (square) | `square-font-awesome` |
| Font Awesome (square, stroked) | `square-font-awesome-stroke` |
| Fonticons | `fonticons` |
| Fonticons Fi | `fonticons-fi` |
| Forgejo | `forgejo` |
| Fort Awesome | `fort-awesome` |
| Fort Awesome Alternative | `fort-awesome-alt` |
| Forumbee | `forumbee` |
| Foursquare | `foursquare` |
| Free Code Camp | `free-code-camp` |
| FreeBSD | `freebsd` |
| Fulcrum | `fulcrum` |
| Galactic Republic | `galactic-republic` |
| Galactic Senate | `galactic-senate` |
| Get Pocket | `get-pocket` |
| Gg | `gg` |
| Gg (circle) | `gg-circle` |
| Git | `git` |
| Git (square) | `square-git` |
| Git Alternative | `git-alt` |
| Gitee | `gitee` |
| GitHub | `github` |
| GitHub (square) | `square-github` |
| GitHub Alternative | `github-alt` |
| Gitkraken | `gitkraken` |
| GitLab | `gitlab` |
| GitLab (square) | `square-gitlab` |
| Gitter | `gitter` |
| Glide | `glide` |
| Glide G | `glide-g` |
| Globaleaks | `globaleaks` |
| Gofore | `gofore` |
| Golang | `golang` |
| Goodreads | `goodreads` |
| Goodreads G | `goodreads-g` |
| Google | `google` |
| Google Drive | `google-drive` |
| Google Pay | `google-pay` |
| Google Play | `google-play` |
| Google Plus | `google-plus` |
| Google Plus (square) | `square-google-plus` |
| Google Plus G | `google-plus-g` |
| Google Scholar | `google-scholar` |
| Google Wallet | `google-wallet` |
| Gratipay | `gratipay` |
| Grav | `grav` |
| Gripfire | `gripfire` |
| Grunt | `grunt` |
| Guilded | `guilded` |
| Gulp | `gulp` |
| Hacker News | `hacker-news` |
| Hacker News (square) | `square-hacker-news` |
| HackerRank | `hackerrank` |
| Hashnode | `hashnode` |
| Hips | `hips` |
| Hire A Helper | `hire-a-helper` |
| Hive | `hive` |
| Hooli | `hooli` |
| Hornbill | `hornbill` |
| Hotjar | `hotjar` |
| Houzz | `houzz` |
| HTML5 | `html5` |
| Hubspot | `hubspot` |
| Hugging Face | `hugging-face` |
| Ideal | `ideal` |
| IMDb | `imdb` |
| Instagram | `instagram` |
| Instagram (square) | `square-instagram` |
| Instalod | `instalod` |
| Intercom | `intercom` |
| Internet Explorer | `internet-explorer` |
| Invision | `invision` |
| Ioxhost | `ioxhost` |
| Itch Io | `itch-io` |
| iTunes | `itunes` |
| iTunes Note | `itunes-note` |
| Java | `java` |
| Jedi Order | `jedi-order` |
| Jenkins | `jenkins` |
| Jira | `jira` |
| Joget | `joget` |
| Joomla | `joomla` |
| Js | `js` |
| Js (square) | `square-js` |
| Jsfiddle | `jsfiddle` |
| Julia | `julia` |
| Jxl | `jxl` |
| Kaggle | `kaggle` |
| Kakao Talk | `kakao-talk` |
| Keybase | `keybase` |
| Keycdn | `keycdn` |
| Kickstarter | `kickstarter` |
| Kickstarter K | `kickstarter-k` |
| Ko Fi | `ko-fi` |
| Korvue | `korvue` |
| Kubernetes | `kubernetes` |
| Laravel | `laravel` |
| Lastfm | `lastfm` |
| Lastfm (square) | `square-lastfm` |
| Leanpub | `leanpub` |
| LeetCode | `leetcode` |
| Less | `less` |
| Letterboxd | `letterboxd` |
| Letterboxd (square) | `square-letterboxd` |
| Line | `line` |
| Link | `link` |
| LinkedIn | `linkedin` |
| LinkedIn (square) | `square-linkedin` |
| LinkedIn In | `linkedin-in` |
| Linktree | `linktree` |
| Linode | `linode` |
| Linux | `linux` |
| Lumon | `lumon` |
| Lumon Drop | `lumon-drop` |
| Lyft | `lyft` |
| Magento | `magento` |
| Mandalorian | `mandalorian` |
| Markdown | `markdown` |
| Mastodon | `mastodon` |
| MaxCDN | `maxcdn` |
| Mdb | `mdb` |
| Medapps | `medapps` |
| Medium | `medium` |
| Medrt | `medrt` |
| Meetup | `meetup` |
| Megaport | `megaport` |
| Mendeley | `mendeley` |
| Meta | `meta` |
| Microblog | `microblog` |
| Microsoft | `microsoft` |
| Mintbit | `mintbit` |
| Mix | `mix` |
| Mixcloud | `mixcloud` |
| Mixer | `mixer` |
| Mizuni | `mizuni` |
| Modx | `modx` |
| Monero | `monero` |
| Napster | `napster` |
| Neos | `neos` |
| Nfc Directional | `nfc-directional` |
| Nfc Symbol | `nfc-symbol` |
| Nimblr | `nimblr` |
| Node | `node` |
| Node.js | `node-js` |
| Notion | `notion` |
| Npm | `npm` |
| Ns8 | `ns8` |
| Nutritionix | `nutritionix` |
| Obsidian | `obsidian` |
| Octopus Deploy | `octopus-deploy` |
| Odnoklassniki | `odnoklassniki` |
| Odnoklassniki (square) | `square-odnoklassniki` |
| Odysee | `odysee` |
| Old Republic | `old-republic` |
| OpenAI | `openai` |
| Opencart | `opencart` |
| OpenID | `openid` |
| Openstreetmap | `openstreetmap` |
| Opensuse | `opensuse` |
| Opera | `opera` |
| Optin Monster | `optin-monster` |
| Orcid | `orcid` |
| Osi | `osi` |
| Padlet | `padlet` |
| Page4 | `page4` |
| Pagelines | `pagelines` |
| Palfed | `palfed` |
| Pandora | `pandora` |
| Patreon | `patreon` |
| PayPal | `paypal` |
| Perbyte | `perbyte` |
| Periscope | `periscope` |
| Phabricator | `phabricator` |
| Phoenix Framework | `phoenix-framework` |
| Phoenix Squadron | `phoenix-squadron` |
| Phone | `phone` |
| Phone (square) | `square-phone` |
| PHP | `php` |
| Pied Piper | `pied-piper` |
| Pied Piper (square) | `square-pied-piper` |
| Pied Piper Alternative | `pied-piper-alt` |
| Pied Piper Hat | `pied-piper-hat` |
| Pied Piper PP | `pied-piper-pp` |
| Pinterest | `pinterest` |
| Pinterest (square) | `square-pinterest` |
| Pinterest P | `pinterest-p` |
| Pix | `pix` |
| Pixelfed | `pixelfed` |
| Pixiv | `pixiv` |
| Playstation | `playstation` |
| PostgreSQL | `postgresql` |
| Product Hunt | `product-hunt` |
| Pushed | `pushed` |
| Python | `python` |
| QQ | `qq` |
| Quinscape | `quinscape` |
| Quora | `quora` |
| R Project | `r-project` |
| Raspberry Pi | `raspberry-pi` |
| Ravelry | `ravelry` |
| React | `react` |
| Reacteurope | `reacteurope` |
| Readme | `readme` |
| Rebel | `rebel` |
| Red River | `red-river` |
| Reddit | `reddit` |
| Reddit (square) | `square-reddit` |
| Reddit Alien | `reddit-alien` |
| Red Hat | `redhat` |
| Renren | `renren` |
| Replyd | `replyd` |
| Researchgate | `researchgate` |
| Resolving | `resolving` |
| Rev | `rev` |
| Rocketchat | `rocketchat` |
| Rockrms | `rockrms` |
| Rust | `rust` |
| Safari | `safari` |
| Salesforce | `salesforce` |
| Sass | `sass` |
| Scaleway | `scaleway` |
| Schlix | `schlix` |
| Screenpal | `screenpal` |
| Scribd | `scribd` |
| Searchengin | `searchengin` |
| Sellcast | `sellcast` |
| Sellsy | `sellsy` |
| Servicestack | `servicestack` |
| Shirtsinbulk | `shirtsinbulk` |
| Shoelace | `shoelace` |
| Shopify | `shopify` |
| Shopware | `shopware` |
| Signal Messenger | `signal-messenger` |
| Simplybuilt | `simplybuilt` |
| Sistrix | `sistrix` |
| Sith | `sith` |
| Sitrox | `sitrox` |
| Sketch | `sketch` |
| Skyatlas | `skyatlas` |
| Skype | `skype` |
| Slack | `slack` |
| Slideshare | `slideshare` |
| Snapchat | `snapchat` |
| Snapchat (square) | `square-snapchat` |
| Solana | `solana` |
| SoundCloud | `soundcloud` |
| SourceTree | `sourcetree` |
| Space Awesome | `space-awesome` |
| Speakap | `speakap` |
| Speaker Deck | `speaker-deck` |
| Spotify | `spotify` |
| Squarespace | `squarespace` |
| Stack Exchange | `stack-exchange` |
| Stack Overflow | `stack-overflow` |
| StackPath | `stackpath` |
| Staylinked | `staylinked` |
| Steam | `steam` |
| Steam (square) | `square-steam` |
| Steam Symbol | `steam-symbol` |
| Sticker Mule | `sticker-mule` |
| Strava | `strava` |
| Stripe | `stripe` |
| Stripe S | `stripe-s` |
| Stubber | `stubber` |
| Studiovinari | `studiovinari` |
| Stumbleupon | `stumbleupon` |
| Stumbleupon (circle) | `stumbleupon-circle` |
| Superpowers | `superpowers` |
| Supple | `supple` |
| Supportnow | `supportnow` |
| Suse | `suse` |
| Svelte | `svelte` |
| Swift | `swift` |
| Symfony | `symfony` |
| Symfonycasts | `symfonycasts` |
| Tailwind CSS | `tailwind-css` |
| TeamSpeak | `teamspeak` |
| Telegram | `telegram` |
| Tencent Weibo | `tencent-weibo` |
| Tex | `tex` |
| The Red Yeti | `the-red-yeti` |
| Themeco | `themeco` |
| Themeisle | `themeisle` |
| Think Peaks | `think-peaks` |
| Threads | `threads` |
| Threads (square) | `square-threads` |
| Threema | `threema` |
| Tidal | `tidal` |
| TikTok | `tiktok` |
| Tor Browser | `tor-browser` |
| Trade Federation | `trade-federation` |
| Trello | `trello` |
| Tumblr | `tumblr` |
| Tumblr (square) | `square-tumblr` |
| Twitch | `twitch` |
| Twitter | `twitter` |
| Twitter (square) | `square-twitter` |
| TypeScript | `typescript` |
| Typo3 | `typo3` |
| Uber | `uber` |
| Ubuntu | `ubuntu` |
| UIkit | `uikit` |
| Ultralytics | `ultralytics` |
| Ultralytics Hub | `ultralytics-hub` |
| Ultralytics Yolo | `ultralytics-yolo` |
| Umbraco | `umbraco` |
| Uncharted | `uncharted` |
| Uniregistry | `uniregistry` |
| Unison | `unison` |
| Unity | `unity` |
| Unreal Engine | `unreal-engine` |
| Unsplash | `unsplash` |
| Untappd | `untappd` |
| UPS | `ups` |
| Upwork | `upwork` |
| Upwork (square) | `square-upwork` |
| USB | `usb` |
| USPS | `usps` |
| Ussunnah | `ussunnah` |
| Vaadin | `vaadin` |
| Venmo | `venmo` |
| Venmo V | `venmo-v` |
| Viacoin | `viacoin` |
| Viadeo | `viadeo` |
| Viadeo (square) | `square-viadeo` |
| Viber | `viber` |
| Vim | `vim` |
| Vimeo | `vimeo` |
| Vimeo (square) | `square-vimeo` |
| Vimeo V | `vimeo-v` |
| Vine | `vine` |
| Vk | `vk` |
| Vnv | `vnv` |
| Vsco | `vsco` |
| Vue.js | `vuejs` |
| W3C | `w3c` |
| Watchman Monitoring | `watchman-monitoring` |
| Waze | `waze` |
| Web Awesome | `web-awesome` |
| Web Awesome (square) | `square-web-awesome` |
| Web Awesome (square, stroked) | `square-web-awesome-stroke` |
| Webflow | `webflow` |
| Weebly | `weebly` |
| Weibo | `weibo` |
| Weixin | `weixin` |
| WhatsApp | `whatsapp` |
| WhatsApp (square) | `square-whatsapp` |
| WHMCS | `whmcs` |
| Wikipedia W | `wikipedia-w` |
| Windows | `windows` |
| Wirsindhandwerk | `wirsindhandwerk` |
| Wix | `wix` |
| Wizards Of The Coast | `wizards-of-the-coast` |
| Wodu | `wodu` |
| Wolf Pack Battalion | `wolf-pack-battalion` |
| WordPress | `wordpress` |
| WordPress Simple | `wordpress-simple` |
| WPBeginner | `wpbeginner` |
| WPExplorer | `wpexplorer` |
| WPForms | `wpforms` |
| Wpressr | `wpressr` |
| X/Twitter | `x-twitter` |
| X/Twitter (square) | `square-x-twitter` |
| Xbox | `xbox` |
| Xing | `xing` |
| Xing (square) | `square-xing` |
| XMPP | `xmpp` |
| Y Combinator | `y-combinator` |
| Yahoo | `yahoo` |
| Yammer | `yammer` |
| Yandex | `yandex` |
| Yandex International | `yandex-international` |
| Yarn | `yarn` |
| Yelp | `yelp` |
| Yoast | `yoast` |
| YouTube | `youtube` |
| YouTube (square) | `square-youtube` |
| Zhihu | `zhihu` |
| Zoom | `zoom` |
| Zulip | `zulip` |
# Videos
Source: https://loops.so/docs/creating-emails/editor/videos
Email clients do not widely support embedded video. Instead use an image or GIF.
**Video is not widely supported in email clients**, making it hard to embed videos in your emails.
We suggest adding a still image from the video into your email—with a play button overlaid on top if you want—and then add a link to the image pointing to your hosted video.
You can also use GIFs to add a video-like effect to your email. Just make sure that the GIF's file size is small enough to load quickly.
# Font support
Source: https://loops.so/docs/creating-emails/font-support
Font support via Google Fonts is now available in Loops.
## Web font support
Custom font support via Google Fonts is now available in Loops. You can use any font from Google Fonts in your emails.
To use a custom font, create or enable a Theme, then click **Edit**. You will then be able to apply a custom font in the Text styles.
Please note that not all fonts are supported by all email clients. See the table below for a list of supported email clients.
| Support | Platform |
| ------- | ---------- |
| No | Gmail |
| No | Outlook |
| Yes | Apple Mail |
| Yes | Samsung |
| Yes | Comcast |
| No | AOL |
| No | Yahoo |
## Font fallback chain
When a custom font isn't supported by an email client, Loops uses a fallback chain to ensure your emails still look great. The fallback chain follows this pattern:
`[selected font], [ui fallback by category], [generic category]`
| Category | Fallback chain |
| --------- | --------------------------------------- |
| Sans | SelectedFont, ui-sans-serif, sans-serif |
| Serif | SelectedFont, ui-serif, serif |
| Monospace | SelectedFont, ui-monospace, monospace |
## General list of email-safe fonts
The following fonts are supported by all modern email clients. They're considered "email safe" fonts because they're available on most devices and email clients.
* Arial
* Courier New
* Georgia
* Lucida Sans Unicode
* Tahoma
* Times New Roman
* Trebuchet MS
* Verdana
# Guardian
Source: https://loops.so/docs/creating-emails/guardian
Sending emails protected by Guardian.
Guardian checks your emails in real time and flags issues *before* you hit send. It catches misconfigured or missing elements in campaigns, workflows, and transactional emails so you ship with confidence.
## Guardian checks
### Misplaced variables
Occasionally, content will be copied between email types (campaign \<> workflow \<> transactional) and certain types of dynamic content may not be supported. Instead of stripping the variables or blocking the paste, Guardian flags the errors so you can make sure the content is adjusted and formatted properly for the email type.
## Save states
We transparently poll for the last saved checkpoint and keep you updated. This is especially helpful in poor network conditions such as hotspots on the go or on a plane. If local changes fall out of sync we'll let you know.
## Missing button links
If you add a button, we assume you would like a link (href) attached. Now we'll flag that for you to block any accidental sends.
## Missing fallback variables
When using contact and event properties in campaigns and workflows, emails will not be sent if the values are missing from the contact or event.
For example, if you have inserted "Hello `First Name`" in your email, and the contact doesn't have a first name value, the workflow or campaign email will not be sent. In some cases, that can be desired behavior but we've found most of the time a fallback should be added. We've added it as a warning and now Guardian will alert you when there are missing fallbacks and emails will send as expected.
Learn about using fallback values for your dynamic content.
## Future improvements
* Content checks
* Boosted spellcheck
* Adhering to design/brand guidelines
Should we add something else? [Let our support team know](https://app.loops.so/settings?page=support).
# LMX
Source: https://loops.so/docs/creating-emails/lmx
Author email content with Loops Markup Language (LMX), a structured markup for sections, components, variables, and themed styling in Loops emails.
To manage Loops with an AI agent, [connect Loops to Claude](/docs/mcp-server), or
install the [Loops CLI and agent skills](/docs/quickstart-agents) for Claude Code,
Codex, or Cursor.
LMX (Loops Markup Language) is an XML-based format for writing email content in Loops. Every piece of content is represented by an explicit PascalCase tag.
LMX can be used with the Content API to create and update campaign, workflow, and transactional email messages programmatically.
LMX sits between the API and our editor, making it possible to edit emails across both the editor and API.
You can keep LMX in your codebase and update draft email messages through the
[Content API](/docs/api-reference/update-email-message) or
[email messages CLI](/docs/cli/email-messages). Fetch the current
`contentRevisionId`, edit the LMX in Git, and send the update back with
revision protection.
Create campaigns, query themes and components, and update email messages with LMX.
Send serialized LMX through the Content API for campaign, workflow, and transactional email messages.
Create a draft campaign and email message before adding LMX content.
Use the CLI for local setup, authentication, and Content API workflows.
Find theme IDs and style attributes for the LMX `` tag.
Find reusable components for the LMX `` tag.
## LMX examples
Here are some examples of how to use LMX tags to create emails.
```xml theme={"dark"}
Hello
World
```
**A list**
```xml theme={"dark"}
Release checklist
Finish QAPrepare announcementNotify teams
```
**Two-column layout**
```xml theme={"dark"}
Starter
Best for new projects.
Pro
Advanced automation and analytics.
```
**Full campaign example**
```xml theme={"dark"}
Hi {contact.firstName}, your weekly update is ready
Here is your weekly account summary. Read the docs for the full changelog.
Top priorities
Fix onboarding drop-off on step 2Publish changelog for API v2Review deliverability metrics
Docs
Start with the quickstart.Review your latest activity and update your settings.
Need help? Contact support.
```
**Workflow email example**
```xml theme={"dark"}
Thanks for your order, {contact.firstName}
Your order total was {event.orderTotal}. We will send updates to {contact.email}.
```
**Transactional email example**
```xml theme={"dark"}
Reset your password
Hi {data.firstName}, we received a request to reset your password.
If you did not request this, you can ignore this email.
```
## Core rules
* LMX is XML, not HTML or Markdown. Tags are case-sensitive (for example, ``, not ``).
* LMX is not MJML. Do not use MJML tags (such as ``).
* Do not use the editor's dynamic tag syntax in LMX documents (for example `{EVENT_PROPERTY:orderId}` or `{DATA_VARIABLE:resetLink}` or unprefixed `{firstName}`). Use `{contact.*}` for contact properties, `{event.*}` for workflow event properties, and `{data.*}` for transactional email data variables.
* A document is a sequence of top-level **block tags**, optionally with one top-level ``.
* Text is not allowed at the top level. Wrap top-level text in a block tag like ``.
* Top-level inline tags and variables are invalid.
* Required attributes must be present (for example, `` requires `src`).
* Self-closing tags must end with `/>` (for example, ``, ` `).
* Unknown tags are rejected. Use only documented attributes in public LMX examples.
* Attribute values are quoted strings. Numbers and booleans are passed as strings.
* Whitespace in text content collapses like HTML, except inside ``, where interior whitespace is preserved. Use ` ` for explicit line breaks.
* Whitespace between block tags is ignored, so pretty-printing is safe.
* Escape text and attributes as needed (`<`, `&`, `"`).
Emails automatically have a footer appended, so you don't need to include your address or unsubscribe link at the bottom of your LMX content.
## Source control
You can keep LMX files alongside the rest of your application code (for example, in an `emails/` directory in your repo) and sync them with Loops through the CLI or Content API. LMX is plain text, so it diffs and reviews cleanly in pull requests.
A typical Git-based workflow:
1. **Pull the latest content** before editing. Run [`loops email-messages get `](/docs/cli/email-messages#get) to fetch the current LMX and `contentRevisionId`, and save the LMX to a file in your repo.
2. **Edit the `.lmx` file** locally and commit your changes.
3. **Push back to Loops** with [`loops email-messages update `](/docs/cli/email-messages#update), passing `--expected-revision-id` set to the `contentRevisionId` from step 1 and `--lmx-file ./path/to/email.lmx`. The update is rejected if the email was edited in Loops in the meantime, so you don't overwrite concurrent changes.
4. **Resolve conflicts** by pulling again with `loops email-messages get` and re-applying your edits. Use `--force` instead of `--expected-revision-id` only when you intentionally want to overwrite the server copy.
The owning campaign must be in `Draft` status for updates to apply. The same flow works through the [Update email message API](/docs/api-reference/update-email-message) if you prefer to call the API directly from CI.
## Tags
Top-level tags define the structure of an email.
| Tag | Description | Self-closing |
| ---------------------- | ------------------------------- | :---------------------------: |
| `` | Style metadata (top-level only) | yes |
| `