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
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.
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.
- [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.
- [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.
- [03]
Create a key
Scoped, optionally pinned to one domain and an IP allowlist. Shown once, then only ever a prefix.
- [04]
Send
One POST. You get a 202 with a message id, and the events start arriving at your webhook endpoint.
One call. Five languages.
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_01J8XQ7M2K9PQRSTUVimport { 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);
});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.
| Address | Simulated 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 |
- 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.