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-idand durable work
Import the SDK
Add the SDK to your Deno project: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..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 sameDeno.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 atconsole.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
substitutionsper recipient and use{{ variable }}in the subject or body. - Batch sends:
recipientsaccepts 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 tosend(), 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 }]. Setbase64: truefor binary files such as PDFs.
Troubleshooting
PermissionDenied running locally
PermissionDenied running locally
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.401 AhaSendAuthenticationError
401 AhaSendAuthenticationError
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.Webhook verification fails with 400
Webhook verification fails with 400
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.400 error mentioning the from address
400 error mentioning the from address
The
from address must belong to a verified sending domain on your account. Check domain status in the dashboard.
