Webhooks

Get a callback the moment mail arrives — or a scan, open request, or shipment changes — instead of polling.

A webhook tells your system that something happened in the mailbox as it happens. Instead of polling /v1/mails on a timer, you give USGM an HTTPS endpoint and we POST a JSON payload to it the moment mail arrives or a request changes state — enough to drive a CRM, a Slack alert, or any downstream automation.

Each subscription pairs one event with one endpoint URL. Every delivery is signed with a secret only you and USGM know, so you can prove it came from us before you act on it.

Webhooks are configured in the dashboard — there are no webhook endpoints in the REST API. They require a plan that includes webhooks; if yours doesn’t, the settings page explains how to upgrade.

Set up a webhook

1

Open the webhook settings

Go to Settings → Developer → Webhooks in your USGM dashboard.

2

Add a subscription

Choose an event, enter your endpoint URL, and click Add. You can point several endpoints at the same event — they all get the delivery — but the same URL can only be registered once per event; adding it again changes nothing.

3

Send a test

Use Send test on the new subscription to POST a sample payload to your endpoint. It’s the fastest way to confirm your signature check works before real mail arrives.

You can deactivate a subscription to pause delivery without losing it, reactivate it later, or delete it permanently. An account can have up to 10 active webhooks — only active ones count, so deactivating one frees a slot.

Events

EventFires when
new-mailA new mail item arrives in the mailbox.
scan-updateA scan request changes status — most usefully when it reaches COMPLETED.
open-updateAn open-and-scan (unboxing) request changes status.
shipment-updateA shipment request changes status.

Payloads

Every delivery is a POST with Content-Type: application/json. The body is the event payload — there is no envelope, so the event type is implied by the endpoint you registered for it.

Payload fields mirror the mailroom’s internal vocabulary, so statuses and types are uppercase here (INBOX, LETTER, COMPLETED) — unlike the REST API, which returns them lowercase.

new-mail

1{
2 "id": 111111,
3 "mail_type": "LETTER",
4 "mail_status": "INBOX",
5 "weight": "0.03",
6 "measurement": { "width": "9", "height": "0.01", "length": "4" },
7 "recipient_name": "John Smith",
8 "sender_name": "US Government",
9 "image_url": "https://usgm-fe-media-prod.s3.us-east-1.amazonaws.com/sample_mail.jpg",
10 "message": "New mail received."
11}

id is the mail item’s id — pass it to GET /v1/mails/{id} to pull the full item. weight is in pounds and measurement is in inches.

scan-update

1{
2 "id": 111111,
3 "uuid": "b3ba0f4f-9ae6-4256-8b70-cd5f404756a9",
4 "mail_type": "LETTER",
5 "current_status": "COMPLETED",
6 "scan_result_url": "https://usgm-fe-media-prod.s3.amazonaws.com/sample.pdf",
7 "ai_label": "Invoice",
8 "ai_summary": "Invoice #INV-2026-0423 from Acme Corp for $142.50, due April 30, 2026.",
9 "recipient_name": "John Smith",
10 "sender_name": "Acme Corp",
11 "message": "Your scan request has been updated!!"
12}

uuid is the scan’s id (the one GET /v1/scans/{id} takes); id is the mail item it belongs to. ai_label and ai_summary are only present on plans that include scan labeling.

open-update

1{
2 "id": 111111,
3 "uuid": "b3ba0f4f-9ae6-4256-8b70-cd5f404756a9",
4 "mail_type": "PACKAGE",
5 "current_status": "COMPLETED",
6 "open_result_url": "https://usgm-fe-media-prod.s3.amazonaws.com/sample.pdf",
7 "recipient_name": "John Smith",
8 "sender_name": "Acme Corp",
9 "message": "Your open request has been updated!!"
10}

shipment-update

