
openclaw inbound email webhook setup with HMAC verification
Step-by-step tutorial for wiring LobsterMail inbound email into OpenClaw using webhooks with HMAC-SHA256 signature verification.
Your agent just received an email. LobsterMail fires a POST request to your OpenClaw endpoint. Before your agent does anything with that payload, it needs to answer one question: did this actually come from LobsterMail?
That's what HMAC verification solves. Without it, anyone who guesses your webhook URL can send fake email events to your agent. With it, your agent cryptographically confirms the payload is legitimate before acting on it. It takes about five minutes to set up and eliminates an entire class of injection attacks.
This tutorial covers the full setup: provisioning a LobsterMail inbox, registering a webhook, implementing the signature check, and wiring it into your OpenClaw agent's HTTP handler.
Before you start#
You need:
- A LobsterMail account (free tier works fine)
- An OpenClaw agent with an HTTPS-accessible endpoint
- The
lobstermailnpm package installed
npm install lobstermail
If your agent doesn't have an inbox yet, it can provision one in about 60 seconds. The inbox and the webhook are separate resources, so create the inbox first and layer the webhook on top.
Step 1: Create the webhook#
Initialize the LobsterMail client and register a webhook against your endpoint. Pay close attention to what happens with the secret.
import { LobsterMail } from '@lobsterkit/lobstermail';
const lm = await LobsterMail.create({ token: process.env.LOBSTERMAIL_API_KEY });
const webhook = await lm.createWebhook({
url: 'https://your-openclaw-agent.example.com/hooks/lobstermail',
events: ['email.received'],
});
// The secret is returned exactly once — store it now
console.log('Webhook ID:', webhook.id);
console.log('Webhook secret:', webhook.secret);
Copy webhook.secret into your environment variables or secrets manager before doing anything else. LobsterMail returns it once at creation time and never again. Lose it and the only path forward is deleting the webhook and creating a new one.
# Add to .env or your secrets manager
LOBSTERMAIL_WEBHOOK_SECRET=whsec_...
Warning
There is no "show secret again" option. Store the webhook secret immediately after creation — if it's gone, you're starting over with a new webhook.
Step 2: Understand the payload#
Every LobsterMail webhook request includes an X-LobsterMail-Signature header containing an HMAC-SHA256 signature of the raw request body. The JSON payload for an inbound email looks like this:
{
"event": "email.received",
"timestamp": "2026-03-02T12:00:00Z",
"data": {
"emailId": "em_abc123",
"inboxId": "in_xyz789",
"from": "vendor@example.com",
"subject": "Your invoice is ready",
"preview": "Hi there, please find attached your invoice for..."
}
}
The preview field contains the first 200 characters of the email body. For most routing decisions — who sent it, what's the subject, is it a verification code — that's enough. You only fetch the full body when you actually need it.
Step 3: Implement the signature check#
The verification function is short. The common mistake is parsing the JSON before running the check. Don't. You need the raw bytes, exactly as LobsterMail sent them.
import { createHmac } from 'node:crypto';
function verifyLobsterMailWebhook(
rawBody: string,
signature: string,
secret: string
): boolean {
const expected = createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
return expected === signature;
}
Two things cause signature mismatches in practice. First, parsing before verifying: JSON.parse followed by re-serialization can change key ordering or whitespace, producing a different hash. Read the raw bytes first, verify, then parse. Second, middleware that consumes the body stream: Express's json() middleware, if it runs before your handler, will have already parsed req.body and the raw bytes are gone. Use express.raw() on your webhook route specifically, not as a global middleware.
Step 4: Build the handler#
Here's a complete handler that verifies the signature, responds quickly, and processes asynchronously:
async function handleLobsterMailWebhook(req: Request): Promise<Response> {
const rawBody = await req.text();
const signature = req.headers.get('x-lobstermail-signature') ?? '';
const secret = process.env.LOBSTERMAIL_WEBHOOK_SECRET ?? '';
if (!verifyLobsterMailWebhook(rawBody, signature, secret)) {
console.warn('Rejected webhook: invalid signature');
return new Response('Unauthorized', { status: 401 });
}
const payload = JSON.parse(rawBody);
if (payload.event === 'email.received') {
// Don't await — respond immediately, process in background
processIncomingEmail(payload.data).catch(console.error);
}
return new Response('OK', { status: 200 });
}
LobsterMail waits up to 10 seconds for a 2xx response. If your agent's email logic takes longer than that, returning 200 quickly and processing async is the right call — missing that window triggers a retry.
Step 5: Fetch the full email when you need it#
Your agent doesn't need the full email body for every message. Build the fetch call into the processing function and gate it on subject-line routing:
async function processIncomingEmail(data: {
emailId: string;
inboxId: string;
from: string;
subject: string;
preview: string;
}) {
const lm = await LobsterMail.create({ token: process.env.LOBSTERMAIL_API_KEY });
if (isVerificationCode(data.subject, data.preview)) {
const email = await lm.getEmail(data.emailId);
await agent.handleVerificationCode(email.body);
return;
}
if (isFromTrustedVendor(data.from)) {
const email = await lm.getEmail(data.emailId);
await agent.processVendorEmail(email);
return;
}
// Everything else: log and skip
console.log(`Skipping: "${data.subject}" from ${data.from}`);
}
This keeps your agent from fetching every email body when most routing decisions only need the subject and sender.
Step 6: Handle retries and idempotency#
LobsterMail retries failed deliveries up to 10 times with exponential backoff. After 10 consecutive failures, the webhook is disabled automatically. Re-enable it with:
await lm.updateWebhook(webhookId, { enabled: true });
Because retries can deliver the same email more than once, your handler should be idempotent. Track processed email IDs and skip duplicates:
const processedEmails = new Set<string>();
async function processIncomingEmail(data: { emailId: string; [key: string]: unknown }) {
if (processedEmails.has(data.emailId)) {
return; // Already handled
}
processedEmails.add(data.emailId);
// ... rest of processing
}
For persistent agents, use your state store or a database rather than an in-memory Set. The emailId is stable across retries, so it works as a deduplication key regardless of how many times the webhook fires.
Testing before you deploy#
Test locally using a tunnel so you can verify end-to-end before pointing production traffic at your endpoint:
ngrok http 3000
# Use the ngrok HTTPS URL when creating the test webhook
During testing, log the raw body and the computed signature on each request. The three most common causes of mismatches: middleware consuming the body stream before your handler, an environment variable with a trailing newline in the secret, and a proxy layer that modifies the request body. If you're running on a Cloudflare Worker or an edge runtime, test behind the same infrastructure you'll use in production — some runtimes handle streaming body reads differently than Node.
Once you've confirmed that sending a real email to your LobsterMail inbox produces a verified webhook call in your OpenClaw agent, the setup is done. For a broader look at when polling is a reasonable alternative to webhooks, see webhooks vs polling for inbound email. If you're building a simpler agent and want to skip the full event-driven architecture, there's a lighter approach worth knowing about.
Frequently asked questions
What is HMAC-SHA256 and why does it matter for webhooks?
HMAC-SHA256 is a cryptographic message authentication algorithm. LobsterMail signs each webhook request body with your secret key, and you recompute the signature on your end to verify the request is genuine. Without this check, anyone who finds your webhook URL can POST fake events to your agent.
Where do I find my webhook secret after creation?
You don't — it's only returned when you first create the webhook. If you missed it, delete the webhook via lm.deleteWebhook(id) and create a new one. There is no retrieve-secret endpoint.
Can I use LobsterMail webhooks without the npm SDK?
Yes. Register webhooks directly via POST /v1/webhooks with url and events in the body. The signature verification is plain Node.js crypto with no SDK dependency. The SDK just wraps these calls.
What happens if my endpoint is down when LobsterMail fires a webhook?
LobsterMail retries up to 10 times with exponential backoff. After 10 consecutive failures, the webhook is automatically disabled. Re-enable it with PATCH /v1/webhooks/:id and { "enabled": true }.
Can I register multiple webhooks on the same account?
Yes. You can have multiple webhooks pointing to different endpoints. Use lm.listWebhooks() to see all registered webhooks. Each has its own ID and secret.
Does the webhook payload include the full email body?
No — the preview field contains the first 200 characters. Fetch the full body with lm.getEmail(emailId) when you need it. For subject-line routing and verification code detection, the preview is usually sufficient.
My signature verification keeps failing. Where should I look first?
Check these three things in order: (1) you're verifying against the raw request body, not req.body after JSON parsing, (2) your LOBSTERMAIL_WEBHOOK_SECRET environment variable doesn't have a trailing newline, (3) a middleware layer isn't modifying the request body before it reaches your handler. Logging the raw body and computed signature during debugging makes the mismatch visible quickly.
Is this different from polling the inbox?
Yes. Webhooks are push-based — LobsterMail notifies your agent the moment an email arrives. Polling is pull-based — your agent checks on a schedule and may miss emails or react slowly. For real-time workflows, webhooks are the right approach. See webhooks vs polling for a full comparison.
Can I filter webhooks to only fire for a specific inbox?
Webhooks currently register at the account level and fire for all inboxes. Filter by inboxId in your handler if you only want to process emails to a specific address.
How do I rotate my webhook secret?
Delete the existing webhook and create a new one. LobsterMail doesn't currently support in-place secret rotation. Update your environment variable with the new secret before deleting the old webhook to avoid a gap in coverage.
Can my OpenClaw agent provision its own LobsterMail inbox without human involvement?
That's exactly what LobsterMail is built for. Your agent calls a single function and gets its own address — no human signup, no OAuth flow, no configuration step. See the 60-second setup guide for the full walkthrough.
Is LobsterMail free to use with OpenClaw?
The free tier supports 1,000 emails per month with no credit card required. The Builder plan at $9/month adds up to 10 inboxes and 5,000 emails per month for agents with higher volume.
Your agent's inbox is live and locked down. Get started with LobsterMail — it's free.


