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 & pathKeysWhat it does
POST /v1/modelspk · skRegister a model, get an upload URL back. Bytes never travel through the API itself.
PUT /v1/models/{id}/contentpk · skFallback 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 · skPoll 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}/contentpk · skThe 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/quotespk · skQuote one or more lines. ?wait=0–30 holds for a finished quote; otherwise 202 and a pending one.
GET /v1/quotes/{id}pk · skFetch a quote by id.
GET /v1/machinespk · skThe 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/machinesskOnboard a printer from a G-code you sliced recently. Returns the recovered settings for confirmation.
GET /v1/materialspk · skMaterials this shop offers, with densities.
GET /v1/healthnoneLiveness, and which slicing engines this deployment has. Says nothing about any shop.
GET /v1noneWhat 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.

LimitValueWhat you get past it
Model file50 MB in and out; .stl, .step/.stp or .3mfmodel_too_large / unsupported_file_type
Lines per quote20too_many_lines
wait0–30 secondsClamped, not rejected
Requests60/min per shoptoo_many_requests, with RateLimit-* headers
Quotes slicing at once2 per shopQueued up to 30 s, then quote_queue_full
Quotes per month, Quote plan750monthly_quota_exceeded, with X-Quota-Limit / X-Quota-Used
Quotes per month, Quote & Checkout2,000As 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"
} }
HTTPtypeWhen
400invalid_request_errorMalformed, missing or contradictory input.
401authentication_errorMissing, unknown, revoked or expired key.
403permission_errorReal key, not allowed here — wrong origin, wrong key class, or a feature your plan doesn't include.
404not_found_errorUnknown id — or an id belonging to another shop, which looks identical on purpose.
409conflict_errorIdempotency key reused with a different body, or still in flight.
422slice_errorInput was fine; quoting couldn't honestly be done.
429rate_limit_errorOver 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.
500api_errorOurs.
501api_errorA 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.

codeHTTPMeaning
missing_api_key401No Authorization: Bearer header.
invalid_api_key401Unknown or revoked. Check for a copied space, and that a live key is on the live host.
publishable_key_without_origin403A pk_ from a server or a script. Use your secret key there.
origin_not_allowed403That origin is not on your allowlist. See Origins.
secret_key_in_browser403An sk_ arrived with an Origin. Treat it as compromised and roll it.
secret_key_required403That route needs your secret key. Only POST /v1/machines does.
shop_not_configured403The key is fine; the shop has no machine or rate card yet, so there is nothing to quote against.
subscription_required403Your keys are switched off while a subscription is lapsed. Resubscribe and they work again immediately — the keys themselves are not destroyed.
checkout_not_enabled403Orders need the Quote & Checkout plan.
model_not_found · quote_not_found · machine_not_found404Unknown id, or one belonging to another shop — identical on purpose. Model and quote ids both live 7 days.
unknown_endpoint · unknown_version404Not a route. Everything lives under /v1/.
idempotency_key_reused409Same Idempotency-Key, different body. Use a new key for a new request.
idempotency_key_in_use409Same 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_large400See Limits. .stl, .step or .3mf up to 50 MB, before and after conversion.
no_lines · too_many_lines · unknown_material · invalid_scale400Bad quote body. scale must be a positive multiplier.
model_not_ready400Quoted before the bytes landed. Poll the model until ready.
invalid_json · empty_body · payload_too_large400The body itself.
unreadable_gcode400Onboarding a machine from a file we could not read a profile out of.
material_unavailable400 · 422The material is real and priced, but no printer on this shop is set up to run it.
profile_incomplete422The shop's config would not load in full. Refused rather than quoted off a partial profile.
build_volume_exceeded422Too big on some axis, with scale applied. Names both sizes.
model_unreadable422Not a mesh we can parse, so its volume — and its grams — can't be trusted.
model_unavailable422The id is fine, the bytes are gone — past the seven-day file retention. Nothing wrong with the model; upload it again.
unsupported_slicer_family422Cura. Refused on purpose, not missing.
engine_unavailable422That machine's slicer is not in this deployment. GET /v1/health says which are.
engine_timeout422The slice exceeded its ceiling. Retryable.
engine_failed422The slicer declined the model. Send us the request_id.
profile_not_saved500We 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_auth429See Limits. Retry-After says when.
not_implemented501Orders. 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