Read this before you build
The API is live and the quoting is real: every number comes from a real slicer
running on the shop's own profile, and each release is checked against a 67-part reference
corpus before it can ship. The base URL is
https://api.3dash.in/v1.
One limit worth knowing: the service runs single-instance, so treat throughput as a pilot rather than production. See Limits for the numbers you can actually plan against.
Quoting is the whole of v1. Models, quotes, machines and materials are built and running. Orders, webhooks and rate-card reads are not — see Not in v1. Nothing on this page describes an endpoint that does not answer today.
Quoting API
Post a model, get back your price, the grams and the print time — from a real slice on your own printer profile, not a per-gram formula.
The widget is a client of this API and has no private endpoints. Anything it can do, you can do.
New here? The quickstart gets you from a key to a real quote in three requests. This page is the full reference.
Quickstart
Three calls: register the model, upload the bytes, ask for a
quote. wait holds the
response for up to 30 seconds so a simple integration doesn't need polling.
# 1. register the model
curl -X POST https://api.3dash.in/v1/models \
-H "Authorization: Bearer $SK" -H "Content-Type: application/json" \
-d '{"filename":"bracket.stl","bytes":4831922}'
# -> { "id": "mdl_8fA2k9", "upload": { "method": "PUT", "url": "..." } }
# 2. upload the bytes to the url you were given
curl -X PUT "$UPLOAD_URL" --data-binary @bracket.stl
# 3. quote it
curl -X POST "https://api.3dash.in/v1/quotes?wait=10" \
-H "Authorization: Bearer $SK" -H "Content-Type: application/json" \
-d '{"material":"pla",
"lines":[{"model_id":"mdl_8fA2k9","quantity":2,"infill":20}]}'
Authentication
Authorization: Bearer <key>.
Two kinds, and the difference matters more than it looks.
pk_live_…
Publishable. Meant to sit in page source. Can create a model, create a quote, read that quote, and list materials and machines.
Constrained by an origin
allowlist rather than by secrecy: a request from an origin you have not
registered is rejected, and a request with no Origin at
all is rejected too, because a publishable key only means anything in a browser. Read
what
that does and does not stop before you rely on it.
sk_live_…
Secret. Server-side only. Everything a publishable key can do, plus registering machines. There are no list endpoints in v1 — you hold the ids we returned, or you read them in your dashboard.
Secret-key responses carry
no CORS headers, so a browser physically cannot use one cross-origin.
If we see a secret key arrive with an Origin header we
refuse it and tell you to roll the key.
There is one mode. Your keys are live from the first day of the free trial — no separate sandbox key to swap out before you launch, and no chance of shipping one by mistake. Your allowance is set by your plan; see Limits.
What the origin allowlist does
Add the sites you call us from on your account page. A
publishable key presented from anywhere else is refused with origin_not_allowed, and one
presented with no Origin at all — curl, a server, a script — is
refused with publishable_key_without_origin.
What it stops
Your key being lifted out of your page source and used from someone else's site, or from a script. That is the ordinary case and the allowlist handles it.
What it does not stop
The widget. It
runs in an iframe served from 3dash.in, so the browser
stamps our origin on its requests, not yours — wherever it is pasted. The
allowlist cannot tell your copy from a copy, and we would rather say so than let you
believe otherwise.
Because of that, our own origins are always accepted
alongside yours. You do not need to add 3dash.in to make the
widget work, and adding your own domain is what you need for calls your own pages make
with a publishable key.
Endpoints
| Method & path | Keys | What it does |
|---|---|---|
POST /v1/models | pk · sk | Register a model, get an upload URL back. Bytes never travel through the API itself. |
PUT /v1/models/{id}/content | pk · sk | Fallback upload path. Always PUT to the upload.url you were given — in production that is storage, not us. Only hardcode this path if you enjoy being surprised. |
GET /v1/models/{id} | pk · sk | Poll until status is ready. This poll is also what triggers analysis of a storage upload. A .step or .3mf upload passes through converting first — ?wait=0–30 holds for it, as on a quote. |
GET /v1/models/{id}/content | pk · sk | The binary STL behind a ready model, streamed. Use the absolute content_url the model gives you — it lives on the API's own origin, not this hostname, so files over 32 MB arrive whole. For a STEP or 3MF upload that is the mesh PrusaSlicer made from it (geometry only — a 3MF's own print settings are discarded), which is what every number on the model describes. |
POST /v1/quotes | pk · sk | Quote one or more lines. ?wait=0–30 holds for a finished quote; otherwise 202 and a pending one. |
GET /v1/quotes/{id} | pk · sk | Fetch a quote by id. |
GET /v1/machines | pk · sk | The shop's printer profiles. Only needed to pin a part to one — leave machine_id off a quote and each part is routed for you. |
POST /v1/machines | sk | Onboard a printer from a G-code you sliced recently. Returns the recovered settings for confirmation. |
GET /v1/materials | pk · sk | Materials this shop offers, with densities. |
GET /v1/health | none | Liveness, and which slicing engines this deployment has. Says nothing about any shop. |
GET /v1 | none | What is routed, and where the docs are. |
That is the whole surface. There is no list
endpoint for any resource in v1, and anything not in this table returns
404 unknown_endpoint.
The three you read from
GET /v1/machines — your
printers. status is the field that matters: only
ready can be quoted on, and a machine says so here rather than
failing at quote time.
{ "object": "list", "data": [
{ "id": "mch_a1", "object": "machine", "name": "Bambu Lab A1 0.4 nozzle",
"family": "bambu", "engine": "bambu-studio",
"status": "ready", "settings_recovered": 556 }
] }
# status: ready | unsupported (Cura) | engine_unavailable | incomplete | missing
# no rate, no hourly cost — see "Amounts, never rates"
GET /v1/materials — what
this shop offers, and the density we compute grams with.
{ "object": "list", "data": [
{ "id": "pla", "name": "PLA", "density_g_cm3": 1.24 }
] }
# the id is what you send as "material" on a quote
GET /v1/health — no key, never
rate-limited. engines is the machine-readable version of what
this page claims: if a family reads false, machines in it are
listed engine_unavailable rather than quoted.
{ "object": "health", "ok": true, "version": "v1",
"engines": { "prusa": true, "bambu": true, "orca": true, "cura": false } }
Onboarding a printer
Send a G-code you already sliced this week. Every PrusaSlicer, Orca and Bambu Studio G-code carries its full resolved configuration in the comments — 347 settings for Prusa, 556 for the Bambu family, including your whole support strategy even if supports were switched off for that job.
curl -X POST https://api.3dash.in/v1/machines \ -H "Authorization: Bearer $SK" --data-binary @benchy.gcode
The response comes back unconfirmed on purpose. A
G-code carries that job's overrides, so a one-off print you sliced at 5 perimeters
and 80 mm/s would otherwise become your standing profile and quietly skew every quote after
it. Show the values back to yourself, then confirm.
Infill is not one of those
values. Whatever density your G-code was sliced at, it does not govern your
quotes — infill is sent per quote line, so the number your
customer picks in the configurator is the number we slice at, every time. The settings
worth checking on this screen are the ones nobody sends per quote: perimeters, top and
bottom layers, speeds, temperatures and your support strategy. See what the line
overrides.
Cura is not supported. A
Cura profile is returned with status: "unsupported" and
quoting is refused. Its quality profiles hold Python expressions that CuraEngine won't
evaluate when they're passed in, and it will accept an unevaluated formula as a setting
and slice anyway. A partly-resolved profile gives a confident wrong number, which is worse
than no support, so we don't half-do it.
What a quote is, and isn't
It's a measurement
Every quote comes from a real slice. The response names the engine, its version and the profile id, so you can run the same file through your own slicer and compare.
Amounts, never rates
Integer paise with an explicit currency. Line amounts and totals only — no ₹/gram, no machine hourly rate, no setup or minimum. Your pricing stays on our server even if you proxy this API into a public page.
Flags are triage
A flags
entry means "this looks risky, take a look". An empty
flags array means nothing at all and must
never be shown to a customer as a pass.
There is no error code for "we
guessed." If a number can't be produced from your real profile, the call
fails with profile_incomplete and no quote at all. Nothing
in this API returns an estimate wearing a quote's clothes — a stale profile once quoted
9% over on plastic and 10% under on time with nothing surfaced anywhere, and that class of
failure is what the whole design is arranged against.
When the slice outlasts your wait
A slice is p50 0.84 s and p95 4.97 s per line, so
?wait=10 returns a finished quote almost every time and cannot
promise to. Past the hold you get 202 and a pending quote — the
same object, with null where the numbers will be — and you poll
it. Handle both from one code path: the shape does not change.
POST /v1/quotes?wait=10 -> 202 { "id": "qte_M3xQ1", "status": "pending",
"lines": [ { "grams": null, "amount": null } ] }
# then, until it settles — a second or two apart is plenty
GET /v1/quotes/qte_M3xQ1 -> 200 { "status": "completed", ... }
-> 200 { "status": "failed", "error": { ... } }
# a FAILED quote is a 200 with an error inside it, not an HTTP error:
# the request was fine, the slice was not. Branch on `status`, not the code.
Which printer a part goes to
machine_id is optional.
Leave it off and every line is routed on its own to the printer best suited to that part. Send
it, on the body or on a single line, and that machine is used as given.
Two hard filters
The machine has to be one you've said runs
the material, and the part — with scale applied
— has to fit its bed. Nothing capable is material_unavailable; nothing that fits is build_volume_exceeded, naming the largest part you can
print on any of your printers.
Then your preference
If you've pinned a material to a printer in Settings, that one wins whenever it survives both filters. Otherwise it's the first printer in your list that can take the job — so put your preferred machine at the top.
Why you have to tell us what each printer can run. Whether a material needs an enclosure, a hardened nozzle or a 300 °C hotend is a fact about the material; whether a printer has those is a fact about the machine. Neither is in the STL, and neither is in the G-code you onboarded with — that file records one slice, so it proves the printer ran PLA once, not what it's capable of. Tick the materials under Can run on each printer in Settings.
Routing never silently substitutes for a machine you
named: a pinned machine_id is used or the request errors.
It doesn't try to guess your cheapest printer. An hourly rate doesn't rank machines by what a part actually costs — the profile changes grams and print time too, and those dominate. On one test part, a ₹40/h machine came out 44% cheaper for the job than a ₹25/h one, being faster and laying down less plastic. Knowing the real answer would mean slicing on every candidate, so your list order decides instead.
What the line overrides
A customer who picks 60% infill has to get a 60% price, so the quote line wins over your profile for the few things a customer actually chooses. Everything else comes from your profile — which is the entire point of onboarding from a G-code you sliced yourself.
| Line field | Who decides | If you omit it |
|---|---|---|
infill |
The line, always. Sent to the engine on every slice. | 20% — not your profile's density |
layer_height_mm |
The line, when you send it | Your profile's |
supports |
Nobody — always on. Sent or not, it is ignored | Supports on |
scale |
The line | 1.0 |
| Perimeters, top and bottom layers, speeds, temperatures, support style and angle — around 340 more | Your profile, always. No line field touches these. | — |
Omitting infill is not
"use my default"
It means 20%. A profile that declares 15% still gets sliced at 20% for any caller that leaves the field out, which on our reference part is 13% more filament. Send the value you mean.
Supports are always on, and that is free
Supports are what the geometry needs, not
a preference — so supports is accepted and ignored. The
slicer only generates them under real overhangs: on a 40 mm cube, on and off are
both 26.16 g to the gram. On a part that needs them it is 101.89 vs
125.07 g — material the shop would otherwise absorb.
A part that doesn't fit is refused, not quoted
We read the bed out of your profile and
check it against the part's size with scale applied, before spending a slice.
Too big on any axis is build_volume_exceeded, naming both
sizes. A 90° turn on the bed is allowed; lying the part on its side is not, because
nothing does that for you.
We measure each part in the
file, not the file. A file holding six brackets laid out on a plate has a bounding box
no bed will take and six parts any bed will — that box is the layout, not the
work. It's refused only when a part is genuinely too big, and the error names that
part. See parts_bbox_mm on the model.
Limits
All of these are enforced. Sizing your integration against them beats discovering them.
| Limit | Value | What you get past it |
|---|---|---|
| Model file | 50 MB in and out; .stl, .step/.stp or .3mf | model_too_large / unsupported_file_type |
| Lines per quote | 20 | too_many_lines |
wait | 0–30 seconds | Clamped, not rejected |
| Requests | 60/min per shop | too_many_requests, with RateLimit-* headers |
| Quotes slicing at once | 2 per shop | Queued up to 30 s, then quote_queue_full |
| Quotes per month, Quote plan | 750 | monthly_quota_exceeded, with X-Quota-Limit / X-Quota-Used |
| Quotes per month, Quote & Checkout | 2,000 | As above |
A model id lives 7 days
As long as the file itself, which is
deleted after seven days. Quote against an id for as long as you hold it; past that
the id still resolves but its bytes do not, which is
model_unavailable and means upload it again.
A quote is readable for 7 days
That is what
expires_at says and it holds across restarts — every
quote is stored, not just held in memory. A pending
one is readable too, so polling works no matter which server answers.
Errors
One envelope for every failure, including inside a failed
quote object. Send us the request_id.
{ "error": {
"type": "slice_error",
"code": "build_volume_exceeded",
"message": "The model is 262 mm tall; this machine's Z is 210 mm.",
"param": "lines[0].model_id",
"doc_url": "https://business.3dash.in/api-docs#errors",
"request_id": "req_9Kd21"
} }
| HTTP | type | When |
|---|---|---|
400 | invalid_request_error | Malformed, missing or contradictory input. |
401 | authentication_error | Missing, unknown, revoked or expired key. |
403 | permission_error | Real key, not allowed here — wrong origin, wrong key class, or a feature your plan doesn't include. |
404 | not_found_error | Unknown id — or an id belonging to another shop, which looks identical on purpose. |
409 | conflict_error | Idempotency key reused with a different body, or still in flight. |
422 | slice_error | Input was fine; quoting couldn't honestly be done. |
429 | rate_limit_error | Over the per-shop rate (too_many_requests), too many quotes slicing at once (quote_queue_full), or too many rejected keys from one address (too_many_failed_auth). Retry-After tells you when. |
500 | api_error | Ours. |
501 | api_error | A real endpoint we have not built yet. Only POST /v1/orders answers this. |
Every code we return
type tells you whether to
retry; code is the one to branch on. New codes may be added
inside v1, so treat an unfamiliar one as its
type rather than throwing.
| code | HTTP | Meaning |
|---|---|---|
missing_api_key | 401 | No Authorization: Bearer header. |
invalid_api_key | 401 | Unknown or revoked. Check for a copied space, and that a live key is on the live host. |
publishable_key_without_origin | 403 | A pk_ from a server or a script. Use your secret key there. |
origin_not_allowed | 403 | That origin is not on your allowlist. See Origins. |
secret_key_in_browser | 403 | An sk_ arrived with an Origin. Treat it as compromised and roll it. |
secret_key_required | 403 | That route needs your secret key. Only POST /v1/machines does. |
shop_not_configured | 403 | The key is fine; the shop has no machine or rate card yet, so there is nothing to quote against. |
subscription_required | 403 | Your keys are switched off while a subscription is lapsed. Resubscribe and they work again immediately — the keys themselves are not destroyed. |
checkout_not_enabled | 403 | Orders need the Quote & Checkout plan. |
model_not_found · quote_not_found · machine_not_found | 404 | Unknown id, or one belonging to another shop — identical on purpose. Model and quote ids both live 7 days. |
unknown_endpoint · unknown_version | 404 | Not a route. Everything lives under /v1/. |
idempotency_key_reused | 409 | Same Idempotency-Key, different body. Use a new key for a new request. |
idempotency_key_in_use | 409 | Same key, same body, first request still running. Nothing is wrong — send it again in a moment and you get the reply. Retry-After says when. |
missing_filename · unsupported_file_type · model_too_large | 400 | See Limits. .stl, .step or .3mf up to 50 MB, before and after conversion. |
no_lines · too_many_lines · unknown_material · invalid_scale | 400 | Bad quote body. scale must be a positive multiplier. |
model_not_ready | 400 | Quoted before the bytes landed. Poll the model until ready. |
invalid_json · empty_body · payload_too_large | 400 | The body itself. |
unreadable_gcode | 400 | Onboarding a machine from a file we could not read a profile out of. |
material_unavailable | 400 · 422 | The material is real and priced, but no printer on this shop is set up to run it. |
profile_incomplete | 422 | The shop's config would not load in full. Refused rather than quoted off a partial profile. |
build_volume_exceeded | 422 | Too big on some axis, with scale applied. Names both sizes. |
model_unreadable | 422 | Not a mesh we can parse, so its volume — and its grams — can't be trusted. |
model_unavailable | 422 | The id is fine, the bytes are gone — past the seven-day file retention. Nothing wrong with the model; upload it again. |
unsupported_slicer_family | 422 | Cura. Refused on purpose, not missing. |
engine_unavailable | 422 | That machine's slicer is not in this deployment. GET /v1/health says which are. |
engine_timeout | 422 | The slice exceeded its ceiling. Retryable. |
engine_failed | 422 | The slicer declined the model. Send us the request_id. |
profile_not_saved | 500 | We read your G-code and could not store the profile. Retry the upload. |
too_many_requests · quote_queue_full · monthly_quota_exceeded · too_many_failed_auth | 429 | See Limits. Retry-After says when. |
not_implemented | 501 | Orders. Not built in v1. |
A failed quote carries this same
envelope in its error field, inside a 200.
The request was valid; the slice was not.
Conventions
Versioning
The major version is in the path. Inside
v1 we add fields and enum values but never remove or
rename one. Ignore unknown fields — that's the compatibility contract,
and it's on your side.
Idempotency
Every POST
takes Idempotency-Key, kept 24 hours. Replaying it
returns the original response; reusing it with a different body is a
409, and so is replaying one that has not finished yet.
A 429 or 5xx releases the
key instead of being stored, so retrying with it genuinely retries.
Rate limits
Per shop, not per key.
RateLimit-Limit,
-Remaining and -Reset on
every authenticated response — a rejected key has no shop to count against. A
slice occupies a container, so concurrency is the real limit. See
Limits.
Polling, not webhooks
There is no webhook delivery in v1. A
quote settles inside your wait or you poll it — see
the
202 path. When webhooks land they will be signed and documented here; until
then there is nothing to subscribe to.
Not in v1
- ·Orders.
POST /v1/ordersanswers501 not_implemented. Checkout exists today in the widget, which takes payment on your own Razorpay account; quote with this API and take the money there. This page named orders as a working endpoint for a while, which was wrong. - ·Webhooks. Nothing is delivered to a URL of yours. Poll a pending quote instead.
- ·List
endpoints, and reading your rate card over the API. No
GET /v1/quotes, no/v1/rate-cards. Keep the ids we hand you; your rates live under Manage your store, and no endpoint returns them at any tier. - ·Anything that routes money through us. Payments run on your own Razorpay account and settle to your bank. We are software, never the merchant of record.
- ·Per-quote or per-order fees, so there is nothing to meter. Billing is a flat subscription and lives outside this API.
- ·Plate optimisation. Each part is sliced alone and quantity multiplies a single slice, so inter-copy travel and shared plates are not modelled. Said plainly rather than papered over.
- ·Resin slicing. MSLA has no toolpaths — resin is estimated from volume and height, and the response says so.
- ·Cura. Not "coming soon" — refused on purpose until it can be resolved properly.