Skip to content
MailHaap
[01]Transactional plane

Accept fast. Send async. Tell the truth about what happened.

A REST API and SMTP relay for app email. Accept p99 under 150 ms, idempotency keys, signed webhooks with a seven-attempt retry schedule, unlimited test mode, and SDKs for Node, Python, Go, PHP and Ruby.

  • #REST
  • #SMTP-relay
  • #Idempotency
  • #Webhooks
  • #Test-mode
  • #Five-SDKs
[02]What you get

One database round trip

The accept path does key lookup, rate limiting, idempotency, validation, identity check and suppression in Redis or in process — then one Postgres transaction that writes the message row and the queue job together. Target p99 under 150 ms.

No orphans, ever

The message and its job are inserted in the same transaction. If the process dies between them, neither exists. That is why the job queue lives in Postgres rather than Redis.

Idempotency that means something

Any creating POST accepts an Idempotency-Key, scoped per workspace and retained 24 hours. A replay returns the original response with Idempotent-Replay: true — not a second email.

Webhooks that do not lie

HMAC-SHA256 over {timestamp}.{raw body}, stable event ids across retries, and a documented schedule: 0s, 30s, 2m, 10m, 1h, 6h, 24h. After seven consecutive failures the endpoint is disabled and you are emailed — and every undelivered event stays fetchable.

Test mode with real behaviour

Test keys accept identical requests, consume no quota, need no verified domain, and emit fully simulated webhook events on a realistic delay curve. Simulator addresses produce every outcome on demand.

Errors as a permanent contract

RFC 9457 problem documents with a stable machine code, a request id, and a docs URL for every failure. Codes are never changed, only added.

[03]How it works

From nothing to a delivered email in five minutes.

This is a measured target, not a slogan: it is tested on a fresh account by someone outside the team before the gate closes.

  1. [01]

    Add your domain

    We read your current DNS first and adapt, so the wizard can never tell you to do something that breaks the email you already have.

  2. [02]

    Publish the records

    Copy them one at a time, copy the whole set as a zone file, or connect Cloudflare or Route 53 and we write them for you — one zone, narrowest scope, with a diff you confirm.

  3. [03]

    Create a key

    Scoped, optionally pinned to one domain and an IP allowlist. Shown once, then only ever a prefix.

  4. [04]

    Send

    One POST. You get a 202 with a message id, and the events start arriving at your webhook endpoint.

[04]Send

One call. Five languages.

send.ts
import { MailHaap } from "@mailhaap/node";

const mh = new MailHaap(process.env.MAILHAAP_API_KEY);

const { id } = await mh.emails.send({
  from: "Kadıköy Property <[email protected]>",
  to: "[email protected]",
  subject: "Order A-1043 confirmed",
  html: "<p>Hello {{ first_name }}</p>",
  tags: [{ name: "category", value: "order_confirmation" }],
});

console.log(id); // msg_01J8XQ7M2K9PQRSTUV
webhook.ts
import { verifyWebhook } from "@mailhaap/node";

app.post("/hooks/mailhaap", async (req, res) => {
  // Throws when the signature is wrong or |now - t| > 300s.
  const event = verifyWebhook({
    payload: req.rawBody,
    signature: req.headers["mailhaap-signature"],
    secret: process.env.MAILHAAP_WEBHOOK_SECRET,
  });

  // Event ids are stable across retries — consumers must be idempotent.
  if (await alreadyHandled(event.id)) return res.sendStatus(200);

  switch (event.type) {
    case "email.bounced":
      await suppress(event.data.to, event.data.bounce.class);
      break;
    case "email.complained":
      await unsubscribe(event.data.to);
      break;
  }

  res.sendStatus(200);
});
[04b]Simulator addresses

Every outcome, on demand.

These work in both live and test mode and never reach a real inbox. Use them to prove your bounce handling actually works before a real bounce proves it does not.

Simulator addresses and their outcomes
AddressSimulated outcome
[email protected]Delivered after ~2 s
[email protected]Hard bounce, SMTP 550
[email protected]Soft bounce, then delivery on retry
[email protected]Delivered, then complaint after ~10 s
[email protected]Rejected at accept with recipient_suppressed
[email protected]Delivered after ~90 s
[email protected]Delivered, then open + click events
[05]Numbers
Accept latency, p50 / p99
25 ms / 150 ms
Batch endpoint, 100 messages, p99
400 ms
Rate limit, /emails
100 req/s per key, burst 2×
Rate limit, everything else
10 req/s per key
Recipients per call
50 across to + cc + bcc
Batch size
100 messages
Max message size
25 MB encoded
Max attachment size
20 MB
Scheduling window
Up to 30 days out, cancellable until sending
SMTP relay ports
587 STARTTLS, 465 implicit TLS, 2587 fallback
Open tracking default
Off — tracking a password reset is a privacy problem
SDKs
Node, Python, Go, PHP, Ruby

Your password resets are never stopped by someone else's campaign.

Every message belongs to exactly one plane, decided at accept time. The transactional plane has its own isolated container at the provider, its own metrics, its own automatic pause policy, and its own worker deployment — so a campaign backlog cannot consume transactional worker slots even at the operating-system level.