# MailHaap documentation

> One domain. Your team's mailboxes, your app's transactional email, and your marketing campaigns — with the deliverability engineering already done.

Source: https://mailhaap.com/docs · Last updated 2026-08-21

---

# Quickstart

Add a domain, publish the DNS records, create an API key, and POST to /v1/emails. On a fresh account this takes under five minutes, and you can do the whole thing in test mode first without a verified domain.

## 1. Create a key

Keys are scoped, can be pinned to a single domain and an IP allowlist, and are shown exactly once. Start with a test key — test sends need no verified domain, consume no quota, and produce real webhook events on a realistic delay curve.

```bash
export MAILHAAP_API_KEY="mh_test_a1b2c3d4e5f6g7h8i9j0k1"
```

## 2. Send

```bash
curl -X POST https://api.mailhaap.com/v1/emails \
  -H "Authorization: Bearer $MAILHAAP_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: quickstart-1" \
  -d '{
    "from": "You <hello@yourdomain.com>",
    "to": ["delivered@sim.mailhaap.com"],
    "subject": "It works",
    "html": "<p>Hello from MailHaap</p>"
  }'
```

You get a 202 with a message id. The send itself happens in a worker — the API never blocks on the provider.

```json
{
  "id": "msg_01J8XQ7M2K9PQRSTUV",
  "status": "queued",
  "created_at": "2026-08-21T16:04:11Z"
}
```

## 3. Watch what happened

Every message carries a trace id that is queryable in the dashboard, so “why did this email not arrive” is answerable from the message id alone. The simulator addresses below produce each outcome deliberately, so you can prove your bounce and complaint handling works before a real bounce proves it does not.

| Address | Outcome |
|---|---|
| delivered@sim.mailhaap.com | Delivered after ~2 s |
| bounced@sim.mailhaap.com | Hard bounce, SMTP 550 |
| soft-bounced@sim.mailhaap.com | Soft bounce, then delivery on retry |
| complained@sim.mailhaap.com | Delivered, then a complaint after ~10 s |
| suppressed@sim.mailhaap.com | Rejected at accept with recipient_suppressed |
| slow@sim.mailhaap.com | Delivered after ~90 s |
| opened@sim.mailhaap.com | Delivered, then open and click events |

> **Test mode is not a sandbox with different behaviour** — A test key accepts identical requests against the same code path. The only difference is that nothing is delivered and nothing is billed. If it works in test mode, the live call is the same call with a different key.


---

# Authentication

Every request carries a bearer token in the Authorization header. Keys are formatted mh_{mode}_{22 base62 characters} where mode is live or test. We store only a SHA-256 hash and a 14-character display prefix — the full key is shown once, at creation, and cannot be recovered.

```http
Authorization: Bearer mh_live_a1b2c3d4e5f6g7h8i9j0k1l2
```

## Scopes

A key carries only the scopes it needs. A key that only sends should not be able to read your contact list.

| Scope | Grants |
|---|---|
| emails:send | Send transactional email and batches |
| emails:read | Read message status, events and metadata |
| emails:read_body | Read stored bodies — every call is audit-logged |
| domains:read | List domains and their DNS state |
| domains:write | Add, verify and modify domains |
| contacts:write | Create and modify audiences, contacts and suppressions |
| campaigns:write | Create, schedule and control campaigns |
| templates:read | Read and render templates |
| webhooks:write | Manage webhook endpoints |
| analytics:read | Read aggregated analytics |

## Restricting a key further

- Pin the key to a single domain, so a leaked key cannot send from your other identities.
- Add an IP allowlist, so it only works from your own servers.
- Set an expiry, which is what you want for anything a contractor holds.

> **Keys found in public repositories are revoked automatically** — We run a secret-scanner integration. A key committed to a public repository is auto-revoked and the owner notified — which is disruptive, and much less disruptive than the alternative.


---

# Error codes

Errors are RFC 9457 problem documents with a stable machine code, a request id and a documentation URL. Read the code, not the HTTP status or the human-readable title — codes are a permanent contract and are never changed, only added.

```json
{
  "type": "https://docs.mailhaap.com/errors/domain_not_verified",
  "title": "Domain not verified",
  "status": 422,
  "code": "domain_not_verified",
  "detail": "The domain abcemlak.com has 2 of 4 required DNS records in place.",
  "request_id": "req_01J8XQ7M2K9P",
  "docs_url": "https://docs.mailhaap.com/domains/verification",
  "missing_records": [
    { "kind": "dkim", "record_type": "CNAME", "host": "mh1._domainkey.abcemlak.com" }
  ]
}
```

