
We moved six authentication email templates off Klaviyo for a meditation app. Here is the gotcha nobody documents, and the abstraction layer that made the cutover a config change.
A marketing email that lands in spam costs you a click.
A one-time passcode that lands in spam costs you a customer.
We recently moved the app email layer for inHarmony, a wellness brand whose iOS and Android app is the access layer to a paid content library and to vibroacoustic hardware sitting in the user's living room. When a password reset fails to arrive, the user is not mildly inconvenienced. They are locked out of a device they spent real money on.
Six templates moved. Four of them were authentication. Here is what we learned, including one platform behaviour that will silently break your login flow if you do not design around it.
The gotcha: DND blocks transactional email
This is the single most important thing to know before moving app authentication emails to GoHighLevel, so it goes first.
In GoHighLevel, when a contact unsubscribes from an email, the platform defaults to enabling Do Not Disturb across all channels for that contact. Once DND is on, workflow email actions to that contact are blocked. The block does not distinguish between a promotional newsletter and a password reset.
HighLevel has since exempted three built-in email types from DND invoices, calendar updates, and system alerts. Custom workflow emails, including anything fired from an inbound webhook, are not covered. If your app sends its own OTPs and password resets, the block still applies.
It remains one of the most-requested items on GoHighLevel's public feature board, with agencies describing exactly this failure: a customer unsubscribes from marketing, then makes a purchase, and the invoice email silently fails.
Worse, HighLevel's own documentation confirms that removing email DND requires the contact's email to be valid and not on a suppression list. Clearing the CRM flag alone does not necessarily restore deliverability, because the contact may also sit on the underlying Mailgun suppression list.
Migrate naively and you do not fix the original problem. You reproduce it with different branding.
How we handled it
- Kept app transactional sending isolated from any list carrying a standard marketing unsubscribe, so DND is never set by a campaign in the first place.
- Made the marketing unsubscribe copy explicit that it governs marketing only, with account and security email handled separately.
- Added a pre-send DND check with alerting. A blocked authentication send raises an internal alarm rather than disappearing. A user who cannot log in should page us, not open a support ticket.
- Documented a remediation path for support that includes the suppression-list step, not just the CRM flag.
Mailgun also exposes a suppression bypass header, X-Mailgun-Suppressions-Bypass: true, which is worth knowing about if you are hitting this on the LC Email System.
Why a marketing platform eventually breaks transactional email
Worth being precise about the failure mode, because "Klaviyo has bad deliverability" is neither accurate nor the lesson.
Klaviyo is an excellent marketing automation platform. The problem is structural: it is built around a marketing profile with a consent status, and transactional email does not fit that model cleanly.
Transactional status is not a toggle you control. Marking a flow as transactional requires review and approval. It is not something a developer flips on at 2am when OTPs stop landing.
Even approved transactional flows skip certain profiles. Klaviyo documents that suppressed profiles still receive transactional messages, except where the suppression came from a hard bounce, seven consecutive soft bounces, or a prior spam complaint. Those profiles are silently skipped. From the app's perspective the API call succeeded. From the user's perspective nothing arrived.
Marketing and app traffic share a sender reputation. A campaign that draws complaints degrades the inbox placement of password resets sent hours later.
Consent status leaks into operational messaging. A user who unsubscribed from the newsletter in 2023 is still entitled to reset their password in 2026.
None of this is a Klaviyo defect. It is what happens when a marketing tool carries an authentication workload for five years while the app around it grows.
Scope
| Template | Trigger | Failure impact |
|---|---|---|
| Single-use coupon | Admin panel | Promised discount never arrives |
| Sign-up verification | iOS / Android | New user cannot activate account |
| Forgot password | iOS / Android | Existing user locked out of paid content |
| Welcome (admin-created) | Admin panel | Onboarded user never receives credentials |
| Admin password resend | Admin panel | Support cannot resolve an access issue |
| OTP resend | iOS / Android | Auth loop: user retries, generating more failures |
Four of six are authentication. This was not a marketing migration. It was an availability migration that happened to involve email.
1. The provider abstraction layer came first
Before touching a single template, we introduced an email provider interface. The application code stopped knowing that Klaviyo existed and started calling a generic dispatcher.
export type TransactionalEmail =
| { type: 'VERIFY_SIGNUP'; to: string; data: VerifyData }
| { type: 'FORGOT_PASSWORD'; to: string; data: ResetData }
| { type: 'OTP_RESEND'; to: string; data: OtpData }
| { type: 'SINGLE_USE_COUPON'; to: string; data: CouponData }
| { type: 'ADMIN_WELCOME'; to: string; data: SetPasswordData }
| { type: 'ADMIN_PASSWORD_RESEND'; to: string; data: SetPasswordData };
export interface EmailProvider {
send(
email: TransactionalEmail,
ctx: { requestId: string },
): Promise<{ providerMessageId: string }>;
}
With KlaviyoProvider and GoHighLevelProvider both implementing that interface, provider selection became a runtime config value rather than a deployment.
That single decision is what made everything downstream possible: shadow sending, percentage rollout, and instant rollback.
If you take one thing from this post: never call an ESP SDK directly from your business logic. The abstraction costs about a day and buys you the entire migration strategy.
2. Translating the templates
There is no automated bridge between Klaviyo's Django-style tags and GoHighLevel's merge fields.
| Klaviyo | GoHighLevel |
|---|---|
{{ event.coupon_code }} |
{{inboundWebhookRequest.coupon_code}} |
{{ person.first_name }} |
{{inboundWebhookRequest.first_name}} |
{% if event.expires_on %}…{% endif %} |
Workflow branch, not in-template |
| Block-builder layout | Raw HTML in a custom code block |
Two practical notes:
- Klaviyo's builder output is heavily inlined and wrapped in MSO conditional comments. Pasting it into a WYSIWYG editor will mangle it. Import the raw HTML into GoHighLevel's code block instead, preserving Outlook rendering.
- GoHighLevel does not support arrays in custom values from a webhook payload. Anything Klaviyo handled as a loop must be flattened into discrete, pre-formatted fields by the backend before dispatch.
3. One workflow per template, triggered by inbound webhook
Each template became its own workflow using the Inbound Webhook trigger, which generates a unique URL and fires on an incoming POST.
{
"email": "user@example.com",
"first_name": "Alex",
"otp": "482913",
"expires_in_mins": 10,
"request_id": "a4f1e0c2-...",
"template": "OTP_RESEND"
}
Three constraints worth knowing before you design your payloads:
- An email or phone number is mandatory in every payload. GoHighLevel's model is contact-first. The webhook performs a find-or-create against the CRM before the workflow proceeds.
- The mapping reference must be re-saved whenever your payload shape changes. Add a field to the JSON and forget this step, and the merge field silently resolves to empty. We caught two of these in staging.
- The webhook URL is the only credential. There is no signing secret. Treat it as a secret in your config store. Rotating it means deleting the trigger and creating a new one, so build the URL as an environment variable, never a hardcoded string.
4. Isolating the sending reputation
This was the fix for the actual root cause, and it is the step teams skip.
We separated app transactional sending from marketing sending: a dedicated sending subdomain with full SPF, DKIM, and DMARC authentication, warmed gradually rather than switched on at full volume.
The point is not the specific configuration. It is the principle: a promotional campaign should never be able to damage the inbox placement of a password reset. If those two streams share a domain reputation, you have rebuilt the original problem on a new platform.
A security improvement we shipped along the way
Two templates involved the admin panel putting credentials in front of a user by email.
Emailing a password, even a temporary one, means that password now lives in an inbox indefinitely, replicated across mail servers and any device that syncs it.
We moved both flows to single-use, time-limited set-password links. The email carries a token rather than a credential, the token expires, and it invalidates on first use. The admin experience is unchanged. The security posture is meaningfully different.
A platform migration is one of the few times you have licence to reopen flows nobody has questioned in five years. Use it.
Cutting over without breaking live users
Four stages, all controlled by the abstraction layer from step one.
Stage 1, shadow send. Klaviyo remained the live provider. Every triggered email also fired a GoHighLevel dispatch to internal seed inboxes. We compared rendering across iOS Mail, Gmail mobile, and Outlook, and ran spam-scoring on each template. Real users were unaffected.
Stage 2, percentage rollout. A small share of live traffic routed to GoHighLevel, with delivery, bounce, and complaint rates monitored against the Klaviyo baseline.
Stage 3, full cutover. One hundred per cent of app transactional traffic on GoHighLevel, with Klaviyo credentials left live and a one-line config rollback available for a defined watch period.
Stage 4, decommission. Klaviyo app-group flows retired only after the watch window closed clean.
Throughout, every send was logged with a correlation ID, the provider message ID, and the resulting delivery event. "Did the user receive it?" became a query rather than a guess. That observability did not exist before the migration, and it is arguably as valuable as the platform change itself.
What we would tell another team doing this
- Classify your email before you choose a platform. Authentication email and marketing email have different uptime requirements. Decide whether one system can honestly serve both.
- Abstract the provider on day one. It converts a risky big-bang cutover into a config change with a rollback.
- Separate the sending reputation. The same domain for campaigns and password resets means one bad campaign takes down your login flow.
- Read the consent model, not the feature list. Both platforms will block sends based on consent state in ways that surprise you. Find those rules before you migrate, not after.
- Instrument everything. The worst failure mode in transactional email is silent success: the API returns 200 and the user gets nothing.
- Treat the migration as a chance to fix what is underneath.
Six templates is not a big migration. But when four of them stand between a user and the content they have paid for, routed through hardware sitting in their home, the size of the surface has very little to do with the size of the risk.
If your app's authentication emails are riding on a marketing platform, it is worth a look before it becomes a support queue.
Originally published on the Bitcot blog.
Top comments (0)