Bevell API
Image to DXF API
Turn photos, sketches, scans and PDF drawings into editable DXF and true-scale SVG from your own scripts and products. Every mode the converter has, with webhooks. Part of Bevell Unlimited, with an MCP server for AI assistants in early access.
Loading your API settings…
Quickstart
Three calls: start a conversion, ask until it has finished, download the file. Conversions run in a queue, so the first call answers at once and the drawing follows, usually within a minute.
# 1. Start a conversion (answers at once with its id) curl https://bevell.app/api/v1/conversions \ -H "Authorization: Bearer $BEVELL_API_KEY" \ -H "Idempotency-Key: bracket-0001" \ -F file=@bracket.png \ -F mode=lineart \ -F scale=0.1 # 2. Ask until status is succeeded or failed curl https://bevell.app/api/v1/conversions/$ID \ -H "Authorization: Bearer $BEVELL_API_KEY" # 3. Download the DXF (or files/svg) curl -o bracket.dxf https://bevell.app/api/v1/conversions/$ID/files/dxf \ -H "Authorization: Bearer $BEVELL_API_KEY"
import os, time, requests
API = "https://bevell.app/api/v1"
headers = {"Authorization": f"Bearer {os.environ['BEVELL_API_KEY']}"}
with open("bracket.png", "rb") as f:
r = requests.post(
f"{API}/conversions",
headers={**headers, "Idempotency-Key": "bracket-0001"},
files={"file": f},
data={"mode": "lineart", "scale": "0.1"},
)
if not r.ok:
raise SystemExit(r.json()["error"]["message"])
conversion = r.json()
while conversion["status"] in ("queued", "running"):
time.sleep(conversion.get("retryAfterMs", 2000) / 1000)
conversion = requests.get(f"{API}/conversions/{conversion['id']}", headers=headers).json()
if conversion["status"] != "succeeded":
raise SystemExit(conversion["error"]["message"])
dxf = requests.get(conversion["result"]["files"]["dxf"], headers=headers)
dxf.raise_for_status()
with open("bracket.dxf", "wb") as out:
out.write(dxf.content)import { readFile, writeFile } from "node:fs/promises";
const API = "https://bevell.app/api/v1";
const headers = { Authorization: `Bearer ${process.env.BEVELL_API_KEY}` };
const form = new FormData();
form.append("file", new Blob([await readFile("bracket.png")], { type: "image/png" }), "bracket.png");
form.append("mode", "lineart");
form.append("scale", "0.1");
const started = await fetch(`${API}/conversions`, {
method: "POST",
headers: { ...headers, "Idempotency-Key": "bracket-0001" },
body: form,
});
if (!started.ok) throw new Error((await started.json()).error.message);
let conversion = await started.json();
while (conversion.status === "queued" || conversion.status === "running") {
await new Promise((resolve) => setTimeout(resolve, conversion.retryAfterMs ?? 2000));
conversion = await (await fetch(`${API}/conversions/${conversion.id}`, { headers })).json();
}
if (conversion.status !== "succeeded") throw new Error(conversion.error.message);
const dxf = await fetch(conversion.result.files.dxf, { headers });
await writeFile("bracket.dxf", Buffer.from(await dxf.arrayBuffer()));Authentication
Send your key as a bearer token: Authorization: Bearer bvl_…. Make and revoke keys on this page while signed in, up to 5 at a time. A key is shown once, when it is made, so keep it in your secrets store. Keys are for servers and scripts: the API sends no CORS headers, so a key never belongs in a web page.
The API is part of Bevell Unlimited. If the subscription ends, every call answers 402 subscription_required; conversions already started still finish, and your files stay in your account.
Base URL: https://bevell.app/api/v1. Every answer to a call with a key carries a Bevell-Request-Id header; quote it if you write to us about a request.
Starting a conversion
POST /conversions as multipart form data, with one input: file (PNG, JPEG or WebP up to 10 MB, PDF or TIFF up to 50 MB), or sourceConversionId to convert an earlier upload of yours again, or retraceConversionId with scale to size an AI redraw. Without a file the fields can also be sent as a JSON body. It answers 202 with the conversion, status: "queued".
| Field | What it does | Default |
|---|---|---|
| mode | auto picks what bevell.app would for the file, and modeReason says why. Or name one: precise (outline of a photographed object), default (AI redraw), lineart (Direct trace of a drawing or scan), logo (flat-colour artwork) | auto |
| scale | Millimetres per pixel of the upload. Measure one known length on the image and divide. Left out, a PDF is sized at print size and a TIFF at its own resolution, in lineart and precise; anything else at 0.025. | auto |
| page | Which page of a PDF, from 1. A TIFF is traced from its first page. | 1 |
| specialised | none, pcb or blueprint | none |
| pcbOutline | board or measured, for pcb | board |
| readText | true reads lettering as CAD text. Off unless you ask, blueprints included. | false |
| refineNote | What to change, up to 200 characters. Needs mode set to default or logo. | none |
| metadata | A JSON object of up to 20 string values, returned on the conversion and its webhooks: your order number, a customer id | none |
Leave mode out and the API picks the mode the converter on the site would for the same file: Direct trace for a PDF or a drawing, Outline only for a scanned part, Logo for flat-colour artwork and AI redraw for a photo. The conversion says which in mode and why in modeReason. AI redraw and Logo send the upload, and any refineNote, to OpenAI to redraw it, and count towards the daily AI generations. So with no mode a photo goes to OpenAI; send mode=precise for an outline of a photo with no AI step.
Send an Idempotency-Key header (8 to 128 letters, digits and . _ : -) and a retry after a lost answer returns the same conversion, counted once, with Idempotent-Replayed: true. The same key with a different file or options is 409 idempotency_conflict.
Sizing an AI redraw
AI redraw draws at its own size, so it is measured on its drawing, not on the photo. Nothing is generated again, and it does not count against the AI allowance.
# AI redraw draws at its own size. Fetch its marked drawing, # measure a feature you know in pixels, then trace it again at that size: curl -o drawing.png https://bevell.app/api/v1/conversions/$ID/files/drawing \ -H "Authorization: Bearer $BEVELL_API_KEY" # A 40 mm hole that measures 320 px across is 40 / 320 = 0.125 mm per pixel curl https://bevell.app/api/v1/conversions \ -H "Authorization: Bearer $BEVELL_API_KEY" \ -F retraceConversionId=$ID \ -F scale=0.125
Another mode, or a refine
# The same upload in another mode, or refined, without sending it again curl https://bevell.app/api/v1/conversions \ -H "Authorization: Bearer $BEVELL_API_KEY" \ -F sourceConversionId=$ID \ -F mode=default \ -F refineNote="Leave out the text"
Following a conversion
GET /conversions/{id} answers with the conversion. Its status moves from queued to running to succeeded or failed, or cancelled; while it is unfinished, retryAfterMs says how long to wait before asking again. A failure is not an HTTP error: the conversion is failed and its error.message says what to try, such as another mode for a noisy scan.
{
"id": "6c1e…",
"object": "conversion",
"status": "succeeded",
"channel": "api",
"mode": "lineart",
"modeReason": null,
"options": { "scale": 0.1, "scaleFrom": "request", "page": 1, "specialised": "none", "pcbOutline": "board", "readText": false, "refineNote": null },
"input": { "fileName": "bracket.png", "contentType": "image/png", "bytes": 482113, "sourceConversionId": null, "retraceConversionId": null },
"metadata": { "order": "1042" },
"createdAt": "2026-10-15T12:00:00Z",
"startedAt": "2026-10-15T12:00:02Z",
"completedAt": "2026-10-15T12:00:41Z",
"progress": null,
"error": null,
"result": {
"files": { "dxf": "https://bevell.app/api/v1/conversions/6c1e…/files/dxf", "svg": "…", "preview": "…" },
"stats": { "paths": 212, "page": 1, "pageCount": 1 },
"refined": null,
"previewBackground": "dark"
},
"retryAfterMs": null
}GET /conversions lists your conversions newest first, the ones made on bevell.app since 23 September 2026 included: limit (1 to 100, default 20), status, and startingAfter with the last id of the page before.
DELETE /conversions/{id} deletes a conversion and all its files, and cannot be undone. One still waiting is cancelled first, and its webhook says conversion.cancelled. A conversion already running cannot be stopped: delete it once it has finished.
Files
GET /conversions/{id}/files/{file} answers with the file itself. The links are also in result.files. Downloads are included in Unlimited and spend no download credits.
| File | What it is |
|---|---|
| dxf | The drawing, with no watermark. Opens in any CAD package. |
| svg | The same drawing as true-scale SVG. A logo's is its filled artwork. |
| preview | The linework as a watermarked PNG |
| points | The preview with the fitted points marked |
| drawing | AI redraw's drawing, marked, to measure on before a re-trace |
| overlay | AI redraw fitted on the photo, when it could be |
| overlay-points | The overlay with the fitted points marked |
A file of an unfinished conversion is 409 not_ready, and of a failed one 409 conversion_failed. An SVG that could not be written is 409 format_unavailable; the DXF is always there.
Limits
Fair use, per account. A conversion that fails or is cancelled does not count. A daily limit answers with resetsAt, when the oldest counted conversion turns 24 hours old, and a Retry-After header. Need more? Write to natalie@bevell.app.
| Limit | Number | Past it |
|---|---|---|
| Conversions running at once | 1 | The next waits in the queue and starts when the last finishes |
| Conversions unfinished at once | 10 | 429 too_many_pending |
| Conversions in any 24 hours | 300 | 429 daily_limit_reached |
| AI generations in any 24 hours (AI redraw, Logo, refines) | 30 | 429 ai_daily_limit_reached |
| Requests a minute, polling included | 300 | 429 rate_limited |
| Keys | 5 | 409 too_many_keys |
| Webhook endpoints | 3 | 409 too_many_endpoints |
Errors
Every error has the same shape: { "error": { "code", "message", "field", "resetsAt" } }. Branch on code; show message to a person.
| Status | Codes |
|---|---|
| 400 | invalid_request (with field), idempotency_key_invalid |
| 401 | unauthorized: no key, or an unknown or revoked one |
| 402 | subscription_required |
| 404 | not_found, source_unavailable |
| 409 | idempotency_conflict, not_ready, conversion_failed, conversion_running, format_unavailable, too_many_keys, too_many_endpoints |
| 413, 415, 422 | file_too_large, unsupported_file, unreadable_file |
| 429 | rate_limited, too_many_pending, daily_limit_reached, ai_daily_limit_reached |
| 500, 503 | internal_error, service_unavailable (with Retry-After). Nothing was charged or granted. |
Webhooks
Instead of asking, have Bevell tell you. Add up to 3 https endpoints on this page or with POST /webhook-endpoints, and each conversion you started through the API sends conversion.succeeded, conversion.failed or conversion.cancelled when it ends.
{
"id": "6f0c…",
"type": "conversion.succeeded",
"createdAt": "2026-10-15T12:00:00Z",
"data": { "conversion": { "id": "6c1e…", "status": "succeeded", "metadata": { "order": "1042" }, "result": { "files": { "dxf": "…" } } } }
}Each delivery is signed with the endpoint's secret (whsec_…, shown once): Bevell-Signature: t=…,v1=…, an HMAC-SHA256 of the timestamp, a dot and the raw body. Check it, and refuse a timestamp more than five minutes old. Any 2xx within 10 seconds counts as delivered; anything else is tried again 7 times over about 11 hours. A delivery can arrive twice, so dedupe on Bevell-Webhook-Id. Send test delivers type: "ping" with data.endpointId and no conversion.
import crypto from "node:crypto";
// rawBody: the request body exactly as it arrived, before any JSON parsing.
// secret: the endpoint's whole secret, whsec_ included.
export function verifyBevellWebhook(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(signatureHeader.split(",").map((part) => part.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
const given = Buffer.from(parts.v1 ?? "", "hex");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}import hashlib, hmac, time
# raw_body: the request body exactly as it arrived, as bytes.
# secret: the endpoint's whole secret, whsec_ included.
def verify_bevell_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:
parts = dict(part.split("=", 1) for part in signature_header.split(","))
t = int(parts.get("t", "0"))
if abs(time.time() - t) > 300:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))AI assistants (MCP)
Bevell's MCP server lets Claude Desktop, Claude Code, Cursor and other assistants that run a local server convert a file on your computer and save the DXF beside it: "turn ~/Desktop/bracket.jpg into a DXF, 120 mm wide". It runs on your machine with your key and calls this API. It is in early access while it is packaged for npm: write to natalie@bevell.app with the assistant you use and we will send it. Once it is on your machine, the configuration looks like this:
{
"mcpServers": {
"bevell": {
"command": "node",
"args": ["/path/to/bevell-mcp/index.js"],
"env": { "BEVELL_API_KEY": "bvl_..." }
}
}
}