| Status | Code | What to do |
|---|---|---|
| 400 | invalid_request | A field failed validation. The detail names it. |
| 400 | invalid_json | The body was not parseable JSON. |
| 400 | unsupported_content_type | Send application/json. |
| 401 | missing_api_key | No Authorization header. |
| 401 | invalid_api_key | The key does not exist or the hash did not match. |
| 401 | revoked_api_key | The key was revoked. Create a new one. |
| 403 | insufficient_scope | The key lacks the scope this endpoint needs. |
| 403 | ip_not_allowed | The request came from outside the key's IP allowlist. |
| 403 | tenant_suspended | The workspace is suspended. Contact support. |
| 404 | not_found | Also returned for cross-workspace access — we never confirm existence. |
| 409 | idempotency_conflict | The same key was reused with a different body. |
| 422 | domain_not_verified | Publish the missing DNS records listed in the response. |
| 422 | identity_not_owned | The From address is not on a verified domain of this workspace. |
| 422 | recipient_suppressed | The address is on your suppression list. Remove it deliberately or not at all. |
| 422 | attachment_too_large | 20 MB per attachment, 25 MB per message after encoding. |
| 422 | invalid_recipient | The address failed syntax or MX validation. |
| 422 | template_render_failed | A merge tag could not resolve. The detail names it. |
| 422 | missing_unsubscribe | A campaign-plane message with no unsubscribe. |
| 429 | rate_limited | Honour Retry-After. Retry with jitter, never in a tight loop. |
| 451 | content_blocked | The abuse filter rejected the content. |
| 500 | internal_error | Retry with backoff. Quote the request id to support. |
| 503 | provider_unavailable | Retry with backoff. Queued work is unaffected. |

> **Why cross-workspace access returns 404, not 403** — A 403 confirms the object exists. Returning 404 for anything outside your workspace means an attacker with a valid key learns nothing by probing ids. There is an automated test asserting this for every resource type, and it blocks merges.


---

# Webhooks

Configure up to five endpoints per workspace. Every delivery carries an HMAC-SHA256 signature over "{timestamp}.{raw body}". Verify it in constant time and reject anything where the timestamp is more than 300 seconds old. Event ids are stable across retries, so your consumer must be idempotent.

```http
Mailhaap-Signature: t=1755792000,v1=5257a869e7ecebeda32affa62cdca3fa...
Mailhaap-Event-Id: evt_01J8XQ...
Mailhaap-Delivery-Attempt: 1
```

## Retry schedule

Seven attempts over 24 hours: immediately, then 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours, 24 hours. After seven consecutive failures the endpoint moves to disabled_by_failures and the owner is emailed. Every undelivered event stays fetchable from GET /v1/events, so a disabled endpoint never means lost data.

## Event types

- email.queued · email.sent · email.delivered · email.deferred
- email.bounced · email.complained · email.failed
- email.opened · email.clicked
- contact.unsubscribed
- domain.verified · domain.degraded
- campaign.completed · campaign.throttled · campaign.blocked
- tenant.limit_reached

```json
{
  "id": "evt_01J8XQ...",
  "type": "email.bounced",
  "created_at": "2026-08-21T16:05:02.114Z",
  "api_version": "2026-08-01",
  "data": {
    "message_id": "msg_01J8XQ...",
    "to": "mehmet@example.com",
    "from": "info@abcemlak.com.tr",
    "subject": "Order A-1043 confirmed",
    "tags": [{ "name": "category", "value": "order_confirmation" }],
    "bounce": {
      "class": "hard",
      "subtype": "mailbox_not_found",
      "smtp_code": "550"
    }
  }
}
```

> **A block is not a hard bounce** — bounce.class can be hard, soft, block or technical. A block is a rejection caused by receiver policy or sender reputation — it is about us, not the recipient. Suppressing on a block would destroy your list for a problem that was ours, so we do not, and neither should you.


---

# Idempotency

Send an Idempotency-Key header on any creating POST and a retry returns the original response instead of sending a second email. Keys are scoped per workspace and retained 24 hours. True exactly-once delivery does not exist across a network boundary, so here is what we do guarantee.

