Submitting drawings
Send the drawing as multipart/form-data, with the file in a part named file, to POST /v1/extractions:
#!/usr/bin/env bashset -euo pipefailAPI=https://api.speccapture.comAUTH="Authorization: Bearer $SPECCAPTURE_API_KEY"
DRAWING=bracket.pdf # the drawing to send: a PDF, TIFF, PNG, JPEG, BMP or WebP
job=$(curl -sS --fail-with-body "$API/v1/extractions" \ -H "$AUTH" \ -H "Idempotency-Key: $(openssl rand -hex 16)" \ -F "file=@$DRAWING") || { echo "$job" >&2; exit 1; }jq -r '"Job \(.jobId) is \(.status): \(.sheetCount) sheets, \(.credits.reserved) credits reserved"' <<<"$job"Save as submit.sh, set SPECCAPTURE_API_KEY, and run: bash submit.sh. Needs curl; scripts also use bash and jq.
using System.Text.Json;
using HttpClient http = new() { BaseAddress = new Uri("https://api.speccapture.com") };http.DefaultRequestHeaders.Authorization = new("Bearer", Environment.GetEnvironmentVariable("SPECCAPTURE_API_KEY"));
string drawing = args[0]; // the drawing to send: a PDF, TIFF, PNG, JPEG, BMP or WebP
using MultipartFormDataContent form = new() { { new StreamContent(File.OpenRead(drawing)), "file", Path.GetFileName(drawing) } };JsonElement job = await Json(await Call(HttpMethod.Post, "/v1/extractions", form, Guid.NewGuid().ToString()));Console.WriteLine($"Job {job.GetProperty("jobId")} is {job.GetProperty("status")}: {job.GetProperty("sheetCount")} sheets, {job.GetProperty("credits").GetProperty("reserved")} credits reserved");
// Sends a request; stops with the problem's code and detail if the API refuses it.async Task<HttpResponseMessage> Call(HttpMethod method, string path, HttpContent? body = null, string? idempotencyKey = null) { using HttpRequestMessage request = new(method, path) { Content = body }; if (idempotencyKey is not null) request.Headers.Add("Idempotency-Key", idempotencyKey); HttpResponseMessage response = await http.SendAsync(request); if (!response.IsSuccessStatusCode) { JsonElement problem = await Json(response); string? detail = (problem.TryGetProperty("detail", out JsonElement text) ? text : problem.GetProperty("title")).GetString(); Console.Error.WriteLine($"{(int)response.StatusCode} {problem.GetProperty("code")}: {detail}"); Environment.Exit(1); } return response;}
// Reads an answer as JSON.static async Task<JsonElement> Json(HttpResponseMessage response) => JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;Save as submit.cs, set SPECCAPTURE_API_KEY, and run: dotnet run submit.cs -- bracket.pdf. Needs .NET 10 or later.
import osimport sysimport uuid
import requests
API = "https://api.speccapture.com"session = requests.Session()session.headers["Authorization"] = f"Bearer {os.environ['SPECCAPTURE_API_KEY']}"
def call(method, path, **kwargs): """Send a request; stop with the problem's code and detail if the API refuses it.""" response = session.request(method, API + path, **kwargs) if not response.ok: problem = response.json() sys.exit(f"{response.status_code} {problem['code']}: {problem.get('detail', problem['title'])}") return response
drawing = sys.argv[1] # the drawing to send: a PDF, TIFF, PNG, JPEG, BMP or WebP
with open(drawing, "rb") as file: job = call( "POST", "/v1/extractions", files={"file": file}, headers={"Idempotency-Key": str(uuid.uuid4())}, ).json()print(f"Job {job['jobId']} is {job['status']}: {job['sheetCount']} sheets, {job['credits']['reserved']} credits reserved")Save as submit.py, set SPECCAPTURE_API_KEY, and run: python submit.py bracket.pdf. Needs Python 3.9+ and requests (pip install requests).
import { openAsBlob } from "node:fs";import { basename } from "node:path";import { randomUUID } from "node:crypto";
const API = "https://api.speccapture.com";const headers = { Authorization: `Bearer ${process.env.SPECCAPTURE_API_KEY}` };
const [drawing] = process.argv.slice(2); // the drawing to send: a PDF, TIFF, PNG, JPEG, BMP or WebP
const form = new FormData();form.append("file", await openAsBlob(drawing), basename(drawing));const job = await call("POST", "/v1/extractions", { body: form, headers: { "Idempotency-Key": randomUUID() },}).then((response) => response.json());console.log(`Job ${job.jobId} is ${job.status}: ${job.sheetCount} sheets, ${job.credits.reserved} credits reserved`);
// Sends a request; stops with the problem's code and detail if the API refuses it.async function call(method, path, init = {}) { const response = await fetch(API + path, { ...init, method, headers: { ...headers, ...init.headers } }); if (!response.ok) { const problem = await response.json(); console.error(`${response.status} ${problem.code}: ${problem.detail ?? problem.title}`); process.exit(1); } return response;}Save as submit.mjs, set SPECCAPTURE_API_KEY, and run: node submit.mjs bracket.pdf. Needs Node.js 20 or later.
A request takes one drawing. A form with a second part named file is refused whole with 400 invalid_request,
and nothing in it is stored or charged - the first drawing included - so a drawing is never silently left unread.
To send several, send a request for each (a loop over your files, each with its own Idempotency-Key), or upload
them together in the SpecCapture dashboard, which takes several at once.
The answer is 202 Accepted with the job, and a Location header saying where to follow it
(/v1/extractions/{jobId}). Nothing has been read yet: the job is queued, and its sheets already hold one
entry for every page of the file, each waiting to be read. Send an Idempotency-Key header with every
submission, so a retry after a dropped connection answers with the first job instead of making a second
(Idempotency).
- Formats: PDF, TIFF, PNG, JPEG, BMP or WebP, recognised from the file’s bytes, never from its name or extension.
- Sheets: every page of a PDF and TIFF is a sheet of its own; a file in any other format is one sheet. One file may hold up to 200 sheets.
- Size: up to 50 MB a file, and up to
150 megapixels a page: room for
a D-size sheet scanned at 400 dpi, or an E-size or A0 sheet at 300 dpi.
GET /v1/accountstates the limits your key works within as numbers:limits.maximumFileSizeMegabytes,limits.maximumSheetsPerFileandlimits.maximumPageMegapixels(see Credits).
Sheets and credits
Section titled “Sheets and credits”Each sheet costs 4 credits. A submission reserves them for every sheet
of the file at once, before anything is read; each sheet’s credits are spent when it has been read, and a sheet
that cannot be read is refunded. A file needing more credits than the workspace has available is refused whole,
with 402 not_enough_credits saying how many it needs and how many there are, and nothing is stored or reserved.
See Credits.
Choosing a template
Section titled “Choosing a template”A template decides which of the values SpecCapture reads a job keeps, what each is called and in what
order. Name one with the optional form fields templateId - drawing for every field under its standard names, or
an id from GET /v1/templates - and templateVersion, a published version (its newest when left out):
curl https://api.speccapture.com/v1/extractions \ -H "Authorization: Bearer $SPECCAPTURE_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -F "file=@bracket.pdf" \ -F "templateId=6b0d5d2e-3c8a-4f6e-9a7b-2f1e0c9d8b7a"Left out, the job uses the workspace’s default template. Every template reads the drawing the same way, so the
choice never changes a value; the job’s templateId, templateVersion and templateName say which one it keeps
its values with, for good. A template id the workspace does not have, or a version it never published, is
422 template_not_found, and nothing is stored or reserved.
Asynchronous, always
Section titled “Asynchronous, always”Reading a sheet takes tens of seconds, so a submission never waits for it: the answer comes at once, and the job says when its sheets are ready. Ask for the job, or let a webhook tell you.
Not for export-controlled drawings
Section titled “Not for export-controlled drawings”SpecCapture is not built for export-controlled technical data. Never send anything controlled under ITAR or the EAR (other than EAR99), anything classified or marked CUI, or anything carrying distribution statement B, C, D, E or F; the terms of service say it in full. Every drawing a key sends falls under its workspace’s confirmation of this: before the workspace’s first upload, or with its first API key, its owner or an admin ticks this sentence once in the dashboard, and SpecCapture records who and when:
I confirm that we will not upload export-controlled drawings (ITAR or EAR) to this workspace, and I understand that SpecCapture is decision support, not a substitute for checking the drawing.
The API itself never asks it and refuses nothing over it, so an integration that works keeps working, including one whose key was made before the dashboard asked (14 October 2026): its workspace is asked at its next upload or new key.
A drawing that says it is controlled is flagged, never refused. SpecCapture reads each sheet’s own export-control
marking, as printed on the sheet and as read into titleBlock.exportControlMarking, and when it names Distribution
Statement B, C, D, E or F, ITAR (or a USML category, or 22 CFR 120-130), the EAR (or an export classification number
such as 9A610, or 15 CFR 730-774), CUI (or FOUO), NOFORN or a warning that the drawing’s export is restricted, the
sheet carries markedControlled: true and controlledMarking naming which, on the job and on the sheet’s values, and
the dashboard warns the workspace to delete it unless it is sure the drawing is not controlled. A proprietary notice,
distribution statement A, EAR99 and a notice saying the drawing is not controlled are not flagged - ITAR CONTROLLED: NO, ITAR: N/A ECCN: EAR99, EXPORT CONTROLLED: [ ] YES [X] NO, THE ITAR DOES NOT APPLY - and a “not” denies only
the control it governs: NOT ITAR CONTROLLED - SUBJECT TO THE EAR, ECCN 9A991 is flagged EAR.
A sheet the drawing itself says is export-controlled is not shared outside the workspace until a person kept it.
Until the workspace’s owner or an admin keeps its drawing in the dashboard (controlledKept), a
view link to a marked sheet is refused with 409 not_shareable, whose detail names the
marking, and a link made before the sheet was marked shows only that it does not work; the dashboard’s Share does
not offer the sheet and says why, and the MCP tools never suggest create_view_link for it. Once kept, the sheet is
shared like any other, and its link’s page says nothing of the marking.
And what the API returns is decision support: check values against the drawing before they reach a quote, an ERP or
production, starting with those whose reviewState is needsReview.
When a submission is refused
Section titled “When a submission is refused”A file is checked before anything is stored or charged, and the first check it fails answers: file_missing
(the form has no part named file), invalid_request (the form has more than one part named file),
unsupported_media_type (the body is not multipart/form-data),
file_empty, not_a_drawing, unreadable_drawing, file_too_large, too_many_sheets or page_too_large.
Then the template it names: invalid_request (a templateId or templateVersion that is not one) or
template_not_found.
A refusal for a limit names the limit in its detail and carries it as a number: maximumFileSizeMegabytes,
maximumSheetsPerFile or maximumPageMegapixels. Then the workspace: workspace_deleting (its deletion has been asked for), trial_ended (its free trial has
ended, and its credits with it), or not_enough_credits. Each is a problem with a stable code, and none
of them stores the file or spends a credit. If the file cannot be stored once its job exists, the answer is
503 storage_unavailable: that job fails and is refunded in full within minutes, and the file can simply be
sent again.