Skip to main content
Deno Deploy can run npm packages without a Node build step, so you can use the AhaSend SDK directly from a Deno application.

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:
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 all three as Secrets. 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. Add sandbox: true to the send request to validate it 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. Size that bound to the events you subscribe to. Delivery-status events are a few kilobytes, but an inbound message.routing event embeds base64 attachment data and is routinely much larger. Rejecting an oversized delivery with a 413 counts as a failed delivery, and 100 consecutive failures disable the webhook, so raise MAX_WEBHOOK_BODY_BYTES toward the SDK’s 30,000,000-byte verification ceiling if you route inbound mail.
main.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

  • 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.
  • Scheduling: set schedule: { first_attempt: new Date(Date.now() + 60 * 60 * 1000).toISOString() } to defer delivery by an hour; the first attempt must be in the future and within 7 days.
  • Your own idempotency keys: pass { idempotencyKey: "order-123" } as the second argument to send(), reuse it only for the exact same payload, and account for the 24-hour result-retention window.
  • Attachments: pass attachments: [{ data, content_type, file_name, base64: true }]. Set base64: true for binary files such as PDFs.
See the API reference for every endpoint the SDK exposes. Running on another modern runtime? Review both the runtime compatibility inventory and the SDK security policy before production; the Bun and Cloudflare Workers guides cover their platform-specific setup.

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.