# Process inbound email

Lettermint sends each accepted inbound message as a signed `message.inbound` webhook.

A safe endpoint completes these actions:

1. Verify the signature against the raw request body.
2. Record the webhook delivery ID.
3. Save the event or add it to a queue.
4. Return a `2xx` response.

## Create the webhook

<Stepper>

### Open the inbound route

1. In the dashboard, select **Projects**.
2. Select your project and open **Routes**.
3. Select the inbound route.

### Add the endpoint

1. Select **Webhooks**.
2. Select **Create webhook**.
3. Enter the public HTTPS URL for your endpoint.

Lettermint automatically subscribes the webhook to `message.inbound`.

Copy the signing secret after you create the webhook. Store the secret in your application secret manager.

### Test a real message

Send an email to the route **Inbound address**. Open the webhook delivery log.

Check the request, response status, and retry attempts.

</Stepper>

## Verify and queue the event

Verify `X-Lettermint-Signature` against the unmodified request body before you parse the JSON. Follow [Signed webhooks](/platform/webhooks/signing) for complete framework examples.

The following handlers queue the verified event and return a response:

<CodeTabs syncKey="lettermint-language">
```javascript title="Node.js"
app.post('/webhooks/lettermint', verifyLettermintSignature, async (req, res) => {
  const event = req.body

  if (event.event !== 'message.inbound') {
    return res.sendStatus(204)
  }

  await inboundQueue.add('process-inbound', event.data, {
    // Let the queue reject a repeated delivery of the same webhook.
    jobId: event.id,
  })

  return res.sendStatus(204)
})
```

```php title="PHP"
Route::post('/webhooks/lettermint', function (Request $request) {
    // This middleware verifies the signature and provides the decoded payload.
    $event = $request->attributes->get('lettermint_webhook_payload');

    if ($event['event'] !== 'message.inbound') {
        return response()->noContent();
    }

    ProcessInboundEmail::dispatch(
        deliveryId: $event['id'],
        message: $event['data'],
    );

    return response()->noContent();
})->middleware(VerifyWebhookSignature::class);
```

```python title="Python"
@app.post('/webhooks/lettermint')
def lettermint_webhook():
    # verify_lettermint_signature must use request.get_data(), not re-encoded JSON.
    event = verify_lettermint_signature(request)

    if event['event'] != 'message.inbound':
        return '', 204

    process_inbound.apply_async(
        args=[event['data']],
        task_id=event['id'],
    )

    return '', 204
```
</CodeTabs>

## Prevent duplicate processing

Lettermint uses the same top-level `id` for each retry of one webhook delivery. Store this ID with a unique database constraint. You can also use the duplicate prevention feature in your queue.

:::warning
Always verify the signature before you trust the event. Use the exact raw request body. A parsed and serialized body has different bytes.
:::

## Use the right address field

SMTP envelope data and visible message headers have different purposes. Use the envelope recipient to route a message. Use header addresses for display.

| Field | Use |
| --- | --- |
| `id` | Identify one webhook delivery. Lettermint uses it again for retries. |
| `data.message_id` | Identify the inbound message and its stored content. |
| `timestamp` | Read the time that Lettermint created the webhook event. |
| `data.date` | Read the time that Lettermint received the inbound message. |
| `data.recipient` | Route the message by its SMTP envelope recipient. |
| `data.to` and `data.cc` | Display the parsed `To` and `Cc` headers. |
| `data.envelope.mail_from` | Check the SMTP envelope sender for delivery problems or bounces. |
| `data.from` | Display the parsed `From` header. |
| `data.reply_to` | Select the parsed reply address. This field can be `null`. |

Forwarding, aliases, and blind copies can change the envelope recipient. Therefore, it can differ from `To` or `Cc`.

Do not route a tenant or workflow from a visible header.

For a user reply, use `reply_to` if it has a value. Otherwise, use `from.email`.

Treat both fields as untrusted input. Do not send an automatic reply only because an address is in the payload.

## Process content safely

- Sanitize `data.body.html` before you display it. Email HTML can contain scripts, tracking resources, and false links.
- Use `data.body.text` if you do not need rich formatting. Each body field can be `null`.
- Do not log message bodies, authentication headers, attachment content, or signed URLs.
- Check [spam and authentication results](/platform/inbound-mail/spam-filtering) before you start a sensitive action.
- Follow the [attachment and raw email guide](/platform/inbound-mail/attachments-and-raw-email) before you store files.

## Return a response

Return a `2xx` response after you save or queue the event. Do not wait for other processing to finish.

Lettermint retries a delivery after a timeout or a response outside the `2xx` range. Your endpoint can receive the same event more than once. See [Delivery and retries](/platform/webhooks/introduction#delivery-and-retries) for the retry schedule.

For the complete event shape, field types, and nullable values, see the [`message.inbound` webhook reference](/platform/webhooks/events#messageinbound).

## Troubleshooting

<details>
<summary>Why is there no webhook delivery?</summary>

- Confirm that the webhook belongs to the inbound route.
- Confirm that the endpoint is available through public HTTPS.
- For a generated address, confirm that the recipient is an exact match.
- For a custom domain, confirm that the domain is verified.

</details>

<details>
<summary>Why does signature verification fail?</summary>

Capture the raw request body before the JSON parser runs. Use the signing secret for this webhook.

Do not use an API token or a secret from a different webhook.

</details>

<details>
<summary>Why did my endpoint receive the event more than once?</summary>

Lettermint retries after a timeout or a response outside the `2xx` range. Store the top-level `id` with a unique constraint.

Make sure that later operations can safely receive the same event again.

</details>

<details>
<summary>Why did the wrong workflow receive the message?</summary>

Route the message with `data.recipient`. For plus addressing, also use `data.subaddress`.

Do not use the first visible `To` address as the delivery destination.

</details>
