Webhooks: get notified when your documents finish
When your document finishes processing, Mathpix sends a signed notification to your webhook URL, and your code reacts to the notification instead of polling for status.
- A handler that receives notifications and verifies each one came from Mathpix
- Proof your webhook URL and handler work, before any real document is involved
- A clear answer for when a notification does not arrive
The endpoint-by-endpoint contract is in the webhooks API reference.
1. Set up your notification handler
Decide your webhook URL
Your webhook URL is the URL where your handler receives notifications, for example
https://your-app.example.com/mathpix/webhook. You send it as the callback_url on each
submission; step 2 verifies it first.
Your webhook URL must meet two requirements:
- The URL uses HTTPS.
- The URL is reachable from the internet. URLs on private networks are rejected.
Fetch your signing secret
Every notification Mathpix sends is signed with your signing secret.
curl https://api.mathpix.com/files/v1/webhook-config -H 'app_key: APP_KEY'
The signing secret is sensitive information, stored where you store passwords. It is the key notifications are signed with, and the same key your handler verifies them with.
Write the handler
A webhook handler has exactly three jobs:
- Prove the request came from Mathpix. Recompute the signature from the raw request bytes and your signing secret. Reject any request whose signature does not match, or whose timestamp is more than five minutes old.
- Answer early. Acknowledge with a success status first, process afterward. A slow answer counts as no answer and causes a retry.
- Tolerate the same notification twice. Delivery is at least once. Acknowledge a duplicate
and skip it; never process it again. A duplicate is a notification you have already seen with
the same
eventandfile_id, or the sameeventandjob_idforjob.completed.
Our Python client, mpxpy, carries a verify_signature
function, so a Python handler can use that instead of the signature code below.
- Python (Flask)
- JavaScript (Express)
import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
application = Flask(__name__)
# Read the secret from your configuration. Fetch it once with GET /files/v1/webhook-config.
SIGNING_SECRET = os.environ["MATHPIX_WEBHOOK_SIGNING_SECRET"]
TOLERANCE_SECONDS = 300
def signature_is_valid(header: str, raw_body: bytes, secret: str) -> bool:
"""Check that a notification was signed by Mathpix and is recent.
The header looks like "t=1721760000,v1=<hexadecimal digest>". The digest
is an HMAC (SHA-256) over "<t>.<raw body>", keyed with your signing
secret. Returns False for a missing or malformed header, a timestamp
outside the tolerance window, or a digest that does not match.
"""
try:
parts = dict(item.split("=", 1) for item in header.split(","))
timestamp = int(parts["t"])
received = parts["v1"]
except (KeyError, ValueError, AttributeError):
return False
# Reject notifications that are too old (or too far in the future):
# they could be replays of a request an attacker captured earlier.
if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
return False
signed_payload = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received)
@application.post("/mathpix/webhook")
def mathpix_webhook():
header = request.headers.get("Mathpix-Signature", "")
if not signature_is_valid(header, request.get_data(), SIGNING_SECRET):
abort(400)
notification = request.get_json()
# What your own code does here:
# - deduplicate: skip a notification already processed, matching on event with
# file_id, or event with job_id for job.completed
# - answer first, work afterward: put the notification on your queue and return,
# so reading the document never delays the answer
queue_for_processing(notification)
return "", 200
const crypto = require("node:crypto");
const express = require("express");
const application = express();
// Read the secret from your configuration. Fetch it once with GET /files/v1/webhook-config.
const SIGNING_SECRET = process.env.MATHPIX_WEBHOOK_SIGNING_SECRET;
const TOLERANCE_SECONDS = 300;
/**
* Check that a notification was signed by Mathpix and is recent.
*
* The header looks like "t=1721760000,v1=<hexadecimal digest>". The digest
* is an HMAC (SHA-256) over "<t>.<raw body>", keyed with your signing
* secret. Returns false for a missing or malformed header, a timestamp
* outside the tolerance window, or a digest that does not match.
*/
function signatureIsValid(header, rawBody, secret) {
const parts = new URLSearchParams((header || "").replaceAll(",", "&"));
const timestamp = Number.parseInt(parts.get("t") ?? "", 10);
const received = parts.get("v1");
if (!Number.isFinite(timestamp) || !received) return false;
// Reject notifications that are too old (or too far in the future):
// they could be replays of a request an attacker captured earlier.
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest("hex");
const receivedBytes = Buffer.from(received, "hex");
const expectedBytes = Buffer.from(expected, "hex");
return receivedBytes.length === expectedBytes.length
&& crypto.timingSafeEqual(expectedBytes, receivedBytes);
}
// express.raw so the signature check sees the exact bytes that were signed
application.post("/mathpix/webhook", express.raw({ type: "application/json" }), (request, response) => {
if (!signatureIsValid(request.get("Mathpix-Signature"), request.body, SIGNING_SECRET)) {
return response.sendStatus(400);
}
const notification = JSON.parse(request.body);
// What your own code does here:
// - deduplicate: skip a notification already processed, matching on event with
// file_id, or event with job_id for job.completed
// - answer first, work afterward: put the notification on your queue and return,
// so reading the document never delays the answer
queueForProcessing(notification);
response.sendStatus(200);
});
Done when: your handler is deployed, and the signing secret is in your configuration, not in your code.
2. Send yourself a test notification
-
Send a test notification to your webhook URL:
curl -X POST https://api.mathpix.com/files/v1/webhook-config/test \
-H 'app_key: APP_KEY' \
-H 'Content-Type: application/json' \
--data '{ "callback_url": "https://your-app.example.com/mathpix/webhook" }' -
Read the response to the test call. The response is immediate:
delivered, or what your endpoint answered instead. Fix your handler and repeat until the response isdelivered.
The test notification carries the reserved diagnostic identifier
00000000-0000-0000-0000-000000000000 as its file_id. That identifier is never a real document,
so your handler can tell test notifications apart from real work.
Done when: the test call answers delivered, and your handler's log shows one verified notification.
3. Submit a document and receive its notification
Submit a document through any of the submission endpoints (POST /v3/pdf, POST /files/v1,
POST /files/v1/uri, POST /files/v1/jobs), adding callback_url to the call. A single document
notifies as it finishes, and a batch you finalize sends one
job.completed notification at the end; choosing different events for a batch is covered
below.
curl -X POST https://api.mathpix.com/files/v1/uri \
-H 'app_key: APP_KEY' \
-H 'Content-Type: application/json' \
--data '{
"source_uri": "s3://my-bucket/a.pdf",
"callback_url": "https://your-app.example.com/mathpix/webhook"
}'
The submission response is not the notification
- The immediate response (
{"file_id": ...}) only confirms the submission was accepted. - The notification is a separate request, from Mathpix to your handler, sent when the document
finishes. The notification is the request with an
eventfield.
Fetch the document's result
A document's file.completed waits for its OCR result and every conversion format the submission
requested, so a requested docx or md is ready to download the moment the notification arrives.
When the notification arrives, fetch the document's result:
- From the status and result endpoints, with the notification's
file_idand your API key. - From your own storage, if you submitted the document with
destination_uri.
For batches, choose which events to receive
Set callback_events on the batch submission:
- A batch's default event is the single
job.completednotification for the whole batch, sent one to two minutes after every document in it (and each document's conversions) has finished or failed and carrying the same counts the job status endpoint reports. job.completedis sent only after you finalize the job. CallPOST /files/v1/jobs/{job_id}/finalizewhen you have submitted every file you intend to, which is what tells Mathpix the batch is complete rather than still being assembled. A job you never finalize never sendsjob.completed; its documents' own events are unaffected.- Subscribe to
file.erroras well to hear about failures as they happen, and addfile.completedif you want a notification per document as each one finishes. job.completedcarries the batch's counts, not its documents. When it arrives, readfiles_errored; if it is not zero, list the job's files withstatus=errorto see which ones failed.- Per-document success notifications on large batches mean one request per document. Read receiving at volume first.
callback_events: []on any submission, batch or single-file, turns notifications off for just that submission, keeping itscallback_urlin place.
Done when: one real document produced its notification, and your system fetched the result without a human involved.
4. Handle failures and missed notifications
Poll for documents whose notification never arrived
A failed delivery is retried with increasing delays, eight attempts in total over about an hour, and not beyond that, so a notification can still be missed. This schedule makes a missed notification harmless:
- Keep your own list of submitted file identifiers.
- Mark each one resolved as its notification arrives.
- On a schedule, poll anything still unresolved after your expected processing time.
When every notification fails verification, check the clock
The signature carries the time it was signed, and your handler compares it against its own clock. A handler on a machine whose clock is more than five minutes out rejects every notification, including the test notification, with a signature that is otherwise perfectly valid. Keep the clock synchronized, from the network time protocol or whatever your platform provides.
The test notification is the quickest way to tell this apart from a delivery problem. A handler that rejects a correctly signed notification answers your rejection status, and the test call reports it back:
{
"status": "failed",
"response_code": 400,
"detail": "your endpoint answered with HTTP status 400"
}
The notification reached your handler, so the address and the network are fine, and the signature was valid when it was sent. A clock outside the five-minute window is the first thing to check.
Rejecting with a client error also tells Mathpix your endpoint is misconfigured for that notification, so it is not retried. Poll for anything submitted while the clock was wrong.
Handle a failed document
A file.error notification means the work itself failed, not the delivery:
- Read
error_info. Some errors are temporary and worth one resubmission; others will fail identically every time. - A conversion format that fails does not produce
file.error: the OCR result is there, sofile.completedstill fires, and the failed format's status is informats.file.erroris reserved for the document itself failing. - Never resubmit a document because a notification was missed. The result already exists, and resubmitting pays for the work again.
Done when: the polling schedule runs unattended, and your team polls the document's status first when a notification is missing.