Skip to main content
This guide shows how to send transactional email from Deno and Deno Deploy with AhaSend, keep credentials on the server, and test a sandbox send. Deno Deploy can run npm packages without a Node build step, so you can use the AhaSend SDK directly from a Deno application. Use a send-only key from Credentials → Add → API Key v2 with messages:send:{your-domain}. Use messages:send:all when sending from many domains. For management tasks such as creating a webhook, use a separate full key from Account Settings → API Keys with the needed scopes. See send-only credentials and authentication. Keep credentials on the server. Use sandbox mode for tests. Reuse a stable, business-event idempotency key only with the exact same payload; use different keys for sandbox and live sends. Check every recipient’s status and return a failure when any item is error; HTTP 202 alone does not mean every recipient was accepted. For webhooks, verify the raw request bytes using the full secret as the raw UTF-8 HMAC key, including its aha-whsec- or aha-rsec- prefix. Never base64-decode the secret. The AhaSend SDK verifiers handle this. These examples use a 30,000,000-byte webhook body limit; a hosting platform may impose a smaller limit. Acknowledge verified unknown event types with 2xx. See verification and retry policy.

Prerequisites

  • Deno installed locally, and a Deno Deploy organization and application
  • An AhaSend account with a verified sending domain
  • An API key scoped to messages:send:{your-domain}, and your account ID
  • For webhook side effects, a database operation that can atomically commit a unique webhook-id and durable work

Import the SDK

Add the SDK to your Deno project:

Try a Sandbox Send

Run this short SDK check locally before adding the longer server integration below. Use a verified sender, a send-only API key, and your account ID. It sends to a reserved test address with sandbox: true, so no email is delivered. In the server project directory, add .env to .gitignore, then create .env with AHASEND_API_KEY, AHASEND_ACCOUNT_ID, and AHASEND_FROM (an address on your verified domain). Keep these values out of browser code and AI prompts. Use Deno 2.x. Save this as quick-send.mjs:
quick-send.mjs
Run deno run --env-file=.env --allow-net=api.ahasend.com --allow-env=AHASEND_API_KEY,AHASEND_ACCOUNT_ID,AHASEND_FROM quick-send.mjs. Deno resolves the npm: import. This is a local SDK check; the server function integration follows below. The output lists recipient statuses and the process fails on a rejected recipient or failed request. This writes the dependency to deno.json. Commit both deno.json and deno.lock, then import the saved bare specifier:

Configure Environment Variables

On Deno Deploy, add the variables to the application under Settings → Environment Variables and mark the API key and webhook secret as Secrets (the account ID is not a secret). Attach live credentials only to the Production context. If preview revisions need email tests, give the Development context separate domain-scoped sandbox credentials and a separate webhook secret; do not expose production credentials to code from development branches.
For local development, create .env yourself and ensure it is ignored by git before adding values — a local source deployment walks the same .gitignore, so that one step is also what keeps the file out of the uploaded bundle. Run with only the permissions this example needs:

Create the Client

Create the client once at module scope and reuse it across requests:
lib/ahasend.ts

Send an Email from a Deno.serve Route

The same Deno.serve handler API runs locally and on Deno Deploy, although their permission models differ:
main.ts
requireAuthenticatedSignup represents your application’s session/authorization check and returns the immutable signup snapshot used for this message. Do not expose an endpoint that accepts an arbitrary recipient from an unauthenticated request; authorize it, validate inputs at the trust boundary, and rate-limit it. 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 SDK retries transient failures automatically, but a retry after a 5xx can still send twice. Reuse a stable idempotencyKey only for the exact same request payload. Stored message results expire after 24 hours, so reconcile an uncertain operation rather than assuming a later retry is deduplicated. The send example uses sandbox: true to validate the request without delivering anything. Sandbox and live sends share one idempotency namespace per account, and sandbox is part of the request body, so flipping it while reusing a key raises AhaSendIdempotencyMismatchError instead of sending.

Handle Webhooks

The webhooks module lives on its own subpath, so importing it doesn’t pull in the API client. verifier.parse() accepts Fetch Headers and raw bytes and must be awaited. Bound the request while reading its stream, then pass those exact bytes rather than parsing and re-serializing JSON. The example uses the SDK’s 30,000,000-byte ceiling, including for inbound message.routing events with attachments. Keep the proxy limit in step and cap concurrent requests. A 413 counts as a failed attempt; more than 100 consecutive failed attempts disable the webhook.
webhook.ts
webhookDeliveries.enqueueOnce is application-owned: in one database transaction it must insert the unique verified webhook-id and durable work, returning false for an already-committed ID. Do not merely insert the ID before doing work, and do not log recipient addresses, event/error objects, signatures, bodies, secrets, or idempotency keys. Drain that work with idempotent side effects from a separate consumer — a Deno.cron job declared at module top level, or a worker outside Deno Deploy. Do not leave it running as a floating promise after you return the response: Deno Deploy keeps an application alive only while requests and responses are still flowing and shuts the isolate down after an idle period, so post-response work is not guaranteed to finish. Create the webhook in your AhaSend dashboard pointing at https://your-app.your-org.deno.net/webhooks/ahasend, and copy its secret into AHASEND_WEBHOOK_SECRET exactly as shown, including the aha-whsec- prefix. Replace the example host with the production domain shown for your Deno Deploy application.

Deploy

Create or link an application at console.deno.com. GitHub-linked applications build on each push. For a local source deployment, make the organization, application, dynamic entrypoint, and production target explicit so a stale CLI context cannot deploy to the wrong app:

Going Further

For substitutions, attachments and scheduled sends, use the sending guide. The Node.js SDK and Go SDK references explain client options, per-recipient results and retries. Use idempotency rules when your application retries a business operation.

Troubleshooting

Local Deno is permission-scoped: allow the listener and api.ahasend.com, plus only the three AHASEND_* variables shown above. The managed Deno Deploy runtime currently runs applications with --allow-all; custom runtime permission flags cannot be passed there.
The API key is missing, malformed, or revoked. Locally, confirm .env is loaded without printing it. On Deno Deploy, check that the three values are Secrets attached to the revision’s Production or Development context, then deploy a new revision.
Pass the bounded raw bytes to verifier.parse(), not a re-serialized JSON.stringify(await req.json()): re-serialization changes byte layout, so the signature no longer matches. Also confirm the secret includes the aha-whsec- prefix.
The from address must belong to a verified sending domain on your account. Check domain status in the dashboard.