Webhooks
A webhook is a signed HTTPS request Mathpix sends to the callback_url on your submission when
that document or batch reaches its final state. A webhook replaces polling for status with being
told.
The notification carries the same state you would read by polling. Polling keeps working exactly as today, and polling remains the authoritative record: if a notification is ever missed, nothing is lost, and the status endpoints still answer.
This is a separate mechanism from the callback object on v3/text, v3/latex, and v3/batch.
Those endpoints keep working exactly as documented today.
| Endpoint | Description |
|---|---|
| GET /files/v1/webhook-config | Your signing secret |
| POST /files/v1/webhook-config/test | Send a test notification to a URL you name |
Two sections describe the requests Mathpix makes to you:
| Section | Description |
|---|---|
| Callback parameters at submission | callback_url, callback_headers, callback_events on the four submission endpoints |
| The notification | The request body, the signature, and the delivery guarantees |
GET /files/v1/webhook-config
GET api.mathpix.com/files/v1/webhook-config
Returns your signing secret, creating it on the first call. The secret belongs to the application
the calling app_key authenticates, so every key on that application returns the same secret, and
creating another key does not produce a new one. The secret is your only stored webhook setting:
where a notification goes, what headers it carries, and which events fire are all
parameters on the submission.
signing_secret is sensitive information, stored like a password. It is the key notifications are
signed with, and the same key your handler verifies them with.
Example
- cURL
- Python
- JavaScript / TypeScript
- Go
- Java
curl https://api.mathpix.com/files/v1/webhook-config \
-H 'app_key: APP_KEY'
import requests
r = requests.get("https://api.mathpix.com/files/v1/webhook-config",
headers={"app_key": "APP_KEY"},
)
print(r.json())
const response = await fetch("https://api.mathpix.com/files/v1/webhook-config", {
headers: { app_key: "APP_KEY" },
});
const config = await response.json();
console.log(config.signing_secret); // "whsec_REDACTED"
req, _ := http.NewRequest("GET", "https://api.mathpix.com/files/v1/webhook-config", nil)
req.Header.Set("app_key", "APP_KEY")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
config, _ := io.ReadAll(resp.Body)
fmt.Println(string(config))
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.mathpix.com/files/v1/webhook-config"))
.header("app_key", "APP_KEY")
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
{
"signing_secret": "whsec_REDACTED"
}
Response body
signing_secret The key you verify notification signatures with. Created on your first call to this endpoint.
Errors
| Code | HTTP | When it fires |
|---|---|---|
unauthorized | 401 | The request carries no app_key header (Missing app_key), or an app_key that does not resolve to an account (Invalid app_key). |
POST /files/v1/webhook-config/test
POST api.mathpix.com/files/v1/webhook-config/test
Sends one signed sample notification to the callback_url in the request body, and answers
synchronously with the outcome. You see delivered, or exactly what your endpoint answered instead,
in one round trip, before any real document is involved.
Because the target is named per call, you can verify an endpoint before any submission points at it, and verify a second endpoint without disturbing the first.
The test is a single attempt with no retries: you are present to read the answer, fix your handler,
and call again. Up to 10 test notifications per minute can be sent per app_key; beyond that, the
call answers a rate limit error until the next minute begins.
The sample notification is a file.completed body carrying the reserved diagnostic identifier
00000000-0000-0000-0000-000000000000. That identifier is never a real document, so your handler
can recognize test notifications structurally and keep them out of business logic.
Request parameters
callback_url The endpoint to send the test notification to. The same rules as callback_url at submission:
HTTPS only, and URLs that resolve to private or internal networks are rejected.
callback_headers Headers to send with the test, so you can verify your own authentication too. The same rules as
callback_headers at submission.
Example
- Request body
- cURL
- Python
- JavaScript / TypeScript
- Go
- Java
{
"callback_url": "https://your-app.example.com/mathpix/webhook",
"callback_headers": { "Authorization": "Bearer <a token you issued>" }
}
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" }'
import requests
r = requests.post("https://api.mathpix.com/files/v1/webhook-config/test",
json={"callback_url": "https://your-app.example.com/mathpix/webhook"},
headers={
"app_key": "APP_KEY",
"Content-Type": "application/json",
},
)
print(r.json()) # {"status": "delivered", ...}
const response = await fetch("https://api.mathpix.com/files/v1/webhook-config/test", {
method: "POST",
headers: {
app_key: "APP_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({
callback_url: "https://your-app.example.com/mathpix/webhook",
}),
});
const outcome = await response.json();
console.log(outcome.status); // "delivered"
body := bytes.NewBufferString(`{
"callback_url": "https://your-app.example.com/mathpix/webhook"
}`)
req, _ := http.NewRequest("POST", "https://api.mathpix.com/files/v1/webhook-config/test", body)
req.Header.Set("app_key", "APP_KEY")
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
outcome, _ := io.ReadAll(resp.Body)
fmt.Println(string(outcome))
HttpClient client = HttpClient.newHttpClient();
String body = """
{
"callback_url": "https://your-app.example.com/mathpix/webhook"
}
""";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.mathpix.com/files/v1/webhook-config/test"))
.header("app_key", "APP_KEY")
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
{
"status": "delivered",
"response_code": 200,
"detail": null
}
Response body
status delivered when your endpoint accepted the notification, failed otherwise. A test is one
attempt, so delivering never appears here.
response_code The HTTP status your endpoint answered, or null when no answer arrived.
detail A plain sentence saying what went wrong, or null when the notification was delivered.
An endpoint that refuses the test is not an error in this call. The request succeeded, and the answer describes what your endpoint did, so an endpoint that never answered reads like this:
{
"status": "failed",
"response_code": null,
"detail": "no response was received: Connection refused"
}
Errors
| Code | HTTP | When it fires |
|---|---|---|
unauthorized | 401 | The request carries no app_key header (Missing app_key), or an app_key that does not resolve to an account (Invalid app_key). |
bad_request | 400 | The request has no body (Missing request body), names no callback_url (callback_url is required: the endpoint to send the test notification to), or gives one that is not a string (callback_url must be a string). |
bad_request | 400 | callback_headers is not an object of string values (callback_headers must be an object of string values), carries more than ten headers (callback_headers: at most 10 headers are allowed), exceeds the total size cap (callback_headers: headers exceed the total size cap of 4096 bytes), names a header Mathpix sets (callback_headers: header name is reserved: Mathpix-Signature), or names one that is not a valid HTTP token (callback_headers: header name is not a valid HTTP token: bad header). |
Callback parameters at submission
All four submission endpoints accept the same optional fields in their existing request body:
POST /v3/pdfPOST /files/v1POST /files/v1/uriPOST /files/v1/jobs
callback_url HTTPS URL Mathpix sends this submission's notifications to. URLs that resolve to private or internal networks are rejected with a bad request response at submission time. A submission without one is not notified at all.
callback_headers Sent on this submission's notifications, so you can authenticate the request on your side. Belongs
with callback_url: set both together, because a submission that sets callback_url and no
callback_headers is notified with no headers. At most 10 headers, 4 KB in total. Names must be
valid HTTP header tokens (custom names like X-My-Auth are fine), but these are reserved and
rejected with a 400: Host, Content-Type, Content-Length, Transfer-Encoding, Connection,
User-Agent, and any Mathpix- name (such as Mathpix-Signature).
callback_events The events to receive for this submission. An empty array turns notifications off for
this one submission, keeping its callback_url in place.
Errors
A callback parameter the submission endpoint refuses fails the whole submission, and no document is created, so the identifier the error body echoes has nothing behind it.
| Code | HTTP | When it fires |
|---|---|---|
bad_request | 400 | callback_headers or callback_events without callback_url (callback_headers requires callback_url: without it this submission sends no notifications). |
bad_request | 400 | callback_url is empty, is not a string, or is not https (callback_url must be a non-empty https URL), or resolves to a private or internal address (callback_url rejected: <reason>). |
bad_request | 400 | callback_headers is not an object of string values (callback_headers must be a JSON object of string values), or one of its names is reserved or malformed (callback_headers rejected: header name is reserved: Mathpix-Signature). |
bad_request | 400 | callback_events names an event that does not exist (callback_events allows only: file.error, file.completed, job.completed). |
Events
| Event | Fires | Default |
|---|---|---|
file.completed | Per document, once it and all requested conversions finish | On for single-file submissions; opt-in for batches |
file.error | Per document that fails | On for single-file submissions; opt-in for batches |
job.completed | Once, after you finalize a batch and every document (with its conversions) has finished | On for /files/v1/jobs batches |
Which events a submission receives is resolved in two steps:
- The submission's own
callback_events, when present, decides alone, on every submission endpoint, including the empty array as the off-switch. - Otherwise the submission's shape decides: a batch receives
job.completedonly, and a single-file submission receives both per-document events.
Set callback_events on the submission whenever you want anything other than the default for its
shape.
file.completed fires only once the document's OCR result and every conversion format the submission
requested have settled, so a requested docx or md is downloadable the moment the notification
arrives. A conversion that fails does not turn the event into file.error: the OCR result is there,
so file.completed still fires, and the failed format's status is visible in formats.
file.error is reserved for the document itself failing.
job.completed requires finalizing the jobA job is an open container: job_id is yours to choose, so any later request naming the same job
appends to it. Mathpix therefore cannot tell a batch that has finished from one whose next request has
not arrived, and will not guess. It sends job.completed only once you have said the batch is
complete, by calling
POST /files/v1/jobs/{job_id}/finalize.
A job you never finalize never sends job.completed. Its documents' file.completed and
file.error notifications are unaffected, and GET /files/v1/jobs/{job_id} reports its state as
always.
Finalize whenever you are done submitting, before or after the documents finish. Finalizing a batch whose documents are all already done sends the notification in one to two minutes; finalizing one still processing sends it one to two minutes after the last document finishes. A job has to be quiet for a minute before the notification is sent, and a check runs every minute, so the wait is one minute plus up to one more.
A batch's job.completed fires once for the job. Finalizing is one-way, so there is no second one to
send; a job that needs more files is a new job.
A batch's job.completed and its documents' file.completed notifications have no guaranteed
order. In practice the job notification arrives last: each document's own notification is sent as
that document finishes, and the batch's one to two minutes after the final one.
The three single-file endpoints (POST /v3/pdf, POST /files/v1, and POST /files/v1/uri) have
exactly one document and therefore one event, file.completed or file.error, delivered
at least once, so your handler can still receive it twice. You can disable
notifications for that document by adding a request-level callback_events: [].
Subscribing to file.completed on a large batch means one notification per document, delivered at
our processing pace. Before doing that, read receiving at volume.
The notification
A notification is an HTTPS POST request to your webhook URL:
- The request carries
Content-Type: application/json, theMathpix-Signatureheader,User-Agent: Mathpix-Webhook/1, and anycallback_headersyou configured. The user agent is stable, so you can allowlist or filter on it; its version rises only if what we send changes. - Any answer with an HTTP status from 200 through 299 counts as delivered.
- Your endpoint has 10 seconds to answer; a slower answer counts as no answer and the attempt is retried.
- Redirects are never followed: an answer that redirects counts as a rejected notification, the same as a client error.
The body carries the same state polling would return:
statusiscompletedorerror, with the same meaning as the status endpoints.- Error details use the same
erroranderror_infoshape as every other error in this API. - A field that does not apply is omitted, never sent as null: read fields by name and treat an absent one as not applicable.
A notification identifies the document and reports its outcome. It does not carry the document's content or a link to it: fetch the result the same way you do when polling.
{
"event": "file.completed",
"file_id": "b1c9c3a8-55e4-4a09-b7d0-218ba5de4c4d",
"custom_id": "invoice-001",
"job_id": "contracts-2026-05",
"status": "completed",
"num_pages": 30,
"num_pages_completed": 30,
"formats": {
"docx": "completed",
"md": "completed"
}
}
The formats map appears because that submission requested docx and md. A submission that
requested no conversion format has no such map in its notification.
When you submitted the document with a destination_uri, the notification echoes where the result
was written, including the basename after any server-side defaulting, which you cannot derive:
{
"event": "file.completed",
"file_id": "b1c9c3a8-55e4-4a09-b7d0-218ba5de4c4d",
"status": "completed",
"destination_uri": "s3://my-bucket/out/",
"destination_basename": "invoice-001",
"num_pages": 30,
"num_pages_completed": 30
}
file.error carries the same identifiers with status set to error, plus the error and
error_info pair used everywhere else in this API:
{
"event": "file.error",
"file_id": "b1c9c3a8-55e4-4a09-b7d0-218ba5de4c4d",
"status": "error",
"error": "content_too_large",
"error_info": { "id": "content_too_large", "message": "Document exceeds the page limit for this endpoint." },
"num_pages": 30,
"num_pages_completed": 12
}
A batch's job.completed reports the job rather than one document, with the same counts
GET /files/v1/jobs/{job_id} returns:
{
"event": "job.completed",
"job_id": "contracts-2026-05",
"status": "completed",
"file_count": 500,
"files_completed": 498,
"files_errored": 2,
"num_pages_sent": 14500,
"num_pages_completed": 14440
}
status is completed for a finished batch even when some of its documents failed: files_errored
is where that shows. There is no file_id: to see which documents failed, list the job's files with
status=error.
Notification fields
event Which event this notification carries: file.completed, file.error, or
job.completed. Always present; read this first.
file_id The document the notification is about. Present on file.completed and file.error; a
job.completed notification has no single document and carries job_id instead.
custom_id Your identifier for the document, echoed when the submission carried one. File events only.
job_id The batch the document belongs to. On file events, present when the document was submitted as part
of a batch; on job.completed, always present.
status completed or error on file events, with the same meaning as the status endpoints. On
job.completed, always completed: the batch finished, and the two count fields carry the
outcome.
destination_uri Where the result was written, echoed on file.completed when the submission carried a
destination_uri.
destination_basename The output basename at the destination, after any server-side defaulting, which you cannot derive.
Present together with destination_uri.
error The error code, on file.error only. The same code the status endpoints return.
error_info The error's id and message pair, on file.error only. The same shape as every other error in
this API.
num_pages The document's page count, on file events. 0 when the document failed before we could read it, so
a file.error with num_pages: 0 never started processing.
num_pages_completed How many of the document's pages finished, on file events; on job.completed, how many pages
finished across the whole batch. On file.error this is how far processing got before the failure.
formats Each requested conversion format's own outcome, on file.completed and only when the submission
requested conversions. An object keyed by format, each value "completed" or "error", for example
{ "docx": "completed", "md": "error" }. Identical to the formats object
GET /files/v1/{file_id} returns, so one handler reads both. A failed format
does not make the event file.error; the document's OCR result is still ready.
file_count How many documents the batch contains. job.completed only, as are the counts below.
files_completed How many documents finished successfully.
files_errored How many documents failed. Above zero, list the failures through the job's
file listing with status=error.
num_pages_sent How many pages the batch submitted for processing. Pages are counted once a document is split, so a
batch whose documents all failed before that reports 0 here against a non-zero files_errored.
Fetching the result requires your API key, exactly like polling: use the notification's file_id
with the status and result endpoints. The notification by itself grants
no access to the document.
The signature
Every notification carries a Mathpix-Signature header:
Mathpix-Signature: t=1721760000,v1=REDACTED
To verify the signature: compute a hash-based message authentication code (HMAC) with the SHA-256
hash function over the timestamp, a period, and the raw request body, using your signing secret as
the key. Compare your result to the
v1 value with a constant-time comparison, and reject any notification whose t is more than
five minutes old. Complete, runnable verification code in Python and JavaScript is in the
webhooks guide.
Compute the HMAC over the raw request bytes, before any JSON parsing. Re-encoding the body can reorder keys or change whitespace, and the signature will not match.
The t value is compared against the clock on the machine running your handler, so that machine
needs a synchronized clock, from the network time protocol or whatever your platform provides. A
clock more than five minutes away from real time rejects every notification, in either direction,
and if your handler answers a client error when it rejects one, we treat your endpoint as
misconfigured and stop retrying that notification. Nothing is lost, because the result is still
there to poll, but no notification arrives until the clock is corrected.
Delivery, retries, and what we do not promise
What we promise:
- A notification is sent only after the state it reports is true. Completed means completed: the results are there when the notification arrives.
- At least one delivery attempt, and retries for about an hour. If your endpoint answers with a server error or does not answer in time, we retry with growing delays: 8 attempts in total, approximately 30 seconds after the first failure, then 1, 2, 4, 8, 16, and 32 minutes.
- Your endpoint can pace retries. An answer of HTTP status 429 (too many requests) is retried
like a server error, and a numeric
Retry-Afterheader on that answer sets the wait before the next attempt: never less than 1 second, and never more than the 32-minute longest delay of the retry schedule. ARetry-Aftervalue in date form is ignored. - Every notification is verifiable. The signature proves the notification came from Mathpix.
What we deliberately do not promise, and what to do about each:
- Exactly one delivery. You may receive the same notification twice. Deduplicate on
eventplusfile_id(eventplusjob_idforjob.completed): if you have already processed that pair, acknowledge and skip. Each delivery reflects the document's state at the moment that delivery was sent, exactly as polling at that moment would; keeping whichever copy you process first is safe. - Delivery forever. If your endpoint stays unreachable past the retry window, that notification is never sent again. The result still exists: poll the status endpoint and fetch the result as usual. A missed notification never loses a result.
- Order. Two documents from one batch may notify in any order.
- Retries after your endpoint rejects a notification. If your endpoint answers with a client error, for example 404 not found or 401 unauthorized, we treat your URL as misconfigured for that notification and stop retrying; HTTP status 429 is the one client error that is retried. Fix the endpoint, then rely on polling for anything missed in the meantime.
Answer with a success status as soon as you have durably accepted the notification, and do your processing afterward. A slow answer counts as no answer and turns a success into a retry, which you will see as a duplicate.
Receiving at volume
Notifications arrive at our processing pace, not yours. A batch of one hundred thousand documents
subscribed to file.completed is one hundred thousand requests to your endpoint within the
processing window.
For batches at that scale and beyond:
- Subscribe to
job.completedplusfile.error. - Have results written to your storage with
destination_uri. - List per-file outcomes through the job's file listing.
For the largest workloads, one notification per document does not fit; your account team can advise on queue-based delivery.
See also
- Webhooks guide: the integration as a checklist, with a verified handler in Python and JavaScript.
- Async document lifecycle: the status endpoints the notification body mirrors, and result download.
- Async batch document processing: batch submission, where
callback_eventsapplies.