Skip to content
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 140 additions & 4 deletions frontend/docs/pages/cookbooks/welcome-email.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,20 @@ 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

<Steps>

### 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

Expand Down Expand Up @@ -134,6 +134,142 @@ If you are running the SDK examples locally:

</Steps>

## 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.

<UniversalTabs items={["Python", "Typescript"]}>
<Tabs.Tab title="Python">

```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}",
)
```

</Tabs.Tab>
<Tabs.Tab title="Typescript">

```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<void> {
const body = { transactionalId: templateId, email };

const headers: Record<string, string> = {
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}`,
);
```

</Tabs.Tab>
</UniversalTabs>

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.
Loading