From d3b8e8f6bebe711e5083a32732f9551034a3ea6a Mon Sep 17 00:00:00 2001 From: BloggerBust Date: Mon, 4 May 2026 17:31:29 -0600 Subject: [PATCH 1/2] docs(cookbooks): add Loops welcome email integration --- .../docs/pages/cookbooks/welcome-email.mdx | 144 +++++++++++++++++- 1 file changed, 140 insertions(+), 4 deletions(-) diff --git a/frontend/docs/pages/cookbooks/welcome-email.mdx b/frontend/docs/pages/cookbooks/welcome-email.mdx index 8cc0adaf8f..a258a85909 100644 --- a/frontend/docs/pages/cookbooks/welcome-email.mdx +++ b/frontend/docs/pages/cookbooks/welcome-email.mdx @@ -19,7 +19,7 @@ flowchart TD E --> G[Send follow-up email] ``` -Hatchet's durable execution keeps the workflow alive across the wait. If the worker restarts or the wait lasts days, the workflow picks up where it left off. +Hatchet's durable execution keeps the workflow alive across the wait. If the worker restarts or the wait lasts days, the workflow picks up where it left off. At the end of this cookbook, we show how to swap the placeholder email step to use [Loops](#sending-welcome-emails-with-loops) for production email delivery. ## Setup @@ -27,12 +27,12 @@ Hatchet's durable execution keeps the workflow alive across the wait. If the wor ### Prepare your environment -To run this example you need: +To run this example, you need: - a working local Hatchet environment or access to [Hatchet Cloud](https://cloud.hatchet.run) - a Hatchet SDK example environment (see the [Quickstart](/v1/quickstart)) -No external email provider is required. The example uses `print` / `console.log` in place of real email delivery. +No external email provider is required. The example uses `print` / `console.log` in place of real email delivery. To send real emails, see [Sending welcome emails with Loops](#sending-welcome-emails-with-loops). ### Define the models @@ -134,6 +134,142 @@ If you are running the SDK examples locally: +## Sending welcome emails with Loops + +The local example uses `print` / `console.log` so it runs without external services. In production, [Loops](https://loops.so) can handle the actual email delivery as a [transactional email](https://loops.so/docs/transactional), while Hatchet continues to run the workflow around the task, including retries, concurrency, and observability. + +To use Loops you need a [Loops account](https://loops.so) with a [verified sending domain](https://loops.so/docs/sending-domain), plus a [transactional email](https://loops.so/docs/transactional) created in Loops and its [`transactionalId`](https://loops.so/docs/transactional#review-your-email). Set `LOOPS_API_KEY` in your environment. Since these examples call Loops' Transactional Email API directly, no SDK installation is required. If you prefer using a client library, Loops provides [official SDKs](https://loops.so/docs/sdks#official-sdks) for a number of languages. + +The snippets below replace the `print` / `console.log` calls with Loops API calls. Loops supports an `Idempotency-Key` header, which means Hatchet can safely retry the task without risking duplicate emails. + + + + + ```python + import json + import os + from urllib.error import HTTPError + from urllib.request import Request, urlopen + + LOOPS_API_KEY = os.environ["LOOPS_API_KEY"] + WELCOME_TEMPLATE_ID = "your-welcome-transactional-id" + FOLLOWUP_TEMPLATE_ID = "your-followup-transactional-id" + + def send_loops_email( + template_id: str, + email: str, + idempotency_key: str | None = None, + ) -> None: + body = { + "transactionalId": template_id, + "email": email, + } + + headers = { + "Authorization": f"Bearer {LOOPS_API_KEY}", + "Content-Type": "application/json", + } + if idempotency_key: + headers["Idempotency-Key"] = idempotency_key + + req = Request( + "https://app.loops.so/api/v1/transactional", + data=json.dumps(body).encode(), + headers=headers, + method="POST", + ) + try: + with urlopen(req) as resp: + result = json.loads(resp.read()) + if not result.get("success"): + raise RuntimeError(f"Loops API error: {result}") + except HTTPError as e: + if e.code == 409: + return # already sent with this idempotency key + raise + ``` + + Then inside the durable task, replace the `print` calls: + + ```python + # Step 1: Send the welcome email + send_loops_email( + WELCOME_TEMPLATE_ID, + input.email, + idempotency_key=f"welcome:{WELCOME_TEMPLATE_ID}:{input.user_id}", + ) + + # ... wait for onboarding or timeout ... + + # Step 3b: Timeout -> send follow-up email + send_loops_email( + FOLLOWUP_TEMPLATE_ID, + input.email, + idempotency_key=f"followup:{FOLLOWUP_TEMPLATE_ID}:{input.user_id}", + ) + ``` + + + + + ```typescript + const LOOPS_API_KEY = process.env.LOOPS_API_KEY!; + const WELCOME_TEMPLATE_ID = "your-welcome-transactional-id"; + const FOLLOWUP_TEMPLATE_ID = "your-followup-transactional-id"; + + async function sendLoopsEmail( + templateId: string, + email: string, + idempotencyKey?: string, + ): Promise { + const body = { transactionalId: templateId, email }; + + const headers: Record = { + Authorization: `Bearer ${LOOPS_API_KEY}`, + "Content-Type": "application/json", + }; + if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey; + + const resp = await fetch("https://app.loops.so/api/v1/transactional", { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + if (resp.status === 409) return; // already sent with this idempotency key + + const result = await resp.json(); + if (!result.success) { + throw new Error(`Loops API error: ${JSON.stringify(result)}`); + } + } + ``` + + Then inside the durable task, replace the `console.log` calls: + + ```typescript + // Step 1: Send the welcome email + await sendLoopsEmail( + WELCOME_TEMPLATE_ID, + input.email, + `welcome:${WELCOME_TEMPLATE_ID}:${input.userId}`, + ); + + // ... wait for onboarding or timeout ... + + // Step 3b: Timeout -> send follow-up email + await sendLoopsEmail( + FOLLOWUP_TEMPLATE_ID, + input.email, + `followup:${FOLLOWUP_TEMPLATE_ID}:${input.userId}`, + ); + ``` + + + + +Each idempotency key combines the email purpose, such as `welcome` or `followup`, with the template ID and user ID. This lets Hatchet retry the task after a transient failure without Loops sending the same email twice. Loops returns `409 Conflict` when a key is reused within 24 hours, and the helpers above treat that as already sent. If a user can legitimately receive the same email type more than once within 24 hours, include a workflow run ID or signup event ID in the key to keep each send distinct. + ## Next steps -To send real emails from this example, replace the `print` / `console.log` calls with a call to your email provider. You may also want to extend the timeout to better suit your workflow. Provider-specific delivery concerns are intentionally outside this minimal example. +From here you could extend the timeout to suit your actual onboarding flow, add more follow-up stages, use [Loops](https://loops.so) or another provider for real email delivery, add [data variables](https://loops.so/docs/transactional#add-data-variables) to personalize transactional emails, or combine this pattern with other Hatchet features such as [rate limits](/v1/rate-limits) to throttle email sends across your worker fleet. From 80c8e6ba1d599733e62d9acb857864b2a784bc1e Mon Sep 17 00:00:00 2001 From: BloggerBust Date: Tue, 5 May 2026 13:44:37 -0600 Subject: [PATCH 2/2] docs(cookbooks): add Loops API user agent header --- frontend/docs/pages/cookbooks/welcome-email.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/docs/pages/cookbooks/welcome-email.mdx b/frontend/docs/pages/cookbooks/welcome-email.mdx index a258a85909..51786809ca 100644 --- a/frontend/docs/pages/cookbooks/welcome-email.mdx +++ b/frontend/docs/pages/cookbooks/welcome-email.mdx @@ -168,6 +168,7 @@ The snippets below replace the `print` / `console.log` calls with Loops API call headers = { "Authorization": f"Bearer {LOOPS_API_KEY}", "Content-Type": "application/json", + "User-Agent": "hatchet-cookbook/1.0", } if idempotency_key: headers["Idempotency-Key"] = idempotency_key