API documentation
Receive inbound email as signed JSON, POSTed to your webhook. Free, transactional only, and we never store your email.
Overview
| Base URL | https://ghostparse.com |
|---|---|
| Format | JSON request and response bodies, UTF-8. Send Content-Type: application/json. |
| Auth | Authorization: Bearer <API key> |
| Receiving | We POST each inbound email to your webhook URL as signed JSON. |
Quick start
- Create a free account and verify your email address.
- In Domains, add your domain (or a subdomain such as
inbound.yourapp.com) and publish the DKIM and MX records shown. Click Verify. - Under that domain's Email addresses, add the addresses that should receive mail, e.g.
support. Mail to any other address is refused. - Turn on Inbound for the domain.
- In Inbound webhook, set your
https://URL and copy the signing secret. - Receive your first email:
// composer require wildye/email-parser
use Wildye\EmailParser\Webhook;
$event = Webhook::fromGlobals(getenv('EMAIL_PARSER_WEBHOOK_SECRET')); // throws if the signature is wrong
if ($event->isEmailReceived()) {
$email = $event->email();
saveTicket($email->from?->address, $email->subject, $email->text);
}
http_response_code(200);
// npm install @wildye/email-parser
import { verifyWebhook } from "@wildye/email-parser";
app.post("/webhooks/email", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = verifyWebhook(req.body, req.headers, process.env.EMAIL_PARSER_WEBHOOK_SECRET);
} catch {
return res.sendStatus(400);
}
if (event.type === "email.received") saveTicket(event.data.thread_id, event.data.from.address, event.data.reply_text);
res.sendStatus(200);
});
Then send an email to support@yourdomain from any mailbox and watch it arrive. See
Webhook payload for every field and Verify signatures
for other languages.
Authentication
Inbound email needs no API key: it arrives at your webhook. The API is for testing your
webhook and for setting up domains automatically, and every request needs an API key in the
Authorization header:
Authorization: Bearer ek_live_…
Create and revoke keys in the dashboard under API keys (developer role or above). Keys are shown
once and stored only as a hash, so we can't show them again. Use one key per environment or service, keep them
server-side, and revoke any key that may have leaked. A missing, wrong or revoked key returns
401.
Each key has permissions, chosen when it's created:
domains (Domains API and webhook settings), integrations
(Zapier, Make & n8n, destinations and routing rules) and, for resellers,
accounts (sub-accounts). Calling an endpoint without its permission returns
403 insufficient_scope.
Set up inbound email
- Add and verify your domain (the DKIM record proves you own it), and publish the MX record shown
in the dashboard. Use a subdomain such as
inbound.yourapp.comif your main domain's mail is handled elsewhere. - Under Email addresses, add each address that should receive mail with Receive on. Mail to
any other address is refused with
550 No such recipient. - Turn on Inbound for the domain.
- In Inbound webhook, set your
https://webhook URL and copy the signing secret.
Each accepted message is parsed and POSTed to your URL as an email.received event. Bulk and mailing-list
mail is refused by default; you can change that on the Inbound webhook page.
Webhook payload
POST https://yourapp.com/webhooks/email
Content-Type: application/json
User-Agent: email-parser-api/0.1
webhook-id: evt_6c1d…
webhook-timestamp: 1790932497
webhook-signature: v1,unqzZ64edPDGcZVcnf3zGnmbc6EQ6DznDhh3jhZXPIE=
{
"id": "evt_6c1d…",
"type": "email.received",
"timestamp": "2026-10-02T09:00:00.000Z",
"created_at": "2026-10-02T09:00:00.000Z",
"tenant_id": "acct_…",
"data": {
"id": "inb_…",
"message_id": "<reply-1@example.com>",
"envelope": {
"mail_from": "jane@example.com",
"rcpt_to": ["support+ticket-42@yourapp.com"],
"client_ip": "198.51.100.7",
"helo": "mail.example.com"
},
"from": { "address": "jane@example.com", "name": "Jane Doe" },
"to": [{ "address": "support+ticket-42@yourapp.com", "name": "" }],
"cc": [],
"reply_to": [],
"subject": "Re: Order #1042",
"date": "2026-10-01T10:00:00.000Z",
"in_reply_to": "<out_2f15…@yourapp.com>",
"references": ["<out_2f15…@yourapp.com>"],
"thread_id": "thr_836de45f552c568b8e3b9810",
"thread": { "id": "thr_836de45f552c568b8e3b9810", "root_message_id": "<out_2f15…@yourapp.com>", "position": 1, "is_reply": true, "method": "references", "tag": null },
"text": "Where is my order?\n\nOn Tue, 29 Sep 2026, Your App <support@yourapp.com> wrote:\n> Your order has shipped",
"reply_text": "Where is my order?",
"html": "<p>Where is my order?</p>…",
"headers": { "subject": "Re: Order #1042", "x-mailer": "…" },
"attachments": [
{
"filename": "photo.jpg",
"content_type": "image/jpeg",
"size": 48213,
"content_id": null,
"disposition": "attachment",
"content": "/9j/4AAQSkZJRg…",
"sha256": "9f2c…"
}
],
"authentication": {
"verdict": "pass",
"spf": { "result": "pass", "domain": "example.com", "client_ip": "198.51.100.7" },
"dkim": [{ "result": "pass", "domain": "example.com", "selector": "s1", "aligned": true }],
"dmarc": { "result": "pass", "policy": "reject", "domain": "example.com" },
"arc": { "result": "none" },
"header": "Authentication-Results: mx.ghostparse.com; …"
},
"auto_submitted": null,
"bounce": null,
"calendar": [],
"recipients": [
{ "address": "support+ticket-42@yourapp.com", "base_address": "support@yourapp.com", "tag": "ticket-42", "tag_verified": null }
],
"spam": { "score": 1.2, "action": "no action", "symbols": ["DKIM_VALID", "…"] },
"virus": { "infected": false, "names": [] },
"extracted": {
"parser_id": "prs_…", "parser": "Orders", "method": "rules", "error": null,
"fields": { "order_number": 1042, "total": 42.5, "tracking": "TRK-88213" }
},
"size": 66104,
"received_at": "2026-10-02T09:00:00.000Z"
}
}
| Field | Notes |
|---|---|
type | email.received, or webhook.test for test events. Ignore types you don't recognise. |
tenant_id | Your account ID. |
data.thread_id, thread | The conversation: the same for the first message and every reply. See below. |
data.message_id | The sender's Message-ID. To de-duplicate deliveries, use the webhook-id header instead: see handling duplicates. |
data.envelope.rcpt_to | The address(es) at your domain this delivery is for, as the sender wrote them (including any +tag). Can differ from the To header, e.g. for BCC. |
data.from, to, cc, reply_to | From the message headers. from may be null. |
data.text, html | Either may be null. |
data.reply_text | Just the new part of a reply: quoted history (On … wrote:, > lines, Outlook header blocks, in several languages) and the signature removed. Best effort; text always has everything. |
data.recipients | Each envelope recipient split up: base_address (the listed address it matched), tag (after the +) and tag_verified for signed reply addresses. |
data.auto_submitted, bounce | auto_reply, bounce, auto_generated or null. See below. |
data.calendar | Meeting invites found in the email. See below. |
data.spam, virus | Scan results when this platform runs spam/virus scanning, else null. See below. |
data.extracted | The fields your parser pulled out, or null when the address has none. |
data.size | Size of the original message in bytes. |
data.headers | All headers, names lower-cased; repeated headers are joined with a newline. |
data.attachments[].content | Base64 (or null, depending on your attachment setting). Inline images have a content_id. sha256 is the file's hash. Filenames come from the sender: sanitise them before saving. |
data.authentication | Our SPF, DKIM and DMARC checks. See below. |
Addresses & routing
Each domain has a list of addresses that receive mail. Anything else is refused at the door with
550 No such recipient, so spam to random addresses never reaches you.
| Entry | Receives |
|---|---|
support | support@, plus sub-addresses like support+ticket-42@ (the tag is in recipients[].tag) |
ticket-* | Any address matching the pattern: ticket-1042@, ticket-abc@… Handy when your app makes an address per order or user. |
* | Catch-all: every address on the domain. Expect more spam. |
Exact entries win over patterns, and longer patterns over shorter ones. Patterns can only receive.
Where mail goes: an address can have its own webhook URL (e.g. invoices@ to your
accounting service), otherwise the domain's, otherwise your account's. Mail to several addresses with different
webhooks is delivered to each separately, each seeing only its own recipients. All webhooks are signed with your
account's secret.
A domain can also have its own size limit (up to 25 MB): bigger messages are refused.
Conversations (thread_id)
Every email arrives with a thread_id that is the same for the first message of a conversation and
every reply to it, so you don't have to rebuild threads from email headers yourself. Store it with your ticket,
order or chat, and look it up when the next email arrives.
"thread_id": "thr_836de45f552c568b8e3b9810",
"thread": {
"id": "thr_836de45f552c568b8e3b9810",
"root_message_id": "<out_2f15…@yourapp.com>", // the conversation's first email
"position": 1, // 0 = first message
"is_reply": true,
"method": "references",
"tag": "t42" // from a verified signed reply address, else null
}
- How it's worked out: from
References(its first entry is the conversation's first email, and mail clients keep it), elseIn-Reply-To, else Outlook'sThread-Index, else the email's ownMessage-ID(it starts a new conversation). Nothing is stored on our side. - Emails you send: the thread_id is
thr_+ the first 24 hex characters of the SHA-256 of the Message-ID without its angle brackets (domain part in lower case). Every SDK has a helper (threadIdFor,thread_id_for,Thread::idFor), so you can save it when you send and match the first reply. - Signed reply addresses: when the email came to a signed reply address,
thread.tagis your tag (for example your ticket ID). That works even when the sender's mail client drops the headers, so use both if you can. - A reply from a client that strips
ReferencesandIn-Reply-Tostarts a new thread_id; it still carries your signed tag if you used a reply address.
Auto-replies & bounces
If your app answers inbound email automatically, it must not answer robots, or two auto-responders email each
other forever. Every email says what it is in data.auto_submitted:
| Value | Means | Detected by |
|---|---|---|
auto_reply | Out-of-office or other automatic reply | Auto-Submitted: auto-replied, X-Autoreply, Precedence: auto_reply, or subjects like "Automatic reply:" / "Out of Office" |
bounce | A delivery failure report about an email sent from your address | A delivery-status report, or an empty envelope sender from MAILER-DAEMON / postmaster |
auto_generated | Other machine-sent mail | Auto-Submitted: auto-generated |
null | Sent by a person, as far as we can tell |
"auto_submitted": "bounce",
"bounce": {
"recipient": "gone@example.com",
"action": "failed",
"status": "5.1.1",
"diagnostic": "550 5.1.1 User unknown",
"original_message_id": "<order-42@yourapp.com>"
}
Use bounce.original_message_id to find the email that failed and status (5.x.x permanent,
4.x.x temporary) to decide whether to stop emailing that address. Or switch on Drop out-of-office replies,
bounces and other automatic mail on the Inbound webhook page: we accept them (so the sender doesn't retry)
but don't deliver them, and the delivery log shows them as dropped.
Calendar invites
Meeting invites, updates, cancellations and RSVPs (iCalendar .ics parts) are read for you:
"calendar": [{
"method": "REQUEST",
"uid": "abc-123@example.com",
"sequence": 2,
"status": "CONFIRMED",
"summary": "Kick-off",
"description": "Agenda…",
"location": "Room 4",
"start": "2026-10-05T09:00:00",
"end": "2026-10-05T10:00:00",
"timezone": "Europe/London",
"all_day": false,
"recurrence": null,
"organizer": { "email": "jane@example.com", "name": "Jane Doe" },
"attendees": [{ "email": "support@yourapp.com", "name": "Support", "status": "NEEDS-ACTION", "role": "REQ-PARTICIPANT" }]
}]
method is REQUEST for a new or updated invite (higher sequence = newer),
CANCEL for a cancellation and REPLY for someone's RSVP. Times ending in Z are UTC;
others are local to timezone; all-day events have just a date. The .ics file is still in
attachments.
Attachments & the raw message
Choose how attachments reach you on the Inbound webhook page:
| Setting | What you receive |
|---|---|
| Inside the JSON (default) | application/json; each attachment's content is base64. |
| As separate files | multipart/form-data: a part named event holding the JSON (attachment content is null and part names its file part), plus one file part per attachment: attachment_0, attachment_1… About 25% smaller, and frameworks hand you the files directly. |
| Leave them out | JSON with names, types, sizes and hashes only (content is null). |
Signatures for multipart: the signature covers the event part's
text instead of the whole body. Check each file against its
sha256 in the event, and the files are covered too. This also works in PHP, where
php://input is empty for multipart requests:
// Webhook::fromGlobals() reads $_POST['event'] and $_FILES for multipart requests.
$event = Webhook::fromGlobals($secret);
foreach ($event->email()->attachments as $a) {
$bytes = $a->content(); // the uploaded file, checked against its signed sha256
}
Include the original message: adds the complete .eml: base64 in data.raw,
or as a file part named raw (with data.raw_part: "raw") in multipart mode. Useful for
archiving or your own parsing. Mind the size: up to 25 MB plus encoding.
Parsers & extracted data
A parser pulls the values you care about out of each email (an order number, a total, a tracking code) and
adds them to the webhook as data.extracted, so your code doesn't have to search the text. Create
parsers on the dashboard's Parsers page and choose one per address (or for the instant address).
"extracted": {
"parser_id": "prs_…",
"parser": "Orders",
"method": "rules",
"fields": { "order_number": 1042, "total": 42.5, "tracking": "TRK-88213", "gift": null },
"error": null
}
Each field has a name (order_number) and a type: text, number, whole number, yes/no, date
(YYYY-MM-DD) or list. A field the email doesn't contain is null.
Rules parsers
| Rule | Example | Gives |
|---|---|---|
| Text after | Order number on "Order number: 1042" | 1042 (the rest of the line, after any : # or -) |
| Text between | Tracking ( and ) | whatever is between them |
| Regular expression | \b(TRK-\d+) | the first group in brackets, else the whole match |
Rules look in the subject, the body, both, or the sender's address, and ignore upper/lower case. They run on our servers in a time-limited sandbox: a rule that takes too long reports an error instead of holding up mail.
Extraction never blocks mail: if it fails (a rule times out) the email is still delivered, with fields empty and the reason in
error. Use Try it on a parser's page to test it on pasted text.
SPF, DKIM & DMARC results
Every inbound message is checked, and the results are in data.authentication:
| Field | Values |
|---|---|
verdict | DMARC result for the From domain: pass, fail, none (no DMARC record), temperror, permerror. The best single answer to "is this really from who it says?" |
spf | { result, domain, client_ip }, or null if the sending IP wasn't available. |
dkim | One entry per signature: { result, domain, selector, aligned }. aligned means the signing domain matches the From domain. |
dmarc | { result, policy, domain }; policy is the sender's published policy (none, quarantine, reject). |
header | The Authentication-Results header we computed. |
By default, mail that fails DMARC when the sender publishes p=reject is refused and never reaches you.
You can turn that off on the Inbound webhook page and decide yourself. Trust data.authentication,
not Authentication-Results headers in data.headers, which come from the sender
(we remove any that claim to be ours).
Verify webhook signatures
Webhooks are signed with Standard Webhooks, so our SDKs and any Standard Webhooks library verify them. Each request has three headers:
webhook-id: evt_6c1d… (the event id) webhook-timestamp: 1790932497 (unix seconds) webhook-signature: v1,unqzZ64edPDGcZVcnf3zGnmbc6EQ6DznDhh3jhZXPIE=
The signature is the base64 HMAC-SHA256 of <webhook-id>.<webhook-timestamp>.<raw body>,
keyed with the base64-decoded part of your signing secret after whsec_ (from the Inbound webhook page).
- Use the raw request body; don't parse and re-encode the JSON first.
- After you rotate the secret, there are two space-separated signatures for 24 hours (new secret first): accept the request if any matches.
- Reject the request if the timestamp is more than 5 minutes from your clock (replay protection).
- Respond
400if verification fails,2xxonce you've stored the event. - Skip events whose
webhook-idyou've already processed: see handling duplicates.
import express from "express";
import { verifyWebhook } from "@wildye/email-parser";
const app = express();
// The raw body: re-serialised JSON won't match the signature.
app.post("/webhooks/email", express.raw({ type: "application/json", limit: "40mb" }), (req, res) => {
let event;
try {
event = verifyWebhook(req.body, req.headers, process.env.EMAIL_PARSER_WEBHOOK_SECRET);
} catch {
return res.sendStatus(400);
}
if (event.type === "email.received") {
// event.data.thread_id, event.data.from, event.data.reply_text …
}
res.sendStatus(200);
});
from email_parser import verify_webhook, WebhookVerificationError
@app.post("/webhooks/email")
def email_webhook():
try:
event = verify_webhook(request.get_data(), request.headers, os.environ["EMAIL_PARSER_WEBHOOK_SECRET"])
except WebhookVerificationError:
return "", 400
if event.type == "email.received":
email = event.email # email.thread_id, email.from_address, email.reply_text …
return "", 200
use Wildye\EmailParser\Webhook;
use Wildye\EmailParser\Exception\SignatureVerificationException;
try {
$event = Webhook::fromGlobals(getenv('EMAIL_PARSER_WEBHOOK_SECRET'));
} catch (SignatureVerificationException $e) {
http_response_code(400);
exit;
}
if ($event->isEmailReceived()) {
$email = $event->email();
// $email->threadId, $email->from->address, $email->replyText, $email->attachments …
}
http_response_code(200);
// composer require wildye/email-parser-laravel
// .env: EMAIL_PARSER_WEBHOOK_SECRET=whsec_… Webhook URL: https://your-app/email-parser/webhook
use Wildye\EmailParser\Laravel\Events\InboundEmailReceived;
Event::listen(function (InboundEmailReceived $event) {
Ticket::firstOrCreate(['thread_id' => $event->email->threadId])
->addReply($event->email->replyText);
});
# gem "wildye-email-parser", require: "email_parser"
def create
event = EmailParser::Webhook.verify(request.raw_post, request.headers, ENV.fetch("EMAIL_PARSER_WEBHOOK_SECRET"))
Ticket.find_or_create_by!(thread_id: event.email.thread_id) if event.email_received?
head :ok
rescue EmailParser::WebhookVerificationError
head :bad_request
end
// go get github.com/dwildman86/emailParser/sdk/go
body, _ := io.ReadAll(r.Body)
event, err := emailparser.VerifyWebhook(body, r.Header, os.Getenv("EMAIL_PARSER_WEBHOOK_SECRET"))
if err != nil {
http.Error(w, "bad signature", http.StatusBadRequest)
return
}
if email, ok := event.Email(); ok {
_ = email.ThreadID // email.From.Address, email.ReplyText …
}
w.WriteHeader(http.StatusOK)
import crypto from "node:crypto";
// raw: the request body as a string; secret: "whsec_…"
function verify(raw, headers, secret) {
const id = headers["webhook-id"], ts = headers["webhook-timestamp"];
const key = Buffer.from(secret.slice("whsec_".length), "base64");
const expected = crypto.createHmac("sha256", key).update(`${id}.${ts}.${raw}`).digest("base64");
const fresh = Math.abs(Date.now() / 1000 - Number(ts)) <= 300;
const valid = (headers["webhook-signature"] ?? "").split(" ").some((entry) => {
const sig = entry.slice("v1,".length);
return entry.startsWith("v1,") && sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
});
return fresh && valid;
}
Handling duplicates (idempotency)
Webhooks are delivered at least once, so occasionally the same event arrives twice: for example, your
endpoint stored it but timed out before replying, or a sending mail server delivered the email again after we
asked it to retry. The webhook-id header (the same as the event's id) is the same every
time the same event is delivered:
- email.received: the same email (byte for byte) to the same recipients and endpoint always has the
same
webhook-id, including when the sending server redelivers it hours later. Itsdata.idis stable too. - email.bounced and email.complained: the same report about the same recipient has the
same
webhook-id. - The signature and
webhook-timestampare fresh on every attempt, so the 5-minute check still blocks replays.
So: verify the signature, then record the webhook-id in the same database transaction as the email,
with a unique constraint. A duplicate fails the insert, and you return 200 without processing it again:
CREATE TABLE webhook_events ( id VARCHAR(64) PRIMARY KEY, -- the webhook-id header received_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- for each verified webhook, in one transaction: INSERT INTO webhook_events (id) VALUES (?); -- duplicate key? already processed: commit nothing, respond 200 INSERT INTO emails (...) VALUES (...); COMMIT;
Keep ids for at least a week (sending servers retry for up to 5 days), then delete old rows.
The original signature format
Accounts created before Standard Webhooks support keep the format their endpoints already verify:
webhook-signature: t=<unix seconds>,v1=<hex>, the hex HMAC-SHA256 of
<t>.<raw body> keyed with the whole secret string. All our SDKs accept both formats, so
switching is safe once you use one. Switch on the Inbound webhook page, or with
PATCH /v1/webhook {"webhook_signature": "standard"}. A secret made before Standard Webhooks
support can't be used for it: rotate the secret first (the dashboard offers to do both together).
Multipart deliveries (attachments as separate files): the signature covers the
event form field instead of the whole body, and each file is covered by the sha256 listed in the
signed event. The SDKs check both.
Delivery & retries
- Success is any
2xxresponse within 10 seconds. Respond quickly and do slow work (virus scanning, AI, notifications) in a background job. - Temporary failures (timeouts, network errors,
408,429,5xx) are retried up to 3 attempts in total with a short backoff. If they all fail, we tell the sending mail server to try again later. Mail servers keep retrying for hours to days, so a brief outage on your side doesn't lose email. - Other
4xxresponses (except 408 and 429) are permanent: the email is bounced back to the sender. Return400only for genuinely bad requests, such as a failed signature check. - Redirects aren't followed. Your URL must be public
https://; private and internal addresses are refused. - Duplicates are possible (e.g. your endpoint stored the email but timed out before replying). Make
your handler idempotent: skip events whose
webhook-idyou've already processed (how). - Backup URL: if you set one, it's tried before the sender is asked to retry. See below.
- Source IP addresses: webhooks come from 77.72.7.69, 2a03:2800:500::52f, if your firewall needs to allow them
(also at
/v1/meta). webhook-idis the eventid. It stays the same across our retries, backup URL attempts and the sending server's later redeliveries of the same email. The signature timestamp is fresh on every attempt.
Backup webhook URL
Set a second URL on the Inbound webhook page (or PATCH /v1/webhook
{"backup_webhook_url": "https://…"}). When your main webhook can't be reached, times out or returns
5xx after its 3 attempts, the same signed request goes to the backup before we ask the
sending server to retry. The email is delivered without waiting hours for a redelivery, and we still store nothing.
- A
4xxfrom the main webhook is its final answer: the backup isn't tried. - The delivery log shows these as delivered with a backup flag and the main webhook's error. A run of them triggers the usual "your webhook is failing" alert, saying mail is going to the backup.
- If both fail, the sender is asked to retry later, as before.
- Point it at a different server or region from the main one, e.g. a queue such as an AWS Lambda URL that only
stores the event for later. Use Test backup URL (or
POST /v1/webhooks/test{"target": "backup"}) to check it.
Delivery log & alerts
The dashboard's Delivery log shows what happened to each inbound email for 90 days: delivered (with your endpoint's status code, attempts and response time), retrying, failed, refused (unknown address, DMARC, sender rule, spam, virus, size) or dropped. It records the sender's domain, your recipient addresses and the Message-ID: never the content, subject or the sender's address. The same data is available over the API:
GET /v1/deliveries?status=rejected&limit=50
GET /v1/deliveries?message_id=%3Creply-1%40example.com%3E
GET /v1/deliveries?before=<next_before from the previous page>
{ "deliveries": [ { "id": 812, "status": "deferred", "reason": "Webhook responded 503", "recipients": ["support@yourapp.com"],
"sender_domain": "example.com", "message_id": "<reply-1@example.com>", "http_status": 503, "attempts": 3,
"duration_ms": 3120, "spam_score": 1.2, "flags": { "dmarc": "pass" }, "created_at": "…", … } ],
"next_before": 812 }
Alerts: account owners and admins get an email when the webhook has failed 3 times in a row (once per outage), another when it recovers, and one if the regular DNS check finds a domain's records gone. Turn them off on the Inbound webhook page.
Sender rules, spam & viruses
Sender rules (per domain, in the dashboard or the API):
a block rule refuses mail from an address (jane@example.com), a domain (example.com)
or its subdomains (*.example.com). Add any allow rule and only matching senders are accepted, which
suits an address that should only hear from one supplier or system. Because a From address is easy to fake, allowed
senders must also pass authentication: DMARC, or (for domains without DMARC) DKIM or SPF for their own domain.
Spam and viruses (when this platform has scanning switched on): every email gets
data.spam (an rspamd score; around 6 is probably spam, 15 definitely) and data.virus
(ClamAV). Emails with a virus are refused by default. Set a spam score on the Inbound webhook page to refuse anything
above it; otherwise you get everything with its score and can decide yourself. If a scanner is unavailable, mail is
still delivered, with that field null.
Refused mail gets a permanent SMTP error, so the sender knows it didn't arrive; it never reaches your webhook and shows in the delivery log with the reason.
Test your webhook
POST /v1/webhooks/test
Sends a signed webhook.test event to your configured webhook URL, once, and reports what happened.
There's also a Send test event button on the Inbound webhook page.
curl -X POST https://ghostparse.com/v1/webhooks/test -H "Authorization: Bearer $EMAIL_PARSER_API_KEY"
{ "delivered": true, "event_id": "evt_…" } // 200
{ "delivered": false, "event_id": "evt_…", "error": "Webhook responded 500" } // 502
The test event's data is { "message": "Test event from GhostParse" }.
Sample emails
See exactly what your webhook will receive without writing an email: Send a sample email on the
Domains or Inbound webhook page, email-parser sample, or:
POST /v1/samples
curl -X POST https://ghostparse.com/v1/samples -H "Authorization: Bearer $EMAIL_PARSER_API_KEY" \
-H "Content-Type: application/json" -d '{ "to": "support@yourapp.com" }'
{ "to": "support@yourapp.com", "delivered": true }
A realistic customer reply (with quoted history, an attachment and an order number) goes through the real
pipeline: parsing, checks, your address's parser, signing and delivery, and appears in the delivery log. Without
to it goes to your instant address, else your first receiving address. It carries the header
X-Email-Parser-Sample: true (in data.headers) so your code can tell. 10 per minute.
CLI: email on localhost
Develop your webhook handler on your laptop without deploying it or opening a tunnel:
export EMAIL_PARSER_API_KEY=ek_live_… export EMAIL_PARSER_URL=https://ghostparse.com npx @wildye/email-parser-cli listen --forward http://localhost:3000/webhooks/email Ready. No webhook URL is set, so your mail comes here. Send mail to k3j9x2m4q8a7@in.example.com, or run email-parser sample in another terminal. 10:42:07 200 jane@example.com → support@yourapp.com Re: Order #1042 38 ms
Each email is POSTed to your local URL exactly as a webhook would be, signature included, so your verification code runs unchanged.
- No webhook URL on the account: the CLI is where mail goes. Your local endpoint's status is
passed back: a
2xxaccepts the email, an error makes the sender retry later, just like a webhook. - A webhook URL is set: the CLI gets a copy of every email; your webhook still decides.
| Command | Does |
|---|---|
email-parser listen --forward URL | Stream inbound email to a local URL (without --forward, just print it; --print shows the JSON) |
email-parser sample [--to ADDRESS] | Send a sample email |
email-parser inbox | Print your instant address |
email-parser deliveries [--status S] [--follow] | Show (and watch) the delivery log |
email-parser reply-address ADDRESS TAG | Make a signed reply address |
Needs Node.js 18+; no other dependencies. The CLI uses GET /v1/listen (a Server-Sent Events
stream) and POST /v1/listen/ack; any API key works. Connecting is recorded in the audit log.
Integrations: Zapier, Make, n8n, Slack, Teams, Sheets
Besides your webhook, email can go to destinations: a Slack or Microsoft Teams channel, a Google Sheet, more webhooks, or a Zap, Make scenario or n8n workflow. Set them up on the dashboard's Integrations page (or with the API), and choose which email goes where with routing rules.
- A destination can get every email (optionally only email to addresses matching a pattern), or only what routing rules send it.
- With no webhook URL at all, email still flows as long as the account has a destination.
- Destinations get the email the moment it arrives. Nothing is stored, and the same no-storage promise applies.
Zapier, Make and n8n connect with an API key that has the Zapier, Make & n8n permission
(integrations). Create one under API keys in the dashboard. Each automation you turn on appears on
the Integrations page; turning it off removes it.
Zapier
- In Zapier, make a Zap whose trigger is GhostParse → New Email.
- Connect your account: paste the API key. Leave Server as
https://ghostparse.com. - Optionally set Only email sent to (for example
invoices@yourcompany.comor*@billing.yourcompany.com). - Test trigger loads a sample email, so you can map fields straight away: subject, from address, text, reply text (quoted history removed), attachment names, and your parser's extracted fields.
Make
Add the GhostParse → Watch emails module at the start of a scenario, create a connection
with your API key, and optionally set Only email sent to. Make registers its webhook with us when the
scenario is created, so you never copy a URL. Every field of the email can be
mapped, including extracted.fields.
n8n
Install the community node n8n-nodes-ghostparse (Settings → Community nodes), add a
GhostParse Trigger, and create its credential with your API key. Activating the workflow
registers it with us, and deactivating it removes it. If you also paste your whsec_… signing secret into the
credential, the node rejects any request that isn't signed by us.
Any other platform: REST hooks
The apps above use a small subscription API that any automation tool can use (key with the
integrations permission):
| Endpoint | Does |
|---|---|
POST /v1/hooks | Subscribe a URL to email.received: { "target_url", "source": "zapier"|"make"|"n8n"|"webhook", "filter": { "address": "invoices@*" } }. Returns 201 with the hook's id. |
GET /v1/hooks | List your subscriptions |
DELETE /v1/hooks/{id} | Unsubscribe |
GET /v1/hooks/sample | A realistic email.received event in an array, for field mapping |
GET /v1/me | The account and key (any key): use it to test a connection |
Each email is POSTed to the hook as JSON, signed like your webhook (verify it the same
way). Attachments are included as base64 up to 5 MB in total. Above that, content is null
and the name, type and size are still given. Answer 410 Gone and the hook is removed. Any other
failure is shown on the Integrations page and in the delivery log.
Slack
- At api.slack.com/apps, create an app From scratch, open Incoming Webhooks and switch it on.
- Choose Add New Webhook to Workspace, pick the channel and copy the URL
(
https://hooks.slack.com/services/…). - On our Integrations page, add a Slack destination with that URL.
Each email is posted with its subject, sender, text (untick Include the message text to leave it out), your parser's extracted fields, and the attachment names. The URL is stored encrypted and never shown again.
Microsoft Teams
- In the Teams channel, open ⋯ → Workflows and choose Post to a channel when a webhook request is received.
- Finish the steps and copy the workflow's URL.
- Add a Microsoft Teams destination with it.
Emails arrive as an Adaptive Card. Older Office 365 connector URLs (….webhook.office.com) work as well.
Google Sheets
Each email adds one row with the columns you choose, in order: received_at, from,
from_name, to, cc, subject, text,
reply_text, attachments, attachment_count, spam_score,
thread_id, message_id, email_id, extracted (all fields as
JSON), or extracted.<field> for one parser field. Values are always written as plain text,
so an email can't slip a formula into your sheet.
Connect with a small Apps Script in your sheet. It adds the rows itself, so we never get access to your Google account:
- In the sheet, open Extensions → Apps Script, replace the code with the script below and save.
- Deploy → New deployment → Web app, set Execute as: Me and Who has access: Anyone,
deploy, and copy the URL that ends in
/exec. - Add a Google Sheets destination, choose Apps Script web app and paste the URL.
function doPost(e) {
var body = JSON.parse(e.postData.contents);
var book = SpreadsheetApp.getActiveSpreadsheet();
var sheet = (body.sheet && book.getSheetByName(body.sheet)) || book.getSheets()[0];
if (sheet.getLastRow() === 0) sheet.appendRow(body.columns);
sheet.appendRow(body.row);
return ContentService.createTextOutput("ok");
}
Anyone who knows the /exec URL can add rows, so keep it private, as you would a password.
Routing rules
Send email to different places depending on its subject, sender, recipient, text, attachments or spam score. Rules are checked from the top of the list:
| Action | What happens |
|---|---|
route | Send only to the rule's targets (destinations and/or "webhook", the address's usual webhook). Stop. |
copy | Also send to the rule's destinations, and carry on down the list. |
drop | Accept the email but deliver it nowhere (logged as dropped). Stop. |
Email that no route or drop rule matches goes to your webhook and to every destination set to
get every email. A rule can apply to one domain (domain_id) or to all of them, and matches when
all (or any) of its conditions are true.
field | op |
|---|---|
subject, from, from_domain, from_name, to (your address it was sent to), cc, body, attachment_name, header (with header: the name) |
contains, not_contains, equals, not_equals, starts_with, ends_with, matches, not_matches. Case doesn't matter; matches takes a pattern where * is any text and ? one character. |
spam_score | gte, lt (a number) |
has_attachments | is_true, is_false |
POST /v1/rules
{
"name": "Invoices to the finance sheet",
"conditions": [
{ "field": "from_domain", "op": "ends_with", "value": "stripe.com" },
{ "field": "subject", "op": "contains", "value": "invoice" }
],
"action": "route",
"targets": ["dst_9f2k…", "webhook"]
}
Delivery: the targets an email is routed to must all take it. If one is temporarily down, the
sender is asked to retry, just as when your webhook is down, so a target that already took it may see it again
(de-duplicate on the webhook-id header, or the event id in the body). Copies are best effort: a failing copy never holds up the email,
but it shows on the Integrations page and in the delivery log. If a rule routes to destinations that are all switched
off, the email waits (the sender retries) instead of being lost.
Rules run before your plan's allowance is checked, so dropped email isn't counted.
Destinations & rules API
Everything on the Integrations and Routing rules pages is also available through the API (key with the
integrations permission):
| Endpoint | Does |
|---|---|
GET / POST /v1/destinations | List or add a destination: { "type": "slack"|"teams"|"google_sheets"|"webhook", "name", "url", "all_mail", "filter", "include_body" }; for Sheets, mode, spreadsheet, sheet, columns |
GET / PATCH / DELETE /v1/destinations/{id} | Read, change (e.g. { "enabled": false }) or delete one. URLs are never returned, only a summary. |
POST /v1/destinations/{id}/test | Send it the sample email |
GET / POST /v1/rules | List rules in order, or add one (position puts it at that place, 1 = first) |
GET / PATCH / DELETE /v1/rules/{id} | Read, change (including position) or delete a rule |
POST /v1/rules/test | Where would this email go? { "subject", "from", "to", "body" } → { "action", "targets", "copies", "matched" }. Nothing is sent. |
Deleting a destination removes it from the rules that send to it. A route rule left with no targets is switched off.
Domains API: overview & permissions
Everything you can do on the dashboard's Domains pages is also available through the API, so a web hosting company, agency or SaaS platform can set up email for its own customers automatically: add the customer's domain and the addresses that receive mail, publish the DNS records we return, verify, and route the customer's inbound mail to its own webhook.
domains). Tick it when creating the key under API keys.
| Endpoint | Does |
|---|---|
GET /v1/domains | List domains (filter with ?reference= or ?name=) |
POST /v1/domains | Add a domain, optionally with its addresses and settings |
GET /v1/domains/{id} | Get one domain, including DNS records and their status |
PATCH /v1/domains/{id} | Turn inbound on/off, set its webhook URL or reference |
POST /v1/domains/{id}/verify | Check DNS now (30 per minute per key) |
DELETE /v1/domains/{id} | Remove the domain and its addresses; its mail is refused immediately |
POST /v1/domains/{id}/addresses | Add an email address |
PATCH /v1/domains/{id}/addresses/{addressId} | Change its Receive permission, webhook or signed-tag requirement |
DELETE /v1/domains/{id}/addresses/{addressId} | Remove an email address |
GET / POST /v1/domains/{id}/sender-rules | List or add sender rules |
DELETE /v1/domains/{id}/sender-rules/{ruleId} | Remove a sender rule |
Every change is recorded in your audit log against the API key that made it. Accounts can have up to 20 domains.
Create a domain
POST /v1/domains
curl https://ghostparse.com/v1/domains \
-H "Authorization: Bearer $EMAIL_PARSER_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "customer-shop.com",
"reference": "hosting-customer-1001",
"inbound_enabled": true,
"webhook_url": "https://customer-shop.com/webhooks/email",
"addresses": [
{ "local_part": "info" },
{ "local_part": "support" }
]
}'
| Field | Type | Notes |
|---|---|---|
name required | string | The domain, e.g. customer-shop.com or a subdomain like mail.customer-shop.com. |
reference | string or null | Your own ID for this domain (e.g. your customer number), up to 255 characters. Filter by it with GET /v1/domains?reference=…. |
inbound_enabled | boolean | Receive mail for this domain. Default false. |
max_message_bytes | integer or null | Largest email accepted for this domain, from 1024 up to the platform limit (25 MB). null = the platform limit. |
webhook_url | string or null | Where this domain's inbound mail is POSTed. Overrides the account's webhook URL; leave empty to use the account's. Must be public https://. Webhooks are always signed with your account's signing secret. |
addresses | array | Email addresses to allow, each { local_part }, optionally with webhook_url and require_signed_tag. local_part may be a pattern like ticket-* or *. local_part is the part before the @. |
Returns 201 with the domain object, including the records to
publish. The domain and its addresses are created together: if anything is invalid, nothing is created.
Publish DNS and verify
Each domain gets its own DKIM key. Publish every record in the domain's records array at the
customer's DNS (if you host their DNS, do it automatically), then:
POST /v1/domains/{id}/verify
{
"domain": { "id": "dom_…", "inbound_ready": false, … },
"checks": {
"dkim": { "ok": true, "found": ["v=DKIM1; k=rsa; p=MIIBIjAN…"] },
"mx": { "ok": false, "found": [], "error": "No record found" }
}
}
DNS changes can take a few minutes to propagate. Retry verification with a backoff (e.g. after 1, 5 and 15 minutes) rather than in a tight loop. Verified domains are also re-checked automatically every few hours. Only the DKIM record proves ownership, and a domain can only be verified on one account at a time.
List, update and delete domains
GET /v1/domains → { "domains": [ … ] }
GET /v1/domains?reference=hosting-customer-1001
GET /v1/domains?name=customer-shop.com
GET /v1/domains/{id} → domain object
PATCH /v1/domains/{id} → domain object
DELETE /v1/domains/{id} → 204 No Content
PATCH accepts any of inbound_enabled, webhook_url (set null to
fall back to the account's) and reference:
{ "inbound_enabled": false }
When a hosting customer leaves, DELETE the domain. Their addresses are removed with it, and mail to
the domain is refused from then on.
Manage email addresses
POST /v1/domains/{id}/addresses { "local_part": "billing" }
PATCH /v1/domains/{id}/addresses/{addressId} { "can_receive": false }
DELETE /v1/domains/{id}/addresses/{addressId} → 204 No Content
POST returns 201 and PATCH returns 200, each with the address:
{
"id": "adr_…",
"address": "billing@customer-shop.com",
"local_part": "billing",
"can_receive": true,
"created_at": "2026-10-02T09:00:00.000Z"
}
Local parts are lower-cased and may contain letters, digits and . _ - (and the other characters
allowed in email addresses), but not +: sub-addresses such as billing+invoices@ match their
base address automatically. Up to 100 addresses per domain.
Addresses also take parser_id (a parser from the dashboard; null
for none), webhook_url (send this address's mail to its own endpoint; null to
use the domain's or account's) and require_signed_tag (accept only signed reply
addresses). A local_part containing * is a pattern; responses
say so with is_pattern. Patterns can only receive (422 pattern_cannot_send).
Sender rules
GET /v1/domains/{id}/sender-rules → { "sender_rules": [ { "id": "rul_…", "action": "block", "pattern": "spammer.example", "created_at": "…" } ] }
POST /v1/domains/{id}/sender-rules { "action": "allow", "pattern": "*.supplier.example" } → 201 with the rule
DELETE /v1/domains/{id}/sender-rules/{ruleId} → 204 No Content
action is block or allow; pattern is an address, a domain, or
*. plus a domain for its subdomains (lower-cased). Up to 200 rules per domain. How they're applied:
Sender rules, spam & viruses. The domain object lists them in sender_rules.
The domain object
{
"id": "dom_…",
"name": "customer-shop.com",
"reference": "hosting-customer-1001",
"created_at": "2026-10-02T09:00:00.000Z",
"last_checked_at": "2026-10-02T09:05:00.000Z",
"inbound_enabled": true,
"webhook_url": "https://customer-shop.com/webhooks/email",
"max_message_bytes": null,
"inbound_ready": true,
"records": [
{ "kind": "dkim", "type": "TXT", "host": "ep1a2b3c._domainkey.customer-shop.com", "value": "v=DKIM1; k=rsa; p=MIIBIjAN…", "verified": true, "purpose": "…", "note": "…" },
{ "kind": "mx", "type": "MX", "host": "customer-shop.com", "value": "mx.ghostparse.com", "priority": 10, "verified": true, "purpose": "…", "note": "…" }
],
"addresses": [
{ "id": "adr_…", "address": "info@customer-shop.com", "local_part": "info", "is_pattern": false, "can_receive": true,
"webhook_url": null, "require_signed_tag": false, "parser_id": null, "created_at": "…" }
],
"sender_rules": []
}
| Field | Notes |
|---|---|
records | What to publish. host is the full name; some DNS providers want only the part before the domain. verified is the latest check's result. Just two: DKIM (proves ownership) and MX (routes mail to us). |
inbound_ready | DKIM and MX verified, inbound on, a webhook URL (domain's or account's) set, and at least one address can receive. |
Resellers: sub-accounts
Hosting companies and platforms can run their customers as sub-accounts: each has its own domains,
webhook, signing secret, delivery log and team, while you create and manage them all through one API key. The
platform operator turns reseller features on for your account; then a Reseller page appears in the
dashboard and you can create keys with the Manage sub-accounts permission (scope accounts).
| Endpoint | Does |
|---|---|
GET /v1/accounts | List sub-accounts (filter with ?reference=), with domain, user and delivery counts |
POST /v1/accounts | Create one: name, optional reference (your ID), webhook_url and owner_email (sends a branded invitation to the dashboard). Returns its webhook_secret once. |
GET / PATCH / DELETE /v1/accounts/{id} | Read, rename, suspend ({"suspended": true}) or delete with everything in it |
POST /v1/accounts/{id}/invitations | Invite someone to its dashboard |
Acting on a sub-account: send the Email-Parser-Account header with any other endpoint, and
the request applies to that sub-account with your key's other permissions:
curl -X POST https://ghostparse.com/v1/accounts \
-H "Authorization: Bearer $RESELLER_KEY" -H "Content-Type: application/json" \
-d '{ "name": "Bob'\''s Bakery", "reference": "cust-77", "webhook_url": "https://bobsbakery.example/webhooks/email" }'
# → { "id": "acct_mb_kwrlaq_jhk2AQ", "webhook_secret": "whsec_…", … }
curl -X POST https://ghostparse.com/v1/domains \
-H "Authorization: Bearer $RESELLER_KEY" -H "Email-Parser-Account: acct_mb_kwrlaq_jhk2AQ" \
-H "Content-Type: application/json" \
-d '{ "name": "bobsbakery.example", "inbound_enabled": true, "addresses": [{ "local_part": "orders" }] }'
- Everything a sub-account's own key could do works this way: domains,
/v1/webhooksettings and secret rotation, samples, the delivery log, reply addresses and the CLI listener. - Changes are audit-logged in the sub-account, naming your key.
- Suspended sub-accounts have their mail refused, their users signed out and their own API keys blocked; you can still manage them and unsuspend.
- Up to 500 sub-accounts per reseller; each has the normal per-account limits.
White-label branding
On the Reseller page, set your brand name, logo, colour and support address. Your sub-accounts' users then see your brand, not ours: in the dashboard, and in their account emails (invitations, password resets, alerts), which come from your name with replies going to your support address.
- Your own MX hostname, e.g.
mx.hostco.example: point it atmx.ghostparse.comwith a CNAME (or the same A/AAAA records) and click Check hostnames. Your customers' DNS instructions then use your hostname. Records pointing at ours keep working. - Your own dashboard hostname, e.g.
mail.hostco.example: point it atghostparse.comthe same way. Your customers log in there and see only your brand; sign-up is off on that hostname, because you create their accounts. The platform operator adds the HTTPS certificate for it.
Errors
Errors return a JSON body with a machine-readable error and a human-readable message:
{ "error": "domain_exists", "message": "customer-shop.com is already on your account" }
| Status | error | Meaning | Retry? |
|---|---|---|---|
| 401 | Unauthorized | Missing, wrong or revoked API key. | No |
| 403 | outbound_disabled | Sending email isn't available on this platform: it's for receiving email only. | No |
| 403 | insufficient_scope | The API key lacks the permission this endpoint needs (domains, integrations or accounts). | No |
| 404 | not_found | The domain or address doesn't exist on your account. | No |
| 409 | domain_exists / address_exists | Already on your account. | No |
| 422 | invalid_domain, invalid_address, wrong_domain, duplicate_address, no_permissions, invalid_reference, invalid_webhook_url | Domains API input problems; message says exactly what's wrong. | No |
| 422 | too_many_domains / too_many_addresses / too_many_rules | Account or domain limit reached. | No |
| 422 | pattern_cannot_send, invalid_max_message_bytes, invalid_pattern | An address pattern with Send on, a size cap out of range, or a sender rule that isn't an address or domain. | No |
| 409 | rule_exists | That sender rule is already on the domain. | No |
| 422 | invalid_tag, address_not_receiving | Reply addresses: the tag has characters other than letters, digits, - and _, or the address isn't a receiving address on your domain. | No |
| 422 | invalid_status | Delivery log: unknown status filter. | No |
| 422 | no_receiving_address, not_receiving | Sample emails: nothing on the account can receive mail yet, or to isn't one of your receiving addresses (or no webhook URL or CLI listener is set). | After fixing the setup |
| 404 | parser_not_found | parser_id isn't one of your parsers. | No |
| 422 | invalid_destination, invalid_rule | Destinations and routing rules: message says what's wrong (for example a URL that isn't a Slack webhook, or a spreadsheet not shared with us). | No |
| 404 | destination_not_found, hook_not_found, rule_not_found | Not on your account. | No |
| 422 | too_many_destinations, too_many_routing_rules | An account can have 50 destinations (hooks included) and 100 routing rules. | No |
| 403 | not_reseller | Reseller features aren't on for this account, so it can't use /v1/accounts or the accounts permission. | No |
| 403 | account_suspended | The account is suspended (by its reseller). | No |
| 404 | account_not_found | Not one of your sub-accounts (an Email-Parser-Account header or /v1/accounts/{id}). | No |
| 409 | secret_incompatible | Switching to Standard Webhooks with a secret made before it was supported: rotate the secret first. | After rotating |
| 422 | too_many_accounts | The reseller's sub-account limit is reached. | No |
| 422 | plan_limit | Your plan's limit on domains or team members is reached. Upgrade on the Billing page. | After upgrading |
| 422 | validation_error | The body is invalid. issues lists each problem with its path. | No |
| 429 | Too Many Requests | More than 120 requests per minute for this key. Wait the number of seconds in the Retry-After header. | Yes |
Limits
The service is free; these fair-use limits keep it that way.
| Limit | Value |
|---|---|
| API requests per key | 120 per minute |
| Message size | 25 MB |
| Domains per account | 20 |
| Domain verification checks | 30 per minute per key |
| Email addresses per domain | 100 (patterns count as one) |
| Sender rules per domain | 200 |
| Delivery log | Kept 90 days |
| Parsers | 20 per account, 30 fields each |
| Sample emails | 10 per minute |
| Active API keys | 10 |
| Team members | 25 |
| Sub-accounts per reseller | 500 |
| Inbound email | No volume limit for your listed addresses (transactional use) |
Plans & usage
| Free | Starter | Pro | Business | |
|---|---|---|---|---|
| Price | £0 | £9/month | £29/month | £99/month |
| Inbound emails a month | 1,000 | 10,000 | 50,000 | 250,000 |
| AI extractions a month | 50 | 500 | 2,500 | 10,000 |
| Domains | 1 + instant address | 5 | 25 | Unlimited |
| Team members | 2 | 5 | 15 | Unlimited |
| Delivery log | 3 days | 30 days | 90 days | 90 days |
| Extra emails, per 1,000 | Held back | £1 | £0.80 | £0.50 |
| Reseller sub-accounts | – | – | – | ✓ |
- What counts: each email delivered to your webhook (or CLI listener). Refused, dropped and deferred emails don't, and nor do test events.
- At the limit: paid plans keep delivering and bill extra emails on the next invoice (set a
spending cap on the Billing page if you want one). Free accounts get 10% extra, then new email is
deferred: SMTP senders get
451(HTTP relays503), so they retry, usually for several days. Upgrading in that time delivers it. The delivery log shows these as deferred withflags.code: "quota_exceeded"(or"spend_cap"). Owners and admins get emails at 80% and 100%. - Periods: Free counts calendar months (UTC); paid plans follow their billing period. Yearly plans pool 12 months' allowance over the year.
- Resellers: sub-accounts' emails count towards the reseller's plan, and they use its limits.
GET /v1/usage
{
"plan": "pro", "plan_name": "Pro", "interval": "month",
"period_start": "2026-10-01T09:00:00.000Z", "period_end": "2026-11-01T09:00:00.000Z",
"emails": { "used": 51200, "included": 50000, "overage": 1200 },
"ai": { "used": 830, "included": 2500 },
"overage_pence_per_1000": 80, "overage_cost_pence": 96
}
Any API key can read its account's usage. Plans are changed on the dashboard's Billing page.
Status, IP addresses & data
- Status page: /status shows receiving (SMTP), webhook delivery and the
API, each checked every minute, with 90 days of uptime and any incidents. Machine-readable at
/status.json. - IP addresses: webhooks come from 77.72.7.69, 2a03:2800:500::52f.
GET /v1/meta(no key needed) lists the addresses webhooks come from, our MX hostname and where email is processed, for firewalls and compliance checks. - Data residency: email is processed on servers in the United Kingdom. It isn't stored: only delivery metadata is kept, for 90 days.
- Data Processing Agreement: our DPA (UK GDPR and EU GDPR Article 28) is part of the Terms, with the list of sub-processors. Nothing to sign.
Libraries & OpenAPI
Every SDK verifies webhooks (both signature formats, JSON and multipart), gives you the typed email, has a
thread_id helper, and wraps the API, including reseller sub-accounts.
| Language | Install |
|---|---|
| Node.js / TypeScript | npm install @wildye/email-parser |
| Python | pip install wildye-email-parser |
| PHP | composer require wildye/email-parser |
| Laravel | composer require wildye/email-parser-laravel: a webhook route that dispatches InboundEmailReceived events |
| Ruby | gem "wildye-email-parser" |
| Go | go get github.com/dwildman86/emailParser/sdk/go |
| CLI | npx @wildye/email-parser-cli: stream email to localhost, send samples, watch the delivery log (details) |
Any Standard Webhooks library also verifies our webhooks.
OpenAPI 3.1: /openapi.json. Import it into Postman or Insomnia,
or generate a client for your language.
Questions? Contact hello@wildye.com.