API documentation

Receive inbound email as signed JSON, POSTed to your webhook. Free, transactional only, and we never store your email.

Overview

Base URLhttps://ghostparse.com
FormatJSON request and response bodies, UTF-8. Send Content-Type: application/json.
AuthAuthorization: Bearer <API key>
ReceivingWe POST each inbound email to your webhook URL as signed JSON.
Transactional email only. Replies, support requests, notifications, documents emailed into your app. Newsletters, promotions, bulk or unsolicited email break the Terms and get accounts closed.
We don't store email. Your webhook receives each inbound email once it's accepted. Save what you need on your side.

Quick start

  1. Create a free account and verify your email address.
  2. In Domains, add your domain (or a subdomain such as inbound.yourapp.com) and publish the DKIM and MX records shown. Click Verify.
  3. Under that domain's Email addresses, add the addresses that should receive mail, e.g. support. Mail to any other address is refused.
  4. Turn on Inbound for the domain.
  5. In Inbound webhook, set your https:// URL and copy the signing secret.
  6. 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);

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

  1. 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.com if your main domain's mail is handled elsewhere.
  2. 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.
  3. Turn on Inbound for the domain.
  4. 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"
  }
}
FieldNotes
typeemail.received, or webhook.test for test events. Ignore types you don't recognise.
tenant_idYour account ID.
data.thread_id, threadThe conversation: the same for the first message and every reply. See below.
data.message_idThe sender's Message-ID. To de-duplicate deliveries, use the webhook-id header instead: see handling duplicates.
data.envelope.rcpt_toThe 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_toFrom the message headers. from may be null.
data.text, htmlEither may be null.
data.reply_textJust 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.recipientsEach envelope recipient split up: base_address (the listed address it matched), tag (after the +) and tag_verified for signed reply addresses.
data.auto_submitted, bounceauto_reply, bounce, auto_generated or null. See below.
data.calendarMeeting invites found in the email. See below.
data.spam, virusScan results when this platform runs spam/virus scanning, else null. See below.
data.extractedThe fields your parser pulled out, or null when the address has none.
data.sizeSize of the original message in bytes.
data.headersAll headers, names lower-cased; repeated headers are joined with a newline.
data.attachments[].contentBase64 (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.authenticationOur 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.

EntryReceives
supportsupport@, 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.

Signed reply addresses

When your app emails someone about a ticket or thread, set its Reply-To to an address that says which one, and that nobody else can make up:

POST /v1/reply-addresses

curl https://ghostparse.com/v1/reply-addresses \
  -H "Authorization: Bearer $EMAIL_PARSER_API_KEY" -H "Content-Type: application/json" \
  -d '{ "address": "support@yourapp.com", "tag": "t42" }'

{ "address": "support+t42.k3j9x2m4q8a7b@yourapp.com", "base_address": "support@yourapp.com", "tag": "t42" }

The tag is your own ID (1-40 letters, digits, - and _; lower-cased). Replies arrive with recipients[0].tag = "t42" and tag_verified: true. A tampered or guessed signature gives tag_verified: false; a plain support+anything@ gives null.

Tick Signed tags only on an address (or set require_signed_tag through the API) to refuse everything except valid signed addresses: then only people you've emailed can write to it. Make the address once when you send, store it if you like; it never expires. Any API key can call this endpoint.

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), else In-Reply-To, else Outlook's Thread-Index, else the email's own Message-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.tag is 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 References and In-Reply-To starts 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:

ValueMeansDetected by
auto_replyOut-of-office or other automatic replyAuto-Submitted: auto-replied, X-Autoreply, Precedence: auto_reply, or subjects like "Automatic reply:" / "Out of Office"
bounceA delivery failure report about an email sent from your addressA delivery-status report, or an empty envelope sender from MAILER-DAEMON / postmaster
auto_generatedOther machine-sent mailAuto-Submitted: auto-generated
nullSent 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:

