HeyFox Send API
Transaktions- und Systemmails per HTTP senden – Bestellbestätigungen, Passwort-Links, Rechnungen, Widerrufsbestätigungen. Über eigene Server in der Schweiz, mit Zustellstatus für jeden Empfänger.
| Adresse | https://api.heyfox.email/v1 |
|---|---|
| Format | JSON hinein und heraus, UTF-8, Content-Type: application/json |
| Anmeldung | Authorization: Bearer hfs_… – Schlüssel im Dashboard unter Zugänge → API |
| Absender | Jede Adresse einer im Dashboard bestätigten Domain (DKIM und Rückweg gesetzt) |
| Version | v1 · Stand 26.10.2026 (Webhooks). Neue Felder können dazukommen; bestehende ändern wir nicht ohne neue Version. |
Jede Antwort trägt den Kopf HeyFox-Request-Id. Nenne ihn, wenn du uns wegen einer Anfrage schreibst.
Schnellstart
Drei Schritte im Dashboard: Domain hinzufügen und die zwei DNS-Einträge setzen, Freischaltung abwarten, API-Schlüssel anlegen. Dann:
curl https://api.heyfox.email/v1/messages \ -H "Authorization: Bearer $HEYFOX_SEND_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: bestellung-1001" \ -d '{ "from": "Shop <bestellung@dein-shop.de>", "to": ["kundin@beispiel.de"], "subject": "Deine Bestellung 1001", "html": "<p>Danke für deine Bestellung!</p>", "text": "Danke für deine Bestellung!", "tags": ["bestellung"], "metadata": { "order_id": "1001" } }'
// WordPress (ohne zusätzliche Bibliothek) $response = wp_remote_post('https://api.heyfox.email/v1/messages', [ 'headers' => [ 'Authorization' => 'Bearer ' . HEYFOX_SEND_KEY, 'Content-Type' => 'application/json', 'Idempotency-Key' => 'bestellung-1001', ], 'body' => wp_json_encode([ 'from' => 'Shop <bestellung@dein-shop.de>', 'to' => ['kundin@beispiel.de'], 'subject' => 'Deine Bestellung 1001', 'html' => '<p>Danke für deine Bestellung!</p>', ]), 'timeout' => 20, ]); $data = json_decode(wp_remote_retrieve_body($response), true); // 202 → $data['id'] (msg_…), sonst $data['error']['code']
const res = await fetch('https://api.heyfox.email/v1/messages', { method: 'POST', headers: { Authorization: `Bearer ${process.env.HEYFOX_SEND_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': 'bestellung-1001', }, body: JSON.stringify({ from: 'Shop <bestellung@dein-shop.de>', to: ['kundin@beispiel.de'], subject: 'Deine Bestellung 1001', html: '<p>Danke für deine Bestellung!</p>', }), }); const body = await res.json(); if (!res.ok) throw new Error(body.error.code); console.log(body.id); // msg_…
import os, requests res = requests.post( "https://api.heyfox.email/v1/messages", headers={ "Authorization": f"Bearer {os.environ['HEYFOX_SEND_KEY']}", "Idempotency-Key": "bestellung-1001", }, json={ "from": "Shop <bestellung@dein-shop.de>", "to": ["kundin@beispiel.de"], "subject": "Deine Bestellung 1001", "html": "<p>Danke für deine Bestellung!</p>", }, timeout=20, ) res.raise_for_status() print(res.json()["id"]) # msg_…
Antwort (202 Accepted):
{
"id": "msg_8k2m4p9qa7bcx3dfr6tw2ynz",
"status": "queued",
"created_at": "2026-10-25T21:14:03.120Z",
"recipients": [
{ "email": "kundin@beispiel.de", "type": "to", "status": "queued" }
]
}
Anmeldung mit API-Schlüssel
API-Schlüssel beginnen mit hfs_ und gehören zu genau einem Versand-Konto. Den Inhaber eines Kontos legt sie im Dashboard unter Zugänge → API an. Wir zeigen jeden Schlüssel genau einmal und speichern ihn nur als Prüfsumme – geht er verloren, leg einen neuen an und widerrufe den alten.
Schicke den Schlüssel bei jeder Anfrage im Kopf Authorization: Bearer hfs_…. Ein widerrufener Schlüssel gilt ab sofort nicht mehr.
Mail senden
Eine Nachricht an bis zu 50 Empfänger (an, Cc und Bcc zusammen). Alle Empfänger bekommen dieselbe Mail; Bcc-Empfänger sieht niemand. Für personalisierte Mails je Empfänger eine eigene Anfrage senden.
| Feld | Typ | Bedeutung |
|---|---|---|
fromPflicht | Adresse | Absender. Die Domain muss im Konto bestätigt sein. |
toPflicht | Adresse oder Liste | Mindestens ein Empfänger. |
cc, bcc | Adresse oder Liste | Weitere Empfänger. |
reply_to | Adresse oder Liste | Antwortadresse(n), höchstens 5. |
subjectPflicht | Text | Eine Zeile, höchstens 998 Zeichen. |
html, text | Text | Inhalt – mindestens eins von beiden. Am besten beides senden. |
attachments | Liste | Höchstens 20 Dateien, zusammen höchstens 15 MB: {"filename", "content" (Base64), "content_type", "content_id"}. Mit content_id wird die Datei ein eingebettetes Bild: im HTML als <img src="cid:logo">. |
headers | Objekt | Eigene Kopfzeilen, höchstens 30, z. B. List-Unsubscribe, In-Reply-To, X-…. Nicht erlaubt sind die, die HeyFox Send selbst setzt oder die ein eigenes Feld haben: From, To, Cc, Bcc, Subject, Reply-To, Sender, Date, Message-ID, Return-Path, MIME-Version, Content-Type, Content-Transfer-Encoding, DKIM-Signature, Received, X-Tenant und die Authentifizierungs-Köpfe. |
tags | Liste von Texten | Höchstens 10, je bis 64 Zeichen aus Buchstaben, Ziffern und _ . : -. Zum Filtern in GET /v1/messages?tag=…. |
metadata | Objekt | Bis zu 20 eigene Werte (Schlüssel bis 40 Zeichen, Wert bis 500 Zeichen), z. B. eure Bestellnummer. Kommt im Status zurück. |
Adressen gehen als Text "kundin@beispiel.de", mit Namen "Kundin <kundin@beispiel.de>" oder als Objekt {"email": "kundin@beispiel.de", "name": "Kundin"}.
Unbekannte Felder lehnen wir ab (Fehler unknown_field) – so fällt ein Tippfehler wie htm sofort auf, statt dass eine leere Mail rausgeht.
Antwort
202 Accepted, sobald unser Mailserver die Nachricht übernommen hat – mit der Nachrichten-ID und dem Ergebnis je Empfänger:
| Status je Empfänger | Bedeutung |
|---|---|
| queued | Übernommen, die Zustellung läuft. |
| suppressed | Steht auf der Sperrliste des Kontos und wurde nicht gesendet. reason: bounce, complaint oder manual. |
| rejected | Vom Mailserver nicht angenommen. reason: daily_limit oder recipient_rejected, dazu detail. |
Wird kein einziger Empfänger angenommen, antwortet die API mit 422 all_recipients_rejected und derselben Liste.
Zustellstatus abfragen
Der aktuelle Stand je Empfänger, so wie ihn die Server der Empfänger an uns zurückmelden. Meist steht nach wenigen Sekunden delivered da. Abrufbar 30 Tage lang.
{
"id": "msg_8k2m4p9qa7bcx3dfr6tw2ynz",
"status": "delivered",
"created_at": "2026-10-25T21:14:03.120Z",
"from_domain": "dein-shop.de",
"tags": ["bestellung"],
"metadata": { "order_id": "1001" },
"recipients": [
{ "email": "kundin@beispiel.de", "type": "to", "status": "delivered", "updated_at": "2026-10-25T21:14:05.410Z" }
]
}
Werte in metadata kommen immer als Text zurück.
Nachrichten auflisten
Die neuesten per API gesendeten Nachrichten zuerst. limit 1–100 (Standard 20), tag filtert, before blättert: die ID der letzten Nachricht der vorigen Seite – sie steht in next_before, solange has_more wahr ist.
{
"data": [
{ "id": "msg_…", "status": "delivered", "created_at": "…", "from_domain": "dein-shop.de", "recipients": 1, "tags": ["bestellung"], "metadata": {} }
],
"has_more": true,
"next_before": "msg_…"
}
Konto prüfen
Für „Verbindung testen“ in eurer Software: Ist der Schlüssel gültig, ist das Konto freigeschaltet, mit welchen Domains darf gesendet werden?
{
"id": "…", "name": "Buchhandlung Seitenwind",
"status": "active",
"daily_limit": 2000, "sent_last_24h": 137,
"domains": [ { "domain": "seitenwind-buecher.de", "verified": true } ]
}
status: active, pending_release (wartet auf unsere Freischaltung), paused oder blocked.
Domains
Alle Absender-Domains des Kontos mit Prüfstatus und den DNS-Einträgen, die dafür gesetzt sein müssen (dns_records: Typ, Name, Wert). Domains hinzufügen geht im Dashboard.
Webhooks – Ereignisse empfangen
Für jede Mail eures Kontos schicken wir euch, was mit ihr passiert ist – per POST mit JSON an eine Adresse eurer Wahl. Damit speichert eure Software den Zustellstatus selbst, z. B. als Nachweis, dass eine Widerrufsbestätigung angekommen ist. Das gilt für Mails über die API (mit message_id, tags und metadata) und über SMTP (mit smtp_message_id, der Message-ID eurer Mail).
Einrichten im Dashboard unter Einstellungen → Webhooks: Adresse (https://, aus dem Internet erreichbar), Ereignisse wählen, das Geheimnis whsec_… sicher speichern – es ist nur einmal zu sehen. „Test senden“ schickt sofort ein Ereignis webhook.test und zeigt eure Antwort. Bis zu 5 Adressen je Konto.
| Ereignis | Wann |
|---|---|
message.delivered | Der Empfänger-Server hat die Mail angenommen. |
message.deferred | Der Empfänger-Server bittet um einen späteren Versuch. Einmal je Mail, beim ersten Aufschub; wir versuchen es weiter. |
message.bounced | Abgelehnt (status: bounced) oder nach 24 Stunden aufgegeben (status: expired). bounce_type sagt, ob die Adresse auf die Sperrliste kam (hard) oder nicht (soft). |
message.complained | Der Empfänger hat die Mail als Spam markiert. Die Adresse steht jetzt auf der Sperrliste. |
webhook.test | Nur über „Test senden“ im Dashboard. |
Aufbau eines Ereignisses
{
"id": "evt_7k2fq9mxt4hc3wnp8db6jrse",
"type": "message.bounced",
"created_at": "2026-10-26T09:14:05.410Z",
"data": {
"account_id": "5b0c…",
"message_id": "msg_5gq8kz2nw7hx4tcr9mfp3jdv",
"smtp_message_id": null,
"recipient": "kundin@beispiel.de",
"from_domain": "dein-shop.de",
"status": "bounced",
"reason": "InvalidRecipient",
"bounce_type": "hard",
"detail": "550 5.1.1 Requested action not taken: mailbox unavailable",
"tags": ["widerruf"],
"metadata": { "order_id": "1001" },
"submitted_at": "2026-10-26T09:14:01.882Z"
}
}
| Feld | Bedeutung |
|---|---|
id | Eindeutige ID des Ereignisses – dieselbe wie im Kopf webhook-id. Nehmt sie, um doppelte Zustellungen zu erkennen. |
type | Siehe Tabelle oben. Steht auch im Kopf HeyFox-Event. |
created_at | Wann es passiert ist (UTC). Die Reihenfolge der Zustellung ist nicht garantiert – sortiert danach. |
data.message_id | Die msg_-ID aus POST /v1/messages; null bei SMTP-Mails. |
data.smtp_message_id | Bei SMTP-Mails der Kopf Message-ID eurer Mail (z. B. <abc@dein-shop.de>); sonst null. |
data.recipient | Die Empfängeradresse. Bei mehreren Empfängern kommt je Empfänger ein eigenes Ereignis. |
data.status | delivered, deferred, bounced, expired oder complained – wie in Statuswerte. |
data.reason | Die Art der Ablehnung, z. B. InvalidRecipient, QuotaIssues, SpamBlock, MessageExpired; complaint bei Beschwerden; sonst null. |
data.bounce_type | hard (Adresse gibt es nicht – auf der Sperrliste) oder soft (vorübergehend, z. B. Postfach voll); nur bei message.bounced. |
data.detail | Die Antwort des Empfänger-Servers; E-Mail-Adressen darin sind gekürzt. |
data.tags, data.metadata | Was ihr beim Senden über die API mitgegeben habt (Werte als Text); bei SMTP leer. |
data.submitted_at | Wann wir die Mail angenommen haben. |
Signatur prüfen
Jedes Ereignis ist signiert nach dem offenen Standard Standard Webhooks – die fertigen Bibliotheken dafür (PHP, Node, Python, Go …) funktionieren mit eurem Geheimnis whsec_… direkt. Von Hand:
webhook-id | ID des Ereignisses (evt_…) |
webhook-timestamp | Sekunden seit 1970 (Unix-Zeit) beim Senden |
webhook-signature | v1, + Base64 von HMAC-SHA256 über {webhook-id}.{webhook-timestamp}.{Inhalt}. Schlüssel: der Teil des Geheimnisses nach whsec_, Base64-dekodiert. |
Prüft gegen den unveränderten Inhalt (vor dem JSON-Dekodieren), vergleicht in konstanter Zeit und lehnt Zeitstempel ab, die mehr als 5 Minuten abweichen. Mehrere Signaturen stehen durch Leerzeichen getrennt; eine passende genügt.
// Geheimnis aus HeyFox Send (whsec_…), z. B. in wp-config.php: define('HEYFOX_WEBHOOK_SECRET', 'whsec_…'); $secret = base64_decode(substr(HEYFOX_WEBHOOK_SECRET, 6)); $body = file_get_contents('php://input'); $id = $_SERVER['HTTP_WEBHOOK_ID'] ?? ''; $time = $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? ''; $sigs = explode(' ', $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? ''); $expected = 'v1,' . base64_encode(hash_hmac('sha256', "$id.$time.$body", $secret, true)); $valid = abs(time() - (int) $time) < 300 && count(array_filter($sigs, fn ($s) => hash_equals($expected, $s))) > 0; if (!$valid) { http_response_code(401); exit; } $event = json_decode($body, true); // $event['type'], $event['data']['message_id'], $event['data']['status'] … speichern http_response_code(200);
import { createHmac, timingSafeEqual } from 'node:crypto'; // Express: app.post('/heyfox', express.raw({ type: 'application/json' }), handler) function handler(req, res) { const secret = Buffer.from(process.env.HEYFOX_WEBHOOK_SECRET.slice(6), 'base64'); const body = req.body.toString('utf8'); // unverändert, vor JSON.parse const id = req.get('webhook-id'); const time = req.get('webhook-timestamp'); const expected = 'v1,' + createHmac('sha256', secret).update(`${id}.${time}.${body}`).digest('base64'); const valid = Math.abs(Date.now() / 1000 - Number(time)) < 300 && req.get('webhook-signature').split(' ').some((s) => s.length === expected.length && timingSafeEqual(Buffer.from(s), Buffer.from(expected))); if (!valid) return res.sendStatus(401); const event = JSON.parse(body); // event.type, event.data.message_id … res.sendStatus(200); }
Zustellung und Wiederholungen
- Antwortet mit einem Status
2xx, sobald ihr das Ereignis gespeichert habt – innerhalb von 10 Sekunden. Längere Arbeit erledigt danach. Weiterleitungen (3xx) folgen wir nicht. - Klappt es nicht, versuchen wir es erneut: nach 1, 5 und 30 Minuten, dann nach 2, 6, 12 und 24 Stunden (8 Versuche in knapp 2 Tagen). Im Dashboard seht ihr jeden Versuch mit eurer Antwort und könnt einzelne Ereignisse erneut senden.
- Kommt an eine Adresse drei Tage lang nichts an, schalten wir sie ab und schreiben dem Inhaber des Kontos. Wieder einschalten geht im Dashboard.
- Ein Ereignis kann selten doppelt kommen. Verarbeitet jede
webhook-idnur einmal. - Ereignisse und ihr Protokoll bewahren wir 30 Tage auf. Dauerhafte Nachweise speichert ihr bei euch.
Statuswerte
| Empfänger | Bedeutung |
|---|---|
| queued | Bei uns übernommen. |
| accepted | Unser Mailserver hat die Mail eingeplant. |
| deferred | Der Empfänger-Server bittet um einen späteren Versuch. Wir versuchen es 24 Stunden lang weiter, anfangs alle 5 Minuten, später stündlich. |
| delivered | Der Empfänger-Server hat die Mail angenommen. Ob sie im Posteingang oder im Spam liegt, entscheidet sein Anbieter. |
| bounced | Abgelehnt. reason nennt die Art, z. B. InvalidRecipient (Adresse gibt es nicht), QuotaIssues (Postfach voll), SpamBlock; detail die Antwort des Servers. |
| expired | Nach 24 Stunden mit wiederholten Versuchen aufgegeben. |
| complained | Der Empfänger hat die Mail als Spam markiert. |
| suppressed, rejected | Nicht gesendet, siehe Antwort beim Senden. |
Der Gesamtstatus einer Nachricht: delivered (alle zugestellt), sending oder deferred (noch unterwegs), partially_delivered (teils zugestellt, teils nicht) oder failed (an niemanden).
metadata.Idempotency – nie doppelt senden
Netzwerke brechen ab. Damit ein erneuter Versuch keine zweite Mail auslöst, schickt beim Senden einen Kopf Idempotency-Key mit, der für diese eine Mail eindeutig ist – z. B. bestellung-1001-bestaetigung (bis 255 Zeichen).
- Gleicher Schlüssel, gleicher Inhalt innerhalb von 24 Stunden → dieselbe Antwort wie beim ersten Mal, mit Kopf
Idempotent-Replayed: true. Es geht keine zweite Mail raus. - Gleicher Schlüssel, anderer Inhalt →
422 idempotency_key_reused. - Die erste Anfrage läuft noch →
409 idempotency_in_progress. Kurz warten und wiederholen. - Vorübergehende Fehler (
429,5xx) merken wir uns nicht – der Versuch mit demselben Schlüssel geht danach normal durch.
Limits
| Anfragen | je API-Schlüssel kurz bis zu 50 auf einmal, dauerhaft 10 pro Sekunde. Jede Antwort zeigt RateLimit-Limit, RateLimit-Remaining und RateLimit-Reset (Sekunden). Darüber: 429 rate_limited mit Retry-After. |
| Tageslimit | Empfänger je Konto in 24 Stunden, im Dashboard zu sehen. Neue Konten starten klein und wachsen mit sauberen Zahlen. Erreicht: 429 daily_limit_reached bzw. Empfänger mit reason: daily_limit. |
| Größe | Anfrage bis 25 MB, Anhänge zusammen bis 15 MB, 50 Empfänger je Nachricht. |
| Aufbewahrung | Status und Empfänger 30 Tage, Inhalte gar nicht. |
Sperrliste und Bounces
Gibt es eine Adresse nicht (harter Bounce) oder markiert jemand eure Mail als Spam, kommt sie automatisch auf die Sperrliste des Kontos. An gesperrte Adressen senden wir nichts mehr – auch keine Bestellmails, sonst ließen sich Newsletter als Bestellmail tarnen. Eure Software erkennt das am Status suppressed und kann die Person anders informieren.
Einträge entfernen und Adressen von Hand sperren geht im Dashboard unter Sperrliste.
403 account_paused.Fehlercodes
Fehler kommen immer im selben Format, mit festem Code und der Meldung auf Englisch und Deutsch:
{
"error": {
"code": "domain_not_verified",
"message": "The sender domain is not verified for this account. …",
"message_de": "Die Absender-Domain ist für dieses Konto nicht bestätigt. …",
"field": "from",
"detail": "fremde-domain.de",
"docs": "https://api.heyfox.email/#fehler"
}
}
| Code | HTTP | Was tun |
|---|---|---|
unauthorized | 401 | Schlüssel fehlt, ist falsch oder widerrufen. |
invalid_json | 400 | Der Inhalt ist kein gültiges JSON. |
invalid_request | 400 | Ein Feld fehlt oder ist ungültig – field und detail sagen welches. |
unknown_field | 400 | Ein Feld gibt es nicht – field nennt es. |
too_many_recipients | 400 | Mehr als 50 Empfänger – auf mehrere Nachrichten aufteilen. |
unsupported_media_type | 415 | Als application/json senden. |
payload_too_large, attachments_too_large | 413 | Anfrage über 25 MB bzw. Anhänge über 15 MB. |
account_not_active | 403 | Das Konto wartet noch auf unsere Freischaltung. |
account_paused, account_blocked | 403 | Pausiert bzw. gesperrt – den Grund zeigt das Dashboard. |
domain_not_verified | 422 | Absender-Domain im Dashboard hinzufügen und DNS-Prüfung bestehen. |
all_recipients_rejected | 422 | Kein Empfänger angenommen – siehe recipients. |
idempotency_key_reused | 422 | Für eine andere Mail einen neuen Schlüssel verwenden. |
idempotency_in_progress | 409 | Kurz warten, dann wiederholen. |
not_found | 404 | Nachricht oder Pfad gibt es nicht (oder älter als 30 Tage). |
rate_limited, daily_limit_reached | 429 | Nach Retry-After bzw. später erneut versuchen. |
account_initialising, temporarily_unavailable | 503 | Vorübergehend – mit demselben Idempotency-Key wiederholen. |
submission_failed, internal_error | 502, 500 | Wiederholen; bleibt es, mit der HeyFox-Request-Id melden. |
Faustregel: 4xx außer 409 und 429 nicht unverändert wiederholen, 409, 429 und 5xx mit Pause und demselben Idempotency-Key wiederholen.
Lieber SMTP?
Für WordPress (FluentSMTP, WP Mail SMTP, Post SMTP), Shops und CRM-Programme geht es auch ohne Code: smtp.heyfox.email, Port 587, STARTTLS, Benutzername und Passwort eines SMTP-Zugangs aus dem Dashboard. Es gelten dieselben Regeln, die Mails erscheinen im selben Protokoll.