Browse the docs
By language
Receive email in PHP
Receive and process inbound email in PHP or Laravel: verify Inboundr's signed webhook and process each message and its attachments.
This guide receives every email sent to your domain in plain PHP or Laravel, verifies it came from Inboundr, and saves its attachments. No package is needed. PHP 8.1 or later.
Before you start#
- An address to receive on. Your account comes with an
instant domain such as
amber-falcon-4821.inboundr.email, so any address on it works without DNS setup. A verified domain of your own works the same way. - An API key, from API keys in the console:
bash export INBOUNDR_API_KEY="inb_xxxxxxxxxxxxxxxxxxxxxxxx" - A public HTTPS URL for your handler. Inboundr only delivers to public
addresses, so while developing, expose your local port with a tunnel:
bash ngrok http 8000 # or: cloudflared tunnel --url http://localhost:8000
1. Create an endpoint and route mail to it#
An endpoint is the URL Inboundr POSTs each email to. Create one pointing at your tunnel:
curl https://inboundr.net/api/v1/endpoints \
-H "Authorization: Bearer $INBOUNDR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Local handler", "url": "https://<your-tunnel>/inboundr.php" }'The response includes a secret (whsec_…). It's shown only once, so store it
next to your API key:
export INBOUNDR_WEBHOOK_SECRET="whsec_xxxxxxxxxxxxxxxxxxxxxxxx"Then send the domain's mail to the endpoint. Find the domain's ID (the instant
domain is the one with "managed": true) and set it as the catch-all:
curl https://inboundr.net/api/v1/domains \
-H "Authorization: Bearer $INBOUNDR_API_KEY"
curl -X PATCH https://inboundr.net/api/v1/domains/dom_123 \
-H "Authorization: Bearer $INBOUNDR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "catchAllEndpointIds": ["ep_123"] }'To route a single address instead, see addresses.
2. Write the handler#
The handler gets the parsed message as JSON: from, to, recipients,
subject, text, html, attachment metadata and the spam, SPF and DKIM
verdicts. See the full payload. The signature check:
<?php
/** Checks X-Inboundr-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "t.body">. */
function inboundrVerify(string $rawBody, string $header, string $secret): bool
{
$parts = [];
foreach (explode(",", $header) as $kv) {
[$key, $value] = array_pad(explode("=", $kv, 2), 2, "");
$parts[$key] = $value;
}
$t = $parts["t"] ?? "";
// Reject old deliveries so a captured request can't be replayed.
if (!ctype_digit($t) || abs(time() - (int) $t) > 300) {
return false;
}
$expected = hash_hmac("sha256", "{$t}.{$rawBody}", $secret);
return hash_equals($expected, $parts["v1"] ?? "");
}
function inboundrProcess(array $email, string $apiKey): void
{
error_log("{$email['from']} -> " . implode(", ", $email["recipients"]) . ": {$email['subject']}");
@mkdir(__DIR__ . "/attachments");
foreach ($email["attachments"] as $i => $attachment) {
if (empty($attachment["downloadUrl"])) {
continue; // not stored on this plan
}
$ch = curl_init($attachment["downloadUrl"]);
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ["Authorization: Bearer {$apiKey}"],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FAIL_ON_ERROR => true,
CURLOPT_TIMEOUT => 60,
]);
$bytes = curl_exec($ch);
curl_close($ch);
if ($bytes === false) {
continue;
}
// Inline images often have no filename, and two can share one, so the
// index keeps them apart. basename: never trust a sender's name as a path.
$name = basename($attachment["filename"] ?? "attachment-{$i}");
$file = __DIR__ . "/attachments/{$email['id']}-{$i}-{$name}";
file_put_contents($file, $bytes);
}
}Then the route:
<?php
// webhook.php, served at the endpoint's URL
require __DIR__ . "/inboundr.php";
$secret = getenv("INBOUNDR_WEBHOOK_SECRET");
$apiKey = getenv("INBOUNDR_API_KEY");
$raw = file_get_contents("php://input"); // raw bytes, before json_decode
$header = $_SERVER["HTTP_X_INBOUNDR_SIGNATURE"] ?? "";
if (!inboundrVerify($raw, $header, $secret)) {
http_response_code(401);
exit("invalid signature");
}
$email = json_decode($raw, true)["email"];
// Answer now, then keep working: slow handlers time out and get retried.
http_response_code(200);
echo "ok";
if (function_exists("fastcgi_finish_request")) {
fastcgi_finish_request(); // PHP-FPM: sends the response, keeps running
}
inboundrProcess($email, $apiKey);<?php
// routes/api.php (served under /api, with no CSRF check).
// Laravel 11+: run `php artisan install:api` first if the file doesn't exist.
use App\Jobs\ProcessInboundEmail;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Route;
Route::post("/webhooks/inboundr", function (Request $request) {
$raw = $request->getContent(); // raw bytes, before any JSON parsing
$header = $request->header("X-Inboundr-Signature", "");
if (!inboundrVerify($raw, $header, config("services.inboundr.webhook_secret"))) {
abort(401, "invalid signature");
}
$email = json_decode($raw, true, flags: JSON_THROW_ON_ERROR)["email"];
// Retries keep their delivery ID: skip ones already queued. Checked only
// after parsing succeeded, so a delivery that failed here is retried.
$deliveryId = $request->header("X-Inboundr-Delivery-Id");
if ($deliveryId && !Cache::add("inboundr:{$deliveryId}", true, now()->addDay())) {
return response("ok");
}
ProcessInboundEmail::dispatch($email);
return response("ok");
});In Laravel, load inboundr.php through Composer's autoload.files, add
'inboundr' => ['webhook_secret' => env('INBOUNDR_WEBHOOK_SECRET')] to
config/services.php, and call inboundrProcess from the queued job's
handle() method. The endpoint's URL becomes
https://<your-tunnel>/api/webhooks/inboundr.
3. Test it#
Send a sample delivery. It's signed with your endpoint's real secret, so it also tests your signature check:
curl -X POST https://inboundr.net/api/v1/endpoints/ep_123/test \
-H "Authorization: Bearer $INBOUNDR_API_KEY""ok": true means your handler answered with a 2xx. A 401 in status
means the signature didn't match: see troubleshooting.
Then send a real email, with an attachment, from your own mailbox to any
address on your domain, such as test@amber-falcon-4821.inboundr.email. It
reaches your handler within seconds. Locally, php -S localhost:8000 serves the plain PHP version.
Read mail without a server#
Webhooks need a public URL. For scripts and cron jobs, read the inbox on demand instead. Mail is stored either way:
<?php
$ch = curl_init("https://inboundr.net/api/v1/emails?direction=inbound&limit=20");
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("INBOUNDR_API_KEY")],
CURLOPT_RETURNTRANSFER => true,
]);
$page = json_decode(curl_exec($ch), true);
foreach ($page["data"] as $email) {
echo "{$email['receivedAt']} {$email['fromAddress']} {$email['subject']}\n";
}Fetch one message in full with GET /v1/emails/{id}, and pass nextCursor
back as cursor for the next page. See the
emails API.
Troubleshooting#
- Every delivery fails the signature check. The HMAC is computed over the
exact bytes sent. Read the raw body before any JSON parsing, and don't
re-serialize it. Also check you're using the endpoint's
whsec_…secret, not yourinb_API key. - Deliveries time out. Your handler has 30 seconds by default. Acknowledge first, then do slow work (AI calls, downloads, database writes) in the background.
- The same email arrives twice. A delivery is retried when your handler
doesn't answer
2xxin time, and a retry keeps itsX-Inboundr-Delivery-Id. Store handled IDs and skip repeats. - The body is empty. Some hosts drop request bodies. Every delivery also carries the email's ID with its own signature, so you can fetch it instead: see when the body is missing.
- Nothing arrives. Check the email under Emails in the console. If it's there, open its delivery to see your handler's response. If it isn't, the message never reached Inboundr: check the address and, for your own domain, its MX record.