SettingWhat you receive
Inside the JSON (default)application/json; each attachment's content is base64.
As separate filesmultipart/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 outJSON 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

RuleExampleGives
Text afterOrder number on "Order number: 1042"1042 (the rest of the line, after any : # or -)
Text betweenTracking ( 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:

FieldValues
verdictDMARC 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.
dkimOne 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).
headerThe 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).

  1. Use the raw request body; don't parse and re-encode the JSON first.
  2. After you rotate the secret, there are two space-separated signatures for 24 hours (new secret first): accept the request if any matches.
  3. Reject the request if the timestamp is more than 5 minutes from your clock (replay protection).
  4. Respond 400 if verification fails, 2xx once you've stored the event.
  5. Skip events whose webhook-id you'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);
});

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. Its data.id is stable too.
  • email.bounced and email.complained: the same report about the same recipient has the same webhook-id.
  • The signature and webhook-timestamp are 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 2xx response 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 4xx responses (except 408 and 429) are permanent: the email is bounced back to the sender. Return 400 only 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-id you'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-id is the event id. 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 4xx from 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 2xx accepts 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.
CommandDoes
email-parser listen --forward URLStream 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 inboxPrint your instant address
email-parser deliveries [--status S] [--follow]Show (and watch) the delivery log
email-parser reply-address ADDRESS TAGMake 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

  1. In Zapier, make a Zap whose trigger is GhostParse → New Email.
  2. Connect your account: paste the API key. Leave Server as https://ghostparse.com.
  3. Optionally set Only email sent to (for example invoices@yourcompany.com or *@billing.yourcompany.com).
  4. 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):

EndpointDoes
POST /v1/hooksSubscribe a URL to email.received: { "target_url", "source": "zapier"|"make"|"n8n"|"webhook", "filter": { "address": "invoices@*" } }. Returns 201 with the hook's id.
GET /v1/hooksList your subscriptions
DELETE /v1/hooks/{id}Unsubscribe
GET /v1/hooks/sampleA realistic email.received event in an array, for field mapping
GET /v1/meThe 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

  1. At api.slack.com/apps, create an app From scratch, open Incoming Webhooks and switch it on.
  2. Choose Add New Webhook to Workspace, pick the channel and copy the URL (https://hooks.slack.com/services/…).
  3. 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

  1. In the Teams channel, open ⋯ → Workflows and choose Post to a channel when a webhook request is received.
  2. Finish the steps and copy the workflow's URL.
  3. 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:

  1. In the sheet, open Extensions → Apps Script, replace the code with the script below and save.
  2. Deploy → New deployment → Web app, set Execute as: Me and Who has access: Anyone, deploy, and copy the URL that ends in /exec.
  3. 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:

ActionWhat happens
routeSend only to the rule's targets (destinations and/or "webhook", the address's usual webhook). Stop.
copyAlso send to the rule's destinations, and carry on down the list.
dropAccept 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.

fieldop
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_scoregte, lt (a number)
has_attachmentsis_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):

EndpointDoes
GET / POST /v1/destinationsList 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}/testSend it the sample email
GET / POST /v1/rulesList 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/testWhere 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.

Permissions. These endpoints need an API key with the Manage domains & addresses permission (scope domains). Tick it when creating the key under API keys.
EndpointDoes
GET /v1/domainsList domains (filter with ?reference= or ?name=)
POST /v1/domainsAdd 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}/verifyCheck 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}/addressesAdd 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-rulesList 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" }
    ]
  }'
FieldTypeNotes
name requiredstringThe domain, e.g. customer-shop.com or a subdomain like mail.customer-shop.com.
referencestring or nullYour own ID for this domain (e.g. your customer number), up to 255 characters. Filter by it with GET /v1/domains?reference=….
inbound_enabledbooleanReceive mail for this domain. Default false.
max_message_bytesinteger or nullLargest email accepted for this domain, from 1024 up to the platform limit (25 MB). null = the platform limit.
webhook_urlstring or nullWhere 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.
addressesarrayEmail 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": []
}
FieldNotes
recordsWhat 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_readyDKIM 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).

