# Guide to Lob Webhooks: Features and Best Practices

Lob webhooks tell your systems what happens to the mail you send through Lob: a letter is rendered, mailed, scanned in transit, delivered, re-routed or returned. Lob is a print-and-mail and address verification API, and its webhooks carry both its own processing events and the USPS tracking events it receives, so you can notify customers, trigger follow-ups and reconcile mail without polling.

This guide covers how Lob webhooks are created and signed, the event object and event types, Lob's retry, disable and replay rules, and the practices that keep a Lob receiver reliable.

## What are Lob webhooks?

A Lob webhook is a subscription: a URL plus the event types it should receive, in either the Test or the Live environment. When a subscribed event occurs in that environment, Lob sends a `POST` with the whole event object as JSON.

Each webhook has its own secret, shown on its details page in the dashboard. Every delivery carries a `Lob-Signature-Timestamp` header and a `Lob-Signature` header, the hex HMAC-SHA256 of the timestamp, a `.`, and the raw body. One exception catches people out: events sent from Lob's webhook debugger are signed with the literal string `secret`, not the webhook's secret.

## Lob webhook features

| Feature | Details |
| --- | --- |
| Configuration | Dashboard **Webhooks** page, per environment (Test or Live) |
| Limits | Live webhooks are limited by your Print & Mail edition. No limit in Test |
| Rate limit | Optional, per webhook, in requests per 5-second interval |
| Signature header | `Lob-Signature`, bare hex |
| Timestamp header | `Lob-Signature-Timestamp`. Format not documented |
| Scheme | HMAC-SHA256 |
| Signed content | `<timestamp>.<raw body>` |
| Secret | One per webhook, on its details page. Debugger events use `secret` |
| Other auth | Optional Basic auth from `https://user:pass@...` in the webhook URL |
| Payload | The full event object. PII and PHI fields are redacted by default |
| TLS | HTTPS only, with a fully chained certificate |
| Retries | 8 attempts over about 27.5 hours |
| Auto-disable | After 5 consecutive days of failures, with an email |
| Replay | From the dashboard, for all non-`2xx` events since a start date |
| Source IPs | Not published |

## Common events

Lob lists about 93 event types, grouped by resource. The common ones follow the same pattern for postcards, self mailers, letters and checks:

| Stage | Events (letters shown) | Fires when |
| --- | --- | --- |
| Processing | `letter.created`, `letter.rejected`, `letter.rendered_pdf`, `letter.rendered_thumbnails`, `letter.deleted` | The mailpiece is created or rejected at creation, its PDF or thumbnails are ready, or it's canceled |
| USPS tracking | `letter.mailed`, `letter.in_transit`, `letter.in_local_area`, `letter.processed_for_delivery`, `letter.delivered` | USPS scans the piece through the mailstream |
| Exceptions | `letter.re-routed`, `letter.returned_to_sender`, `letter.failed`, `letter.international_exit` | The piece gets a Re-Routed, Returned to Sender or International Exit scan, or a failed rendering or tracking event |
| Engagement | `letter.viewed`, `letter.informed_delivery.email_sent`, `.email_opened`, `.email_clicked_through` | The recipient scans the QR code or opens the URL, or acts on an Informed Delivery email |
| Certified mail | `letter.certified.mailed`, `.in_transit`, `.delivered`, `.pickup_available`, `.issue` and more | Tracking for certified letters |
| Return envelopes | `letter.return_envelope.created`, `.in_transit`, `.processed_for_delivery`, `.returned_to_sender` and more | Tracking for USPS Courtesy Reply Mail, for Enterprise customers |
| Other resources | `address.created`, `address.deleted`, `bank_account.created`, `bank_account.deleted`, `bank_account.verified` | Address and bank account changes |

Note the hyphen in `re-routed`. Tracking events exist only in the Live environment, so you can't subscribe to them in Test. Use the debugger to send a mock one.

Every delivery is an event object. Here is a trimmed version of Lob's example, with redacted fields:

