Accepting a send is the easy half. The useful half is what happens next: delivered, delayed, bounced, or complained. If those events live only in a vendor dashboard, support opens a second console and your app keeps mailing a dead address.
A webhook is how the same event reaches your code. This guide is the marketing-and-API version of ArawaMail’s outgoing webhooks docs — the page people find, then bounce from. You should leave with a handler that keeps the list clean without leaving the workspace.
Two webhook jobs people mix up
| Job | Fires when | What you do |
|---|---|---|
| Delivery / status | A message you sent is delivered, delayed, hard-bounced, or complained | Stop mailing bad addresses. Tell support what happened to that receipt or reset. |
| Incoming notify | A mailbox you host receives a message | Open a ticket, start an automation, or archive the mail in your own system. |
ArawaMail’s documented outgoing webhooks are the second job: an HTTPS POST of inbound mail as JSON, including signed attachment URLs. Sent mail still has a first-class record. POST /emails returns an id, GET /emails/{id} returns last_event, and the same send is visible next to the domain’s inboxes. That is the point of keeping product mail and people-mail together.
Treat the four status outcomes as product events even when you first see them in the workspace rather than in a payload:
- Delivered — the receiving system accepted the message. The user can still ignore it; your job is done.
- Delayed — temporary. Retry is the provider’s problem. Do not create a second receipt.
- Bounced — hard failures (unknown user, rejected domain) should suppress that address. Soft failures are greylist and full mailboxes; wait.
- Complained — the recipient marked it as spam. Suppress immediately. A complaint is worse than a bounce.
What the outgoing webhook actually posts
Configure a notify URL under Settings → Outgoing webhooks. ArawaMail POSTs JSON whenever matching inbound mail arrives. Optional filters limit a URL to one mailbox or to every mailbox on a domain. Set a secret.
{
"id": 481,
"message_id": "[email protected]",
"thread_id": 466,
"recipient": "[email protected]",
"from_name": "Jane Doe",
"from_email": "[email protected]",
"to_recipients": [{ "name": "Support", "email": "[email protected]" }],
"cc_recipients": [],
"subject": "Order 1842 never arrived",
"text_body": "Hi team, the package is still missing...",
"html_body": "<p>Hi team, the package is still missing...</p>",
"snippet": "Hi team, the package is still missing...",
"has_attachments": true,
"received_at": "2026-07-16T09:24:11+03:00",
"attachments": [
{
"filename": "label.pdf",
"content_type": "application/pdf",
"size": 482113,
"url": "https://example.com/signed-download"
}
]
}
Attachment bytes are not in the JSON. Fetch url within 24 hours if you need the file. Deduplicate on id or message_id — deliveries can arrive more than once.
Signed requests, retries, and a fast 2xx
If you set a secret, every request includes it:
X-Webhook-Secret: your-secret-value
Compare with hash_equals (PHP) or a constant-time compare (Node). Reject anything else with 401. The secret is stored encrypted and is not shown again in the dashboard.
Your endpoint has 15 seconds. Any 2xx counts as delivered. Anything else, or a timeout, is retried up to three attempts total, waiting 30 seconds, 2 minutes, then 5 minutes. After that the notification is dropped; the email stays in the mailbox.
Acknowledge first. Open the ticket or download the attachment after you return 204.
Laravel listener
Verify the secret, ignore duplicates, then dispatch work. Do not parse HTML in the request thread.
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Route;
Route::post('/webhooks/incoming-email', function (Request $request) {
abort_unless(
hash_equals(
(string) config('services.arawamail.webhook_secret'),
(string) $request->header('X-Webhook-Secret')
),
401
);
$email = $request->json()->all();
$providerId = $email['id'] ?? null;
if ($providerId && cache()->add('email-hook:'.$providerId, 1, now()->addDay())) {
Log::info('Incoming email', [
'id' => $providerId,
'from' => $email['from_email'] ?? null,
'recipient' => $email['recipient'] ?? null,
'subject' => $email['subject'] ?? null,
]);
// Dispatch a job: create a ticket, fetch attachments, notify Slack.
}
return response()->noContent();
});
Put the secret in services.arawamail.webhook_secret, not in the route file. If the payload is a bounce or an auto-reply to an address you already suppressed, do not open a second ticket for the same message_id.
Next.js route handler
Same rules: secret, idempotency, then 204.
// app/api/webhooks/email/route.ts
import { NextResponse } from 'next/server';
import { timingSafeEqual } from 'node:crypto';
function secretsMatch(provided: string | null, expected: string): boolean {
if (!provided) return false;
const a = Buffer.from(provided);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}
export async function POST(request: Request) {
const expected = process.env.ARAWA_WEBHOOK_SECRET ?? '';
if (!secretsMatch(request.headers.get('x-webhook-secret'), expected)) {
return new NextResponse('Unauthorized', { status: 401 });
}
const email = await request.json();
// cache.add('email-hook:' + email.id) — skip if you have already seen it.
if (email.has_attachments) {
// Queue a fetch of each attachment.url. They expire in 24 hours.
}
return new NextResponse(null, { status: 204 });
}
Do not put this route behind the same session middleware as the dashboard. It must be reachable from the internet over HTTPS. ArawaMail will not follow a local localhost URL.
Keep the list clean
The handler’s job is not “log JSON.” It is to stop the next send from repeating a known failure.
- On a hard bounce, mark the address unmailable and skip it in the next receipt or reset.
- On a complaint, suppress the address and inspect the template. Security mail that looks like a promo will earn more of these.
- On a delay, do nothing to the user-facing flow. The original message already has an id.
- Never delete the provider id you stored when you called
POST /emails. That is how support finds the same send.
If you only have inbound notify webhooks today, you can still keep the list clean: persist every outbound id, read last_event when a user says “I never got it,” and let a human confirm the same event in the ArawaMail workspace instead of asking engineering to tail logs.
What support should see
Support should answer “did we send the reset?” from the same place they read support@. They should not need a second vendor login.
In the app, store at least:
- The ArawaMail message id from the send response.
- The application event (
receipt:1842,password_reset:91). - The recipient and the time you handed the message off.
In the workspace they can open the mailbox thread or retrieve the sent object. Your webhook then keeps the CRM or helpdesk in sync when the customer writes back. That is a complete loop: send, status, reply — one domain.
For the payload fields, retries, and filters, use the outgoing webhooks guide. For the send side, start with Send transactional email from Next.js or Send email in Laravel.
Create a free ArawaMail workspace, point one notify URL at the handler above, and send a message to that mailbox. When the 204 lands, you are done with the second console.