| Boundary | Guarantee |
|---|---|
| Accept | At-most-once per (workspace, idempotency key) |
| Provider | At-least-once, with duplicate suppression by provider message id |
| Campaigns | Effectively-once, enforced by the recipient primary key |

We document this honestly because a customer building billing emails needs the real guarantee, not a marketing claim. A replayed request returns the stored response with an Idempotent-Replay: true header. Reusing a key with a different body is an idempotency_conflict, not a silent overwrite.

```http
POST /v1/emails
Idempotency-Key: order-1043-confirmation

# ... retried after a timeout ...

HTTP/1.1 202 Accepted
Idempotent-Replay: true
{ "id": "msg_01J8XQ7M2K9PQRSTUV", "status": "queued" }
```

> **Pick a key from your own domain** — The best idempotency key is something meaningful from your side — an order id plus the email type, not a random UUID generated at call time. A random key regenerated on retry defeats the whole mechanism.


---

# Adding a domain

Add a domain, publish the records we generate, and verification starts polling automatically. We read your existing DNS first and adapt — the wizard will never tell you to publish something that breaks the email you already have, and it can physically not generate an instruction to add a second SPF record.

| Kind | Type | Host | Required |
|---|---|---|---|
| ownership | TXT | _mailhaap.{domain} | Yes |
| dkim (×3) | CNAME | {selector}._domainkey.{domain} | Yes |
| spf | TXT | {domain} | Yes — merged, never a second record |
| mail_from_mx | MX | bounce.{domain} | Yes for sending |
| mail_from_spf | TXT | bounce.{domain} | Yes for sending |
| dmarc | TXT | _dmarc.{domain} | Strongly recommended |
| tracking | CNAME | track.{domain} | Optional, strongly advised |
| mx (×2) | MX | {domain} | Only if you host mailboxes with us |
| autodiscover | CNAME | autodiscover.{domain} | Mailbox only |
| tls_rpt | TXT | _smtp._tls.{domain} | Optional |

## Why the custom MAIL FROM subdomain is not optional

The bounce.{domain} records give you a Return-Path aligned with your own domain. Without them the Return-Path belongs to the sending provider, SPF alignment under DMARC fails, and you can never move to p=reject. Two records, permanent benefit.

## How verification works

Every check queries four sources in parallel and requires agreement from at least two: Cloudflare, Google and Quad9 over DNS-over-HTTPS, plus the domain's own authoritative nameservers found via an NS lookup. Querying the authoritative server is what makes propagation messaging accurate — it lets us say “the record exists, your resolver has not caught up” instead of “missing”.

1. Every 10 seconds for the first 5 minutes
2. Every 60 seconds for the next 55 minutes
3. Every 15 minutes for the next 24 hours
4. Hourly for 72 hours, after which the domain is marked failed

> **Proxied DKIM records break DKIM** — If a DKIM CNAME sits behind Cloudflare's proxy (the orange cloud), the record resolves to Cloudflare rather than to the key, and signing fails. Switch it to DNS only. We detect this specific case and say so by name.


---

# DNS health monitoring

Every verified domain is re-checked every six hours for the first week, then daily. If a required record goes missing the domain moves to degraded: sending continues for 72 hours with a dashboard banner, a domain.degraded webhook and an email. If it is still missing after 72 hours the domain fails and sending from it stops.

```text
verified ──(required record missing)──► degraded ──(72h unresolved)──► failed
    ▲                                      │                             │
    └──────────(records restored)──────────┴─────────────────────────────┘
```

This has stopped countless silent deliverability collapses caused by someone migrating DNS providers and losing DKIM in the process. Every state change is written to the audit log with the observed DNS snapshot attached, so you can see exactly what changed and when — including what the record used to be.

> **Failing a domain is the point** — Blocking sending from a domain whose DKIM has been broken for three days feels aggressive. Continuing to send unsigned mail from it for six months, and discovering the problem from your open rate, is worse.


---

# Campaign preflight

Preflight runs before a campaign can be scheduled. It returns the audience after scrubbing, a per-check result, and the distribution across receiving providers. A fail on any check blocks scheduling outright. A warning requires an explicit acknowledgement, which is recorded in the audit log with your user id against it.