```json
{
  "event_type": {
    "resource": "self mailers",
    "enabled_for_test": true,
    "id": "self_mailer.rendered_thumbnails",
    "object": "event_type"
  },
  "reference_id": "sfm_c29bbbca5f7bd489",
  "date_created": "2021-12-16T01:34:18.159Z",
  "id": "evt_d47ff1b815373f67",
  "body": {
    "id": "sfm_c29bbbca5f7bd489",
    "description": "self mailer create",
    "metadata": { "secretId": "REDACTED" },
    "to": { "name": "REDACTED", "address_line1": "REDACTED" },
    "url": "REDACTED",
    "carrier": "USPS",
    "tracking_events": [],
    "size": "6x18_bifold",
    "mail_type": "usps_first_class",
    "expected_delivery_date": "2021-12-27",
    "send_date": "2021-12-16T01:39:12.078Z",
    "object": "self_mailer"
  },
  "object": "event"
}

```

Route on `event_type.id`. The mailpiece or other resource is in `body`. By default Lob replaces the to and from addresses, URLs, metadata, descriptions and merge variables with `REDACTED`, plus the memo and bank details on checks. You can change this under **Account > Webhooks Payload** in settings, but Lob can't recover payload data retroactively for events sent while redaction was on. Events follow the API version on your account at the time the event is created.

## Setting up Lob webhooks

1. In the Lob dashboard, select **Webhooks**, choose the Test or Live environment and click **Create**.
2. Enter a description and your HTTPS URL, set a rate limit if you need one, and select the event types.
3. Click **Create**, then open the webhook's details page and copy its secret, for example as `LOB_WEBHOOK_SECRET`.

Your endpoint must accept `POST` with `application/json`, be reachable by Lob, and present a fully chained TLS certificate, or every attempt fails. Exempt it from CSRF protection if your framework enables that by default.

**With Hookdeck.** Lob is a supported source type in Hookdeck. Create a source with the Lob type, paste the webhook's secret from its details page, and use the Hookdeck source URL as the webhook URL in Lob. Events sent from Lob's webhook debugger are signed with a fixed test secret, so they fail verification. Real deliveries are signed with the webhook's secret. The source accepts `POST` only and checks `Lob-Signature` against `<Lob-Signature-Timestamp>.<raw body>`. It doesn't check Basic auth credentials. A request that fails the check still gets a `200` from Hookdeck, but Hookdeck marks it as failed verification and doesn't deliver it to your service.

## Securing Lob webhooks

Join the timestamp header, a literal `.`, and the raw body bytes. Compute an HMAC-SHA256 with the webhook's secret as a UTF-8 string and compare the hex digests in constant time.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyLobSignature(rawBody, signature, timestamp, secret) {
  if (!signature || !timestamp || !secret) return false;
  if (!/^[0-9a-f]{64}$/i.test(signature)) return false; // validate before decoding

  const signed = Buffer.concat([Buffer.from(`${timestamp}.`, 'latin1'), rawBody]);
  const expected = crypto.createHmac('sha256', secret).update(signed).digest();
  const received = Buffer.from(signature, 'hex');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

const app = express();