EndpointDoes
GET /v1/accountsList sub-accounts (filter with ?reference=), with domain, user and delivery counts
POST /v1/accountsCreate 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}/invitationsInvite 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/webhook settings 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 at mx.ghostparse.com with 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 at ghostparse.com the 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" }
StatuserrorMeaningRetry?
401UnauthorizedMissing, wrong or revoked API key.No
403outbound_disabledSending email isn't available on this platform: it's for receiving email only.No
403insufficient_scopeThe API key lacks the permission this endpoint needs (domains, integrations or accounts).No
404not_foundThe domain or address doesn't exist on your account.No
409domain_exists / address_existsAlready on your account.No
422invalid_domain, invalid_address, wrong_domain, duplicate_address, no_permissions, invalid_reference, invalid_webhook_urlDomains API input problems; message says exactly what's wrong.No
422too_many_domains / too_many_addresses / too_many_rulesAccount or domain limit reached.No
422pattern_cannot_send, invalid_max_message_bytes, invalid_patternAn address pattern with Send on, a size cap out of range, or a sender rule that isn't an address or domain.No
409rule_existsThat sender rule is already on the domain.No
422invalid_tag, address_not_receivingReply addresses: the tag has characters other than letters, digits, - and _, or the address isn't a receiving address on your domain.No
422invalid_statusDelivery log: unknown status filter.No
422no_receiving_address, not_receivingSample 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
404parser_not_foundparser_id isn't one of your parsers.No
422invalid_destination, invalid_ruleDestinations 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
404destination_not_found, hook_not_found, rule_not_foundNot on your account.No
422too_many_destinations, too_many_routing_rulesAn account can have 50 destinations (hooks included) and 100 routing rules.No
403not_resellerReseller features aren't on for this account, so it can't use /v1/accounts or the accounts permission.No
403account_suspendedThe account is suspended (by its reseller).No
404account_not_foundNot one of your sub-accounts (an Email-Parser-Account header or /v1/accounts/{id}).No
409secret_incompatibleSwitching to Standard Webhooks with a secret made before it was supported: rotate the secret first.After rotating
422too_many_accountsThe reseller's sub-account limit is reached.No
422plan_limitYour plan's limit on domains or team members is reached. Upgrade on the Billing page.After upgrading
422validation_errorThe body is invalid. issues lists each problem with its path.No
429Too Many RequestsMore 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.

LimitValue
API requests per key120 per minute
Message size25 MB
Domains per account20
Domain verification checks30 per minute per key
Email addresses per domain100 (patterns count as one)
Sender rules per domain200
Delivery logKept 90 days
Parsers20 per account, 30 fields each
Sample emails10 per minute
Active API keys10
Team members25
Sub-accounts per reseller500
Inbound emailNo volume limit for your listed addresses (transactional use)

Plans & usage

FreeStarterProBusiness
Price£0£9/month£29/month£99/month
Inbound emails a month1,00010,00050,000250,000
AI extractions a month505002,50010,000
Domains1 + instant address525Unlimited
Team members2515Unlimited
Delivery log3 days30 days90 days90 days
Extra emails, per 1,000Held 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 relays 503), so they retry, usually for several days. Upgrading in that time delivers it. The delivery log shows these as deferred with flags.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.

LanguageInstall
Node.js / TypeScriptnpm install @wildye/email-parser
Pythonpip install wildye-email-parser
PHPcomposer require wildye/email-parser
Laravelcomposer require wildye/email-parser-laravel: a webhook route that dispatches InboundEmailReceived events
Rubygem "wildye-email-parser"
Gogo get github.com/dwildman86/emailParser/sdk/go
CLInpx @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.