> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.usglobalmail.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.usglobalmail.com/_mcp/server.

# Webhooks

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.

> **Note**
>
> 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

#### Open the webhook settings

Go to **Settings → Developer → Webhooks** in your
[USGM dashboard](https://inbox.usglobalmail.com/app/settings/developer/webhooks).

#### 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.

#### 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

| Event             | Fires when                                                                 |
| ----------------- | -------------------------------------------------------------------------- |
| `new-mail`        | A new mail item arrives in the mailbox.                                    |
| `scan-update`     | A scan request changes status — most usefully when it reaches `COMPLETED`. |
| `open-update`     | An open-and-scan (unboxing) request changes status.                        |
| `shipment-update` | A 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.

> **Note**
>
> 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`

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

`id` is the mail item's id — pass it to [`GET /v1/mails/{id}`](/api/api-reference) to pull the full
item. `weight` is in pounds and `measurement` is in inches.

### `scan-update`

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

`uuid` is the scan's id (the one [`GET /v1/scans/{id}`](/api/api-reference) 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`

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

### `shipment-update`

```json
{
  "id": "b3ba0f4f-9ae6-4256-8b70-cd5f404756a9",
  "status": "PROCESSED",
  "service": "DHL Express Easy",
  "tracking_data": "1234567890",
  "auto_forward": "false",
  "destination_address": {
    "name": "Home Address",
    "address_line": "2204 18th Street NW",
    "address_line_2": "Apt 3B",
    "address_line_3": "",
    "city": "Washington",
    "state": "District of Columbia",
    "postal_code": "20009",
    "country": "United States",
    "address_type": "residential",
    "tax_id": "123-45-6789",
    "phone_number": "+1-202-555-0143"
  },
  "message": "Shipment request updated"
}
```

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

> **Warning**
>
> `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:

| Header                   | Meaning                                                   |
| ------------------------ | --------------------------------------------------------- |
| `USGM-Webhook-Signature` | HMAC-SHA256 of the signed string, hex-encoded.            |
| `USGM-Webhook-Timestamp` | When 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.

> **Warning**
>
> 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.

**`Node.js`**

```javascript title="Node.js"
const express = require('express');
const crypto = require('crypto');

const app = express();
const webhookSecret = process.env.USGM_WEBHOOK_SECRET;

// Keep the raw body — the signature is computed over the exact bytes we received.
app.use(express.json({
  verify: (req, res, buf) => {
    req.rawBody = buf;
  }
}));

app.post('/webhook', (req, res) => {
  const signature = req.get('USGM-Webhook-Signature');
  const timestamp = req.get('USGM-Webhook-Timestamp');

  if (!signature || !timestamp) {
    return res.status(400).send('Missing signature or timestamp');
  }

  const dataToSign = `${timestamp}.${req.rawBody.toString()}`;
  const expectedSignature = crypto
    .createHmac('sha256', webhookSecret)
    .update(dataToSign)
    .digest('hex');

  const valid = crypto.timingSafeEqual(
    Buffer.from(signature, 'utf8'),
    Buffer.from(expectedSignature, 'utf8')
  );

  if (!valid) {
    return res.status(401).send('Invalid signature');
  }

  // req.body is the event payload — handle it, then reply 2xx.
  res.status(200).send('Webhook received');
});

app.listen(3000);
```

**`Python`**

```python title="Python"
from flask import Flask, request, jsonify
import hmac
import hashlib
import os

app = Flask(__name__)
WEBHOOK_SECRET = os.environ['USGM_WEBHOOK_SECRET'].encode()

@app.route('/webhook', methods=['POST'])
def webhook():
    signature = request.headers.get('USGM-Webhook-Signature')
    timestamp = request.headers.get('USGM-Webhook-Timestamp')
    payload = request.get_data()  # raw bytes, not request.json

    if not signature or not timestamp:
        return 'Missing signature or timestamp', 400

    data_to_sign = f'{timestamp}.{payload.decode()}'.encode()
    expected_signature = hmac.new(WEBHOOK_SECRET, data_to_sign, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(expected_signature, signature):
        return 'Invalid signature', 401

    return jsonify({'message': 'Webhook received'}), 200

if __name__ == '__main__':
    app.run(port=5000)
```

**`PHP`**

```php title="PHP"
<?php

$secret = getenv('USGM_WEBHOOK_SECRET');
$payload = file_get_contents('php://input'); // raw body
$headers = getallheaders();

$timestamp = $headers['USGM-Webhook-Timestamp'] ?? null;
$signature = $headers['USGM-Webhook-Signature'] ?? null;

if (!$timestamp || !$signature) {
    http_response_code(400);
    exit('Missing headers');
}

$dataToSign = $timestamp . '.' . $payload;
$expected = hash_hmac('sha256', $dataToSign, $secret);

if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit('Invalid signature');
}

$data = json_decode($payload, true);
http_response_code(200);
echo 'Webhook verified';
```

**`Go`**

```go title="Go"
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"io"
	"net/http"
	"os"
)

var webhookSecret = []byte(os.Getenv("USGM_WEBHOOK_SECRET"))

func verifySignature(timestamp, payload, signature string) bool {
	data := timestamp + "." + payload
	mac := hmac.New(sha256.New, webhookSecret)
	mac.Write([]byte(data))
	expected := hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(expected), []byte(signature))
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
	timestamp := r.Header.Get("USGM-Webhook-Timestamp")
	signature := r.Header.Get("USGM-Webhook-Signature")

	body, _ := io.ReadAll(r.Body)
	payload := string(body)

	if timestamp == "" || signature == "" || !verifySignature(timestamp, payload, signature) {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}

	w.WriteHeader(http.StatusOK)
	w.Write([]byte("Webhook verified"))
}

func main() {
	http.HandleFunc("/webhook", webhookHandler)
	http.ListenAndServe(":8080", nil)
}
```

## 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.

> **Warning**
>
> 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`](/api/api-reference).
* **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

| Limit                       | Value                                   |
| --------------------------- | --------------------------------------- |
| Active webhooks per account | 10 — inactive ones don't count          |
| Endpoints per event         | Many — but each URL only once per event |
| Retries                     | None                                    |
| Result URL lifetime         | 3 days                                  |
| Plan                        | Requires a plan that includes webhooks  |