app.post('/webhooks/lob', express.raw({ type: '*/*' }), (req, res) => {
  // Use the string "secret" only when testing with Lob's webhook debugger.
  const secret = process.env.LOB_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  const ok = verifyLobSignature(
    req.body,
    req.get('lob-signature'),
    req.get('lob-signature-timestamp'),
    secret
  );
  if (!ok) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event.id, route on event.event_type.id, then process asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac
import re

HEX_64 = re.compile(r"[0-9a-fA-F]{64}")

def verify_lob_signature(raw_body: bytes, signature, timestamp, secret: str) -> bool:
    if not signature or not timestamp or not secret:
        return False
    if not HEX_64.fullmatch(signature):
        return False
    signed = timestamp.encode("latin-1") + b"." + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).digest()
    return hmac.compare_digest(bytes.fromhex(signature), expected)

```

Lob's fourth verification step, checking that the timestamp is within a tolerance (it suggests 5 minutes), is marked optional. Lob doesn't say what format `Lob-Signature-Timestamp` uses, so confirm it from a real delivery before you add such a check. Hookdeck applies no timestamp tolerance, and its retries and replays resend the original headers. Lob's own replays may also arrive long after the event, so a strict window is a poor fit here. Deduplicate on the event `id` instead.

> **Make Lob webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `Lob-Signature` header, stores every mailpiece and tracking event, and delivers it to your services with retries you control.

## Lob webhook limitations and pain points

### Debugger events never verify

**The Problem:** Test events from the debugger fail verification with the webhook's real secret.

**Why It Happens:** Lob signs debugger requests with the fixed string `secret`. The webhook's own secret signs only real deliveries.

**Workarounds:**

* Verify debugger traffic with `secret` in a test environment only, never in production.
* Test the real secret with Test-environment events from your Test API key.

**How Hookdeck Can Help:** Hookdeck shows each debugger request as failed verification in the request log, with the full headers and body, so you can still inspect the debugger's event shape.

### Tracking events are delayed and arrive in batches

**The Problem:** Events arrive late, or several tracking events arrive together.

**Why It Happens:** Lob says events may not be sent in real time. When you subscribe to a later tracking event such as `postcard.processed_for_delivery`, you also receive the earlier ones, such as `postcard.in_transit` and `postcard.mailed`.

**Workarounds:**

* Use `body.tracking_events[].time` for the USPS scan time, not the arrival time of the webhook.
* Make state transitions tolerant of events that arrive out of order.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can route tracking events to a different destination from processing events, so a burst of scans doesn't hold up other work.

### Redacted payloads

**The Problem:** Addresses, URLs and metadata come through as `REDACTED`.

**Why It Happens:** Lob redacts fields that could contain PII or PHI by default, and can't recover them for events sent while redaction was on.

**Workarounds:**

* Look up the mailpiece by `reference_id` through the API when you need unredacted fields.
* Change the **Webhooks Payload** setting only if your receiver is approved to hold that data.

**How Hookdeck Can Help:** Keeping redaction on means Hookdeck stores only the redacted payload, which limits where sensitive data lives.

### A long outage disables the subscription

**The Problem:** After a few days of failures, events stop entirely.

**Why It Happens:** Lob makes 8 attempts over about 27.5 hours, then gives up on that event. If every webhook for a subscription fails for 5 consecutive days, Lob disables it and emails you.

**Workarounds:**

* Re-enable the webhook in the dashboard after fixing the endpoint, then use **Replay Events** with a start date to queue every non-`2xx` event since then.
* Return `2xx` quickly so timeouts don't count as failures.

**How Hookdeck Can Help:** Hookdeck accepts each delivery as soon as it arrives, so your downtime doesn't count against Lob's disable policy, and then delivers to your service with [automatic retries](/docs/retries) on your schedule.

## Best practices

### Verify against the raw body

Lob warns that parsing or re-serialising the body can change it. Use `express.raw()` in Express, `await request.arrayBuffer()` in a Next.js route handler, and `await request.body()` in FastAPI.

### Deduplicate on the event `id`

Retries and dashboard replays resend events. Store each `evt_...` ID and skip ones you've already processed. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Return useful response bodies

Lob stores whatever you return, whatever the status, and shows it in the dashboard. A short JSON body with an error reason makes failed attempts easier to debug.

### Set a rate limit if your service needs one

Lob can throttle deliveries per webhook, in requests per 5-second interval. Use it when a burst of tracking events could overwhelm your receiver.

### Acknowledge fast and process asynchronously

Lob asks for a `2xx` "in a timely manner" and recommends returning it straight away, then doing complex work afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Lob signs each delivery with `Lob-Signature`, a bare hex HMAC-SHA256 of `<Lob-Signature-Timestamp>.<raw body>`, keyed with the webhook's secret, except for debugger events, which use `secret`. Verify against the raw bytes, route on `event_type.id`, deduplicate on the event `id`, and expect tracking events to arrive late, together and redacted.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks every Lob signature, and holds each event until your service has processed it, so an outage on your side doesn't lead Lob to disable your subscription.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Lob webhooks reliably in minutes.