Receive email in real time with PHP
PHP can receive a new email as an HTTP request instead of checking an IMAP mailbox on a timer. This receiver verifies MailKite's signed raw body, stores the parsed-email payload in SQLite, and acknowledges only after the write succeeds. MailKite is the platform we build.
The runnable source and delivery checks live in the shared receiving-email demo repository.
Save this as receive.php beside Composer’s vendor directory. The PHP receiving guide offers the same complete receiver as a download.
<?php
require __DIR__ . '/vendor/autoload.php';
$secret = getenv('MAILKITE_WEBHOOK_SECRET');
if (!$secret) { throw new RuntimeException('Set MAILKITE_WEBHOOK_SECRET'); }
if (parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) !== '/hooks/mailkite') {
http_response_code(404); exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); exit; }
$mk = new \MailKite\Client('unused-for-verify');
$raw = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_MAILKITE_SIGNATURE'] ?? '';
if (!$mk->verifyWebhook($signature, $raw, $secret)) {
http_response_code(401); exit;
}
try { $event = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); }
catch (JsonException $e) { http_response_code(400); exit; }
if (!is_array($event)) { http_response_code(400); exit; }
if (($event['type'] ?? '') === 'email.received') {
if (!is_string($event['id'] ?? null) || $event['id'] === '') {
http_response_code(400); exit;
}
try {
$db = new PDO('sqlite:' . (getenv('INBOX_DB') ?: __DIR__ . '/inbox.sqlite'));
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->exec('PRAGMA busy_timeout = 5000');
$db->exec('CREATE TABLE IF NOT EXISTS inbox (id TEXT PRIMARY KEY, payload TEXT NOT NULL)');
$insert = $db->prepare('INSERT INTO inbox (id, payload) VALUES (?, ?) ON CONFLICT(id) DO NOTHING');
$insert->execute([$event['id'], $raw]);
} catch (PDOException $e) { http_response_code(503); exit; }
}
header('Content-Type: application/json');
echo $mk->replyOk(); // SQLite's autocommit write has finished before we acknowledge.
PHP does not need to be a mail server
The HTTP receiver handles JSON, not SMTP. The mail provider accepts SMTP, decodes MIME, and POSTs the result to your endpoint. Your PHP process can run behind an ordinary web server or PHP-FPM. It doesn’t need an IMAP extension or an always-running polling task.
For the local example, use PHP 8.1+, Composer and PDO SQLite. First add and verify your receiving domain, then create an exact-address webhook route such as inbound@your-receiving-domain pointing to your public HTTPS /hooks/mailkite URL. A development tunnel can forward that URL to local port 3000. Obtain the signing secret from this route and supply it as MAILKITE_WEBHOOK_SECRET through your environment before starting PHP:
composer require mailkite/mailkite
php -S 127.0.0.1:3000 receive.php
Verification is local, so the client constructor’s placeholder is not a real API credential and no authenticated API call is made. Different routes can have different signing secrets; the receiver must use the secret for the exact route you created.
The built-in server is a development tool. For production, keep the SQLite file outside the public document root, give the PHP process write permission, and use persistent storage. INBOX_DB selects a different database location. A container’s throwaway filesystem will not preserve the inbox through a replacement.
Verify php://input before decoding it
php://input is the raw body stream. Pass that string to verifyWebhook before json_decode; never sign or verify a newly encoded version of the object. Header names reach raw PHP through $_SERVER, where the signing header becomes HTTP_X_MAILKITE_SIGNATURE.
The SDK checks the signature and timestamp. An invalid signature returns 401. Validly signed malformed JSON returns 400. The original sender’s SPF, DKIM and DMARC results are separate event fields: a legitimate webhook can still contain a forged or untrusted email.
In Laravel or Symfony, apply the same sequence using the framework’s raw-body accessor. A provider webhook is not a browser form, so use a narrowly scoped webhook route rather than weakening CSRF protection across the whole application. The signature docs describe that boundary.
Let a duplicate stop at the database
The message id is the inbox primary key. ON CONFLICT(id) DO NOTHING makes another delivery of the same message harmless at the storage layer. PDO’s insert runs in autocommit mode, so the write completes before replyOk() acknowledges it.
A worker still needs to read pending rows, perform the work and record completion. This example stores the inbox; it does not implement the worker or guarantee exactly-once side effects. An external action needs its own idempotency key or reconciliation mechanism.
In a larger PHP application, the equivalent boundary is a transaction that saves the event and enqueues work durably. If dispatching a queue job is only an in-process action, it is not durable acceptance. Replay a delivery during testing and check both the row count and the number of downstream jobs.
Receive from a new address or an existing inbox
For your own receiving domain, publish the exact MX record returned by the dashboard and complete verification. An available, verified hosted subdomain is another entry point. The setup guide covers domain setup; keep the route scoped to your chosen receiving address.
Download the synthetic checker as check.mjs. It requires Node.js 18+ and the same MAILKITE_WEBHOOK_SECRET as the running PHP receiver. In a second terminal, run:
node check.mjs http://127.0.0.1:3000/hooks/mailkite
The checker exercises valid and duplicate events, tampered bodies, missing signatures and signed malformed JSON. It sends HTTP requests, not email, so it does not validate MX records or route matching. The dashboard’s domain test targets the catch-all *@domain webhook; it is not validation of your exact-address route. After the local check, send a real email directly to that exact receiving address and inspect its MailKite delivery record.
Download the inbox inspector beside your local database. With Python 3 installed, read the stored message using:
python3 inspect-inbox.py inbox.sqlite
Use your actual INBOX_DB path if it differs. The inspector prints the latest ten stored messages, not a row-count assertion. Keep its output and database private; do not expose the inspector as an HTTP endpoint. Replay the real delivery and check that its id still identifies one row. Once you add a worker, check downstream jobs separately.
To keep an existing Gmail or iCloud mailbox, forward new messages to that separate address. Gmail’s confirmation message must reach MailKite before you can enable the forwarding rule. iCloud’s server-side forwarding doesn’t require Apple Mail to stay open. Use the Gmail or iCloud Mail guide for the provider-specific steps.
Live delivery still includes SMTP and any forwarding delay. If your application must control Gmail labels or send as the Gmail user, choose Gmail’s API. Postmark inbound is an alternative for application domains; SES receipt rules or a self-hosted SMTP receiver give you more control at the cost of operating the parsing and retry path.
Treat bodies and attachments as optional data
Sender and recipient fields are objects, not simple strings. A subject may be null, and an HTML-only email may have no text part. Prefer text, then textFromHtml, and retain the original event when your application needs richer processing later.
Attachment URLs are signed and time-limited. Download required attachments after accepting the event and before the URL expires; don’t tie the entire HTTP request to those downloads. The event reference and delivery reference describe the common payload and replay behavior. Start with the downloadable receiver, then add your worker to the stored inbox.