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.

curl
# 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"
Python (requests)
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)
Node 20+ (fetch)
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".

FieldWhat it doesDefault
modeauto 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
scaleMillimetres 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
pageWhich page of a PDF, from 1. A TIFF is traced from its first page.1
specialisednone, pcb or blueprintnone
pcbOutlineboard or measured, for pcbboard
readTexttrue reads lettering as CAD text. Off unless you ask, blueprints included.false
refineNoteWhat to change, up to 200 characters. Needs mode set to default or logo.none
metadataA JSON object of up to 20 string values, returned on the conversion and its webhooks: your order number, a customer idnone

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.

curl
# 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

curl
# 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.

A finished conversion
{
  "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.

FileWhat it is
dxfThe drawing, with no watermark. Opens in any CAD package.
svgThe same drawing as true-scale SVG. A logo's is its filled artwork.
previewThe linework as a watermarked PNG
pointsThe preview with the fitted points marked
drawingAI redraw's drawing, marked, to measure on before a re-trace
overlayAI redraw fitted on the photo, when it could be
overlay-pointsThe 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.

LimitNumberPast it
Conversions running at once1The next waits in the queue and starts when the last finishes
Conversions unfinished at once10429 too_many_pending
Conversions in any 24 hours300429 daily_limit_reached
AI generations in any 24 hours (AI redraw, Logo, refines)30429 ai_daily_limit_reached
Requests a minute, polling included300429 rate_limited
Keys5409 too_many_keys
Webhook endpoints3409 too_many_endpoints

Errors

Every error has the same shape: { "error": { "code", "message", "field", "resetsAt" } }. Branch on code; show message to a person.

StatusCodes
400invalid_request (with field), idempotency_key_invalid
401unauthorized: no key, or an unknown or revoked one
402subscription_required
404not_found, source_unavailable
409idempotency_conflict, not_ready, conversion_failed, conversion_running, format_unavailable, too_many_keys, too_many_endpoints
413, 415, 422file_too_large, unsupported_file, unreadable_file
429rate_limited, too_many_pending, daily_limit_reached, ai_daily_limit_reached
500, 503internal_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.

The body
{
  "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.

Verify in Node
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);
}
Verify in Python
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:

claude_desktop_config.json
{
  "mcpServers": {
    "bevell": {
      "command": "node",
      "args": ["/path/to/bevell-mcp/index.js"],
      "env": { "BEVELL_API_KEY": "bvl_..." }
    }
  }
}