```json
{
  "audience_size": 128430,
  "after_suppression": 121905,
  "after_dedupe": 121340,
  "checks": [
    { "id": "unsubscribe_header", "status": "pass" },
    { "id": "dmarc_present", "status": "pass", "detail": "p=quarantine" },
    { "id": "warmup_ceiling", "status": "warn",
      "detail": "Your current daily cap is 40,000. This campaign will send over 4 days." },
    { "id": "link_reputation", "status": "pass" },
    { "id": "spam_score", "status": "pass", "score": 1.4 },
    { "id": "image_text_ratio", "status": "warn", "detail": "82% image area" },
    { "id": "consent_evidence", "status": "pass" }
  ],
  "isp_distribution": {
    "gmail.com": 0.48, "hotmail.com": 0.17, "yahoo.com": 0.06, "other": 0.29
  }
}
```

## What the scrub removes

1. Your workspace suppression list
2. The global platform suppression list — known spam traps, repeat complainers, invalid domains
3. Role addresses (postmaster@, abuse@, noreply@) — configurable, on by default
4. Duplicates, collapsed by address hash
5. Contacts whose status is not subscribed
6. Addresses that have hard-bounced before
7. Disposable domains — configurable

Every skipped recipient carries a reason, exposed in the UI, so you can see exactly why 128,430 became 121,340 rather than being told a smaller number with no explanation.


---

# Open and click tracking

Open tracking is far less reliable than the industry pretends. Apple Mail Privacy Protection pre-fetches every remote image through Apple's proxy shortly after delivery, whether or not a human ever looked. In many audiences roughly half of consumer opens are machine opens. So we show three numbers, never one.

| Number | What it is |
|---|---|
| Total opens | Every pixel fetch, including proxies and scanners |
| Human opens (estimated) | Non-proxy fetches that pass our timing and ASN heuristics |
| Machine / proxy opens | Apple MPP, Gmail's image proxy, corporate security scanners |

Geography and device breakdowns are computed from non-proxy events only, and every geo view carries a caption saying how many of the opens could actually be located. Reputation scoring weighs clicks far more heavily than opens for exactly this reason.

## Link rewriting and the open-redirect problem

A tracked link becomes a compact signed token — version, workspace, message, link and expiry — validated by HMAC at the edge with no database read, which is what makes a sub-25 ms redirect possible. The destination is resolved from the link id against the campaign's own link table, never from a URL in the query string. An open redirect on a mail domain is a phishing gift and a fast route to a blacklist.

- Never rewritten: mailto:, tel:, anchors, the unsubscribe link, and anything marked data-mh-no-track.
- Tokens stop resolving after 12 months by default.
- Customers who add the track. CNAME get their own domain in the link, which improves both click-through and deliverability.

> **Security-critical messages are never tracked** — Password resets, one-time codes, login alerts and payment receipts have open tracking hard-disabled. There is no toggle. A tracking pixel in a password-reset email is a privacy liability for you and a deliverability problem for us.


---

# SDKs

All five SDKs are generated from the OpenAPI specification with hand-written ergonomics on top. Each ships with typed models, automatic retry with jitter on 429 and 5xx but never on 4xx, built-in idempotency key generation for send(), a webhook signature verification helper, and a test-mode client that requires no network at all.

```bash
npm install @mailhaap/node
pip install mailhaap
go get github.com/mailhaap/mailhaap-go
composer require mailhaap/mailhaap
gem install mailhaap
```

> **Retry on 429 and 5xx, never on 4xx** — A 4xx means the request was wrong and will be wrong again. Retrying it wastes your rate limit and, on a send endpoint, is how a bug becomes an incident. The SDKs enforce this so you do not have to remember it.


---

# SMTP relay

Authenticate with your API key prefix as the username and the full key as the password. The relay applies the same workspace checks, suppression, rate limits and tracking rewrites as the REST API — it is the same pipeline behind a different door, not a lesser path.

```text
Host:     smtp.mailhaap.com
Ports:    587   STARTTLS (required)
          465   implicit TLS
          2587  fallback, for networks that block 587
Username: the API key prefix, e.g. mh_live_a1b2c3
Password: the full API key
```

Three custom headers are honoured and stripped before delivery, so they never reach the recipient: X-MH-Tag, X-MH-Campaign-Id and X-MH-Track-Opens.

> **There is no open relay, ever** — Submission requires authentication without exception. Automated relay tests run hourly against our own submission hosts and alert on any anonymous acceptance.


---
