Skip to main content
Two Encore.ts specifics shape the code below: credentials come from secret() rather than process.env, and the webhook endpoint must be declared raw, since signature verification needs the unparsed body.

Prerequisites

  • Node.js 22 or newer (the SDK’s supported floor) and the Encore CLI
  • An Encore app with a service: the files below live in a service directory (email/, alongside its encore.service.ts)
  • An AhaSend account with a verified sending domain
  • An API key with the domain-scoped messages:send:{yourdomain.com} permission, and your account ID

Install the SDK

Store Credentials as Encore Secrets

Encore has first-class secrets instead of .env files. Set each secret for local development (and again with --type prod for production):
Because the values are per-environment, give the local (and any preview) environment a dedicated sandbox-mode API key and its own webhook secret. A sandbox credential simulates the whole pipeline without delivering, so encore run on a developer’s machine cannot mail a real customer even when the code forgets to ask for it. In code, declare secrets with secret() from encore.dev/config. Each declaration returns a function you call to read the value:
email/ahasend.ts
Read the secret on every call rather than closing over it once. Encore refreshes secret values in a running process, so a client cached forever would keep presenting a rotated-away API key until the next deploy; comparing the value rebuilds the client only when the key actually changes. The SDK still generates a new automatic idempotency key for each logical call and reuses that key only for that call’s internal retries.

Send an Email from an Encore API Endpoint

email/welcome.ts
The explicit Promise<SendWelcomeResponse> annotation is load-bearing: Encore derives the endpoint’s response schema from the handler’s declared return type, not from what it actually returns, so an unannotated handler compiles into an endpoint that answers with an empty body. This assumes your app has an Encore auth handler whose auth data contains immutable userID and verified email fields. Taking the recipient from authenticated server data prevents the endpoint from becoming an arbitrary-recipient mail relay. A 202 is multi-status: result.data carries one entry per recipient, and an individual recipient can come back status: "error" with a null id (a suppressed address, say) while the call itself succeeds, so check every entry, not just the first. The business-stable idempotency key lets a retried signup reuse the same operation. Use the same key only for the exact same payload. AhaSend retains ordinary idempotency results for 24 hours, but server-error outcomes are not stored and a retry can re-execute the send, so this is not an exactly-once guarantee. Persist a completed-welcome marker in your application database when duplicate prevention must last longer, and make the workflow safe to reconcile after an uncertain result. Run it locally:
Add sandbox: true to the send request to validate it without delivering anything. Give that trial its own idempotency key: AhaSend matches a key against a hash of the request body, so a sandbox call under the welcome- key makes the first real send for that user fail with 422 instead of delivering.

Handle Webhooks with a Raw Endpoint

Signature verification needs the raw request body, which Encore’s typed endpoints parse away. Use api.raw instead: its handler receives a Node IncomingMessage, so you collect the body chunks into a Buffer and hand them to the verifier along with the headers. verifier.parse() is asynchronous, so await it:
email/webhooks.ts
This minimal receiver deliberately verifies and acknowledges without performing a business side effect. Before adding one, atomically commit the verified webhook-id header together with durable queue/outbox work; acknowledge an already-committed ID with 2xx, and process the durable work idempotently. Timestamp verification alone does not prevent a valid delivery from being replayed inside the tolerance window. bodyLimit is Encore’s own cap for this endpoint — it defaults to 2 MiB when left unset — and the streaming counter bounds what the handler buffers even if that cap is later raised; an oversized body is refused either way. Keep any proxy in front at least as strict, and keep failures opaque. The verifier is rebuilt when the secret value changes, for the same reason the API client is: a webhook secret rotated in the dashboard would otherwise leave a stale verifier rejecting every delivery as 400 until the next deploy, and AhaSend gives up on an event after 6 retries and disables the endpoint after 100 consecutive failures. Create the webhook in your AhaSend dashboard pointing at https://your-app.com/webhooks/ahasend (locally, encore run serves it at http://localhost:4000/webhooks/ahasend), and set the secret via encore secret set exactly as shown in the dashboard, including the aha-whsec- prefix. Not on Encore everywhere? The same SDK, including dedicated webhook adapters, powers the Express and Fastify guides.

Going Further

  • Templating: pass substitutions per recipient and use {{ variable }} in the subject or body.
  • Batch sends: recipients accepts up to 100 entries; each gets a separate, individually-substituted message.
  • Tags: pass tags on the send to group messages for filtering and statistics later.
  • Secrets for everything: the webhook secret belongs in encore secret set alongside the API key, so neither value lives in your repo or in a .env file.
  • Attachments: pass attachments: [{ data, content_type, file_name, base64: true }]. Set base64: true for binary files such as PDFs.
See the Node.js SDK guide for client configuration options, and the API reference for every endpoint the SDK exposes.

Troubleshooting

Secrets are per-environment. If it works locally but fails when deployed, you set --type local but not --type prod (or the environment type you deployed to).
The AhaSendApiKey secret holds a missing, malformed, or revoked key. Re-set it and verify the key exists in your dashboard.
Either the endpoint isn’t api.raw (typed endpoints consume the raw body), or AhaSendWebhookSecret doesn’t match the dashboard value exactly.