Process Stroke Data
Process handwriting stroke coordinates directly, without needing to generate an image first. Supports the same options as image processing but with lower latency.
Send stroke data
Send stroke data to the v3/strokes endpoint. Strokes are represented as arrays of x and y coordinates, where each sub-array is one continuous stroke.
The request body has a double-nested strokes key:
- The outer
strokesis the API parameter name - The inner
strokescontains the coordinate data (xandyarrays)
The example below sends four strokes that form the handwritten expression :
x and y coordinate arrays.- Request body
- cURL
- Python
- JavaScript / TypeScript
- Go
- Java
{
"strokes": {
"strokes": {
"x": [
[131,131,130,130,131,133,136,146,151,158,161,162,162,162,162,159,155,147,142,137,136,138,143,160,171,190,197,202,202,202,201,194,189,177,170,158,153,150,148],
[231,231,233,235,239,248,252,260,264,273,277,280,282,283],
[273,272,271,270,267,262,257,249,243,240,237,235,234,234,233,233],
[296,296,297,299,300,301,301,302,303,304,305,306,306,305,304,298,294,286,283,281,281,282,284,284,285,287,290,293,294,299,301,308,309,314,315,316]
],
"y": [
[213,213,212,211,210,208,207,206,206,209,212,217,220,227,230,234,236,238,239,239,239,239,239,239,241,247,252,259,261,264,266,269,270,271,271,271,270,269,268],
[231,231,232,235,238,246,249,257,261,267,270,272,273,274],
[230,230,230,231,234,240,246,258,268,273,277,281,281,283,283,284],
[192,192,191,189,188,187,187,187,188,188,190,193,195,198,200,205,208,213,215,215,215,214,214,214,214,216,218,220,221,223,223,223,223,221,221,220]
]
}
}
}
curl -X POST https://api.mathpix.com/v3/strokes \
-H 'app_id: APP_ID' \
-H 'app_key: APP_KEY' \
-H 'Content-Type: application/json' \
--data '{ "strokes": {"strokes": {
"x": [[131,131,130,130,131,133,136,146,151,158,161,162,162,162,162,159,155,147,142,137,136,138,143,160,171,190,197,202,202,202,201,194,189,177,170,158,153,150,148],[231,231,233,235,239,248,252,260,264,273,277,280,282,283],[273,272,271,270,267,262,257,249,243,240,237,235,234,234,233,233],[296,296,297,299,300,301,301,302,303,304,305,306,306,305,304,298,294,286,283,281,281,282,284,284,285,287,290,293,294,299,301,308,309,314,315,316]],
"y": [[213,213,212,211,210,208,207,206,206,209,212,217,220,227,230,234,236,238,239,239,239,239,239,239,241,247,252,259,261,264,266,269,270,271,271,271,270,269,268],[231,231,232,235,238,246,249,257,261,267,270,272,273,274],[230,230,230,231,234,240,246,258,268,273,277,281,281,283,283,284],[192,192,191,189,188,187,187,187,188,188,190,193,195,198,200,205,208,213,215,215,215,214,214,214,214,216,218,220,221,223,223,223,223,221,221,220]]
}}}'
import requests, json
strokes_data = {
"strokes": {
"x": [
[131,131,130,130,131,133,136,146,151,158,161,162,162,162,162,159,155,147,142,137,136,138,143,160,171,190,197,202,202,202,201,194,189,177,170,158,153,150,148],
[231,231,233,235,239,248,252,260,264,273,277,280,282,283],
[273,272,271,270,267,262,257,249,243,240,237,235,234,234,233,233],
[296,296,297,299,300,301,301,302,303,304,305,306,306,305,304,298,294,286,283,281,281,282,284,284,285,287,290,293,294,299,301,308,309,314,315,316]
],
"y": [
[213,213,212,211,210,208,207,206,206,209,212,217,220,227,230,234,236,238,239,239,239,239,239,239,241,247,252,259,261,264,266,269,270,271,271,271,270,269,268],
[231,231,232,235,238,246,249,257,261,267,270,272,273,274],
[230,230,230,231,234,240,246,258,268,273,277,281,281,283,283,284],
[192,192,191,189,188,187,187,187,188,188,190,193,195,198,200,205,208,213,215,215,215,214,214,214,214,216,218,220,221,223,223,223,223,221,221,220]
]
}
}
r = requests.post("https://api.mathpix.com/v3/strokes",
json={"strokes": strokes_data},
headers={
"app_id": "APP_ID",
"app_key": "APP_KEY",
"Content-type": "application/json"
}
)
print(json.dumps(r.json(), indent=4, sort_keys=True))
const response = await fetch("https://api.mathpix.com/v3/strokes", {
method: "POST",
headers: {
app_id: "APP_ID",
app_key: "APP_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({
strokes: {
strokes: {
x: [
[131,131,130,130,131,133,136,146,151,158,161,162,162,162,162,159,155,147,142,137,136,138,143,160,171,190,197,202,202,202,201,194,189,177,170,158,153,150,148],
[231,231,233,235,239,248,252,260,264,273,277,280,282,283],
[273,272,271,270,267,262,257,249,243,240,237,235,234,234,233,233],
[296,296,297,299,300,301,301,302,303,304,305,306,306,305,304,298,294,286,283,281,281,282,284,284,285,287,290,293,294,299,301,308,309,314,315,316],
],
y: [
[213,213,212,211,210,208,207,206,206,209,212,217,220,227,230,234,236,238,239,239,239,239,239,239,241,247,252,259,261,264,266,269,270,271,271,271,270,269,268],
[231,231,232,235,238,246,249,257,261,267,270,272,273,274],
[230,230,230,231,234,240,246,258,268,273,277,281,281,283,283,284],
[192,192,191,189,188,187,187,187,188,188,190,193,195,198,200,205,208,213,215,215,215,214,214,214,214,216,218,220,221,223,223,223,223,221,221,220],
],
},
},
}),
});
const result = await response.json();
console.log(JSON.stringify(result, null, 2));
body := bytes.NewBufferString(`{
"strokes": {"strokes": {
"x": [[131,131,130,130,131,133,136,146,151,158,161,162,162,162,162,159,155,147,142,137,136,138,143,160,171,190,197,202,202,202,201,194,189,177,170,158,153,150,148],[231,231,233,235,239,248,252,260,264,273,277,280,282,283],[273,272,271,270,267,262,257,249,243,240,237,235,234,234,233,233],[296,296,297,299,300,301,301,302,303,304,305,306,306,305,304,298,294,286,283,281,281,282,284,284,285,287,290,293,294,299,301,308,309,314,315,316]],
"y": [[213,213,212,211,210,208,207,206,206,209,212,217,220,227,230,234,236,238,239,239,239,239,239,239,241,247,252,259,261,264,266,269,270,271,271,271,270,269,268],[231,231,232,235,238,246,249,257,261,267,270,272,273,274],[230,230,230,231,234,240,246,258,268,273,277,281,281,283,283,284],[192,192,191,189,188,187,187,187,188,188,190,193,195,198,200,205,208,213,215,215,215,214,214,214,214,216,218,220,221,223,223,223,223,221,221,220]]
}}
}`)
req, _ := http.NewRequest("POST", "https://api.mathpix.com/v3/strokes", body)
req.Header.Set("app_id", "APP_ID")
req.Header.Set("app_key", "APP_KEY")
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
result, _ := io.ReadAll(resp.Body)
fmt.Println(string(result))
HttpClient client = HttpClient.newHttpClient();
String body = """
{
"strokes": { "strokes": {
"x": [[131,131,130,130,131,133,136,146,151,158,161,162,162,162,162,159,155,147,142,137,136,138,143,160,171,190,197,202,202,202,201,194,189,177,170,158,153,150,148],[231,231,233,235,239,248,252,260,264,273,277,280,282,283],[273,272,271,270,267,262,257,249,243,240,237,235,234,234,233,233],[296,296,297,299,300,301,301,302,303,304,305,306,306,305,304,298,294,286,283,281,281,282,284,284,285,287,290,293,294,299,301,308,309,314,315,316]],
"y": [[213,213,212,211,210,208,207,206,206,209,212,217,220,227,230,234,236,238,239,239,239,239,239,239,241,247,252,259,261,264,266,269,270,271,271,271,270,269,268],[231,231,232,235,238,246,249,257,261,267,270,272,273,274],[230,230,230,231,234,240,246,258,268,273,277,281,281,283,283,284],[192,192,191,189,188,187,187,187,188,188,190,193,195,198,200,205,208,213,215,215,215,214,214,214,214,216,218,220,221,223,223,223,223,221,221,220]]
}}
}
""";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.mathpix.com/v3/strokes"))
.header("app_id", "APP_ID")
.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());
{
"request_id": "cea6b8e4-0ab4-550a-c467-ce2eb00430be",
"is_printed": false,
"is_handwritten": true,
"auto_rotate_confidence": 0.0020149118193977245,
"auto_rotate_degrees": 0,
"confidence": 1,
"confidence_rate": 1,
"latex_styled": "3 x^{2}",
"text": "\\( 3 x^{2} \\)",
"version": "SuperNet-200"
}
In the example response, the latex_styled field renders as:
Reading the response
Each request is recognized on its own
A request is recognized from the strokes it contains and nothing else. There is no memory of what you sent before, and sending the same session ID does not carry context forward.
This matters most for a live canvas, because the natural implementation — keep every stroke the user has drawn, and resend the whole set after each change — asks us what the whole page says. And we answer about the whole page:
{
"text": "\\( \\begin{array}{l}2 x+3=7 \\\\ 2 x=4\\end{array} \\)",
"latex_styled": "\\begin{array}{l}\n2 x+3=7 \\\\\n2 x=4\n\\end{array}",
"confidence": 1
}
Multiple expressions are combined into a single \begin{array}{l} block (or \begin{aligned} if you send idiomatic_eqn_arrays). This is not an error and not a setting you have missed — it is the correct answer to the question the request asked.
Send one expression per request. You get one clean LaTeX string back, each expression is recognized without the rest of the page influencing it, and the request stops growing as the page fills. Split the strokes on the client: you already know where the expressions are, because you captured the coordinates that drew them. A vertical-gap test between stroke bounding boxes, or a pen-idle timer, is enough — and both are instant, where asking us is a round trip.
latex_styled is optional — read text as well
latex_styled is only present when the input can be rendered as a single LaTeX string. It is omitted when the input holds:
- more than one line of text
- a chemistry diagram
- a chart
- a table with merged cells (
\multirow/\multicol)
text is populated whenever anything is recognized, as Mathpix Markdown with math in \( \) and \[ \] delimiters. So a client that renders only when latex_styled is present will silently do nothing on those responses — and on a live canvas that reads as the display freezing on whatever it last drew, rather than as an error.
// Wrong: does nothing at all on any response without latex_styled.
if (result.latex_styled) render(result.latex_styled);
// Right: latex_styled when it is there, text otherwise.
const math = result.latex_styled || result.text;
if (math) render(math);
Live stroke sessions
For live digital ink with updating results (e.g., in a mobile app), use app tokens with stroke sessions:
- Get an
app_tokenwithstrokes_session_idfrom your server:
{
"include_strokes_session_id": true,
"expires": 300
}
- Use the
app_tokenandstrokes_session_idin client requests to v3/strokes.
Live stroke sessions are billed differently from standalone requests — see pricing. You are billed the first time strokes are sent for a session, not when requesting the token.
Working example
Mathpix/live-math-drawing-demo is a small open-source React app that implements this whole loop: a drawing canvas, a live stroke session, and the recognized math rendered underneath as you write.
Three things it already solves that are easy to get wrong:
- Rolling session renewal. A stroke session token lives at most 300 seconds. The demo mints a replacement shortly before the current one expires and carries on without interrupting the drawing, which is what any session longer than five minutes needs.
- Strikethrough and scribble to delete. Draw a line through something already written and those strokes are removed from the set before the next request.
- The full render path, from stroke coordinates to displayed math.
Note that the demo requests the app token from the browser, so the API key ends up in the client bundle. That is fine for running it locally, but in production request the token from your own server and hand only the token to the client — see app tokens.
Capturing good strokes
Recognition quality depends heavily on how many points your client actually captures. A canvas driven by mousemove receives at most one point per rendered frame, roughly a quarter of what a stylus reports, and short fast strokes can degenerate to a single point.
- Listen for pointer events (
pointerdown/pointermove/pointerup/pointercancel) rather than mouse events, and settouch-action: noneon the canvas. - Inside
pointermove, record every point fromevent.getCoalescedEvents(), not just the event's own coordinate. The browser buffers the samples the digitizer produced between frames and discards them unless you ask. - Call
canvas.setPointerCapture(event.pointerId)onpointerdown, so a stroke that leaves the canvas is not silently truncated. - Record the
pointerdownandpointerupcoordinates as points too. - Size the canvas for
devicePixelRatio, or coordinates are quantized to CSS pixels.
Send the raw sample points. Do not smooth or thin them first — smoothing is for what you draw on screen, not for what you send.
Next steps
- v3/strokes reference — Full request parameters and response schema
- Authentication — App tokens for client-side stroke sessions
- live-math-drawing-demo — Open-source React reference implementation of a live stroke session