Automatically Download New Mail Scans
Save every new document scan to cloud storage or an external system.
Overview
When Stable scans the inside of a piece of mail, we send a mailItem.scan.completed webhook. This recipe explores how you can use Webhooks to automatically download every new document scan from your Stable account and save it to your cloud storage (Google Drive, Box, Dropbox, etc.), or to another system like Salesforce.
At a high level, the steps you'll complete are:
- Choose the right scan event for your use case.
- Build a new webhook handler that receives the event.
- Receive and verify the webhook.
- Retrieve the Mail Item and download the document scan.
- Upload the file to another system.
- Register and test your webhook endpoint.
Before you start
- You must have an API key. See Authentication.
- You must be able to create and host Webhooks that Stable can reach over HTTPS.
- You must have a storage account or system with API access, such as Google Drive or Salesforce.
Step 1: Choose the right event for your use case
Before you build your integration, decide which event you'd like to subscribe to:
- mailItem.scan.completed: emitted as soon as the document scan PDF is available.
- mailItem.scan.ocrCompleted: emitted when OCR on the scan has completed. The scan PDF will contain text overlays, and extracted text will be available as a separate property on the Mail Item.
Both of these events return the full Mail Item. If you need to perform any text matching or extraction on the scan contents in your webhook handler, we suggest you use mailItem.scan.ocrCompleted. You can read about the different properties in Scans, OCR, and Summaries.
Here is a minimal subset of the event you'll receive:
{
"eventType": "mailItem.scan.completed",
"data": {
"id": "b67416b1-8b44-40d8-9842-6c457acc4bf8",
"from": "John Doe",
"createdAt": "2026-10-01T09:50:00Z",
...
"scanDetails": {
"status": "completed",
"imageUrl": "https://...", // The document scan PDF
"ocrResultUrls": null,
"summary": null
}
}
}Step 2: Build your webhook handler
Build the webhook handler in the language or framework of your choice. Your webhook must:
- Be accessible publicly over HTTPS.
- Accept
application/jsoncontent types. - Implement signature verification. In this example, we use the Svix libraries, but you can also implement webhook signature verification manually.
For a quick primer on verifying webhook signatures, use the following:
Your handler needs to do the following:
- Receive the event as a JSON payload.
- Verify it using the raw body and its headers.
- Filter for the event you're catching (here, we use mailItem.scan.completed).
- Handle your business logic to save or process the scan.
Here is an example handler that implements these steps:
import express from 'express';
import { Webhook } from 'svix';
const app = express();
const webhook = new Webhook(process.env.STABLE_WEBHOOK_SECRET);
app.post('/webhooks/stable', express.raw({ type: 'application/json' }), async (req, res) => {
let event;
try {
event = webhook.verify(req.body, req.headers);
} catch {
return res.status(400).send();
}
if (event.eventType === 'mailItem.scan.completed') {
await saveScan(event.data.id);
}
res.status(200).send();
});
app.listen(3000);import os
from flask import Flask, request
from svix.webhooks import Webhook, WebhookVerificationError
app = Flask(__name__)
webhook = Webhook(os.environ["STABLE_WEBHOOK_SECRET"])
@app.post("/webhooks/stable")
def handle_stable_webhook():
try:
event = webhook.verify(request.get_data(), request.headers)
except WebhookVerificationError:
return "", 400
if event["eventType"] == "mailItem.scan.completed":
save_scan(event["data"]["id"])
return "", 200Note the following:
- The raw body is passed to the verification library rather than the parsed JSON.
saveScan(...)is called with the Mail Item ID rather than the payload. Although the payload contains the point-in-time state of the Mail Item, consider re-fetching a fresh object to account for any processing delays or expired URLs when replaying events. You can read more about these considerations in Webhooks.
Step 3: Handle the scan
Implement saveScan to send the scan to your system. In this example, it does three things:
- Re-fetches the Mail Item using the Retrieve a mail item API.
- Downloads the scan PDF from
scanDetails.imageUrl. - Uploads the PDF to your system.
const getMailItem = async (mailItemId) => {
const response = await fetch(`https://api.usestable.com/v1/mail-items/${mailItemId}`, {
headers: { 'x-api-key': process.env.STABLE_API_KEY },
});
if (!response.ok) throw new Error(`Failed to retrieve mail item: ${response.status}`);
return response.json();
};
const saveScan = async (mailItemId) => {
const mailItem = await getMailItem(mailItemId);
const scan = await fetch(mailItem.scanDetails.imageUrl);
if (!scan.ok) throw new Error(`Failed to download scan: ${scan.status}`);
const upload = await fetch('https://storage.example.com/files', {
method: 'POST',
headers: {
'Content-Type': 'application/pdf',
'X-File-Name': `${mailItem.id}.pdf`,
},
body: await scan.arrayBuffer(),
});
if (!upload.ok) throw new Error(`Failed to upload scan: ${upload.status}`);
};import requests
def get_mail_item(mail_item_id):
response = requests.get(
f"https://api.usestable.com/v1/mail-items/{mail_item_id}",
headers={"x-api-key": os.environ["STABLE_API_KEY"]},
)
response.raise_for_status()
return response.json()
def save_scan(mail_item_id):
mail_item = get_mail_item(mail_item_id)
scan = requests.get(mail_item["scanDetails"]["imageUrl"])
scan.raise_for_status()
upload = requests.post(
"https://storage.example.com/files",
headers={
"Content-Type": "application/pdf",
"X-File-Name": f"{mail_item['id']}.pdf",
},
data=scan.content,
)
upload.raise_for_status()Note the following:
scanDetails.imageUrlexpires after 24 hours.- If any request fails,
saveScanthrows an error and your handler returns an error status. Stable then retries the event. Only return HTTP 200 when you have successfully handled the scan.
Step 4: Register and test your webhook
Deploy your handler, then register it with Stable:
- In the Stable dashboard, go to Developer > Webhooks.
- Add your handler's URL as a new endpoint.
- Select the event you chose in Step 1.
- Copy the endpoint secret and set it as
STABLE_WEBHOOK_SECRETin your handler's environment.
To test your webhook:
- Select your endpoint in the Stable dashboard, then select the Testing tab.
- Select the event type, then send the example event.
- Check that your webhook handler correctly verifies the payload. It may be unable to process the sample event, since the ID on the sample event won't be accessible to your API key. You can test the full flow by requesting a scan of a Mail Item in the dashboard.
Recap
You've built a webhook handler that saves every new Stable document scan to another system.
Keep these things in mind as you complete your integration:
- As best practice, re-fetch the Mail Item when you handle an event. Scan URLs expire after 24 hours, and if you ever need to replay older events, this will make your webhook handler resilient to retries.
- Stable may send the same event more than once. Use the Mail Item ID to overwrite existing files instead of creating copies.
- Return an error status when processing fails, so Stable retries the event.
See Webhooks, Mail Item Lifecycle, and Scans, OCR, and Summaries to keep learning about the concepts explored in this recipe.
Updated about 3 hours ago