1{
2 "id": "b3ba0f4f-9ae6-4256-8b70-cd5f404756a9",
3 "status": "PROCESSED",
4 "service": "DHL Express Easy",
5 "tracking_data": "1234567890",
6 "auto_forward": "false",
7 "destination_address": {
8 "name": "Home Address",
9 "address_line": "2204 18th Street NW",
10 "address_line_2": "Apt 3B",
11 "address_line_3": "",
12 "city": "Washington",
13 "state": "District of Columbia",
14 "postal_code": "20009",
15 "country": "United States",
16 "address_type": "residential",
17 "tax_id": "123-45-6789",
18 "phone_number": "+1-202-555-0143"
19 },
20 "message": "Shipment request updated"
21}

Here id is the shipment’s own id (a UUID). tracking_data is the carrier tracking number once the shipment has one.

scan_result_url, open_result_url, and image_url are pre-signed links that expire 3 days after delivery. Download the file and store it yourself rather than saving the URL.

Verifying the signature

Anyone can POST to a public URL, so verify every request before acting on it. Each delivery carries two headers:

HeaderMeaning
USGM-Webhook-SignatureHMAC-SHA256 of the signed string, hex-encoded.
USGM-Webhook-TimestampWhen we signed it — a Unix timestamp in milliseconds.

The signed string is the timestamp, a period, and the raw request body:

{timestamp}.{raw_body}

Compute the HMAC-SHA256 of that string with your webhook secret, hex-encode it, and compare it to the signature header using a timing-safe comparison.

Sign the raw body bytes, exactly as received. Parsing the JSON and re-serializing it can change key order or spacing, which produces a different signature and fails verification.

1const express = require('express');
2const crypto = require('crypto');
3
4const app = express();
5const webhookSecret = process.env.USGM_WEBHOOK_SECRET;
6
7// Keep the raw body — the signature is computed over the exact bytes we received.
8app.use(express.json({
9 verify: (req, res, buf) => {
10 req.rawBody = buf;
11 }
12}));
13
14app.post('/webhook', (req, res) => {
15 const signature = req.get('USGM-Webhook-Signature');
16 const timestamp = req.get('USGM-Webhook-Timestamp');
17
18 if (!signature || !timestamp) {
19 return res.status(400).send('Missing signature or timestamp');
20 }
21
22 const dataToSign = `${timestamp}.${req.rawBody.toString()}`;
23 const expectedSignature = crypto
24 .createHmac('sha256', webhookSecret)
25 .update(dataToSign)
26 .digest('hex');
27
28 const valid = crypto.timingSafeEqual(
29 Buffer.from(signature, 'utf8'),
30 Buffer.from(expectedSignature, 'utf8')
31 );
32
33 if (!valid) {
34 return res.status(401).send('Invalid signature');
35 }
36
37 // req.body is the event payload — handle it, then reply 2xx.
38 res.status(200).send('Webhook received');
39});
40
41app.listen(3000);

The webhook secret

Your secret is shown on the Settings → Developer → Webhooks page and is shared by every webhook on the account. Reset secret rotates it — deliveries are signed with the new secret immediately, so roll it out to your endpoint at the same time.

Treat the secret like a password: keep it in an environment variable or a secrets manager, never in source control or client-side code. Reset it immediately if it leaks.

Delivery behavior

  • Respond quickly with a 2xx. Acknowledge first and do the real work in a background job — a slow endpoint holds the delivery open.
  • Failed deliveries are not retried. A non-2xx, a timeout, or an unreachable endpoint means that event is missed, so treat webhooks as a fast notification layer rather than a system of record. If you need to be certain nothing was dropped, reconcile periodically against GET /v1/mails.
  • Expect duplicates and out-of-order arrival. Make your handler idempotent — key on id/uuid rather than assuming each event lands exactly once, in order.
  • Use HTTPS. Payloads carry mail metadata and, for shipments, the destination address.

Limits

LimitValue
Active webhooks per account10 — inactive ones don’t count
Endpoints per eventMany — but each URL only once per event
RetriesNone
Result URL lifetime3 days
PlanRequires a plan that includes webhooks