# LayerCrew — Complete Reference > LayerCrew is a B2B 3D-printing company in Bucharest, Romania (EU). We run a centrally managed, always-on Bambu Lab print farm and we're a makers' hub that happens to run like a factory. Production-grade parts shipped across the EU since founding in 2025, every file DFM-reviewed and printed to ISO 2768-m. One farm, three branches: filament resale, custom production, and ready-made wholesale products. --- ## Company Overview LayerCrew is a Bucharest-based B2B 3D-printing company (founded 2025) built to make production-grade additive manufacturing effortless for businesses across the European Union. We run a centrally managed Bambu Lab print farm spanning the full model range — every machine runs calibrated profiles per material with the same orientation, thermal conditions, and quality gates, so part #1 and part #1,000 are identical. We're not a faceless contract shop and not a hobby printing service. The infrastructure is engineered for manufacturing: strict calibration schedules, per-batch quality inspection, and real-time monitoring across the fleet. Production typically starts within 24–48 hours because the farm is always running, and most EU deliveries arrive in days, not weeks. Contact: - Email: hello@layercrew.com - Phone: +40 722 101 097 / +40 762 646 346 - Website: https://layercrew.com --- ## One Farm, Three Ways In The whole company fits in one question: **do you have a 3D printer?** - 🧵 Got a printer? We keep it fed. → **Filament** - 🛠️ No printer, but you've got an idea? We make it real. → **Production** - 📦 Neither? Grab something that's already on the shelf. → **Products** Three branches, one farm, one team. Across all three, the favourite customer is the same: a business that buys in volume and comes back — a print farm reordering spools by the case, a manufacturer running a 2,000-unit batch, a shop buying a pallet of vases to resell. Hobbyists and one-offs are welcome (no minimums, ever), but batch and B2B are where we point the energy. What's true no matter which door you came through: - **Engineering on tap, not gatekept** — every custom file gets a real DFM look before the nozzle heats up. - **Part #1 = part #1,000** — calibrated profile per material, same quality gate every time. - **Fast off the line** — most orders hit production in 24–48h because the farm is always running. - **EU-direct** — Bucharest warehouse, no transatlantic wait, GDPR-clean, data stays in the EU. - **No tooling, no setup, no minimums** — a single prototype is as valid as a 5,000-unit run. --- ## 🧵 Filament — "Keep your printer fed." (current focus) Got a 3D printer at your business or your bench? We sell the spools — curated, multi-brand, every one production-tested on our own farm, with the calibrated slicer profile thrown in free. Selling filament on as many channels as we can is the current focus of the whole company. What we sell: - **Curated, multi-brand.** We carry the spools we'd actually run ourselves. Current roster: AzureFilm, Bambu Lab, and Polymaker — more brands as we grow. - **Any material the farm runs** — PLA, PETG, ABS, ASA, PC, ABS-GF, ABS-FR, PA12. (TPU is stocked but only recommended when a part genuinely needs to flex.) - **The wedge: every spool is production-tested and the calibrated profile is free.** We've run these materials through our own machines on production jobs — "spools we'd run ourselves, because we do" is literally true. - **Refill or full spool, same price per kg.** Multi-spool packs save up to ~25%; 50+ spool reorders get a custom quote. How we source and ship: we buy **direct from the producers** and resell — no middle-man markup stacked on a middle-man markup. Spools ship EU-direct from Bucharest, often the same business day before 14:00 CET. The ambition: become the biggest filament reseller in Romania, then Europe. It's a volume game — more SKUs, more channels, more reorders. Today the layercrew.com storefront is the only live channel; marketplaces (e.g. eMAG, Amazon) and distributor / wholesale B2B accounts are the near-future plan. Who it's for (B2B first): print farms and service bureaus, businesses with in-house printer fleets, makerspaces / fablabs / universities, and resellers who stock filament. Live stock, colours, and prices are in the storefront — see https://layercrew.com/filaments. --- ## 🛠️ Production — "Bring the idea. We'll make it real." No printer, but you've got an idea? Bring a napkin sketch, a CAD file, or honestly just a description. We help shape the idea, model it, print a sample, then run the batch. Batch production is the main event; everything before it exists to get you there with confidence. What we actually do: 1. **Collaborate on the idea** — sometimes there's no drawing yet, just a problem. We help figure out what to build. 2. **3D modeling & product development** — turn the idea into a real, printable model plus the manufacturing drawings that go with it. 3. **Sampling / prototyping** — hold it, test it, tweak it, iterate fast before committing to volume. 4. **Batch production** — 10 to 5,000+ units, same calibrated profile, same quality gate. It flows the other way too: see a ready-made product you like and want it customised or run as a batch? That's a Production job. Why we're the easy yes: - **Engineering on tap** — real DFM review on every file (wall thickness, overhangs, tolerance stack-ups). If a part would print poorly at scale, we say so and propose a fix. We match the material to the problem — we don't hand you a menu. - **Specialists in FDM** — deep mastery of one process, not a do-everything shop. - **Fast off the line** — 24–48h to production. - **Full IP transfer on payment** — your design is yours, no retained rights. Lifecycle: inquiries arrive via https://layercrew.com/contact-us (files land in EU cloud storage — we store the object name, never the CAD itself) or by phone. Engineering takes a DFM look within ~24h; a formal quote follows in 24–48h; production starts 24–48h after approval. Who we want bringing us ideas: product engineers and designers who need fast iteration with production rigor; manufacturers needing bridge or supplemental production around injection molding; R&D teams with complex geometries; and companies replacing legacy machined tooling with printed equivalents. ### Production services in detail - **Batch Manufacturing** — hundreds or thousands of identical parts without running your own farm. No injection-molding tooling, no setup costs. 10 to 5,000+ units. Bulk breaks: 10% off at 100+ units, 20% off at 250+ units. - **Rapid Prototyping** — fast design iteration and validation with engineering-grade materials before committing to hard tooling. Turnaround starts within 24–48 hours. - **Bridge Manufacturing** — production-quality FDM parts to fill the gap while injection molds are being built. - **Jigs & Fixtures** — custom 3D-printed manufacturing aids for the factory floor: workholding, alignment tools, assembly fixtures. - **End-Use Parts** — direct digital manufacturing of final production parts, ready for consumer use or industrial installation. - **Engineering Support (DFM Review)** — every file gets a design-for-manufacturability review (wall thickness, overhangs, tolerance stack-ups, material selection, orientation) before production. Included at no extra cost. --- ## 📦 Products — "Already made. Built for volume." No printer and no custom idea to build? Grab something that's already on the shelf. We design and print ready-to-sell products — and the real play is B2B wholesale: shops and brands buy a batch and resell it. Classic example: a flower shop batch-buys vases and planters from the Floristry range and sells them in its own store. We win on volume; they win on margin and never have to print anything themselves. Everything here leans into volume as the incentive — wholesale tiers, bulk breaks, reorder-friendly. One-offs are fine, but a pallet is the point. Categories: - **Floristry** — vases, planters, display pieces (flower shops, garden centres) - **Home & Lifestyle** — decor, desk accessories, household objects (homeware retailers, concept stores) - **Lights** — table lamps, pendant shades, night lights (lighting and interior retailers) Why it's an easy stock decision: built like production (part #1 = part #1,000) but ordered as simply as adding to a cart; no printer, no risk, no MOQ headaches; EU-direct from Bucharest; effortless reorders (same SKU, same part, every time). Pricing is unit-based with per-item bulk discounts configured per product (commonly 10/20/30% bands around quantities like 50/100/250). Browse the live catalog at https://layercrew.com/shop. The cross-sell: a wholesale Products account is a prime lead for the other two branches — a shop reselling our vases might one day want a custom line (Production) or, once it gets a printer, become a Filament customer. --- ## Materials — Full Technical Specifications Nine FDM polymers across three tiers. Property dimensions: Tensile Strength (MPa), Elongation at Break (%), Heat Deflection Temperature / HDT (°C), Density (g/cm³), Young's Modulus (MPa). The instant online calculator prices any 3MF, STL or OBJ file — a 3MF with the settings and materials it was saved with, an STL or OBJ with our standard settings; volume runs and engineering work go through a quoted project so DFM and material strategy are reviewed first. ### Standard tier — fast, accurate, low cost #### PLA (Polylactic Acid) Fast-printing thermoplastic with tight dimensional accuracy and a clean surface finish. 52°C HDT limits thermal applications, but low warping and high stiffness make it the everyday workhorse for production runs of dimensionally critical parts, visual models, and assemblies that need a smooth as-printed finish. - Tensile Strength: 37 MPa · Elongation at Break: 6% · HDT: 52°C · Density: 1.24 g/cm³ · Young's Modulus: 2346 MPa - Best for: production batches of dimensionally critical parts, visual/architectural models, fit-check and assembly verification, low-load jigs and alignment fixtures. #### PETG (Polyethylene Terephthalate Glycol) Durable co-polyester with strong layer adhesion and broad chemical resistance. Handles moderate heat (70°C HDT) and impact better than PLA while staying straightforward to print. A reliable mid-range material for functional parts in batch production. - Tensile Strength: 50 MPa · Elongation at Break: 23% · HDT: 70°C · Density: 1.27 g/cm³ · Young's Modulus: 2020 MPa - Best for: functional prototypes under moderate load, enclosures and protective housings, liquid-contact parts, laboratory equipment covers, consumer bridge production. ### Engineering tier — higher heat & impact, structural #### ABS (Acrylonitrile Butadiene Styrene) Industry-standard engineering thermoplastic with 98°C heat deflection and strong impact resistance. Acetone vapor smoothing enables injection-mould-grade surface finish. The baseline for structural production parts across automotive and industrial sectors. - Tensile Strength: 40 MPa · Elongation at Break: 25% · HDT: 98°C · Density: 1.04 g/cm³ · Young's Modulus: 2000 MPa - Best for: structural prototypes and production parts, automotive interior components, end-use consumer housings, manufacturing aids, parts requiring post-processed surface finish. #### ASA (Acrylonitrile Styrene Acrylate) UV-stable thermoplastic engineered for outdoor exposure. Maintains colour and mechanical properties after prolonged weathering — no yellowing, no surface degradation. A drop-in replacement for ABS in any exterior-facing application. - Tensile Strength: 45 MPa · Elongation at Break: 30% · HDT: 95°C · Density: 1.07 g/cm³ · Young's Modulus: 2100 MPa - Best for: outdoor equipment housings, automotive exterior trim, signage and point-of-sale displays, agricultural and marine hardware, exterior sensor enclosures. #### PC (Polycarbonate) Highest-strength FDM thermoplastic in the lineup. 70 MPa tensile strength and 110°C heat deflection make it the default where other materials fall short. Near-optical transparency is available. - Tensile Strength: 70 MPa · Elongation at Break: 15% · HDT: 110°C · Density: 1.20 g/cm³ · Young's Modulus: 2300 MPa - Best for: load-bearing structural components, high-temperature tooling and fixtures, transparent covers and light guides, load-bearing brackets and structural housings, functional replacements for machined parts. ### Specialized tier — reinforced / flame-retardant / flexible #### ABS-GF (Glass Fiber-Reinforced ABS) Glass-fibre-reinforced ABS with 4500 MPa Young's modulus — roughly 2.25× standard ABS — with minimal warping and high dimensional stability under load. The production material for rigid industrial tooling, fixtures, and structural components. - Tensile Strength: 55 MPa · Elongation at Break: 4% · HDT: 105°C · Density: 1.15 g/cm³ · Young's Modulus: 4500 MPa - Best for: precision jigs and assembly fixtures, structural brackets and mounting plates, dimensionally critical tooling, replacements for machined-aluminium fixtures. #### ABS-FR (Flame Retardant ABS) Flame-retardant ABS rated to UL94 V-0 — self-extinguishing within 10 seconds — with low smoke generation. Retains core ABS mechanical properties. - Tensile Strength: 38 MPa · Elongation at Break: 18% · HDT: 90°C · Density: 1.18 g/cm³ · Young's Modulus: 2150 MPa - Best for: electronics and electrical enclosures, control panel housings, railway and transport interior components, battery compartments, industrial switchgear. #### PA12 (Nylon) Tough, fatigue-resistant engineering nylon that tolerates repeated flexing and snap-fit stress. - Tensile Strength: 50 MPa · Elongation at Break: 200% · HDT: 80°C · Density: 1.01 g/cm³ · Young's Modulus: 1300 MPa - Best for: gears, living hinges, snap-fit assemblies, and fatigue-resistant functional parts. #### TPU 95A Flexible elastomer at Shore 95A hardness. We don't lead with TPU — we reach for it only when a part genuinely needs to flex (a gasket, damper, or grip); otherwise we steer to the materials above. - Tensile Strength: 28 MPa · Elongation at Break: 580% · HDT: 60°C · Density: 1.20 g/cm³ · Young's Modulus: 26 MPa - Best for: gaskets, dampers, grips, and other genuinely flexible functional parts. Material-selection rules of thumb: outdoor → ASA; impact at 80°C → PC; dimensional accuracy at volume → PLA. PLA's limit is heat (52°C HDT) — steer hot-environment or sustained-load parts to PETG or PC. FDM parts are weaker in Z than XY, so orientation matters for load-bearing design (the DFM review handles this). --- ## Pricing Model Prices are stored net of VAT (B2B); customer-facing prices are gross (VAT-inclusive) with a secondary "ex VAT" line. Default VAT is 21% (Romania standard, computed per delivery country so the displayed total matches the invoice). Currency is EUR (€); RON checkout is supported with the FX rate captured per order. Shipping fees are VAT-inclusive. Custom manufacturing (per-part) is priced on material type + print time (machine + labour hours) + filament weight + geometry complexity. No tooling costs, no setup fees, no minimum order quantity. Manufacturing bulk breaks: 100+ units → 10%, 250+ units → 20%. Engineering service rates: - 3D modeling — €60 / hour - Revisions — €50 / hour - Consultation — €60 / hour - Manufacturing drawings — included with modeling Filament pricing: per-kg, identical for refill vs full spool, with tiered bulk pricing on most SKUs. Curated multi-spool packs save up to ~25% vs a single spool; 50+ spool reorders get a custom quote. Strikethrough MSRP is shown when list price exceeds the sale price. Ready-made products: unit-priced with per-item bulk discounts configured per product (commonly 10/20/30% bands around quantities like 50/100/250). Filaments and products can also sit in catalog-wide discount groups. All discount tiers are resolved server-side at checkout — never estimate them by hand. For instant pricing, drop a 3MF, STL or OBJ file in the calculator: https://layercrew.com/calculator. For STEP files, custom quotes or complex projects, contact: https://layercrew.com/contact-us. --- ## Print Parameters - Nozzle sizes available: 0.2mm, 0.4mm (default), 0.6mm, 0.8mm - Infill density range: 5% to 100% - Wall count: 1 to 20 perimeters - Support structures: configurable on/off - Tolerance standard: ISO 2768-m (medium) - Accepted file formats: 3MF, STL, OBJ for an instant quote; STEP, STP and more for a custom quote --- ## How Custom Production Works 1. **Upload files** — drop STL, OBJ, 3MF or STEP files. The system validates geometry and flags potential issues before anything hits the queue. 2. **Engineering review** — the team reviews wall thickness, orientation, and material selection, optimising for strength and cost, not just printability. 3. **Production** — parts enter the centrally managed print farm: calibrated machines, controlled profiles, real-time monitoring across the fleet. 4. **Delivery** — quality-inspected parts shipped from Bucharest across the EU. Most orders arrive in days, not weeks. --- ## Frequently Asked Questions Q: What does LayerCrew actually sell? A: Three things off one farm. Filament (curated multi-brand spools, our current focus), custom Production (bring an idea, we model it and run the batch), and ready-made Products (wholesale printed goods shops resell). Start with the question "do you have a 3D printer?" Q: What makes your filament different from any other reseller? A: Every spool is production-tested on our own always-on farm, and the calibrated slicer profile comes free — print-ready out of the box. We buy direct from the producers (AzureFilm, Bambu Lab, Polymaker) and ship EU-direct from Bucharest. Q: Do you sell filament in bulk / to print farms and resellers? A: Yes — that's the target customer. Refill or spool at the same price per kg, multi-spool packs save up to ~25%, and 50+ spool reorders get a custom quote. Live stock is at https://layercrew.com/filaments. Q: What's your typical production turnaround time? A: Production usually starts within 24–48 hours of order confirmation. Delivery depends on volume and shipping method, but most EU orders arrive in days. Q: Which engineering materials do you support? A: PLA, PETG, ABS, ASA, PC, plus reinforced and flame-retardant grades ABS-GF and ABS-FR, and PA12 nylon. TPU is available for genuinely flexible parts. Any 3MF, STL or OBJ gets an instant price in the online calculator; an engineer confirms every file with a DFM review before printing. Q: Do you offer design optimization for 3D printing? A: Yes. Every custom file gets a design-for-manufacturability (DFM) review — wall thickness, overhangs, tolerance stack-ups, material selection, orientation — included at no extra charge. We match the material to the problem. Q: Can I buy finished products to resell in my shop? A: Yes — that's the Products branch. Ready-made printed goods across Floristry, Home & Lifestyle, and Lights, priced with wholesale volume tiers. Browse https://layercrew.com/shop. Q: How do you ensure consistent quality in batch production? A: The farm is centrally managed with calibrated profiles per material, strict calibration schedules, and per-batch quality inspection. Part #1 and part #1,000 are the same part. Q: Do you have minimum order quantities? A: No minimums, ever — a single prototype is as valid as a 5,000-unit run. But volume is where we shine, and bulk discounts reward it. --- ## Key Differentiators - **Makers' hub, factory output** — we love making things and it shows, but the fun never comes at the cost of the parts being right. - **Engineering support included** — DFM review on every custom file, material-selection guidance, orientation optimisation, at no extra cost. - **Filament with receipts** — every spool production-tested on our own farm, calibrated profile free, bought direct from producers. - **Predictable output** — calibrated profiles per material mean part #1 = part #1,000 across an entire batch. - **EU-based** — Bucharest location means fast EU logistics, GDPR compliance, and no transatlantic shipping delays. - **No minimums** — from a single prototype to 5,000+ part runs, or one spool to a pallet. - **Transparent pricing** — no hidden fees, no tooling costs, volume discounts clearly stated. --- ## Site Map - Homepage: https://layercrew.com - Filaments (shop): https://layercrew.com/filaments - Filaments Explore Hub: https://layercrew.com/filaments/explore - Products Shop: https://layercrew.com/shop - Shop — Floristry: https://layercrew.com/shop/floristry - Shop — Home & Lifestyle: https://layercrew.com/shop/home - Shop — Lights: https://layercrew.com/shop/lights - Capabilities: https://layercrew.com/skills - Pricing: https://layercrew.com/pricing - Price Calculator: https://layercrew.com/calculator - Contact / Upload Files: https://layercrew.com/contact-us - Portfolio: https://layercrew.com/portfolio - About Us: https://layercrew.com/about-us - Privacy Policy: https://layercrew.com/privacy - Terms of Service: https://layercrew.com/terms --- # LayerCrew Partner API — developer documentation > Order 3D-printed parts from your own system — a webshop, an ERP, a custom site — over HTTPS and JSON, and have them built, packed and shipped under your name. Free to integrate, no minimum volume, white label by default; test keys on sign-up, live keys once the agreement is countersigned. Base URL https://engine.layercrew.com/v1; bearer API keys; Standard Webhooks signatures; one gap-free event feed. Each link below is the Markdown twin of a page under https://layercrew.com/developers. Pages: [LayerCrew Partner API](https://layercrew.com/developers) · [Quickstart](https://layercrew.com/developers/quickstart) · [Authentication](https://layercrew.com/developers/authentication) · [Files](https://layercrew.com/developers/files) · [Quotes](https://layercrew.com/developers/quotes) · [Orders](https://layercrew.com/developers/orders) · [Lifecycle and events](https://layercrew.com/developers/lifecycle) · [Webhooks](https://layercrew.com/developers/webhooks) · [Errors](https://layercrew.com/developers/errors) · [Rate limits](https://layercrew.com/developers/rate-limits) · [Idempotency](https://layercrew.com/developers/idempotency) · [Test mode](https://layercrew.com/developers/test-mode) · [API reference](https://layercrew.com/developers/reference) · [Changelog](https://layercrew.com/developers/changelog). OpenAPI: https://layercrew.com/openapi.json --- # LayerCrew Partner API > Order 3D-printed parts from your own system and have them built, packed and shipped under your name. Free to integrate, no minimum, white label by default. Updated 2026-10-24 · HTML: https://layercrew.com/developers · Markdown: https://layercrew.com/developers/index.md · OpenAPI: https://layercrew.com/openapi.json ## Your production line, as an API You sell the parts; we print, pack and ship them under your name. Your system — a webshop, an ERP, a custom site — uploads the file, creates the order with your own reference and your customer's address, and hears back on every step: priced, accepted, in production, shipped with tracking, delivered. By signed webhook, or by polling one ordered feed. - **Free to integrate.** No subscription, no minimum volume, no setup fee. You pay per order, at the per-part price the [calculator](https://layercrew.com/calculator) shows, with the quantity tiers applied automatically. - **White label by default.** Neutral packaging, your sender name, your card text, no invoice in the box. - **An engineer reads every order** before it is accepted — the same design-for-manufacturing look every file gets. - **Test keys at once**, from the moment you accept the agreement; live keys once we countersign. - **No SDK needed.** Plain HTTPS and JSON. Every endpoint, field and error is in the [reference](https://layercrew.com/developers/reference) and in the [OpenAPI document](https://layercrew.com/openapi.json). ## How it works 1. **Register your company.** Sign up at [/developers/register](https://layercrew.com/developers/register) and accept the integration agreement and the data processing agreement in your dashboard. Test keys are yours at once; live keys follow once LayerCrew countersigns. 2. **Upload a file.** Declare the file, PUT the bytes to the signed URL, call `complete`. STL, OBJ and 3MF are priced automatically; STEP goes to an engineer through [contact us](https://layercrew.com/contact-us). 3. **Price it, then order it.** Ask for a quote, or create the order straight away: your own reference, the items, your customer's delivery address, your white-label instructions. Send an `Idempotency-Key` so a retry never creates a twin. 4. **We price, review and accept.** The order is priced within minutes. An engineer accepts it, usually within one business day, or declines it with a reason you can act on. Payment instructions arrive with acceptance; on the default plan production starts once the transfer lands. 5. **Follow it to the door.** Every state change is an event with a sequence number. Listen on a webhook, or poll `GET /v1/events`. Carrier and tracking arrive with `order.shipped`; `order.delivered` closes it. ## Who it is for Companies that take orders for printed parts from their own customers and want them produced without running a print farm: design studios, webshops, product brands, agencies, resellers. Partners are companies — a private individual orders through the [calculator](https://layercrew.com/calculator). ## What you get back - A price per item and totals in EUR, provisional within minutes and frozen at acceptance. - Acceptance, or a rejection with a code and the engineer's note. - Production status: in production, packed, shipped with carrier and tracking, delivered. - An invoice per order, as a PDF link. - A log of every request you made and every webhook we attempted — in your dashboard and over the API. ## Start here - [Quickstart](https://layercrew.com/developers/quickstart) — from sign-up to a test order with webhooks. - [Authentication](https://layercrew.com/developers/authentication) — keys, environments, rotation. - [Files](https://layercrew.com/developers/files), [Quotes](https://layercrew.com/developers/quotes), [Orders](https://layercrew.com/developers/orders) — the three objects you work with. - [Lifecycle and events](https://layercrew.com/developers/lifecycle) and [Webhooks](https://layercrew.com/developers/webhooks) — how you hear back. - [Errors](https://layercrew.com/developers/errors), [Rate limits](https://layercrew.com/developers/rate-limits), [Idempotency](https://layercrew.com/developers/idempotency), [Test mode](https://layercrew.com/developers/test-mode). - [API reference](https://layercrew.com/developers/reference) — every endpoint and field, generated from the spec. - For machines: [/openapi.json](https://layercrew.com/openapi.json), [/llms.txt](https://layercrew.com/llms.txt), [/llms-full.txt](https://layercrew.com/llms-full.txt), and every page as Markdown at its URL plus `.md` — this one at [/developers/index.md](https://layercrew.com/developers/index.md). ## Questions **What does the Partner API cost?** Nothing to integrate. There is no subscription, no setup fee and no minimum volume. You pay per order at the per-part price, with the quantity tiers applied automatically; a contract can set a different discount. **Which file formats can I send?** STL, OBJ and 3MF are priced automatically. STEP files are priced by an engineer: send them through the contact form and we come back with a quote. **Is it really white label?** Yes. Neutral packaging, your sender name, your card text, no invoice in the box. The courier label names LayerCrew as the shipping account, a courier requirement, and shows your sender name as the contact line; nothing in or on the parcel carries our name. **Do I need an SDK?** No. The API is plain HTTPS with JSON bodies. The OpenAPI 3.1 document at layercrew.com/openapi.json loads in any client generator, and every page of these docs is available as Markdown by adding .md to its URL. **How fast is an order accepted?** The order is priced within minutes. An engineer then looks at it and accepts or declines it, usually within one business day. **Can I test before going live?** Yes. Test keys work from the moment you accept the agreement. They run the whole flow, with real pricing, without creating production orders, and let you move a test order through its states yourself. --- # Quickstart > From company sign-up to a test order that reports to your webhook, in ten steps — then the one change that takes you live. Updated 2026-10-24 · HTML: https://layercrew.com/developers/quickstart · Markdown: https://layercrew.com/developers/quickstart.md · OpenAPI: https://layercrew.com/openapi.json This walk-through takes you from a company account to a test order that reports its progress to your webhook. Everything happens in the test environment; the last step is the switch to live. > **Conventions.** Base URL `https://engine.layercrew.com/v1`. Every response is JSON in an envelope: `{"data": …}` for one object, `{"data": [...], "meta": {…}}` for lists, `{"error": {code, message, details?}}` on failure. Money is a string with two decimals in EUR; timestamps are RFC 3339 in UTC; identifiers are UUIDs. ## 1. Register your company Sign up at [/developers/register](https://layercrew.com/developers/register) with your company's name, legal identifier or VAT number, country and a contact. Partners are companies, so the form has no private-individual option. A Romanian company is checked against ANAF; an EU company against the VAT register when it answers. ## 2. Accept the agreements In your dashboard under Integrations → Agreement, read and accept the integration agreement and the data processing agreement together. Acceptance is recorded — who, when, from which address, which version — and the PDF is emailed to you. LayerCrew countersigns in AdminCrew and the countersigned PDF goes to both sides; `agreement.countersigned` appears in your event feed. ## 3. Create a test key Under Integrations → Keys, create a test key. It looks like `lc_test_<16 characters>_<64 hex characters>` and is shown once: store it in your secret manager. Check it: ```bash curl "https://engine.layercrew.com/v1/ping" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" ``` _200 — the key, its environment and its plan_ ```json { "data": { "partner": { "id": "0d2f6a1c-3b4e-4d5f-8a9b-1c2d3e4f5a6b", "number": "LC-PT-0007", "name": "Studio NS" }, "environment": "test", "key_id": "k7m2p9q4r1s8t3u6", "plan": { "key": "default", "name": "Default" }, "rate_limit": { "per_second": 2, "burst": 10 }, "server_time": "2026-10-24T09:30:00Z" } } ``` ## 4. Upload a file Uploading is two calls and one PUT. First declare the file with its size in bytes and the lowercase hex SHA-256 of the exact bytes you will send: ```bash curl -X POST "https://engine.layercrew.com/v1/files" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "filename": "bracket-v3.stl", "size_bytes": 1048576, "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" }' ``` _The same declaration with the hash and size computed from the file_ ```bash FILE=bracket-v3.stl SIZE=$(stat -c %s "$FILE") # macOS: stat -f %z HASH=$(sha256sum "$FILE" | cut -d' ' -f1) # macOS: shasum -a 256 curl -X POST "https://engine.layercrew.com/v1/files" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"filename\":\"$FILE\",\"size_bytes\":$SIZE,\"sha256\":\"$HASH\"}" ``` _201 — the file and a one-time upload slot_ ```json { "data": { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "filename": "bracket-v3.stl", "format": "stl", "status": "pending", "size_bytes": 1048576, "created_at": "2026-10-24T09:31:00Z", "upload": { "method": "PUT", "url": "https://storage.googleapis.com/…signed…", "headers": { "Content-Type": "application/octet-stream", "x-goog-content-length-range": "0,1048576" }, "expires_at": "2026-10-24T09:46:00Z" } } } ``` PUT the raw bytes to `upload.url` before `expires_at`, sending exactly the headers in `upload.headers`. **Both headers are mandatory** — `Content-Type` and `x-goog-content-length-range` — because the signature covers them; a PUT missing either is refused by the storage service. ```bash curl -X PUT "$UPLOAD_URL" \ -H "Content-Type: application/octet-stream" \ -H "x-goog-content-length-range: 0,1048576" \ --data-binary @bracket-v3.stl ``` ## 5. Complete the upload Tell us the bytes are there. We check the object's size and hash against what you declared, read the model, strip any embedded slicer scripts from a 3MF and store the canonical copy: ```bash curl -X POST "https://engine.layercrew.com/v1/files/4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d/complete" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" ``` _200 — ready to quote and order_ ```json { "data": { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "filename": "bracket-v3.stl", "format": "stl", "status": "ready", "size_bytes": 1048576, "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "facts": { "triangles": 48210, "objects": 1, "bbox_mm": [ 60, 42.5, 18 ], "sanitised": false }, "created_at": "2026-10-24T09:31:00Z", "ready_at": "2026-10-24T09:32:10Z", "purge_after": "2026-11-23T09:32:10Z" } } ``` `status` is `ready`, or `rejected` with a `rejection_code` you can act on — see [Files](https://layercrew.com/developers/files#rejections). `complete` is idempotent: call it again and you get the same file back. ## 6. Ask for a price (optional) ```bash curl -X POST "https://engine.layercrew.com/v1/quotes" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "file_id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "material": "PLA", "quantity": 2 }' ``` The answer is `202` with the quote in `pending`. Pricing slices the file for real and takes seconds to a few minutes; poll `GET /v1/quotes/{id}` until `status` is `priced`, or listen for `quote.priced`. See [Quotes](https://layercrew.com/developers/quotes). ## 7. Create an order An order needs your own `reference` (unique in this environment), the items, your customer's delivery address and an `Idempotency-Key` header: ```bash curl -X POST "https://engine.layercrew.com/v1/orders" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "reference": "SHOP-10231", "items": [ { "file_id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "quantity": 2, "material": "PLA", "colour": "black", "nozzle": "0.4" } ], "delivery": { "name": "Claire Martin", "phone": "+33 6 12 34 56 78", "email": "claire@example.com", "line1": "12 rue de la Paix", "city": "Lyon", "postal_code": "69001", "country_code": "FR" }, "white_label": { "sender_name": "Studio NS", "card_text": "Merci Claire — Studio NS", "language": "fr" }, "metadata": { "shop_order_id": "10231" } }' ``` _202 — received; pricing has started_ ```json { "data": { "id": "7a3e9c10-2b4d-4f61-9e8a-5c6d7e8f9a0b", "number": "PA-2026-0007", "reference": "SHOP-10231", "environment": "test", "status": "received", "pricing_status": "pending", "items": [ { "id": "e5f6a7b8-c9d0-4e1f-a2b3-c4d5e6f7a8b9", "position": 1, "file": { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "filename": "bracket-v3.stl", "format": "stl" }, "quantity": 2, "material": "PLA", "colour": "black", "nozzle": "0.4", "pricing_status": "pending" } ], "pricing": { "currency": "EUR", "final": false }, "delivery": { "name": "Claire Martin", "city": "Lyon", "postal_code": "69001", "country_code": "FR", "line1": "12 rue de la Paix", "phone": "+33 6 12 34 56 78", "email": "claire@example.com" }, "white_label": { "enabled": true, "sender_name": "Studio NS", "card_text": "Merci Claire — Studio NS", "no_invoice_in_box": true, "language": "fr" }, "metadata": { "shop_order_id": "10231" }, "shipments": [], "invoices": [], "rejection": null, "hold": null, "created_at": "2026-10-24T09:35:00Z", "updated_at": "2026-10-24T09:35:00Z", "links": { "self": "/v1/orders/7a3e9c10-2b4d-4f61-9e8a-5c6d7e8f9a0b", "events": "/v1/orders/7a3e9c10-2b4d-4f61-9e8a-5c6d7e8f9a0b/events" } } } ``` A retry with the same key and the same body returns the first answer with `Idempotent-Replayed: true`; the same key with a different body answers `409 IDEMPOTENCY_KEY_REUSED`. A second order with the same `reference` answers `409 REFERENCE_TAKEN` and names the existing one. See [Orders](https://layercrew.com/developers/orders) and [Idempotency](https://layercrew.com/developers/idempotency). ## 8. Follow the order Every change is an event with a per-partner `seq`. Poll the account feed from the last `seq` you processed; `meta.next_after` and `meta.has_more` drive the loop: ```bash curl "https://engine.layercrew.com/v1/events?after=0&limit=50" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" ``` A live order moves as production happens. In the test environment you move it yourself, and each move emits the same event a live order would: ```bash curl -X POST "https://engine.layercrew.com/v1/test/orders/7a3e9c10-2b4d-4f61-9e8a-5c6d7e8f9a0b/advance" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "accepted" }' ``` ## 9. Receive webhooks ```bash curl -X POST "https://engine.layercrew.com/v1/webhook-endpoints" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://shop.example.com/webhooks/layercrew", "description": "Production events" }' ``` _201 — the secret is shown once_ ```json { "data": { "id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f", "url": "https://shop.example.com/webhooks/layercrew", "description": "Production events", "environment": "test", "event_types": null, "enabled": true, "consecutive_failures": 0, "created_at": "2026-10-24T09:40:00Z", "secret": "whsec_c2VjcmV0LWJ5dGVzLWZvci1kb2NzLW9ubHk=" } } ``` Store the `secret`. Send yourself a signed test event and verify it with the snippet for your language on the [Webhooks](https://layercrew.com/developers/webhooks#verifying) page: ```bash curl -X POST "https://engine.layercrew.com/v1/webhook-endpoints/c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f/ping" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" ``` ## 10. Go live 1. Wait for `agreement.countersigned`, or check `GET /v1/account`: both agreements show `countersigned`. 2. Create a live key under Integrations → Keys (the account owner does this) and store it next to the test one. 3. Register your production webhook endpoint with the live key — endpoints, files, quotes and orders are per environment. 4. Switch the key in your configuration. Nothing else changes: same routes, same bodies, same events. > **Pace.** Keep to two requests a second with small bursts — the default plan; your plan may allow more, and `GET /v1/ping` tells you. A `403` without a JSON body means the network edge throttled your address: back off 90 seconds. Never retry an order creation blindly — send the same `Idempotency-Key` again, or look the order up by `reference`. See [Rate limits](https://layercrew.com/developers/rate-limits). --- # Authentication > Bearer API keys: the key format, test and live environments, who may create keys, rotation and revocation within 30 seconds, and keeping a key secret. Updated 2026-10-24 · HTML: https://layercrew.com/developers/authentication · Markdown: https://layercrew.com/developers/authentication.md · OpenAPI: https://layercrew.com/openapi.json Every call to `/v1/*` carries an API key as a bearer token: `Authorization: Bearer lc_live_…` or `lc_test_…`. There is no OAuth exchange and no session — the key is the identity, and it names the partner, the environment and the plan. ## Key format `lc_` + `live` or `test` + `_` + 16 characters of `[a-z0-9]` (the key id) + `_` + 64 lowercase hex characters (the secret). The whole key is 89 characters long and is parsed by fixed widths before any lookup, so a truncated or edited key is refused as `401 INVALID_API_KEY` without touching the database. Only a hash of the secret is stored; the key is shown once, when you create it. ```text lc_test_k7m2p9q4r1s8t3u6_9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 │ │ │ │ │ │ │ └─ secret: 64 lowercase hex characters (hashed at rest, shown once) │ │ └─ key id: 16 characters [a-z0-9] — appears in your request log and dashboard │ └─ environment: test or live └─ LayerCrew prefix ``` ## Two environments | Prefix | Environment | What it reaches | | --- | --- | --- | | `lc_test_` | test | The same API against the same code, with `environment: test` on every object. Never creates a production order, never touches stock, couriers or invoices; pricing is real; you advance orders yourself. Issued once your account owner has accepted the agreements. See [Test mode](https://layercrew.com/developers/test-mode). | | `lc_live_` | live | Production. Issued by the account owner once LayerCrew has countersigned both agreements; until then the dashboard answers `AGREEMENT_NOT_COUNTERSIGNED`. | Files, quotes, orders, events and webhook endpoints belong to one environment. A test key never sees a live object (`404 NOT_FOUND`), and a key whose prefix differs from the environment it was issued for answers `403 SCOPE_MISMATCH`. A key of a suspended or closed account answers `403 PARTNER_INACTIVE`. ## Who may create keys - **Owner** — test and live keys, every setting, every environment. - **Developer** — test keys and everything in the test environment; live orders, events and requests read-only when the owner grants it. - **Viewer** — read-only in both environments, with your customers' contact fields redacted. Keys are created, named and revoked in your dashboard under Integrations → Keys. One key per system or deployment keeps a leak one revocation away. ## Rotation and revocation Create the new key, deploy it, then revoke the old one. Revocation, a suspension and a plan change take effect on every server within 30 seconds; for those seconds a revoked key may still answer. - A revoked or expired key answers `401 API_KEY_REVOKED`. - Removing a member from your account revokes the keys that member created. - Every creation and revocation is an event — `key.issued`, `key.revoked` — in your feed and in your dashboard's activity log. The event never carries the secret. ## Keep it secret - Treat the key like a password: your secret manager or server environment, never a repository, a client-side bundle, a mobile app or a URL. - Call the API from your servers. If a browser or an app must talk to us, it talks to your backend first. - Use a test key everywhere until the switch to live, and never log the `Authorization` header. ## Checking a key ```bash curl "https://engine.layercrew.com/v1/ping" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" ``` _200_ ```json { "data": { "partner": { "id": "0d2f6a1c-3b4e-4d5f-8a9b-1c2d3e4f5a6b", "number": "LC-PT-0007", "name": "Studio NS" }, "environment": "live", "key_id": "k7m2p9q4r1s8t3u6", "plan": { "key": "default", "name": "Default" }, "rate_limit": { "per_second": 2, "burst": 10 }, "server_time": "2026-10-24T09:30:00Z" } } ``` _401 — the key is wrong_ ```json { "error": { "code": "INVALID_API_KEY", "message": "the API key is missing, malformed or unknown — send a live or test key as Authorization: Bearer lc_live_… / lc_test_…" } } ``` ## Request ids Every response carries `X-Request-ID`, our id for that call. Quote it when you write to us. You may send your own `X-Request-ID` — letters, digits, `. _ -`, up to 128 characters — and it comes back as `X-Client-Request-ID`, stored in your request log next to ours. See [Errors](https://layercrew.com/developers/errors#debugging). --- # Files > Upload STL, OBJ and 3MF in two calls and one PUT; what complete verifies; rejection codes; what we keep, for how long and how to delete. Updated 2026-10-24 · HTML: https://layercrew.com/developers/files · Markdown: https://layercrew.com/developers/files.md · OpenAPI: https://layercrew.com/openapi.json A file is the 3D model an order item points at. You upload it once — two calls and one PUT — and reuse it across quotes and orders. Files belong to one environment and are visible to you alone. ## Formats | Format | Priced | Notes | | --- | --- | --- | | STL | automatically | Binary or ASCII. Units are millimetres. | | OBJ | automatically | Geometry only; materials and textures are ignored. | | 3MF | automatically | Printable objects are read with the settings the file was saved with — material, nozzle, layer height, infill, supports. Embedded slicer scripts are stripped and `facts.sanitised` says so. | | STEP, STP | by an engineer | Send them through [contact us](https://layercrew.com/contact-us) and we come back with a quote. `POST /v1/files` answers `422` for a `.step` or `.stp` filename and says the same. | ## Limits - **Size** — your plan's `max_file_bytes`: 64 MiB on the default plan; it can differ per plan, and `GET /v1/account` names yours. - **Geometry** — up to four million triangles for STL and OBJ; a 3MF is read within the same caps the public [calculator](https://layercrew.com/calculator) uses. Beyond them `complete` answers `rejected` with `too_complex`: simplify the mesh, or send it through [contact us](https://layercrew.com/contact-us). - **Count** — your plan's `max_files`, when set. ## Upload in three steps 1. **Declare.** `POST /v1/files` with `filename` (with its extension), `size_bytes` and the lowercase hex `sha256` of the exact bytes. The answer is `201` with the file in `pending` and a one-time `upload` block: a `url`, the `headers` to send and an `expires_at`. 2. **PUT the bytes** to `upload.url` with exactly the headers in `upload.headers`. **Both are mandatory** — `Content-Type` and `x-goog-content-length-range` — because the signed URL covers them; a PUT missing either is refused by the storage service. The body is the raw file, never multipart. 3. **Complete.** `POST /v1/files/{id}/complete`. We check the object's size against `size_bytes` and its hash against `sha256`, read the model, sanitise a 3MF and write the canonical copy. `status` becomes `ready` and `file.ready` is emitted. _Declare, PUT, complete — with the hash and size computed from the file_ ```bash FILE=bracket-v3.stl SIZE=$(stat -c %s "$FILE") # macOS: stat -f %z "$FILE" HASH=$(sha256sum "$FILE" | cut -d' ' -f1) # macOS: shasum -a 256 "$FILE" | cut -d' ' -f1 # 1. declare DECLARED=$(curl -s -X POST "https://engine.layercrew.com/v1/files" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"filename\":\"$FILE\",\"size_bytes\":$SIZE,\"sha256\":\"$HASH\"}") FILE_ID=$(echo "$DECLARED" | jq -r .data.id) UPLOAD_URL=$(echo "$DECLARED" | jq -r .data.upload.url) RANGE=$(echo "$DECLARED" | jq -r '.data.upload.headers["x-goog-content-length-range"]') # 2. PUT the raw bytes with both signed headers curl -s -X PUT "$UPLOAD_URL" \ -H "Content-Type: application/octet-stream" \ -H "x-goog-content-length-range: $RANGE" \ --data-binary @"$FILE" # 3. complete curl -s -X POST "https://engine.layercrew.com/v1/files/$FILE_ID/complete" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" | jq .data.status ``` ```node import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; const base = "https://engine.layercrew.com/v1"; const headers = { Authorization: `Bearer ${process.env.LAYERCREW_API_KEY}` }; const bytes = await readFile("bracket-v3.stl"); // 1. declare const declared = await fetch(`${base}/files`, { method: "POST", headers: { ...headers, "Content-Type": "application/json" }, body: JSON.stringify({ filename: "bracket-v3.stl", size_bytes: bytes.byteLength, sha256: createHash("sha256").update(bytes).digest("hex"), }), }).then((r) => r.json()); const { id, upload } = declared.data; // 2. PUT the raw bytes with exactly the signed headers const put = await fetch(upload.url, { method: "PUT", headers: upload.headers, body: bytes }); if (!put.ok) throw new Error(`upload failed: ${put.status}`); // 3. complete const file = await fetch(`${base}/files/${id}/complete`, { method: "POST", headers }).then((r) => r.json()); console.log(file.data.status); // "ready", or "rejected" with rejection_code ``` ```python import hashlib, os, requests BASE = "https://engine.layercrew.com/v1" AUTH = {"Authorization": f"Bearer {os.environ['LAYERCREW_API_KEY']}"} data = open("bracket-v3.stl", "rb").read() # 1. declare declared = requests.post(f"{BASE}/files", headers=AUTH, timeout=30, json={ "filename": "bracket-v3.stl", "size_bytes": len(data), "sha256": hashlib.sha256(data).hexdigest(), }).json()["data"] # 2. PUT the raw bytes with exactly the signed headers put = requests.put(declared["upload"]["url"], headers=declared["upload"]["headers"], data=data, timeout=300) put.raise_for_status() # 3. complete file = requests.post(f"{BASE}/files/{declared['id']}/complete", headers=AUTH, timeout=60).json()["data"] print(file["status"]) # "ready", or "rejected" with rejection_code ``` `complete` is idempotent: calling it on a `ready` file returns the file. While another completion of the same file is running it answers `409`; wait a moment and call again. The upload slot expires after `expires_at`; declare the file again for a fresh one. ## Rejections When verification fails the file is `rejected`, `complete` answers `422 FILE_REJECTED`, and `rejection_code` on the file says why: | Code | What happened | Next step | | --- | --- | --- | | `size_mismatch` | The object's size differs from `size_bytes`. | Declare the exact size of the bytes you send, then upload again. | | `hash_mismatch` | The object's SHA-256 differs from `sha256`. | Hash the exact bytes you upload (lowercase hex), then upload again. | | `upload_missing` | No object arrived at the signed URL, or it expired. | Declare the file again and PUT within `expires_at`. | | `not_a_model` | The bytes hold no readable geometry for the declared extension. | Export the part again from your CAD or slicer as STL, OBJ or 3MF. | | `too_complex` | More geometry than we price automatically. | Simplify the mesh, or send the file through [contact us](https://layercrew.com/contact-us). | | `too_large` | Over your plan's `max_file_bytes`. | Reduce the mesh, split the part, or ask us about a larger limit. | A `.step` or `.stp` filename is refused at `POST /v1/files` (`422`, field `filename`): STEP files are priced by an engineer — send them through [contact us](https://layercrew.com/contact-us); STL, OBJ and 3MF are priced automatically here. ## What we read from the file `facts` carries what the parser saw: `triangles`, `objects`, the bounding box in millimetres (`bbox_mm`), the `producer` of a 3MF and whether it was `sanitised`. The geometry itself stays in our systems and is read by the slicer, by the engineer who reviews your order and by the printer that makes it. ## What we keep, for how long, and how to delete Your files are stored in the EU, encrypted at rest, under an object name no signed URL can target once the canonical copy is written. The retention rules are the plan's; the default plan's values are below, and `GET /v1/account` names yours under `limits`. | What | How long | | --- | --- | | A test file no order uses | Deleted after `test_file_retention_days`; `purge_after` on the file says when. Set to null the first time an order uses it. | | A live file no order uses | Kept until you delete it, within your plan's `max_files` when set. | | A file used by an order | Kept while any order needs it. Once its last order reaches a final state (`delivered`, `cancelled`, `rejected`), deleted after `file_retention_days_after_last_order`. | | The production copy on an accepted order | Deleted together with the file. The order keeps the filename, the `facts` and the price, so your history stays complete. | | Your customer's personal data on an order | Redacted after `recipient_retention_days` — 90 days on the default plan — or at once with `DELETE /v1/orders/{id}/recipient` on a finished order. See [Orders](https://layercrew.com/developers/orders#delivery). | To delete a file yourself, call `DELETE /v1/files/{id}`: - No order uses it — deleted now, `204`. - Used only by finished orders — scheduled, deleted within the hour, `202`; `file.purged` follows. - An open order, a pending quote or a running price job still needs it — `409 FILE_IN_USE`, and `error.details` names it. Delete once that has finished. Deleting a file removes its bytes and every production copy; `purged_at` is set and `file.purged` is emitted. The runbook that erases a partner account runs the same for every file. Every endpoint is in the [reference](https://layercrew.com/developers/reference#tag-files). --- # Quotes > Price a ready file in one material at one quantity before you order: how the price is made, tiers and discounts, the estimate, failure codes and materials. Updated 2026-10-24 · HTML: https://layercrew.com/developers/quotes · Markdown: https://layercrew.com/developers/quotes.md · OpenAPI: https://layercrew.com/openapi.json A quote prices one `ready` file in one material at one quantity, without creating an order. Use it to show a price in your own shop, to compare materials or to check a design before you commit. ## How a price is made The file is sliced for real on our quoting profile for the material, with our house settings; a 3MF keeps the settings it was saved with. From the slice we take the weight and the print time, and the per-part price follows — the same price the [calculator](https://layercrew.com/calculator) shows. Quantities are priced on full plates, then one partial plate; the unit price is rounded to the cent before it is multiplied. ## Quantity tiers and your discount The published quantity tiers apply automatically — 100+ of the same file carries 10 percent off the unit price, 250+ carries 20 percent. Your plan's `discount_pct` applies on top of the list price; a contract can set a different one. `tiers` on a priced quote lists the unit price at other quantities so your shop can show the ladder. ## Create a quote ```bash curl -X POST "https://engine.layercrew.com/v1/quotes" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "file_id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "material": "PLA", "nozzle": "0.4", "quantity": 2 }' ``` _202 — pricing has started_ ```json { "data": { "id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e", "file_id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "material": "PLA", "nozzle": "0.4", "quantity": 2, "status": "pending", "created_at": "2026-10-24T09:33:00Z", "priced_at": null } } ``` Pricing takes seconds to a few minutes. Poll `GET /v1/quotes/{id}` until `status` is `priced` or `failed`, or listen for `quote.priced` and `quote.failed` on your webhook. _GET /v1/quotes/{id} — priced_ ```json { "data": { "id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e", "file_id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "material": "PLA", "nozzle": "0.4", "quantity": 2, "status": "priced", "list_unit_price": "12.40", "discount_pct": "0", "unit_price": "12.40", "total": "24.80", "currency": "EUR", "tiers": [ { "min_quantity": 1, "unit_price": "12.40" }, { "min_quantity": 100, "unit_price": "11.16" }, { "min_quantity": 250, "unit_price": "9.92" } ], "estimate": { "weight_grams": 31.2, "time_seconds": 5400, "unit_yield": 1, "printer_model": "Bambu Lab A2L", "nozzle": "0.4", "material": "PLA", "infill_pct": 15, "wall_loops": 3, "support": "auto", "warnings": [], "priced_at": "2026-10-24T09:34:12Z" }, "created_at": "2026-10-24T09:33:00Z", "priced_at": "2026-10-24T09:34:12Z" } } ``` ## What the quote carries | Field | Meaning | | --- | --- | | `list_unit_price` | The per-part price at this quantity before your plan's discount, EUR, two decimals, as a string. | | `discount_pct` | Your plan's discount, as a string. | | `unit_price` | `list_unit_price` less the discount, rounded to the cent. | | `total` | `unit_price` × `quantity`. | | `tiers` | Unit price at other quantities — the ladder your shop can show. | | `estimate` | What the slicer measured and used: `weight_grams`, `time_seconds`, `unit_yield` (parts per plate in a 3MF), `printer_model`, `nozzle`, `material`, `infill_pct`, `wall_loops`, `support`, `warnings`. | | `estimate.warnings` | What the design-for-manufacturing pass flagged — thin walls, steep overhangs, a part laid out close to the bed's edge. Worth showing to whoever drew the part. | ## When pricing fails `status` becomes `failed`, `quote.failed` is emitted, and `failure_code` with `failure_message` say what to do: | Code | What happened | Next step | | --- | --- | --- | | `model_too_large` | The part is laid out larger than our print bed. | Split it into sub-assemblies, or send it through [contact us](https://layercrew.com/contact-us) and we take it by hand. | | `not_a_model` | The file holds no printable geometry. | Export the part again from your CAD or slicer. | | `material_unavailable` | The material is outside your plan or out of stock for this run. | Pick one from `GET /v1/materials`. | | `slicer_rejected` | The slicer refused the geometry. | Check for open meshes, inverted normals and zero-thickness walls, or send it to us. | | `file_unavailable` | The file was deleted or purged before pricing ran. | Upload it again. | ## Quotes and orders A quote is information, never a commitment. The price on an order is computed again when the order is created and frozen when an engineer accepts it; the same file, material and quantity on the same day give the same number. Your plan's `quote_daily_cap`, when set, bounds the quotes you may create in a day; `GET /v1/account` shows `usage.quotes_today`. ## Materials `GET /v1/materials` lists what you can order: each material with its `nozzles` and `colours` (`id`, `name`, `hex`, `in_stock`), filtered by your plan's `allowed_materials` and `allowed_nozzles`. `in_stock` is live and never a promise. A colour left out of an item lets the engineer pick at production. ```bash curl "https://engine.layercrew.com/v1/materials" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" ``` _200_ ```json { "data": [ { "material": "PLA", "description": "Dimensionally critical parts and visual models.", "nozzles": [ "0.2", "0.4", "0.6", "0.8" ], "colours": [ { "id": "black", "name": "Black", "hex": "#111111", "in_stock": true }, { "id": "white", "name": "White", "hex": "#F4F4F4", "in_stock": true } ] }, { "material": "PETG", "description": "Functional parts with moisture and chemical resistance.", "nozzles": [ "0.4", "0.6" ], "colours": [ { "id": "black", "name": "Black", "hex": "#111111", "in_stock": true } ] } ] } ``` Every endpoint is in the [reference](https://layercrew.com/developers/reference#tag-quotes). --- # Orders > Create an order with your reference, items, your customer's address and white-label instructions; pricing, payment, acceptance, holds and cancelling. Updated 2026-10-24 · HTML: https://layercrew.com/developers/orders · Markdown: https://layercrew.com/developers/orders.md · OpenAPI: https://layercrew.com/openapi.json An order is one delivery to one of your customers: one or more items — each a `ready` file in a material at a quantity — plus your own reference, the delivery address, white-label instructions and any metadata you want back. You create it with one call; we price it, review it, accept it, produce it, ship it and tell you about every step. ## Create an order ```bash curl -X POST "https://engine.layercrew.com/v1/orders" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" \ -H "Idempotency-Key: 2f5c1c2e-0f6a-4d2b-9a1e-7b3c4d5e6f70" \ -H "Content-Type: application/json" \ -d '{ "reference": "SHOP-10231", "items": [ { "file_id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "quantity": 2, "material": "PLA", "colour": "black", "nozzle": "0.4", "notes": "Visible face is the flat side." } ], "delivery": { "name": "Claire Martin", "company": "Atelier Martin", "phone": "+33 6 12 34 56 78", "email": "claire@example.com", "line1": "12 rue de la Paix", "line2": "Bâtiment B", "city": "Lyon", "postal_code": "69001", "country_code": "FR" }, "white_label": { "sender_name": "Studio NS", "card_text": "Merci Claire — Studio NS", "packaging_preset": "neutral", "no_invoice_in_box": true, "language": "fr" }, "notes": "Fit check before the batch, please.", "metadata": { "shop_order_id": "10231", "customer_id": "c_8812" } }' ``` ```node import crypto from "node:crypto"; const response = await fetch("https://engine.layercrew.com/v1/orders", { method: "POST", headers: { Authorization: `Bearer ${process.env.LAYERCREW_API_KEY}`, "Idempotency-Key": crypto.randomUUID(), // store it with your shop order so a retry reuses it "Content-Type": "application/json", }, body: JSON.stringify({ reference: "SHOP-10231", items: [{ file_id: "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", quantity: 2, material: "PLA", colour: "black" }], delivery: { name: "Claire Martin", phone: "+33 6 12 34 56 78", email: "claire@example.com", line1: "12 rue de la Paix", city: "Lyon", postal_code: "69001", country_code: "FR", }, white_label: { sender_name: "Studio NS", card_text: "Merci Claire — Studio NS", language: "fr" }, metadata: { shop_order_id: "10231" }, }), }); const payload = await response.json(); if (response.status === 409 && payload.error.code === "REFERENCE_TAKEN") { console.log("already created:", payload.error.details.order_id); } else if (!response.ok) { throw new Error(`${payload.error.code}: ${payload.error.message}`); } console.log(payload.data.number, payload.data.status); // "PA-2026-0007" "received" ``` ```python import os, uuid, requests response = requests.post( "https://engine.layercrew.com/v1/orders", headers={ "Authorization": f"Bearer {os.environ['LAYERCREW_API_KEY']}", "Idempotency-Key": str(uuid.uuid4()), # store it with your shop order so a retry reuses it }, json={ "reference": "SHOP-10231", "items": [{"file_id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "quantity": 2, "material": "PLA", "colour": "black"}], "delivery": { "name": "Claire Martin", "phone": "+33 6 12 34 56 78", "email": "claire@example.com", "line1": "12 rue de la Paix", "city": "Lyon", "postal_code": "69001", "country_code": "FR", }, "white_label": {"sender_name": "Studio NS", "card_text": "Merci Claire — Studio NS", "language": "fr"}, "metadata": {"shop_order_id": "10231"}, }, timeout=30, ) payload = response.json() if response.status_code == 409 and payload["error"]["code"] == "REFERENCE_TAKEN": print("already created:", payload["error"]["details"]["order_id"]) elif not response.ok: raise RuntimeError(f"{payload['error']['code']}: {payload['error']['message']}") print(payload["data"]["number"], payload["data"]["status"]) # PA-2026-0007 received ``` The answer is `202` with the order in `received` and `pricing_status: pending`. Within minutes every item is priced and `order.priced` follows with the order in `pending_review`. The `Idempotency-Key` header is required: see [Idempotency](https://layercrew.com/developers/idempotency). ## The body | Field | Required | Rules | | --- | --- | --- | | `reference` | yes | Your own identifier, up to 120 characters, unique among your orders in this environment. | | `items[]` | yes | One to your plan's `max_items_per_order` (`max_test_items_per_order` in test). | | `items[].file_id` | yes | A `ready` file of yours in the same environment. | | `items[].quantity` | yes | 1 to your plan's `max_quantity_per_item`. | | `items[].material` | yes | A `material` from `GET /v1/materials`. | | `items[].colour` | no | A colour `id` of that material. Left out, the engineer picks at production. | | `items[].nozzle` | no | From the material's `nozzles`; default `0.4`. | | `items[].notes` | no | Up to 500 characters, read by the engineer. | | `delivery` | yes | Your customer's address — below. | | `white_label` | no | Sender name, card text, packaging — below. White label is on even when the block is left out. | | `notes` | no | Up to 2000 characters, read by the engineer. | | `metadata` | no | Up to 20 keys with string values up to 200 characters; returned untouched everywhere. | ### Reference `reference` is your identifier for the order — a shop order number, a job id. It must be unique among your orders in the environment: a duplicate answers `409 REFERENCE_TAKEN` and `error.details.order_id` names the existing order, so even a retry without its `Idempotency-Key` finds its order. Look orders up by it with `GET /v1/orders?reference=SHOP-10231`. ### Items - `file_id` must be a `ready` file of yours in the same environment — `409 FILE_NOT_READY` otherwise. - `material`, and the optional `colour` id, come from `GET /v1/materials` and your plan — `422 MATERIAL_UNAVAILABLE` otherwise. - A 3MF with several objects is priced as the plate it holds; `estimate.unit_yield` says how many parts one plate makes. - Every string passes a cleaning step that drops control characters and markup before it is stored. ### Delivery Your customer's address as the courier needs it: `name`, `phone` (the courier requires it), `line1`, `city`, `postal_code` and `country_code` (ISO 3166-1 alpha-2) are required; `company`, `email`, `line2` and `region` are optional outside Romania. > **Romania.** A Romanian address needs `region` — the county (județ) in its Romanian spelling, for example `Cluj` or `Ilfov`. In Bucharest, `region` is `București` and `city` names the sector, for example `Sector 3`: the invoice and the courier label both need it. Delivery fields are personal data of your customer. We process them under the data processing agreement, show them to production and shipping staff only, redact them after your plan's `recipient_retention_days` (90 days on the default plan) and at once on request: `DELETE /v1/orders/{id}/recipient` on a finished order keeps city, postal code and country for fiscal records and replaces the rest with `[redacted]`, on the order and on its stored events. `order.recipient_redacted` confirms it. ### White label White label is on by default. Every order ships in neutral packaging with no LayerCrew marks on the parts, the packaging or the inserts, and no invoice in the box. | Field | Default | Meaning | | --- | --- | --- | | `enabled` | `true` | Set `false` only if you want our standard packaging. | | `sender_name` | your company name | The contact line on the courier label, up to 80 characters. | | `card_text` | none | Printed on a card in the parcel, up to 500 characters; can differ on every order. | | `packaging_preset` | `neutral` | `neutral`, or `partner_supplied` when you send us your packaging. | | `no_invoice_in_box` | `true` | The invoice goes to you, never into the parcel. | | `language` | your account's locale | The language of any printed insert, as a two-letter code. | > **The courier label.** The courier label names LayerCrew as the shipping account (a courier requirement) and shows your sender name as the contact line; nothing in or on the parcel carries our name. ### Metadata and notes `metadata` is yours: up to 20 keys with string values up to 200 characters, returned untouched on the order and in every webhook — a shop order id, a customer id, a campaign. `notes` (up to 2000 characters) and `items[].notes` are read by the engineer who reviews the order. ## Pricing `pricing` carries `currency` (EUR), `subtotal`, `discount_total`, `shipping_total`, `tax_total`, `tax_treatment` and `grand_total`, each a string with two decimals. Per item: `list_unit_price`, `discount_pct`, `unit_price`, `line_total` and the slicer's `estimate`. The block is provisional at `order.priced` (`final: false`) and frozen at acceptance (`final: true`); once frozen it is the price you pay. | `tax_treatment` | When | | --- | --- | | `standard` | Romanian VAT applies — a Romanian company, or a company without a valid EU VAT number. | | `reverse_charge` | An EU company with a valid VAT number: no VAT on the invoice, you account for it. | | `export` | Delivery outside the EU. | | `exempt` | An exemption agreed in writing. | | `test` | A test order; nothing is invoiced. | _pricing and payment on an accepted order_ ```json { "pricing": { "currency": "EUR", "subtotal": "24.80", "discount_total": "0.00", "shipping_total": "14.99", "tax_total": "0.00", "tax_treatment": "reverse_charge", "grand_total": "39.79", "final": true }, "payment": { "method": "bank_transfer", "status": "unpaid", "ship_before_payment": false, "amount_due": "39.79", "instructions": { "beneficiary": "LAYER CREW SRL", "iban": "RO49AAAA1B31007593840000", "bank": "Example Bank", "reference": "PA-2026-0007" }, "paid_at": null } } ``` ## Payment Payment is by bank transfer against the order. `payment.instructions` appear at acceptance — `beneficiary`, `iban`, `bank` and the `reference` to put on the transfer, which is the order number. On the default plan production starts once the transfer arrives and `order.paid` is emitted; `ship_before_payment: true` on a plan or an agreed override starts production at acceptance instead. The invoice follows as `order.invoiced`: `GET /v1/orders/{id}/invoices` lists it and `GET /v1/orders/{id}/invoices/{invoice_id}/download-url` returns a 15-minute link to the PDF. Card payment is a later addition; a plan with payment terms is agreed in writing. ## Acceptance and rejection Every order is read by an engineer before it is accepted, usually within one business day — the same design-for-manufacturing look every file gets. An accepted order carries `accepted_at`, a frozen `pricing` block and `payment.instructions`. A declined order is `rejected` with `rejection.code`, `rejection.message` (the fact and the next step) and `rejection.note` (what the engineer wrote for you). | `rejection.code` | What it means | Next step | | --- | --- | --- | | `needs_split` | The part is laid out larger than our print bed. | Split it into sub-assemblies, or ask us to take it by hand. | | `design_change_needed` | A feature will not print as drawn; the note says which. | Change the design and order again. | | `material_unavailable` | The material is out of stock for this run. | Pick another material, or wait for stock. | | `out_of_capacity` | The queue is full for the date you need. | Order again with a later date in mind. | | `address_unserviceable` | The courier has no route to the address as given. | Correct the address and order again. | | `price_review` | The order needs a price by hand. | We write to you with the number. | | `other` | See the note. | The note says what to do. | ## Holds When we need something from you — a question about a feature, a colour that ran out — the order goes `on_hold` with `hold.reason` and `hold.since` and `order.on_hold` is emitted. `order.released` brings it back to the state it had before. Holds are possible from `received` through `ready_to_ship`. ## Cancelling `POST /v1/orders/{id}/cancel` with an optional `reason` is allowed while the order is `received`, `pending_review` or `accepted` — before production starts. Once printing has started the call answers `409 CANNOT_CANCEL`; write to us from the order page in your dashboard and we tell you what is still possible. A cancelled order is final and is never invoiced. ## Reading orders - `GET /v1/orders/{id}` takes the id or the number (`PA-2026-0007`). - `GET /v1/orders?status=&reference=&created_after=&page=&per_page=` lists newest first, 25 a page and up to 100. - `GET /v1/orders/{id}/events` returns the order's events in `seq` order; `GET /v1/events` the whole account's. See [Lifecycle and events](https://layercrew.com/developers/lifecycle). - `links.self` and `links.events` on every order point at both. Partner orders are yours alone: they are invisible on the customer-facing routes and on the customer dashboard, and visible under Integrations → Orders in yours. Every endpoint is in the [reference](https://layercrew.com/developers/reference#tag-orders). --- # Lifecycle and events > Every state a partner order passes through, every transition and who makes it, the event envelope with its sequence number, and every event type. Updated 2026-10-24 · HTML: https://layercrew.com/developers/lifecycle · Markdown: https://layercrew.com/developers/lifecycle.md · OpenAPI: https://layercrew.com/openapi.json A partner order moves through a fixed set of states. Every move is an event with a timestamp, an actor and a per-partner sequence number — listable on the order, on the account feed and delivered to your webhooks. ```text received ──order.priced──► pending_review ──order.accepted──► accepted ──► in_production ──► ready_to_ship ──► shipped ──► delivered │ │ │ (final) │ ├──order.rejected──► rejected │ │ │ (final) │ └────────── order.cancelled (you, while received / pending_review / accepted) ──► cancelled (final) any state before shipped ──order.on_hold──► on_hold ──order.released──► back to the previous state ``` ## States | State | Moved there by | What it means | | --- | --- | --- | | `received` | you | Your `POST /v1/orders` was accepted and every item is being priced. You may cancel. | | `pending_review` | pricing | Every item has a price (`pricing_status: priced`), or one needs a hand to price (`failed`); an engineer is looking at the order. You may cancel. | | `accepted` | engineer | In the production queue. The price is frozen (`pricing.final: true`), payment instructions are on the order, and production starts after payment unless your plan says otherwise. You may still cancel. | | `in_production` | production | Printing. From here on, cancellation goes through us: write from the order page. | | `ready_to_ship` | production | Passed quality check and packed. | | `shipped` | courier | Handed to the courier; `shipments[]` carries the tracking number and URL. Tracking updates follow as `order.tracking_updated`. | | `delivered` | courier | The parcel arrived. Final. | | `on_hold` | engineer | We need something from you before going on — `hold.reason` says what. The order returns to its previous state when released. | | `rejected` | engineer | Declined with a reason you can act on: `rejection.code`, `rejection.message` and the engineer's `rejection.note`. Final. | | `cancelled` | you | Cancelled by you (`POST /v1/orders/{id}/cancel`) or by us at your request. Final. | ## Transitions | From | To | Event | Actor | | --- | --- | --- | --- | | `received` | `pending_review` | `order.priced` | pricing | | `received` | `pending_review` | `order.pricing_failed` | pricing | | `pending_review` | `accepted` | `order.accepted` | engineer | | `pending_review` | `rejected` | `order.rejected` | engineer | | `accepted` | `in_production` | `order.in_production` | production | | `in_production` | `ready_to_ship` | `order.ready_to_ship` | production | | `ready_to_ship` | `shipped` | `order.shipped` | courier | | `shipped` | `delivered` | `order.delivered` | courier | | `received` | `cancelled` | `order.cancelled` | you | | `pending_review` | `cancelled` | `order.cancelled` | you | | `accepted` | `cancelled` | `order.cancelled` | you | | `in_production` | `cancelled` | `order.cancelled` | engineer | | `ready_to_ship` | `cancelled` | `order.cancelled` | engineer | | `received` | `on_hold` | `order.on_hold` | engineer | | `pending_review` | `on_hold` | `order.on_hold` | engineer | | `accepted` | `on_hold` | `order.on_hold` | engineer | | `in_production` | `on_hold` | `order.on_hold` | engineer | | `ready_to_ship` | `on_hold` | `order.on_hold` | engineer | | `on_hold` | (previous state) | `order.released` | engineer | ## Who acts - **You** — create the order, cancel it while it is `received`, `pending_review` or `accepted`, erase the recipient once it is final; in test, advance it. - **Pricing** — slices and prices every item within minutes: `order.priced`, or `order.pricing_failed` when an item needs a hand. - **An engineer** — accepts, rejects, holds and releases; prices by hand the items the slicer left unpriced. - **Production** — `in_production` when printing starts, `ready_to_ship` after quality check and packing. - **The courier** — `shipped` with tracking, `tracking_updated`, `delivered`, and `returned` when a parcel comes back. ## The event envelope The envelope is the same on the account feed, on the order's events and in a webhook body: ```json { "id": "9c8b7a6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d", "type": "order.shipped", "api_version": "2026-10-24", "created_at": "2026-10-27T14:05:12Z", "seq": 1234, "environment": "live", "reason_code": null, "data": { "order": { "id": "7a3e9c10-2b4d-4f61-9e8a-5c6d7e8f9a0b", "number": "PA-2026-0007", "reference": "SHOP-10231", "status": "shipped", "shipments": [ { "carrier": "FAN Courier", "tracking_number": "2057100012345", "tracking_url": "https://www.fancourier.ro/awb-tracking/?awb=2057100012345", "status": "shipped", "shipped_at": "2026-10-27T14:05:00Z", "delivered_at": null } ], "metadata": { "shop_order_id": "10231" }, "…": "the rest of the order, exactly as GET /v1/orders/{id} returned it at that moment" } } } ``` | Field | Meaning | | --- | --- | | `id` | The event id. A webhook delivery of this event carries it as `event_id`; the delivery's own id is `webhook-id`. | | `type` | One of the types below. | | `api_version` | The API version the payload follows — `2026-10-24`. | | `seq` | Per partner and per environment, gap-free, strictly increasing. The authoritative order of everything that happened. | | `reason_code` | Set when a transition had a reason you can act on — a hold, a rejection, a reconciliation. | | `data` | The object the event is about: `order` for `order.*`, `quote` for `quote.*`, `file` for `file.*`, `endpoint` for `webhook_endpoint.*`, `key` for `key.*` (never the secret), `usage` for `usage.warning`, `{}` for `test.ping`. Rendered once at event time and stored; `data.order` is exactly what `GET /v1/orders/{id}` returned then, and a redelivery sends the same bytes. | ## Event types | Type | `data` key | Produced by | When | | --- | --- | --- | --- | | `order.received` | `order` | you | Your `POST /v1/orders` was accepted for pricing. | | `order.priced` | `order` | pricing | Every item has a price; the order is `pending_review`. | | `order.pricing_failed` | `order` | pricing | An item needs a hand to price; an engineer prices it by hand or declines the order with a reason. | | `order.accepted` | `order` | LayerCrew | An engineer accepted the order; the price is frozen and payment instructions are on it. | | `order.rejected` | `order` | LayerCrew | The order was declined; `data.order.rejection` carries the code, the message and the engineer's note. | | `order.on_hold` | `order` | LayerCrew | We need something before going on; `data.order.hold.reason` says what. | | `order.released` | `order` | LayerCrew | The hold is over; the order is back in the state it had before. | | `order.in_production` | `order` | LayerCrew | Printing started. | | `order.ready_to_ship` | `order` | LayerCrew | Passed quality check and packed. | | `order.shipped` | `order` | courier | Handed to the courier; `data.order.shipments` carries carrier, tracking number and URL. | | `order.tracking_updated` | `order` | courier | The courier reported a new tracking status. | | `order.delivered` | `order` | courier | The parcel arrived. Final. | | `order.cancelled` | `order` | you | Cancelled by you, or by us at your request. Final. | | `order.paid` | `order` | LayerCrew | Your transfer arrived; `data.order.payment.status` is `paid`. | | `order.invoiced` | `order` | LayerCrew | An invoice was issued; fetch it with `GET /v1/orders/{id}/invoices`. | | `order.returned` | `order` | courier | The parcel came back to us; we write to you. | | `order.recipient_redacted` | `order` | system | Your customer's personal data on this order was erased, on your request or at the end of the retention window. | | `quote.priced` | `quote` | pricing | The quote has a price. | | `quote.failed` | `quote` | pricing | Pricing stopped; `data.quote.failure_code` says why and what to change. | | `file.ready` | `file` | system | `complete` verified the upload; the file can be quoted and ordered. | | `file.rejected` | `file` | system | `complete` refused the upload; `data.file.rejection_code` says why. | | `file.purged` | `file` | system | The file's bytes were deleted at the end of its retention. | | `agreement.accepted` | `agreement` | you | Your account owner accepted the integration and data processing agreements. | | `agreement.countersigned` | `agreement` | LayerCrew | LayerCrew countersigned; live keys can be issued. | | `key.issued` | `key` | you | An API key was created (never carries the secret). | | `key.revoked` | `key` | you | An API key was revoked. | | `webhook_endpoint.disabled` | `endpoint` | system | An endpoint was disabled after sustained failures or a 410; an email goes out too. | | `webhook_endpoint.secret_rotated` | `endpoint` | you | The signing secret was rotated; both secrets are valid for 24 hours. | | `partner.suspended` | `partner` | LayerCrew | The account was suspended; keys answer `PARTNER_INACTIVE` until it is active again. | | `partner.activated` | `partner` | LayerCrew | The account is active. | | `usage.warning` | `usage` | system | A monthly cap on your plan reached 80 percent; once per cap per month. | | `test.ping` | `{}` | you | You asked for a test delivery with `POST /v1/webhook-endpoints/{id}/ping`. | ## Polling the feed ```bash curl "https://engine.layercrew.com/v1/events?after=1200&limit=100&types=order.shipped,order.delivered" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" ``` ```node let after = Number(await store.get("layercrew:last_seq")) || 0; for (;;) { const res = await fetch(`https://engine.layercrew.com/v1/events?after=${after}&limit=200`, { headers: { Authorization: `Bearer ${process.env.LAYERCREW_API_KEY}` }, }); const { data, meta } = await res.json(); for (const event of data) { await handle(event); // idempotent on event.id after = event.seq; await store.set("layercrew:last_seq", String(after)); } if (!meta.has_more) break; } ``` Store the last `seq` you processed and ask for what came after it; `meta.next_after` and `meta.has_more` drive the loop. `GET /v1/orders/{id}/events` does the same for one order. Webhooks carry the same events sooner; the feed is the truth when a webhook is late or missed — reconcile against it on a timer and on every restart. See [Webhooks](https://layercrew.com/developers/webhooks). --- # Webhooks > Signed HTTPS notifications for every event: endpoints, Standard Webhooks headers, verifiers in Node, Python, Go and PHP, retries, ordering and rotation. Updated 2026-10-24 · HTML: https://layercrew.com/developers/webhooks · Markdown: https://layercrew.com/developers/webhooks.md · OpenAPI: https://layercrew.com/openapi.json A webhook endpoint is an HTTPS URL of yours that receives every event — or the types you pick — as a signed POST, soon after it happens. Endpoints are per environment; your plan sets how many you may register (`webhook_endpoints_max`). ## Register an endpoint ```bash curl -X POST "https://engine.layercrew.com/v1/webhook-endpoints" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://shop.example.com/webhooks/layercrew", "description": "Production events", "event_types": [ "order.accepted", "order.rejected", "order.shipped", "order.delivered", "order.cancelled" ] }' ``` _201 — the secret is shown once_ ```json { "data": { "id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f", "url": "https://shop.example.com/webhooks/layercrew", "description": "Production events", "environment": "live", "event_types": [ "order.accepted", "order.rejected", "order.shipped", "order.delivered", "order.cancelled" ], "enabled": true, "disabled_reason": null, "consecutive_failures": 0, "last_success_at": null, "secret_rotated_at": null, "created_at": "2026-10-24T09:40:00Z", "secret": "whsec_c2VjcmV0LWJ5dGVzLWZvci1kb2NzLW9ubHk=" } } ``` - `url` is HTTPS on port 443 or 8443, on a public host that resolves, without credentials — `422 ENDPOINT_URL_INVALID` otherwise. Private and link-local addresses are refused at creation and again at every dial. - `event_types` is null for every event, or a list of names from the [lifecycle page](https://layercrew.com/developers/lifecycle#event-types) — `422 EVENT_TYPE_INVALID` for a name outside it. - `secret` is `whsec_` followed by the base64 of 24 random bytes. It is shown once; store it in your secret manager. - `PATCH` changes `url`, `description`, `event_types` and `enabled`; `DELETE` removes the endpoint and its pending deliveries. ## What a delivery looks like ```http POST /webhooks/layercrew HTTP/1.1 Host: shop.example.com Content-Type: application/json User-Agent: LayerCrew-Webhooks/1.0 (+https://layercrew.com/developers/webhooks) webhook-id: d4e5f6a7-b8c9-4d0e-1f2a-3b4c5d6e7f80 webhook-timestamp: 1793196312 webhook-signature: v1,Yk9tTk5xZ3pRQ3pFQlJ6R1Nnd0RkWlZ4N2Fqb1VqeHNuV3N0Y3F0VmVHaz0= X-LayerCrew-Event: order.shipped X-LayerCrew-Sequence: 1234 X-Request-ID: 01J9Z8Y7X6W5V4U3T2S1R0Q9P8 {"id":"9c8b7a6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d","type":"order.shipped","api_version":"2026-10-24","created_at":"2026-10-27T14:05:12Z","seq":1234,"environment":"live","data":{"order":{…}}} ``` | Header | Meaning | | --- | --- | | `webhook-id` | The delivery id — the same across every retry of this delivery. Deduplicate on it. | | `webhook-timestamp` | Unix seconds of this attempt. Refuse it when more than five minutes from your clock, either way. | | `webhook-signature` | `v1,` — one entry, or two separated by a space during a secret rotation. | | `X-LayerCrew-Event` | The event type, so you can route before parsing. | | `X-LayerCrew-Sequence` | The event's `seq`. | | `X-Request-ID` | Our id for this attempt; it appears in the delivery log. | ## Verifying the signature The scheme is Standard Webhooks, so any of its libraries verifies a LayerCrew delivery. By hand: take the characters after `whsec_` and base64-decode them — that is the HMAC key. Build the signed content `..` from the exact bytes you received, before any parsing. Compute HMAC-SHA256 over it, base64-encode the result, and compare it in constant time against each `v1,` entry of the header. Refuse a timestamp more than five minutes from your clock. Pass both secrets during a rotation. _Read the raw body (express.raw(), not express.json()); secrets is [current] or [current, previous]_ ```node import { createHmac, timingSafeEqual } from "node:crypto"; const TOLERANCE_SECONDS = 300; export function verifyLayerCrewWebhook(headers, rawBody, secrets) { const id = headers["webhook-id"]; const timestamp = headers["webhook-timestamp"]; const signature = headers["webhook-signature"]; if (!id || !timestamp || !signature) return false; const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)); if (!Number.isFinite(skew) || skew > TOLERANCE_SECONDS) return false; const signed = Buffer.concat([Buffer.from(`${id}.${timestamp}.`), rawBody]); const candidates = signature.split(" ").filter((s) => s.startsWith("v1,")).map((s) => s.slice(3)); for (const secret of secrets) { const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64"); const expected = Buffer.from(createHmac("sha256", key).update(signed).digest("base64")); for (const candidate of candidates) { const given = Buffer.from(candidate); if (given.length === expected.length && timingSafeEqual(given, expected)) return true; } } return false; } ``` _raw_body is request.get_data() (Flask) or await request.body() (FastAPI), never the parsed JSON_ ```python import base64, hashlib, hmac, time TOLERANCE_SECONDS = 300 def verify_layercrew_webhook(headers: dict, raw_body: bytes, secrets: list[str]) -> bool: msg_id = headers.get("webhook-id") timestamp = headers.get("webhook-timestamp") signature = headers.get("webhook-signature") if not (msg_id and timestamp and signature): return False try: if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS: return False except ValueError: return False signed = f"{msg_id}.{timestamp}.".encode() + raw_body candidates = [s[3:] for s in signature.split(" ") if s.startswith("v1,")] for secret in secrets: key = base64.b64decode(secret.removeprefix("whsec_")) expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode() if any(hmac.compare_digest(expected, c) for c in candidates): return True return False ``` _body is the exact request body (io.ReadAll(r.Body) before any decoding)_ ```go package webhooks import ( "crypto/hmac" "crypto/sha256" "crypto/subtle" "encoding/base64" "errors" "net/http" "strconv" "strings" "time" ) const tolerance = 5 * time.Minute // Verify checks a LayerCrew delivery; secrets is the current secret, plus the previous one during a rotation. func Verify(h http.Header, body []byte, secrets []string, now time.Time) error { id, ts, sig := h.Get("webhook-id"), h.Get("webhook-timestamp"), h.Get("webhook-signature") if id == "" || ts == "" || sig == "" { return errors.New("webhook: missing webhook-id, webhook-timestamp or webhook-signature") } unix, err := strconv.ParseInt(ts, 10, 64) if err != nil { return errors.New("webhook: webhook-timestamp is not unix seconds") } if d := now.Sub(time.Unix(unix, 0)); d > tolerance || d < -tolerance { return errors.New("webhook: timestamp outside the tolerance window") } signed := append([]byte(id+"."+ts+"."), body...) for _, secret := range secrets { key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_")) if err != nil { continue } mac := hmac.New(sha256.New, key) mac.Write(signed) expected := base64.StdEncoding.EncodeToString(mac.Sum(nil)) for _, candidate := range strings.Fields(sig) { version, given, ok := strings.Cut(candidate, ",") if ok && version == "v1" && subtle.ConstantTimeCompare([]byte(expected), []byte(given)) == 1 { return nil } } } return errors.New("webhook: no matching signature") } ``` _$rawBody = file_get_contents('php://input'); $headers with lower-case names_ ```php TOLERANCE_SECONDS) { return false; } $signed = $id . '.' . $timestamp . '.' . $rawBody; $candidates = []; foreach (explode(' ', $signature) as $entry) { if (str_starts_with($entry, 'v1,')) { $candidates[] = substr($entry, 3); } } foreach ($secrets as $secret) { $key = base64_decode(preg_replace('/^whsec_/', '', $secret), true); if ($key === false) { continue; } $expected = base64_encode(hash_hmac('sha256', $signed, $key, true)); foreach ($candidates as $candidate) { if (hash_equals($expected, $candidate)) { return true; } } } return false; } ``` - **Read the raw body.** A re-serialised JSON body has different bytes and the signature fails. - **Answer `2xx` within 10 seconds**, then do the work. Queue it if it takes longer; a slow handler is retried as a failure. - **Treat every refusal the same.** A missing header, a stale timestamp and a wrong signature all mean the request is not from LayerCrew. - **Deduplicate on `webhook-id`** and order by `seq`, never by arrival time. ## Retries A delivery is tried until your endpoint answers `2xx`, up to `webhook_max_attempts` — 12 on the default plan; it can differ per plan — on this ladder, each wait with ±20 percent jitter: | Attempt | Wait before it | | --- | --- | | 1 | at once | | 2 | 1 minute | | 3 | 5 minutes | | 4 | 30 minutes | | 5 | 2 hours | | 6 and later | 6 hours | Twelve attempts span about two days. A `Retry-After` header on your `429` or `503` is honoured, up to 6 hours. How we read your answer: | Your answer | What happens | | --- | --- | | `2xx` | Delivered. `last_success_at` and the attempt are recorded. | | `410 Gone` | The endpoint is disabled at once; you get `webhook_endpoint.disabled` and an email. | | `429`, `408`, `425`, any `5xx`, a timeout or a connection failure | Retried on the ladder, honouring `Retry-After`; nothing is counted against the endpoint. | | `3xx` and every other `4xx` | Retried on the ladder and counted as a refusal. Redirects are never followed. | ## Ordering Deliveries to one endpoint go out in `seq` order, one at a time: a delivery that keeps failing holds the ones behind it until it succeeds, runs out of attempts or you redeliver it. Across endpoints there is no ordering. Your handler should be idempotent on `webhook-id` and read `seq` to order events; the feed `GET /v1/events?after=` is the authoritative order when in doubt. ## Automatic disabling An endpoint is disabled when 20 consecutive deliveries were refused, when it has had no success for 72 hours and a delivery ran out of attempts, or at once on a `410`. You get `webhook_endpoint.disabled` on the feed and an email; its pending deliveries are marked `skipped`. Re-enable with `PATCH /v1/webhook-endpoints/{id}` and `{"enabled": true}`: the failure count resets and the deliveries skipped in the last 7 days are queued again, in order. A disabled endpoint answers `409 ENDPOINT_DISABLED` to ping and redeliver. ## Rotating the secret `POST /v1/webhook-endpoints/{id}/rotate-secret` returns a new secret once. For 24 hours every delivery carries two signatures — `v1, v1,` — so you deploy the new secret at your own pace; after that only the new one signs. `webhook_endpoint.secret_rotated` marks the moment. ## Testing and the delivery log `POST /v1/webhook-endpoints/{id}/ping` queues a `test.ping` with an empty `data` so you can verify your signature check before going live. The delivery log — `GET /v1/webhook-deliveries` with `endpoint_id`, `status` and `event_id` filters, and `GET /v1/webhook-deliveries/{id}` — shows every attempt with status code, duration, our request id and the first kilobyte of your response. `POST /v1/webhook-deliveries/{id}/redeliver` sends the exact original bytes again with a fresh signature and timestamp; a delivery in flight answers `409 DELIVERY_IN_FLIGHT`. The same log is under Integrations → Webhooks in your dashboard. > **Polling is the truth.** Webhooks are at-least-once and may arrive late. `GET /v1/events?after=` is the authoritative, gap-free order of everything; reconcile against it on a timer and on every restart. See [Lifecycle and events](https://layercrew.com/developers/lifecycle#polling). Every endpoint is in the [reference](https://layercrew.com/developers/reference#tag-webhooks). --- # Errors > One error shape for every failure: the status, a stable code, a message with the fact and the next step, details per field — every code, and how to debug. Updated 2026-10-24 · HTML: https://layercrew.com/developers/errors · Markdown: https://layercrew.com/developers/errors.md · OpenAPI: https://layercrew.com/openapi.json Every error is JSON in one shape: a stable `code` to branch on, a `message` that states the fact and the next step, and `details` when a field or an object is involved. The HTTP status tells you the class; the code tells you what to do. _422 — two fields to fix_ ```json { "error": { "code": "VALIDATION_ERROR", "message": "two fields need a change — see details", "details": { "items.0.material": "pick a material from GET /v1/materials", "delivery.phone": "the courier needs a phone number" } } } ``` _409 — the reference exists_ ```json { "error": { "code": "REFERENCE_TAKEN", "message": "an order with this reference already exists in this environment — see details.order_id", "details": { "order_id": "7a3e9c10-2b4d-4f61-9e8a-5c6d7e8f9a0b" } } } ``` ## Statuses | Status | Class | What to do | | --- | --- | --- | | `401` | The key. | Fix the key — missing, malformed, unknown or revoked — then send again. | | `403` | The account or the environment. | Read the code: the account is inactive, the key's prefix is wrong, or the route is test-only. | | `404` | No such object in this environment. | Check the id and the environment of the key. | | `409` | The request conflicts with the current state. | Read the code; most carry `details` naming the object in the way. | | `413` | The body is over 256 KiB. | File bytes go to the signed upload URL, never into JSON. | | `422` | A field is wrong. | Fix the fields named in `details` and send again. | | `429` | Pace or a monthly cap. | Wait `Retry-After` seconds; see [Rate limits](https://layercrew.com/developers/rate-limits). | | `5xx` | Our side. | Retry with backoff and jitter; quote `X-Request-ID` if it repeats. | ## Codes ### The key Answered before the request is read. Fix the key, then send again. | Code | Status | What it means and what to do | | --- | --- | --- | | `INVALID_API_KEY` | 401 | The API key is missing, malformed or unknown — send a live or test key as `Authorization: Bearer lc_live_…` or `lc_test_…`. | | `API_KEY_REVOKED` | 401 | This API key was revoked or has expired — create a new one in your dashboard under Integrations → Keys. | | `SCOPE_MISMATCH` | 403 | The key's prefix names a different environment than the key was issued for — use the key exactly as it was shown. | | `PARTNER_INACTIVE` | 403 | This partner account is not open for this key yet — live keys need an active account with a countersigned agreement; your dashboard shows the next step. | | `TEST_ONLY` | 403 | This route is for test keys — call it with your `lc_test_` key; a live order moves through its states as production happens. | ### The request The request shape or pace. `error.details` names the field when there is one. | Code | Status | What it means and what to do | | --- | --- | --- | | `RATE_LIMITED` | 429 | Too many requests for this key — wait the seconds in `Retry-After` and send again. | | `QUOTA_EXCEEDED` | 429 | This month's cap for your plan is reached — it resets on the first of next month (UTC); write to hello@layercrew.com for a higher cap. | | `VALIDATION_ERROR` | 422 | A field is wrong — `error.details` maps each field (`items.0.material`) to what to change. | | `PAYLOAD_TOO_LARGE` | 413 | The request body is over 256 KiB — file bytes go to the signed upload URL, never into a JSON body. | | `IDEMPOTENCY_KEY_REQUIRED` | 422 | Send an `Idempotency-Key` header (1 to 255 characters of letters, digits and `. _ : ~ -`) with every order so a retry never creates a second one. | | `IDEMPOTENCY_KEY_REUSED` | 409 | This `Idempotency-Key` was already used for a different request — use a new key for a new request, or send the same request to get the same answer. | | `IDEMPOTENCY_IN_PROGRESS` | 409 | A request with this `Idempotency-Key` is still being processed — wait the seconds in `Retry-After` and send the same request again. | | `NOT_FOUND` | 404 | No such object in this environment — ids are per environment; a test key sees test objects only. | ### Files Uploads and the files an order points at. | Code | Status | What it means and what to do | | --- | --- | --- | | `FILE_REJECTED` | 422 | The uploaded bytes failed verification — `error.details.rejection_code` says why; upload a corrected file. | | `FILE_NOT_READY` | 409 | The file is still uploading or was rejected — call `complete` and wait for `status: ready`, or upload a corrected file. | | `FILE_IN_USE` | 409 | An open order, a pending quote or a running price job still needs this file — `error.details` names it; delete the file once that has finished. | ### Quotes and orders Creating, pricing and cancelling. | Code | Status | What it means and what to do | | --- | --- | --- | | `MATERIAL_UNAVAILABLE` | 422 | This material, nozzle or colour is outside your plan or out of stock for this run — pick one from `GET /v1/materials`. | | `REFERENCE_TAKEN` | 409 | An order with this `reference` already exists in this environment — `error.details.order_id` names it; use a new reference for a new order. | | `CANNOT_CANCEL` | 409 | Production has started on this order — write to us from the order page in your dashboard and we tell you what is still possible. | | `ACCEPT_IN_PROGRESS` | 409 | This order is being accepted right now — read it again in a moment. | ### Webhooks Endpoints and deliveries. | Code | Status | What it means and what to do | | --- | --- | --- | | `ENDPOINT_LIMIT` | 409 | This environment already has the maximum number of webhook endpoints your plan allows — delete one, or ask us to raise the limit. | | `ENDPOINT_URL_INVALID` | 422 | The webhook URL must be HTTPS on port 443 or 8443, without credentials, on a public host that resolves. | | `EVENT_TYPE_INVALID` | 422 | One or more event types are outside the published list — use the names from the lifecycle page, or omit `event_types` to receive every type. | | `ENDPOINT_DISABLED` | 409 | This webhook endpoint is disabled — enable it with `PATCH /v1/webhook-endpoints/{id}` and `{"enabled": true}`, then try again. | | `DELIVERY_IN_FLIGHT` | 409 | This delivery is being attempted right now — wait for its outcome, then redeliver if needed. | ### Account and agreement Met in the dashboard or when a key is issued, rarely on `/v1/*`. | Code | Status | What it means and what to do | | --- | --- | --- | | `TERMS_REACCEPTANCE_REQUIRED` | 409 | A new version of the terms is in force — accept it in your dashboard under Integrations → Agreement to keep ordering. | | `AGREEMENT_REQUIRED` | 409 | Accept the integration agreement and the data processing agreement in your dashboard first. | | `AGREEMENT_NOT_COUNTERSIGNED` | 409 | Live keys are issued once LayerCrew has countersigned your integration and data processing agreements — test keys work meanwhile. | | `TERMS_UNAVAILABLE` | 422 | Payment terms are set by your plan — orders are paid before production by default; ship-before-payment can be agreed instead. | | `ORGANIZATION_ALREADY_REGISTERED` | 409 | This company already has a partner account — ask its account owner for an invitation. | | `EMAIL_TAKEN` | 409 | This email already has a LayerCrew login — sign in and open Integrations to register your company. | ## Validation details `422 VALIDATION_ERROR` maps field paths to messages: `items.0.material`, `delivery.phone`, `white_label.card_text`. Array positions are zero-based. Fix the named fields and send again; the message says what to change. A file-level problem names the file: `FILE_REJECTED` carries `details.rejection_code`, `FILE_IN_USE` the id of the order or quote that needs it. ## A 403 without JSON > **The network edge.** A `403` with an HTML or empty body — no JSON envelope — comes from the network edge in front of the API, throttling your address. Back off 90 seconds, then resume at two requests a second. Never retry a create blindly after it: replay the `Idempotency-Key`, or look the order up by `reference`. See [Rate limits](https://layercrew.com/developers/rate-limits#edge). ## Debugging a call Every response carries `X-Request-ID`. Your request log — `GET /v1/requests` with `from`, `to`, `status` and `route` filters, `GET /v1/requests/{request_id}`, and the Requests page of your dashboard — lists every call made with your keys in the last 90 days: route, status, duration, error code and the id. Bodies are never stored. Quote the id when you write to us at hello@layercrew.com. --- # Rate limits > Two requests a second with small bursts on the default plan, Retry-After on 429, monthly caps, and the non-JSON 403 edge throttle with a 90-second back-off. Updated 2026-10-24 · HTML: https://layercrew.com/developers/rate-limits · Markdown: https://layercrew.com/developers/rate-limits.md · OpenAPI: https://layercrew.com/openapi.json Two limits apply to your keys: a pace per key, and monthly caps per plan. Both answer `429` with a JSON envelope. The network edge in front of the API adds a third, coarser one that answers without JSON. ## Pace The default plan allows 2 requests a second per key with bursts up to 10; your plan may allow more, and `GET /v1/ping` reports your `rate_limit`. Over the limit you get `429 RATE_LIMITED` with `Retry-After` in seconds, `X-RateLimit-Limit` and `X-RateLimit-Remaining`. Throttled calls are logged like any other and counted in your usage. > **X-RateLimit-Remaining is per server.** The number is the bucket of the server that answered. The API runs on more than one, so it is a guide, never a contract: pace your client by `Retry-After` and by your own clock. ## Monthly caps Your plan may cap requests, live orders and test orders per calendar month (UTC). `GET /v1/account` shows the caps under `limits` — `monthly_request_cap`, `monthly_order_cap`, `monthly_test_order_cap`, where `0` means no cap — and this month's `usage`. At 80 percent of a cap you get `usage.warning` once; at the cap, `429 QUOTA_EXCEEDED` until the first of next month. The request cap is checked against a count refreshed every 30 seconds, so a handful of calls may pass over it. A quote cap per day (`quote_daily_cap`) works the same way. ## The edge throttle In front of the API sits a network edge that protects every LayerCrew service. Above roughly 5 requests a second from one address it answers `403` with an HTML or empty body — no JSON envelope, no `X-Request-ID`. - Treat a `403` without JSON as the edge: **back off 90 seconds**, then resume at two requests a second. - Keep to two requests a second per source address, with small bursts, even when your plan's key limit is higher. - **Never retry a create blindly.** For an order, send the same `Idempotency-Key` again or look it up by `reference`; for a file, list your files; for a webhook endpoint, list your endpoints. ## Designing for the limits - Use webhooks for state changes and the events feed to reconcile; polling each order is the pattern that hits the limits first. - Batch your reads: lists take `per_page` up to 100, the feed `limit` up to 200. - One client, one queue: a shared token bucket in your integration keeps bursts in bounds. - Back off on `429` and on `5xx` with jitter; quote `X-Request-ID` if a pattern repeats. _A fetch wrapper that honours Retry-After, backs off the edge and never retries a create without its key_ ```node const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); export async function layercrew(path, init = {}, attempt = 0) { const res = await fetch(`https://engine.layercrew.com/v1${path}`, { ...init, headers: { Authorization: `Bearer ${process.env.LAYERCREW_API_KEY}`, ...(init.headers ?? {}) }, }); const isJson = (res.headers.get("content-type") ?? "").includes("application/json"); if (res.status === 403 && !isJson) { await sleep(90_000); // the edge: back off 90 seconds return layercrew(path, init, attempt + 1); // same init, same Idempotency-Key } if ((res.status === 429 || res.status >= 500) && attempt < 5) { const retryAfter = Number(res.headers.get("retry-after")) || 2 ** attempt; await sleep(retryAfter * 1000 + Math.random() * 500); return layercrew(path, init, attempt + 1); } return res; } ``` ## Body size Request bodies are limited to 256 KiB — `413 PAYLOAD_TOO_LARGE` beyond it. File bytes never travel in JSON; they go to the signed upload URL described under [Files](https://layercrew.com/developers/files#upload). --- # Idempotency > Idempotency-Key makes a create safe to repeat: the first answer is stored 24 hours and replayed with Idempotent-Replayed: true; a reused key answers 409. Updated 2026-10-24 · HTML: https://layercrew.com/developers/idempotency · Markdown: https://layercrew.com/developers/idempotency.md · OpenAPI: https://layercrew.com/openapi.json A retried `POST` must never create a twin. The `Idempotency-Key` header makes a create safe to repeat: the first answer is stored and replayed to every retry with the same key, for 24 hours. ## Where it applies - `POST /v1/orders` — **required**; `422 IDEMPOTENCY_KEY_REQUIRED` without it. - `POST /v1/files`, `POST /v1/quotes`, `POST /v1/webhook-endpoints` — optional and recommended. - Reads, `PATCH` and `DELETE` need none: they are idempotent by nature. ## The key Any string of 1 to 255 characters of letters, digits and `. _ : ~ -`, unique per attempt — a UUID v4 is the easy choice. Keep it free of personal data. Store it with the thing you are creating (your shop order, say) before you send the request, so a crash between the request and the response is recovered by sending the same request again with the same key. ## How it behaves | Situation | Answer | | --- | --- | | First request with a key | The real answer — `202` for an order. | | Same key, same body, within 24 hours | The stored answer, with the header `Idempotent-Replayed: true`; `200` for an order. | | Same key, different body | `409 IDEMPOTENCY_KEY_REUSED`. Use a new key for a new request. | | Same key while the first request is still running | `409 IDEMPOTENCY_IN_PROGRESS` with `Retry-After: 2`. Send the same request again after that. | | Same key after 24 hours | Treated as a new request. | Only successful answers (`2xx`) are stored. When the first attempt failed with a `4xx` — a validation error, say — nothing is kept, and a corrected request with the same key is processed as new. Keys are per partner and per environment: the same string in test and in live are two keys. ## A safe retry loop ```node import crypto from "node:crypto"; // One key per shop order, persisted before the first attempt. async function createOrder(shopOrder) { const key = shopOrder.layercrewIdempotencyKey ?? (await persistKey(shopOrder, crypto.randomUUID())); for (let attempt = 0; attempt < 5; attempt++) { const res = await fetch("https://engine.layercrew.com/v1/orders", { method: "POST", headers: { Authorization: `Bearer ${process.env.LAYERCREW_API_KEY}`, "Idempotency-Key": key, "Content-Type": "application/json", }, body: JSON.stringify(toLayerCrewOrder(shopOrder)), // the same body every time }); if (res.ok) return (await res.json()).data; // 202, or 200 with Idempotent-Replayed: true if (res.status === 409) { const { error } = await res.json(); if (error.code === "IDEMPOTENCY_IN_PROGRESS") { await sleep(2000); continue; } if (error.code === "REFERENCE_TAKEN") return getOrder(error.details.order_id); throw new Error(`${error.code}: ${error.message}`); } if (res.status === 429 || res.status >= 500) { await sleep(1000 * 2 ** attempt); continue; } const { error } = await res.json(); throw new Error(`${error.code}: ${error.message}`); // 4xx: fix the body, keep the key } throw new Error("order creation did not settle; look it up by reference"); } ``` > **Lost the key?.** Look the order up by your `reference`: `GET /v1/orders?reference=SHOP-10231`. A second create with the same `reference` answers `409 REFERENCE_TAKEN` and names the existing order in `error.details.order_id`, so a duplicate is impossible either way. --- # Test mode > Test keys run the whole API with real pricing and real webhooks, never touch production, stock, couriers or invoices, and let you advance orders yourself. Updated 2026-10-24 · HTML: https://layercrew.com/developers/test-mode · Markdown: https://layercrew.com/developers/test-mode.md · OpenAPI: https://layercrew.com/openapi.json Test keys run the whole API against the same code and the same tables as live, in a separate environment marked `environment: test` on every object. Nothing you do with a test key reaches a printer, a courier or an invoice. ## What test keys do - Every route, plus one of their own: `POST /v1/test/orders/{id}/advance`. - **Real pricing.** Files are sliced and priced exactly as live; quotes and `pricing` blocks carry the numbers a live order would carry today. - **Real validation.** The same checks and the same error codes. - **Real events and webhooks.** The same envelope, signatures, retries and delivery log. - Available from the moment your account owner accepts the agreements — before countersignature — so integration work starts on day one. ## What test keys never do - Create a production order, reserve stock, book a courier or issue an invoice. `pricing.tax_treatment` is `test` and `payment` carries no instructions. - See a live object. Ids are per environment; a live id answers `404 NOT_FOUND`. - Share limits with live: `monthly_test_order_cap` and `max_test_items_per_order` are their own, and at most one test price job runs per partner at a time, so a live order is never queued behind a test. ## Advancing an order Live orders move as production happens; test orders move when you say so, and each move emits the same event a live order would: ```bash curl -X POST "https://engine.layercrew.com/v1/test/orders/7a3e9c10-2b4d-4f61-9e8a-5c6d7e8f9a0b/advance" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "shipped" }' ``` _A rejection with a reason, to exercise your handling of order.rejected_ ```bash curl -X POST "https://engine.layercrew.com/v1/test/orders/7a3e9c10-2b4d-4f61-9e8a-5c6d7e8f9a0b/advance" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "rejected", "reason": "design_change_needed" }' ``` | From | To | | --- | --- | | `pending_review` | `accepted` or `rejected` | | `accepted` | `in_production` | | `in_production` | `ready_to_ship` | | `ready_to_ship` | `shipped` — a shipment with a sample carrier and tracking number is added | | `shipped` | `delivered` | | any state before `shipped` | `on_hold`, and from `on_hold` back to the state it had | `received` becomes `pending_review` by itself once pricing finishes — wait for `order.priced` first. Each move answers `200` with the order and emits the event with your key as the actor; a move outside the table answers `409`. A live key on this route answers `403 TEST_ONLY`. ## Retention Test files no order uses are deleted after your plan's `test_file_retention_days` — `purge_after` on the file says when. Test orders and their events stay, so you can replay them. Test requests sit in the same 90-day request log as live ones, marked by their key id. Your customers' addresses on test orders are redacted on the same schedule as live ones; use made-up people in test. ## From test to live - Check `GET /v1/account`: both agreements `countersigned`, `status` `active`. - Create a live key (the account owner does this) and register your production webhook endpoint with it — endpoints are per environment. - Switch the key. Same routes, same bodies, same events; the only visible change is `environment: live` and a `payment` block with instructions on acceptance. - Keep the test key: it stays the place to try a new material, a new file format or a handler change. --- # API reference > Every endpoint of the LayerCrew Partner API, grouped by tag with parameters, request and response fields and examples, generated from the OpenAPI document. Updated 2026-10-24 · HTML: https://layercrew.com/developers/reference · Markdown: https://layercrew.com/developers/reference.md · OpenAPI: https://layercrew.com/openapi.json Every endpoint of the Partner API, generated from the same OpenAPI 3.1 document the engine serves at `GET /v1/openapi.json` and this site at [/openapi.json](https://layercrew.com/openapi.json). Load that URL into your client generator or API tool — it carries CORS headers — or read it here. ## Conventions - Base URL `https://engine.layercrew.com/v1`; HTTPS only; `Authorization: Bearer ` on every route except the spec itself. - Envelope: `{"data": …}` for one object, `{"data": [...], "meta": {page, per_page, total, total_pages}}` for lists, `{"error": {code, message, details?}}` for errors. - Money is a string with two decimals in EUR. Timestamps are RFC 3339 in UTC. Ids are UUIDs; an order's number (`PA-YYYY-NNNN`) is accepted wherever its id is. - Lists take `page` and `per_page` (25 by default, 100 at most) and return newest first. Event feeds take `after` (a `seq`) and `limit` (50 by default, 200 at most) and return oldest first. - Every response carries `X-Request-ID`. Rate-limited responses carry `Retry-After`, `X-RateLimit-Limit` and `X-RateLimit-Remaining`. - Request bodies are JSON up to 256 KiB. `POST /v1/orders` requires an `Idempotency-Key`; other creates accept one. - The API version is `2026-10-24`; every event carries it as `api_version`. ## Account Who you are, your plan and your usage. ### GET /v1/ping **Check your key** Confirms the key works and tells you which partner, environment and plan it belongs to. Responses: - `200` — The key is valid. - `401` — The key is missing, malformed, unknown, revoked or for the other environment (`INVALID_API_KEY`, `API_KEY_REVOKED`, `SCOPE_MISMATCH`). - `403` — The partner account is not active (`PARTNER_INACTIVE`) or the route is test-only (`TEST_ONLY`). - `429` — Too many requests (`RATE_LIMITED`) or a monthly cap reached (`QUOTA_EXCEEDED`). Honour `Retry-After`. Response `200`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `partner` | object | yes | | | `environment` | Environment: `live`, `test` | yes | | | `key_id` | string | yes | | | `plan` | object | yes | | | `rate_limit` | object | yes | | | `server_time` | string (date-time) | yes | | ```json { "data": { "partner": { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "number": "LC-PT-0007", "name": "Claire Martin" }, "environment": "live", "key_id": "k7m2p9q4r1s8t3u6", "plan": { "key": "key", "name": "Claire Martin" }, "rate_limit": { "per_second": 2, "burst": 10 }, "server_time": "2026-10-24T09:30:00Z" } } ``` ```bash curl -X GET "https://engine.layercrew.com/v1/ping" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" ``` ### GET /v1/account **Your plan, limits, usage and agreement status** Responses: - `200` — The account. - `401` — The key is missing, malformed, unknown, revoked or for the other environment (`INVALID_API_KEY`, `API_KEY_REVOKED`, `SCOPE_MISMATCH`). - `429` — Too many requests (`RATE_LIMITED`) or a monthly cap reached (`QUOTA_EXCEEDED`). Honour `Retry-After`. Response `200`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `partner` | object | yes | | | `status` | enum: `pending_verification`, `pending_agreement`, `active`, `suspended`, `closed` | yes | | | `plan` | object | yes | | | `limits` | PlanLimits | yes | The limits in force for you (plan plus any agreed overrides). | | `usage` | object | yes | | | `agreements` | array of object | yes | | ```json { "data": { "partner": { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "number": "number", "name": "Claire Martin", "country_code": "FR", "locale": "en" }, "status": "pending_verification", "plan": { "key": "key", "name": "Claire Martin" }, "limits": { "discount_pct": 1.5, "rate_per_second": 2, "burst": 10, "monthly_request_cap": 0, "monthly_order_cap": 0, "monthly_test_order_cap": 0, "max_file_bytes": 67108864, "max_files": 0, "max_items_per_order": 0, "max_test_items_per_order": 0, "max_quantity_per_item": 0, "allowed_materials": [ "allowed materials" ], "allowed_nozzles": [ "allowed nozzles" ], "ship_before_payment": false, "webhook_endpoints_max": 0, "webhook_max_attempts": 12, "test_file_retention_days": 0, "file_retention_days_after_last_order": 0, "recipient_retention_days": 90, "quote_daily_cap": 0 }, "usage": { "month": "2026-10", "requests": 318, "orders_live": 4, "orders_test": 11, "quotes_today": 2 }, "agreements": [ { "document": "integration_agreement", "version": "2026-10-24", "status": "none", "accepted_at": "2026-10-24T09:30:00Z", "countersigned_at": "2026-10-24T09:30:00Z" } ] } } ``` ```bash curl -X GET "https://engine.layercrew.com/v1/account" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" ``` ### GET /v1/openapi.json **This document** No authentication. Responses: - `200` — The OpenAPI 3.1 description of this API. ```json {} ``` ```bash curl -X GET "https://engine.layercrew.com/v1/openapi.json" ``` ## Materials What you can order parts in. ### GET /v1/materials **Materials, nozzles and colours you can order** The list changes as the catalogue changes. Order with a `material` from this list; a `colour` is optional and must come from the material's colours when given. `in_stock` is live and never a promise. Responses: - `200` — Materials. - `401` — The key is missing, malformed, unknown, revoked or for the other environment (`INVALID_API_KEY`, `API_KEY_REVOKED`, `SCOPE_MISMATCH`). Response `200` (each item of `data`): | Field | Type | Required | Description | | --- | --- | --- | --- | | `material` | string | yes | | | `description` | string | no | | | `nozzles` | array of string | yes | | | `colours` | array of object | yes | | ```json { "data": [ { "material": "PLA", "description": "Production events", "nozzles": [ "0.4" ], "colours": [ { "id": "id", "name": "Claire Martin", "hex": "#111111", "in_stock": true } ] } ] } ``` ```bash curl -X GET "https://engine.layercrew.com/v1/materials" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" ``` ## Files Upload the 3D files your orders reference. ### GET /v1/files **List your files** Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `page` | query | integer | no | | | `per_page` | query | integer | no | | | `status` | query | FileStatus | no | | Responses: - `200` — Files, newest first. - `401` — The key is missing, malformed, unknown, revoked or for the other environment (`INVALID_API_KEY`, `API_KEY_REVOKED`, `SCOPE_MISMATCH`). Response `200` (each item of `data`): | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | | | `filename` | string | yes | | | `format` | FileFormat: `stl`, `obj`, `3mf` | yes | | | `status` | FileStatus: `pending`, `ready`, `rejected`, `purged` | yes | | | `size_bytes` | integer | yes | | | `sha256` | string | no | | | `rejection_code` | enum \| null: `size_mismatch`, `hash_mismatch`, `not_a_model`, `too_complex`, `too_large`, `upload_missing` | no | | | `facts` | object | no | What we read from the file: triangles, bounding box in mm, object count, producer. | | `created_at` | string (date-time) | yes | | | `ready_at` | string (date-time) \| null | no | | | `purge_after` | string (date-time) \| null | no | When the file will be deleted unless an order uses it. | ```json { "data": [ { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "filename": "bracket-v3.stl", "format": "stl", "status": "pending", "size_bytes": 1048576, "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "rejection_code": null, "facts": { "triangles": 48210, "objects": 1, "bbox_mm": [ 60, 42.5, 18 ], "producer": "Bambu Studio 02.08.02.61", "sanitised": false }, "created_at": "2026-10-24T09:30:00Z", "ready_at": "2026-10-24T09:30:00Z", "purge_after": "2026-10-24T09:30:00Z" } ], "meta": { "page": 1, "per_page": 25, "total": 1, "total_pages": 1 } } ``` ```bash curl -X GET "https://engine.layercrew.com/v1/files?page=1&per_page=25" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" ``` ### POST /v1/files **Start an upload** Declares a file and returns a one-time upload URL. PUT the raw bytes to `upload.url` within `upload.expires_at`, sending exactly the headers in `upload.headers` (both `Content-Type` and `x-goog-content-length-range` are mandatory — the signature covers them). Then call `complete`. Formats: STL, OBJ, 3MF. STEP files are priced by an engineer: send them through https://layercrew.com/contact-us. Size limit: your plan's `max_file_bytes`. Units: millimetres. Request body: | Field | Type | Required | Description | | --- | --- | --- | --- | | `filename` | string | yes | With its extension: .stl, .obj or .3mf. (up to 200 characters) | | `size_bytes` | integer | yes | (at least 1) | | `sha256` | string | yes | Lowercase hex SHA-256 of the exact bytes you will upload. | | `content_type` | string | no | (default "application/octet-stream") | ```json { "filename": "bracket-v3.stl", "size_bytes": 1048576, "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "content_type": "application/octet-stream" } ``` Responses: - `201` — Upload instructions. - `401` — The key is missing, malformed, unknown, revoked or for the other environment (`INVALID_API_KEY`, `API_KEY_REVOKED`, `SCOPE_MISMATCH`). - `422` — A field is wrong; `error.details` maps field names to messages (`VALIDATION_ERROR`, `IDEMPOTENCY_KEY_REQUIRED`, `MATERIAL_UNAVAILABLE`, `FILE_REJECTED`, `ENDPOINT_URL_INVALID`). - `429` — Too many requests (`RATE_LIMITED`) or a monthly cap reached (`QUOTA_EXCEEDED`). Honour `Retry-After`. Response `201`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | | | `filename` | string | yes | | | `format` | FileFormat: `stl`, `obj`, `3mf` | yes | | | `status` | FileStatus: `pending`, `ready`, `rejected`, `purged` | yes | | | `size_bytes` | integer | yes | | | `sha256` | string | no | | | `rejection_code` | enum \| null: `size_mismatch`, `hash_mismatch`, `not_a_model`, `too_complex`, `too_large`, `upload_missing` | no | | | `facts` | object | no | What we read from the file: triangles, bounding box in mm, object count, producer. | | `created_at` | string (date-time) | yes | | | `ready_at` | string (date-time) \| null | no | | | `purge_after` | string (date-time) \| null | no | When the file will be deleted unless an order uses it. | | `upload` | object | yes | | ```json { "data": { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "filename": "bracket-v3.stl", "format": "stl", "status": "pending", "size_bytes": 1048576, "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "rejection_code": null, "facts": { "triangles": 48210, "objects": 1, "bbox_mm": [ 60, 42.5, 18 ], "producer": "Bambu Studio 02.08.02.61", "sanitised": false }, "created_at": "2026-10-24T09:30:00Z", "ready_at": "2026-10-24T09:30:00Z", "purge_after": "2026-10-24T09:30:00Z", "upload": { "method": "PUT", "url": "https://shop.example.com/webhooks/layercrew", "headers": { "Content-Type": "application/octet-stream", "x-goog-content-length-range": "0,1048576" }, "expires_at": "2026-10-24T09:30:00Z" } } } ``` ```bash curl -X POST "https://engine.layercrew.com/v1/files" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "filename": "bracket-v3.stl", "size_bytes": 1048576, "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "content_type": "application/octet-stream" }' ``` ### GET /v1/files/{file_id} **Get a file** Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `file_id` | path | string (uuid) | yes | | Responses: - `200` — The file. - `404` — No such object in this environment (`NOT_FOUND`). Response `200`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | | | `filename` | string | yes | | | `format` | FileFormat: `stl`, `obj`, `3mf` | yes | | | `status` | FileStatus: `pending`, `ready`, `rejected`, `purged` | yes | | | `size_bytes` | integer | yes | | | `sha256` | string | no | | | `rejection_code` | enum \| null: `size_mismatch`, `hash_mismatch`, `not_a_model`, `too_complex`, `too_large`, `upload_missing` | no | | | `facts` | object | no | What we read from the file: triangles, bounding box in mm, object count, producer. | | `created_at` | string (date-time) | yes | | | `ready_at` | string (date-time) \| null | no | | | `purge_after` | string (date-time) \| null | no | When the file will be deleted unless an order uses it. | ```json { "data": { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "filename": "bracket-v3.stl", "format": "stl", "status": "pending", "size_bytes": 1048576, "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "rejection_code": null, "facts": { "triangles": 48210, "objects": 1, "bbox_mm": [ 60, 42.5, 18 ], "producer": "Bambu Studio 02.08.02.61", "sanitised": false }, "created_at": "2026-10-24T09:30:00Z", "ready_at": "2026-10-24T09:30:00Z", "purge_after": "2026-10-24T09:30:00Z" } } ``` ```bash curl -X GET "https://engine.layercrew.com/v1/files/4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" ``` ### DELETE /v1/files/{file_id} **Delete a file** A file no order uses is deleted now. A file used only by finished orders is scheduled for deletion within the hour (202). A file an open order, a pending quote or a running price job still needs answers 409 `FILE_IN_USE` and names it. Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `file_id` | path | string (uuid) | yes | | Responses: - `202` — Scheduled for deletion. - `204` — Deleted. - `404` — No such object in this environment (`NOT_FOUND`). - `409` — The request conflicts with the current state; `error.code` says how (`REFERENCE_TAKEN`, `FILE_IN_USE`, `FILE_NOT_READY`, `CANNOT_CANCEL`, `ENDPOINT_LIMIT`, `DELIVERY_IN_FLIGHT`, `IDEMPOTENCY_KEY_REUSED`, `IDEMPOTENCY_IN_PROGRESS`, `TERMS_REACCEPTANCE_REQUIRED`). ```bash curl -X DELETE "https://engine.layercrew.com/v1/files/4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" ``` ### POST /v1/files/{file_id}/complete **Finish an upload** Verifies the uploaded bytes against the declared size and SHA-256, reads the model, strips any embedded slicer scripts from a 3MF, and stores the canonical copy. Idempotent: calling it again on a ready file returns the file. A file that fails verification is `rejected` with a `rejection_code`; upload a corrected file. Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `file_id` | path | string (uuid) | yes | | Responses: - `200` — The file is ready. - `404` — No such object in this environment (`NOT_FOUND`). - `409` — Another completion of this file is running. - `422` — The file was rejected; `error.details.rejection_code` says why. Response `200`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | | | `filename` | string | yes | | | `format` | FileFormat: `stl`, `obj`, `3mf` | yes | | | `status` | FileStatus: `pending`, `ready`, `rejected`, `purged` | yes | | | `size_bytes` | integer | yes | | | `sha256` | string | no | | | `rejection_code` | enum \| null: `size_mismatch`, `hash_mismatch`, `not_a_model`, `too_complex`, `too_large`, `upload_missing` | no | | | `facts` | object | no | What we read from the file: triangles, bounding box in mm, object count, producer. | | `created_at` | string (date-time) | yes | | | `ready_at` | string (date-time) \| null | no | | | `purge_after` | string (date-time) \| null | no | When the file will be deleted unless an order uses it. | ```json { "data": { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "filename": "bracket-v3.stl", "format": "stl", "status": "pending", "size_bytes": 1048576, "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "rejection_code": null, "facts": { "triangles": 48210, "objects": 1, "bbox_mm": [ 60, 42.5, 18 ], "producer": "Bambu Studio 02.08.02.61", "sanitised": false }, "created_at": "2026-10-24T09:30:00Z", "ready_at": "2026-10-24T09:30:00Z", "purge_after": "2026-10-24T09:30:00Z" } } ``` ```bash curl -X POST "https://engine.layercrew.com/v1/files/4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d/complete" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" ``` ## Quotes Price a file before you order it. ### GET /v1/quotes **List your quotes** Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `page` | query | integer | no | | | `per_page` | query | integer | no | | Responses: - `200` — Quotes, newest first. Response `200` (each item of `data`): | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | | | `file_id` | string (uuid) | yes | | | `material` | string | yes | | | `nozzle` | string | yes | | | `quantity` | integer | yes | | | `status` | enum: `pending`, `priced`, `failed` | yes | | | `unit_price` | Money | no | EUR with two decimals, as a string. | | `list_unit_price` | Money | no | EUR with two decimals, as a string. | | `discount_pct` | string | no | | | `total` | Money | no | EUR with two decimals, as a string. | | `currency` | enum: `EUR` | no | | | `tiers` | array of object | no | Unit price at other quantities. | | `estimate` | PriceEstimate | no | What the slicer measured and the settings it used. | | `failure_code` | enum \| null: `model_too_large`, `not_a_model`, `material_unavailable`, `slicer_rejected`, `file_unavailable` | no | | | `failure_message` | string \| null | no | | | `created_at` | string (date-time) | yes | | | `priced_at` | string (date-time) \| null | no | | ```json { "data": [ { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "file_id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "material": "PLA", "nozzle": "0.4", "quantity": 2, "status": "pending", "unit_price": "12.40", "list_unit_price": "12.40", "discount_pct": "0", "total": "12.40", "currency": "EUR", "tiers": [ { "min_quantity": 1, "unit_price": "12.40" } ], "estimate": { "weight_grams": 31.2, "time_seconds": 5400, "unit_yield": 1, "printer_model": "Bambu Lab A2L", "nozzle": "0.4", "material": "PLA", "infill_pct": 15, "wall_loops": 3, "support": "auto", "warnings": [ "warnings" ], "priced_at": "2026-10-24T09:30:00Z" }, "failure_code": null, "failure_message": null, "created_at": "2026-10-24T09:30:00Z", "priced_at": "2026-10-24T09:30:00Z" } ], "meta": { "page": 1, "per_page": 25, "total": 1, "total_pages": 1 } } ``` ```bash curl -X GET "https://engine.layercrew.com/v1/quotes?page=1&per_page=25" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" ``` ### POST /v1/quotes **Price a file** Prices one ready file in one material at one quantity. Pricing takes seconds to a few minutes (the file is sliced for real); poll the quote or listen for `quote.priced`. The price is the one an order of the same file, material and quantity would carry today; it is frozen on an order only when the order is accepted. Request body: | Field | Type | Required | Description | | --- | --- | --- | --- | | `file_id` | string (uuid) | yes | | | `material` | string | yes | | | `nozzle` | string | no | (default "0.4") | | `quantity` | integer | yes | (at least 1) | ```json { "file_id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "material": "PLA", "nozzle": "0.4", "quantity": 2 } ``` Responses: - `202` — Pricing started. - `409` — The request conflicts with the current state; `error.code` says how (`REFERENCE_TAKEN`, `FILE_IN_USE`, `FILE_NOT_READY`, `CANNOT_CANCEL`, `ENDPOINT_LIMIT`, `DELIVERY_IN_FLIGHT`, `IDEMPOTENCY_KEY_REUSED`, `IDEMPOTENCY_IN_PROGRESS`, `TERMS_REACCEPTANCE_REQUIRED`). - `422` — A field is wrong; `error.details` maps field names to messages (`VALIDATION_ERROR`, `IDEMPOTENCY_KEY_REQUIRED`, `MATERIAL_UNAVAILABLE`, `FILE_REJECTED`, `ENDPOINT_URL_INVALID`). - `429` — Too many requests (`RATE_LIMITED`) or a monthly cap reached (`QUOTA_EXCEEDED`). Honour `Retry-After`. Response `202`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | | | `file_id` | string (uuid) | yes | | | `material` | string | yes | | | `nozzle` | string | yes | | | `quantity` | integer | yes | | | `status` | enum: `pending`, `priced`, `failed` | yes | | | `unit_price` | Money | no | EUR with two decimals, as a string. | | `list_unit_price` | Money | no | EUR with two decimals, as a string. | | `discount_pct` | string | no | | | `total` | Money | no | EUR with two decimals, as a string. | | `currency` | enum: `EUR` | no | | | `tiers` | array of object | no | Unit price at other quantities. | | `estimate` | PriceEstimate | no | What the slicer measured and the settings it used. | | `failure_code` | enum \| null: `model_too_large`, `not_a_model`, `material_unavailable`, `slicer_rejected`, `file_unavailable` | no | | | `failure_message` | string \| null | no | | | `created_at` | string (date-time) | yes | | | `priced_at` | string (date-time) \| null | no | | ```json { "data": { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "file_id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "material": "PLA", "nozzle": "0.4", "quantity": 2, "status": "pending", "unit_price": "12.40", "list_unit_price": "12.40", "discount_pct": "0", "total": "12.40", "currency": "EUR", "tiers": [ { "min_quantity": 1, "unit_price": "12.40" } ], "estimate": { "weight_grams": 31.2, "time_seconds": 5400, "unit_yield": 1, "printer_model": "Bambu Lab A2L", "nozzle": "0.4", "material": "PLA", "infill_pct": 15, "wall_loops": 3, "support": "auto", "warnings": [ "warnings" ], "priced_at": "2026-10-24T09:30:00Z" }, "failure_code": null, "failure_message": null, "created_at": "2026-10-24T09:30:00Z", "priced_at": "2026-10-24T09:30:00Z" } } ``` ```bash curl -X POST "https://engine.layercrew.com/v1/quotes" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "file_id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "material": "PLA", "nozzle": "0.4", "quantity": 2 }' ``` ### GET /v1/quotes/{quote_id} **Get a quote** Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `quote_id` | path | string (uuid) | yes | | Responses: - `200` — The quote. - `404` — No such object in this environment (`NOT_FOUND`). Response `200`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | | | `file_id` | string (uuid) | yes | | | `material` | string | yes | | | `nozzle` | string | yes | | | `quantity` | integer | yes | | | `status` | enum: `pending`, `priced`, `failed` | yes | | | `unit_price` | Money | no | EUR with two decimals, as a string. | | `list_unit_price` | Money | no | EUR with two decimals, as a string. | | `discount_pct` | string | no | | | `total` | Money | no | EUR with two decimals, as a string. | | `currency` | enum: `EUR` | no | | | `tiers` | array of object | no | Unit price at other quantities. | | `estimate` | PriceEstimate | no | What the slicer measured and the settings it used. | | `failure_code` | enum \| null: `model_too_large`, `not_a_model`, `material_unavailable`, `slicer_rejected`, `file_unavailable` | no | | | `failure_message` | string \| null | no | | | `created_at` | string (date-time) | yes | | | `priced_at` | string (date-time) \| null | no | | ```json { "data": { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "file_id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "material": "PLA", "nozzle": "0.4", "quantity": 2, "status": "pending", "unit_price": "12.40", "list_unit_price": "12.40", "discount_pct": "0", "total": "12.40", "currency": "EUR", "tiers": [ { "min_quantity": 1, "unit_price": "12.40" } ], "estimate": { "weight_grams": 31.2, "time_seconds": 5400, "unit_yield": 1, "printer_model": "Bambu Lab A2L", "nozzle": "0.4", "material": "PLA", "infill_pct": 15, "wall_loops": 3, "support": "auto", "warnings": [ "warnings" ], "priced_at": "2026-10-24T09:30:00Z" }, "failure_code": null, "failure_message": null, "created_at": "2026-10-24T09:30:00Z", "priced_at": "2026-10-24T09:30:00Z" } } ``` ```bash curl -X GET "https://engine.layercrew.com/v1/quotes/4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" ``` ## Orders Create and follow production orders. ### GET /v1/orders **List your orders** Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `page` | query | integer | no | | | `per_page` | query | integer | no | | | `status` | query | OrderStatus | no | | | `reference` | query | string | no | Exact match on your reference. | | `created_after` | query | string (date-time) | no | | Responses: - `200` — Orders, newest first. - `401` — The key is missing, malformed, unknown, revoked or for the other environment (`INVALID_API_KEY`, `API_KEY_REVOKED`, `SCOPE_MISMATCH`). Response `200` (each item of `data`): | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | | | `number` | string | yes | | | `reference` | string | yes | | | `environment` | Environment: `live`, `test` | yes | | | `status` | OrderStatus: `received`, `pending_review`, `accepting`, `accepted`, `in_production`, `ready_to_ship`, `shipped`, `delivered`, `on_hold`, `cancelled`, `rejected` | yes | received: pricing in progress · pending_review: priced, a LayerCrew engineer is looking at it · accepted: in the production queue · in_production · ready_to_ship: passed quality check and packed · shipped: handed to the courier · delivered (final) · on_hold: we need something, see `hold` · cancelled · rejected, see `rejection`. | | `pricing_status` | enum: `pending`, `priced`, `failed` | yes | | | `items` | array of OrderItem | yes | | | `pricing` | Pricing | yes | | | `payment` | Payment | no | | | `delivery` | Delivery | yes | | | `white_label` | WhiteLabel | yes | White label is on by default: neutral packaging, no LayerCrew marks on parts, packaging or inserts, no invoice in the box. The courier label names LayerCrew as the shipping account (a courier requirement) and shows your sender name as the contact line; packaging and inserts carry your branding settings. | | `metadata` | object | yes | | | `notes` | string | no | | | `shipments` | array of Shipment | yes | | | `invoices` | array of Invoice | yes | | | `rejection` | Rejection \| null | no | | | `hold` | Hold \| null | no | | | `created_at` | string (date-time) | yes | | | `updated_at` | string (date-time) | yes | | | `accepted_at` | string (date-time) \| null | no | | | `shipped_at` | string (date-time) \| null | no | | | `delivered_at` | string (date-time) \| null | no | | | `cancelled_at` | string (date-time) \| null | no | | | `links` | object | yes | | ```json { "data": [ { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "number": "PA-2026-0007", "reference": "SHOP-10231", "environment": "live", "status": "received", "pricing_status": "pending", "items": [ { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "position": 1, "file": { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "filename": "bracket-v3.stl", "format": "stl" }, "quantity": 2, "material": "PLA", "colour": "black", "nozzle": "0.4", "notes": "Fit check before the batch, please.", "pricing_status": "pending", "list_unit_price": "12.40", "discount_pct": "0", "unit_price": "12.40", "line_total": "12.40", "estimate": { "weight_grams": 31.2, "time_seconds": 5400, "unit_yield": 1, "printer_model": "Bambu Lab A2L", "nozzle": "0.4", "material": "PLA", "infill_pct": 15, "wall_loops": 3, "support": "auto", "warnings": [], "priced_at": "2026-10-24T09:30:00Z" }, "failure_code": null, "failure_message": null } ], "pricing": { "currency": "EUR", "subtotal": "12.40", "discount_total": "12.40", "shipping_total": "12.40", "tax_total": "12.40", "tax_treatment": "standard", "grand_total": "12.40", "final": true }, "payment": { "method": "bank_transfer", "status": "unpaid", "ship_before_payment": false, "amount_due": "12.40", "instructions": { "beneficiary": "LAYER CREW SRL", "iban": "RO49AAAA1B31007593840000", "bank": "Example Bank", "reference": "SHOP-10231" }, "paid_at": "2026-10-24T09:30:00Z" }, "delivery": { "name": "Claire Martin", "company": "Atelier Martin", "phone": "+33 6 12 34 56 78", "email": "claire@example.com", "line1": "12 rue de la Paix", "line2": "Bâtiment B", "city": "Lyon", "region": "Auvergne-Rhône-Alpes", "postal_code": "69001", "country_code": "FR" }, "white_label": { "enabled": true, "sender_name": "Studio NS", "card_text": "Thank you for your order — Studio NS", "packaging_preset": "neutral", "no_invoice_in_box": true, "language": "fr" }, "metadata": { "shop_order_id": "10231" }, "notes": "Fit check before the batch, please.", "shipments": [ { "carrier": "FAN Courier", "tracking_number": "2057100012345", "tracking_url": "https://www.fancourier.ro/awb-tracking/?awb=2057100012345", "status": "shipped", "last_tracking_status": "last tracking status", "shipped_at": "2026-10-24T09:30:00Z", "delivered_at": "2026-10-24T09:30:00Z" } ], "invoices": [ { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "number": "number", "kind": "fiscal", "issued_at": "2026-10-24T09:30:00Z" } ], "rejection": null, "hold": null, "created_at": "2026-10-24T09:30:00Z", "updated_at": "2026-10-24T09:30:00Z", "accepted_at": "2026-10-24T09:30:00Z", "shipped_at": "2026-10-24T09:30:00Z", "delivered_at": "2026-10-24T09:30:00Z", "cancelled_at": "2026-10-24T09:30:00Z", "links": { "self": "self", "events": "events" } } ], "meta": { "page": 1, "per_page": 25, "total": 1, "total_pages": 1 } } ``` ```bash curl -X GET "https://engine.layercrew.com/v1/orders?page=1&per_page=25" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" ``` ### POST /v1/orders **Create an order** Creates a production order from ready files. Requires an `Idempotency-Key` header (any string up to 255 characters, unique per attempt; a retry with the same key and the same body returns the first response with `Idempotent-Replayed: true`). Your `reference` must be unique among your orders in this environment; a duplicate answers 409 `REFERENCE_TAKEN` with the existing order id. The order is priced within minutes (`order.priced`), then a LayerCrew engineer accepts it (`order.accepted`, usually within one business day) or declines it with a reason you can act on (`order.rejected`). Payment instructions appear on acceptance. Production starts after payment unless your plan says otherwise. Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `Idempotency-Key` | header | string | yes | Unique per attempt, up to 255 characters of `[A-Za-z0-9._:~-]`. Kept 24 hours. | Request body: | Field | Type | Required | Description | | --- | --- | --- | --- | | `reference` | string | yes | Your own order reference; unique per environment. (up to 120 characters) | | `items` | array of OrderItemCreate | yes | | | `delivery` | Delivery | yes | | | `white_label` | WhiteLabel | no | White label is on by default: neutral packaging, no LayerCrew marks on parts, packaging or inserts, no invoice in the box. The courier label names LayerCrew as the shipping account (a courier requirement) and shows your sender name as the contact line; packaging and inserts carry your branding settings. | | `notes` | string | no | (up to 2000 characters) | | `metadata` | object | no | Returned untouched on the order and in every webhook. (up to 20 keys) | ```json { "reference": "SHOP-10231", "items": [ { "file_id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "quantity": 2, "material": "PLA", "colour": "black", "nozzle": "0.4", "notes": "Fit check before the batch, please." } ], "delivery": { "name": "Claire Martin", "company": "Atelier Martin", "phone": "+33 6 12 34 56 78", "email": "claire@example.com", "line1": "12 rue de la Paix", "line2": "Bâtiment B", "city": "Lyon", "region": "Auvergne-Rhône-Alpes", "postal_code": "69001", "country_code": "FR" }, "white_label": { "enabled": true, "sender_name": "Studio NS", "card_text": "Thank you for your order — Studio NS", "packaging_preset": "neutral", "no_invoice_in_box": true, "language": "fr" }, "notes": "Fit check before the batch, please.", "metadata": { "shop_order_id": "10231" } } ``` Responses: - `200` — Replayed from a previous request with the same `Idempotency-Key`. - `202` — Order received; pricing started. - `401` — The key is missing, malformed, unknown, revoked or for the other environment (`INVALID_API_KEY`, `API_KEY_REVOKED`, `SCOPE_MISMATCH`). - `409` — The request conflicts with the current state; `error.code` says how (`REFERENCE_TAKEN`, `FILE_IN_USE`, `FILE_NOT_READY`, `CANNOT_CANCEL`, `ENDPOINT_LIMIT`, `DELIVERY_IN_FLIGHT`, `IDEMPOTENCY_KEY_REUSED`, `IDEMPOTENCY_IN_PROGRESS`, `TERMS_REACCEPTANCE_REQUIRED`). - `422` — A field is wrong; `error.details` maps field names to messages (`VALIDATION_ERROR`, `IDEMPOTENCY_KEY_REQUIRED`, `MATERIAL_UNAVAILABLE`, `FILE_REJECTED`, `ENDPOINT_URL_INVALID`). - `429` — Too many requests (`RATE_LIMITED`) or a monthly cap reached (`QUOTA_EXCEEDED`). Honour `Retry-After`. Response `200`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | | | `number` | string | yes | | | `reference` | string | yes | | | `environment` | Environment: `live`, `test` | yes | | | `status` | OrderStatus: `received`, `pending_review`, `accepting`, `accepted`, `in_production`, `ready_to_ship`, `shipped`, `delivered`, `on_hold`, `cancelled`, `rejected` | yes | received: pricing in progress · pending_review: priced, a LayerCrew engineer is looking at it · accepted: in the production queue · in_production · ready_to_ship: passed quality check and packed · shipped: handed to the courier · delivered (final) · on_hold: we need something, see `hold` · cancelled · rejected, see `rejection`. | | `pricing_status` | enum: `pending`, `priced`, `failed` | yes | | | `items` | array of OrderItem | yes | | | `pricing` | Pricing | yes | | | `payment` | Payment | no | | | `delivery` | Delivery | yes | | | `white_label` | WhiteLabel | yes | White label is on by default: neutral packaging, no LayerCrew marks on parts, packaging or inserts, no invoice in the box. The courier label names LayerCrew as the shipping account (a courier requirement) and shows your sender name as the contact line; packaging and inserts carry your branding settings. | | `metadata` | object | yes | | | `notes` | string | no | | | `shipments` | array of Shipment | yes | | | `invoices` | array of Invoice | yes | | | `rejection` | Rejection \| null | no | | | `hold` | Hold \| null | no | | | `created_at` | string (date-time) | yes | | | `updated_at` | string (date-time) | yes | | | `accepted_at` | string (date-time) \| null | no | | | `shipped_at` | string (date-time) \| null | no | | | `delivered_at` | string (date-time) \| null | no | | | `cancelled_at` | string (date-time) \| null | no | | | `links` | object | yes | | ```json { "data": { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "number": "PA-2026-0007", "reference": "SHOP-10231", "environment": "live", "status": "received", "pricing_status": "pending", "items": [ { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "position": 1, "file": { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "filename": "bracket-v3.stl", "format": "stl" }, "quantity": 2, "material": "PLA", "colour": "black", "nozzle": "0.4", "notes": "Fit check before the batch, please.", "pricing_status": "pending", "list_unit_price": "12.40", "discount_pct": "0", "unit_price": "12.40", "line_total": "12.40", "estimate": { "weight_grams": 31.2, "time_seconds": 5400, "unit_yield": 1, "printer_model": "Bambu Lab A2L", "nozzle": "0.4", "material": "PLA", "infill_pct": 15, "wall_loops": 3, "support": "auto", "warnings": [ "warnings" ], "priced_at": "2026-10-24T09:30:00Z" }, "failure_code": null, "failure_message": null } ], "pricing": { "currency": "EUR", "subtotal": "12.40", "discount_total": "12.40", "shipping_total": "12.40", "tax_total": "12.40", "tax_treatment": "standard", "grand_total": "12.40", "final": true }, "payment": { "method": "bank_transfer", "status": "unpaid", "ship_before_payment": false, "amount_due": "12.40", "instructions": { "beneficiary": "LAYER CREW SRL", "iban": "RO49AAAA1B31007593840000", "bank": "Example Bank", "reference": "SHOP-10231" }, "paid_at": "2026-10-24T09:30:00Z" }, "delivery": { "name": "Claire Martin", "company": "Atelier Martin", "phone": "+33 6 12 34 56 78", "email": "claire@example.com", "line1": "12 rue de la Paix", "line2": "Bâtiment B", "city": "Lyon", "region": "Auvergne-Rhône-Alpes", "postal_code": "69001", "country_code": "FR" }, "white_label": { "enabled": true, "sender_name": "Studio NS", "card_text": "Thank you for your order — Studio NS", "packaging_preset": "neutral", "no_invoice_in_box": true, "language": "fr" }, "metadata": { "shop_order_id": "10231" }, "notes": "Fit check before the batch, please.", "shipments": [ { "carrier": "FAN Courier", "tracking_number": "2057100012345", "tracking_url": "https://www.fancourier.ro/awb-tracking/?awb=2057100012345", "status": "shipped", "last_tracking_status": "last tracking status", "shipped_at": "2026-10-24T09:30:00Z", "delivered_at": "2026-10-24T09:30:00Z" } ], "invoices": [ { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "number": "number", "kind": "fiscal", "issued_at": "2026-10-24T09:30:00Z" } ], "rejection": null, "hold": null, "created_at": "2026-10-24T09:30:00Z", "updated_at": "2026-10-24T09:30:00Z", "accepted_at": "2026-10-24T09:30:00Z", "shipped_at": "2026-10-24T09:30:00Z", "delivered_at": "2026-10-24T09:30:00Z", "cancelled_at": "2026-10-24T09:30:00Z", "links": { "self": "self", "events": "events" } } } ``` ```bash curl -X POST "https://engine.layercrew.com/v1/orders" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "reference": "SHOP-10231", "items": [ { "file_id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "quantity": 2, "material": "PLA", "colour": "black", "nozzle": "0.4", "notes": "Fit check before the batch, please." } ], "delivery": { "name": "Claire Martin", "company": "Atelier Martin", "phone": "+33 6 12 34 56 78", "email": "claire@example.com", "line1": "12 rue de la Paix", "line2": "Bâtiment B", "city": "Lyon", "region": "Auvergne-Rhône-Alpes", "postal_code": "69001", "country_code": "FR" }, "white_label": { "enabled": true, "sender_name": "Studio NS", "card_text": "Thank you for your order — Studio NS", "packaging_preset": "neutral", "no_invoice_in_box": true, "language": "fr" }, "notes": "Fit check before the batch, please.", "metadata": { "shop_order_id": "10231" } }' ``` ### GET /v1/orders/{order_id} **Get an order** `order_id` is the order's id or its number (`PA-2026-0007`). Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `order_id` | path | string | yes | The order id (UUID) or number (`PA-YYYY-NNNN`). | Responses: - `200` — The order. - `404` — No such object in this environment (`NOT_FOUND`). Response `200`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | | | `number` | string | yes | | | `reference` | string | yes | | | `environment` | Environment: `live`, `test` | yes | | | `status` | OrderStatus: `received`, `pending_review`, `accepting`, `accepted`, `in_production`, `ready_to_ship`, `shipped`, `delivered`, `on_hold`, `cancelled`, `rejected` | yes | received: pricing in progress · pending_review: priced, a LayerCrew engineer is looking at it · accepted: in the production queue · in_production · ready_to_ship: passed quality check and packed · shipped: handed to the courier · delivered (final) · on_hold: we need something, see `hold` · cancelled · rejected, see `rejection`. | | `pricing_status` | enum: `pending`, `priced`, `failed` | yes | | | `items` | array of OrderItem | yes | | | `pricing` | Pricing | yes | | | `payment` | Payment | no | | | `delivery` | Delivery | yes | | | `white_label` | WhiteLabel | yes | White label is on by default: neutral packaging, no LayerCrew marks on parts, packaging or inserts, no invoice in the box. The courier label names LayerCrew as the shipping account (a courier requirement) and shows your sender name as the contact line; packaging and inserts carry your branding settings. | | `metadata` | object | yes | | | `notes` | string | no | | | `shipments` | array of Shipment | yes | | | `invoices` | array of Invoice | yes | | | `rejection` | Rejection \| null | no | | | `hold` | Hold \| null | no | | | `created_at` | string (date-time) | yes | | | `updated_at` | string (date-time) | yes | | | `accepted_at` | string (date-time) \| null | no | | | `shipped_at` | string (date-time) \| null | no | | | `delivered_at` | string (date-time) \| null | no | | | `cancelled_at` | string (date-time) \| null | no | | | `links` | object | yes | | ```json { "data": { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "number": "PA-2026-0007", "reference": "SHOP-10231", "environment": "live", "status": "received", "pricing_status": "pending", "items": [ { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "position": 1, "file": { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "filename": "bracket-v3.stl", "format": "stl" }, "quantity": 2, "material": "PLA", "colour": "black", "nozzle": "0.4", "notes": "Fit check before the batch, please.", "pricing_status": "pending", "list_unit_price": "12.40", "discount_pct": "0", "unit_price": "12.40", "line_total": "12.40", "estimate": { "weight_grams": 31.2, "time_seconds": 5400, "unit_yield": 1, "printer_model": "Bambu Lab A2L", "nozzle": "0.4", "material": "PLA", "infill_pct": 15, "wall_loops": 3, "support": "auto", "warnings": [ "warnings" ], "priced_at": "2026-10-24T09:30:00Z" }, "failure_code": null, "failure_message": null } ], "pricing": { "currency": "EUR", "subtotal": "12.40", "discount_total": "12.40", "shipping_total": "12.40", "tax_total": "12.40", "tax_treatment": "standard", "grand_total": "12.40", "final": true }, "payment": { "method": "bank_transfer", "status": "unpaid", "ship_before_payment": false, "amount_due": "12.40", "instructions": { "beneficiary": "LAYER CREW SRL", "iban": "RO49AAAA1B31007593840000", "bank": "Example Bank", "reference": "SHOP-10231" }, "paid_at": "2026-10-24T09:30:00Z" }, "delivery": { "name": "Claire Martin", "company": "Atelier Martin", "phone": "+33 6 12 34 56 78", "email": "claire@example.com", "line1": "12 rue de la Paix", "line2": "Bâtiment B", "city": "Lyon", "region": "Auvergne-Rhône-Alpes", "postal_code": "69001", "country_code": "FR" }, "white_label": { "enabled": true, "sender_name": "Studio NS", "card_text": "Thank you for your order — Studio NS", "packaging_preset": "neutral", "no_invoice_in_box": true, "language": "fr" }, "metadata": { "shop_order_id": "10231" }, "notes": "Fit check before the batch, please.", "shipments": [ { "carrier": "FAN Courier", "tracking_number": "2057100012345", "tracking_url": "https://www.fancourier.ro/awb-tracking/?awb=2057100012345", "status": "shipped", "last_tracking_status": "last tracking status", "shipped_at": "2026-10-24T09:30:00Z", "delivered_at": "2026-10-24T09:30:00Z" } ], "invoices": [ { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "number": "number", "kind": "fiscal", "issued_at": "2026-10-24T09:30:00Z" } ], "rejection": null, "hold": null, "created_at": "2026-10-24T09:30:00Z", "updated_at": "2026-10-24T09:30:00Z", "accepted_at": "2026-10-24T09:30:00Z", "shipped_at": "2026-10-24T09:30:00Z", "delivered_at": "2026-10-24T09:30:00Z", "cancelled_at": "2026-10-24T09:30:00Z", "links": { "self": "self", "events": "events" } } } ``` ```bash curl -X GET "https://engine.layercrew.com/v1/orders/PA-2026-0007" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" ``` ### GET /v1/orders/{order_id}/events **The order's events, in order** Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `order_id` | path | string | yes | The order id (UUID) or number (`PA-YYYY-NNNN`). | | `after` | query | integer | no | Return events with `seq` greater than this. | | `limit` | query | integer | no | | Responses: - `200` — Events, oldest first. - `404` — No such object in this environment (`NOT_FOUND`). Response `200` (each item of `data`): | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | | | `type` | EventType: `order.received`, `order.priced`, `order.pricing_failed`, `order.accepted`, `order.rejected`, `order.on_hold`, `order.released`, `order.in_production`, `order.ready_to_ship`, `order.shipped`, `order.tracking_updated`, `order.delivered`, `order.cancelled`, `order.paid`, `order.invoiced`, `order.returned`, `order.recipient_redacted`, `quote.priced`, `quote.failed`, `file.ready`, `file.rejected`, `file.purged`, `agreement.accepted`, `agreement.countersigned`, `key.issued`, `key.revoked`, `webhook_endpoint.disabled`, `webhook_endpoint.secret_rotated`, `partner.suspended`, `partner.activated`, `usage.warning`, `test.ping` | yes | | | `api_version` | string | yes | | | `created_at` | string (date-time) | yes | | | `seq` | integer | yes | Per-partner, gap-free, strictly increasing. | | `environment` | Environment: `live`, `test` | yes | | | `reason_code` | string \| null | no | | | `data` | object | yes | `order` for order.* events, `quote` for quote.*, `file` for file.*, `endpoint` for webhook_endpoint.*, `key` for key.* (never the secret), `usage` for usage.warning, `{}` for test.ping. | ```json { "data": [ { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "type": "order.received", "api_version": "2026-10-24", "created_at": "2026-10-24T09:30:00Z", "seq": 1234, "environment": "live", "reason_code": null, "data": { "order": { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "number": "PA-2026-0007", "reference": "SHOP-10231", "environment": "live", "status": "received", "pricing_status": "pending", "items": [ { "id": null, "position": null, "file": null, "quantity": null, "material": null, "colour": null, "nozzle": null, "notes": null, "pricing_status": null, "list_unit_price": null, "discount_pct": null, "unit_price": null, "line_total": null, "estimate": null, "failure_code": null, "failure_message": null } ], "pricing": { "currency": "EUR", "subtotal": "12.40", "discount_total": "12.40", "shipping_total": "12.40", "tax_total": "12.40", "tax_treatment": "standard", "grand_total": "12.40", "final": true }, "payment": { "method": "bank_transfer", "status": "unpaid", "ship_before_payment": false, "amount_due": "12.40", "instructions": { "beneficiary": null, "iban": null, "bank": null, "reference": null }, "paid_at": "2026-10-24T09:30:00Z" }, "delivery": { "name": "Claire Martin", "company": "Atelier Martin", "phone": "+33 6 12 34 56 78", "email": "claire@example.com", "line1": "12 rue de la Paix", "line2": "Bâtiment B", "city": "Lyon", "region": "Auvergne-Rhône-Alpes", "postal_code": "69001", "country_code": "FR" }, "white_label": { "enabled": true, "sender_name": "Studio NS", "card_text": "Thank you for your order — Studio NS", "packaging_preset": "neutral", "no_invoice_in_box": true, "language": "fr" }, "metadata": { "shop_order_id": "10231" }, "notes": "Fit check before the batch, please.", "shipments": [ { "carrier": null, "tracking_number": null, "tracking_url": null, "status": null, "last_tracking_status": null, "shipped_at": null, "delivered_at": null } ], "invoices": [ { "id": null, "number": null, "kind": null, "issued_at": null } ], "rejection": null, "hold": null, "created_at": "2026-10-24T09:30:00Z", "updated_at": "2026-10-24T09:30:00Z", "accepted_at": "2026-10-24T09:30:00Z", "shipped_at": "2026-10-24T09:30:00Z", "delivered_at": "2026-10-24T09:30:00Z", "cancelled_at": "2026-10-24T09:30:00Z", "links": { "self": "self", "events": "events" } }, "quote": { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "file_id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "material": "PLA", "nozzle": "0.4", "quantity": 2, "status": "pending", "unit_price": "12.40", "list_unit_price": "12.40", "discount_pct": "0", "total": "12.40", "currency": "EUR", "tiers": [ { "min_quantity": null, "unit_price": null } ], "estimate": { "weight_grams": 31.2, "time_seconds": 5400, "unit_yield": 1, "printer_model": "Bambu Lab A2L", "nozzle": "0.4", "material": "PLA", "infill_pct": 15, "wall_loops": 3, "support": "auto", "warnings": [], "priced_at": "2026-10-24T09:30:00Z" }, "failure_code": null, "failure_message": null, "created_at": "2026-10-24T09:30:00Z", "priced_at": "2026-10-24T09:30:00Z" }, "file": { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "filename": "bracket-v3.stl", "format": "stl", "status": "pending", "size_bytes": 1048576, "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "rejection_code": null, "facts": { "triangles": 48210, "objects": 1, "bbox_mm": [ 60, 42.5, 18 ], "producer": "Bambu Studio 02.08.02.61", "sanitised": false }, "created_at": "2026-10-24T09:30:00Z", "ready_at": "2026-10-24T09:30:00Z", "purge_after": "2026-10-24T09:30:00Z" } } } ], "meta": { "next_after": 1234, "has_more": false } } ``` ```bash curl -X GET "https://engine.layercrew.com/v1/orders/PA-2026-0007/events?after=0&limit=50" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" ``` ### POST /v1/orders/{order_id}/cancel **Cancel an order** Allowed while the order is `received`, `pending_review` or `accepted` (before production starts). Later, write to us from the order page and we will tell you what is still possible. Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `order_id` | path | string | yes | The order id (UUID) or number (`PA-YYYY-NNNN`). | Request body (optional): | Field | Type | Required | Description | | --- | --- | --- | --- | | `reason` | string | no | (up to 500 characters) | ```json { "reason": "Cancelled by the customer before production." } ``` Responses: - `200` — Cancelled. - `404` — No such object in this environment (`NOT_FOUND`). - `409` — The request conflicts with the current state; `error.code` says how (`REFERENCE_TAKEN`, `FILE_IN_USE`, `FILE_NOT_READY`, `CANNOT_CANCEL`, `ENDPOINT_LIMIT`, `DELIVERY_IN_FLIGHT`, `IDEMPOTENCY_KEY_REUSED`, `IDEMPOTENCY_IN_PROGRESS`, `TERMS_REACCEPTANCE_REQUIRED`). Response `200`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | | | `number` | string | yes | | | `reference` | string | yes | | | `environment` | Environment: `live`, `test` | yes | | | `status` | OrderStatus: `received`, `pending_review`, `accepting`, `accepted`, `in_production`, `ready_to_ship`, `shipped`, `delivered`, `on_hold`, `cancelled`, `rejected` | yes | received: pricing in progress · pending_review: priced, a LayerCrew engineer is looking at it · accepted: in the production queue · in_production · ready_to_ship: passed quality check and packed · shipped: handed to the courier · delivered (final) · on_hold: we need something, see `hold` · cancelled · rejected, see `rejection`. | | `pricing_status` | enum: `pending`, `priced`, `failed` | yes | | | `items` | array of OrderItem | yes | | | `pricing` | Pricing | yes | | | `payment` | Payment | no | | | `delivery` | Delivery | yes | | | `white_label` | WhiteLabel | yes | White label is on by default: neutral packaging, no LayerCrew marks on parts, packaging or inserts, no invoice in the box. The courier label names LayerCrew as the shipping account (a courier requirement) and shows your sender name as the contact line; packaging and inserts carry your branding settings. | | `metadata` | object | yes | | | `notes` | string | no | | | `shipments` | array of Shipment | yes | | | `invoices` | array of Invoice | yes | | | `rejection` | Rejection \| null | no | | | `hold` | Hold \| null | no | | | `created_at` | string (date-time) | yes | | | `updated_at` | string (date-time) | yes | | | `accepted_at` | string (date-time) \| null | no | | | `shipped_at` | string (date-time) \| null | no | | | `delivered_at` | string (date-time) \| null | no | | | `cancelled_at` | string (date-time) \| null | no | | | `links` | object | yes | | ```json { "data": { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "number": "PA-2026-0007", "reference": "SHOP-10231", "environment": "live", "status": "received", "pricing_status": "pending", "items": [ { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "position": 1, "file": { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "filename": "bracket-v3.stl", "format": "stl" }, "quantity": 2, "material": "PLA", "colour": "black", "nozzle": "0.4", "notes": "Fit check before the batch, please.", "pricing_status": "pending", "list_unit_price": "12.40", "discount_pct": "0", "unit_price": "12.40", "line_total": "12.40", "estimate": { "weight_grams": 31.2, "time_seconds": 5400, "unit_yield": 1, "printer_model": "Bambu Lab A2L", "nozzle": "0.4", "material": "PLA", "infill_pct": 15, "wall_loops": 3, "support": "auto", "warnings": [ "warnings" ], "priced_at": "2026-10-24T09:30:00Z" }, "failure_code": null, "failure_message": null } ], "pricing": { "currency": "EUR", "subtotal": "12.40", "discount_total": "12.40", "shipping_total": "12.40", "tax_total": "12.40", "tax_treatment": "standard", "grand_total": "12.40", "final": true }, "payment": { "method": "bank_transfer", "status": "unpaid", "ship_before_payment": false, "amount_due": "12.40", "instructions": { "beneficiary": "LAYER CREW SRL", "iban": "RO49AAAA1B31007593840000", "bank": "Example Bank", "reference": "SHOP-10231" }, "paid_at": "2026-10-24T09:30:00Z" }, "delivery": { "name": "Claire Martin", "company": "Atelier Martin", "phone": "+33 6 12 34 56 78", "email": "claire@example.com", "line1": "12 rue de la Paix", "line2": "Bâtiment B", "city": "Lyon", "region": "Auvergne-Rhône-Alpes", "postal_code": "69001", "country_code": "FR" }, "white_label": { "enabled": true, "sender_name": "Studio NS", "card_text": "Thank you for your order — Studio NS", "packaging_preset": "neutral", "no_invoice_in_box": true, "language": "fr" }, "metadata": { "shop_order_id": "10231" }, "notes": "Fit check before the batch, please.", "shipments": [ { "carrier": "FAN Courier", "tracking_number": "2057100012345", "tracking_url": "https://www.fancourier.ro/awb-tracking/?awb=2057100012345", "status": "shipped", "last_tracking_status": "last tracking status", "shipped_at": "2026-10-24T09:30:00Z", "delivered_at": "2026-10-24T09:30:00Z" } ], "invoices": [ { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "number": "number", "kind": "fiscal", "issued_at": "2026-10-24T09:30:00Z" } ], "rejection": null, "hold": null, "created_at": "2026-10-24T09:30:00Z", "updated_at": "2026-10-24T09:30:00Z", "accepted_at": "2026-10-24T09:30:00Z", "shipped_at": "2026-10-24T09:30:00Z", "delivered_at": "2026-10-24T09:30:00Z", "cancelled_at": "2026-10-24T09:30:00Z", "links": { "self": "self", "events": "events" } } } ``` ```bash curl -X POST "https://engine.layercrew.com/v1/orders/PA-2026-0007/cancel" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "reason": "Cancelled by the customer before production." }' ``` ### GET /v1/orders/{order_id}/invoices **Invoices issued for the order** Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `order_id` | path | string | yes | The order id (UUID) or number (`PA-YYYY-NNNN`). | Responses: - `200` — Invoices. - `404` — No such object in this environment (`NOT_FOUND`). Response `200` (each item of `data`): | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | | | `number` | string | yes | | | `kind` | enum: `fiscal`, `proforma`, `storno` | yes | | | `issued_at` | string (date-time) | yes | | ```json { "data": [ { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "number": "number", "kind": "fiscal", "issued_at": "2026-10-24T09:30:00Z" } ] } ``` ```bash curl -X GET "https://engine.layercrew.com/v1/orders/PA-2026-0007/invoices" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" ``` ### GET /v1/orders/{order_id}/invoices/{invoice_id}/download-url **A 15-minute link to the invoice PDF** Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `order_id` | path | string | yes | The order id (UUID) or number (`PA-YYYY-NNNN`). | | `invoice_id` | path | string (uuid) | yes | | Responses: - `200` — The link. - `404` — No such object in this environment (`NOT_FOUND`). Response `200`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `url` | string (uri) | yes | | | `expires_at` | string (date-time) | yes | | ```json { "data": { "url": "https://shop.example.com/webhooks/layercrew", "expires_at": "2026-10-24T09:30:00Z" } } ``` ```bash curl -X GET "https://engine.layercrew.com/v1/orders/PA-2026-0007/invoices/4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d/download-url" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" ``` ### DELETE /v1/orders/{order_id}/recipient **Erase your customer's personal data from a finished order** Replaces the recipient's name, company, phone, email and street lines with `[redacted]` on the order and on its stored events; city, postal code and country stay for fiscal and zone records. Allowed once the order is delivered, cancelled or rejected. We also do this automatically after the retention window in your plan. Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `order_id` | path | string | yes | The order id (UUID) or number (`PA-YYYY-NNNN`). | Responses: - `204` — Erased. - `404` — No such object in this environment (`NOT_FOUND`). - `409` — The request conflicts with the current state; `error.code` says how (`REFERENCE_TAKEN`, `FILE_IN_USE`, `FILE_NOT_READY`, `CANNOT_CANCEL`, `ENDPOINT_LIMIT`, `DELIVERY_IN_FLIGHT`, `IDEMPOTENCY_KEY_REUSED`, `IDEMPOTENCY_IN_PROGRESS`, `TERMS_REACCEPTANCE_REQUIRED`). ```bash curl -X DELETE "https://engine.layercrew.com/v1/orders/PA-2026-0007/recipient" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" ``` ## Events The ordered feed of everything that happened to your account. ### GET /v1/events **Everything that happened, in order** The authoritative, gap-free sequence of your account's events in this environment. Poll with `after` = the last `seq` you processed. Webhooks carry the same events; this feed is the source of truth when a webhook is late or missed. Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `after` | query | integer | no | Return events with `seq` greater than this. | | `limit` | query | integer | no | | | `types` | query | string | no | Comma-separated event types to include. | Responses: - `200` — Events, oldest first. - `401` — The key is missing, malformed, unknown, revoked or for the other environment (`INVALID_API_KEY`, `API_KEY_REVOKED`, `SCOPE_MISMATCH`). Response `200` (each item of `data`): | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | | | `type` | EventType: `order.received`, `order.priced`, `order.pricing_failed`, `order.accepted`, `order.rejected`, `order.on_hold`, `order.released`, `order.in_production`, `order.ready_to_ship`, `order.shipped`, `order.tracking_updated`, `order.delivered`, `order.cancelled`, `order.paid`, `order.invoiced`, `order.returned`, `order.recipient_redacted`, `quote.priced`, `quote.failed`, `file.ready`, `file.rejected`, `file.purged`, `agreement.accepted`, `agreement.countersigned`, `key.issued`, `key.revoked`, `webhook_endpoint.disabled`, `webhook_endpoint.secret_rotated`, `partner.suspended`, `partner.activated`, `usage.warning`, `test.ping` | yes | | | `api_version` | string | yes | | | `created_at` | string (date-time) | yes | | | `seq` | integer | yes | Per-partner, gap-free, strictly increasing. | | `environment` | Environment: `live`, `test` | yes | | | `reason_code` | string \| null | no | | | `data` | object | yes | `order` for order.* events, `quote` for quote.*, `file` for file.*, `endpoint` for webhook_endpoint.*, `key` for key.* (never the secret), `usage` for usage.warning, `{}` for test.ping. | ```json { "data": [ { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "type": "order.received", "api_version": "2026-10-24", "created_at": "2026-10-24T09:30:00Z", "seq": 1234, "environment": "live", "reason_code": null, "data": { "order": { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "number": "PA-2026-0007", "reference": "SHOP-10231", "environment": "live", "status": "received", "pricing_status": "pending", "items": [ { "id": null, "position": null, "file": null, "quantity": null, "material": null, "colour": null, "nozzle": null, "notes": null, "pricing_status": null, "list_unit_price": null, "discount_pct": null, "unit_price": null, "line_total": null, "estimate": null, "failure_code": null, "failure_message": null } ], "pricing": { "currency": "EUR", "subtotal": "12.40", "discount_total": "12.40", "shipping_total": "12.40", "tax_total": "12.40", "tax_treatment": "standard", "grand_total": "12.40", "final": true }, "payment": { "method": "bank_transfer", "status": "unpaid", "ship_before_payment": false, "amount_due": "12.40", "instructions": { "beneficiary": null, "iban": null, "bank": null, "reference": null }, "paid_at": "2026-10-24T09:30:00Z" }, "delivery": { "name": "Claire Martin", "company": "Atelier Martin", "phone": "+33 6 12 34 56 78", "email": "claire@example.com", "line1": "12 rue de la Paix", "line2": "Bâtiment B", "city": "Lyon", "region": "Auvergne-Rhône-Alpes", "postal_code": "69001", "country_code": "FR" }, "white_label": { "enabled": true, "sender_name": "Studio NS", "card_text": "Thank you for your order — Studio NS", "packaging_preset": "neutral", "no_invoice_in_box": true, "language": "fr" }, "metadata": { "shop_order_id": "10231" }, "notes": "Fit check before the batch, please.", "shipments": [ { "carrier": null, "tracking_number": null, "tracking_url": null, "status": null, "last_tracking_status": null, "shipped_at": null, "delivered_at": null } ], "invoices": [ { "id": null, "number": null, "kind": null, "issued_at": null } ], "rejection": null, "hold": null, "created_at": "2026-10-24T09:30:00Z", "updated_at": "2026-10-24T09:30:00Z", "accepted_at": "2026-10-24T09:30:00Z", "shipped_at": "2026-10-24T09:30:00Z", "delivered_at": "2026-10-24T09:30:00Z", "cancelled_at": "2026-10-24T09:30:00Z", "links": { "self": "self", "events": "events" } }, "quote": { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "file_id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "material": "PLA", "nozzle": "0.4", "quantity": 2, "status": "pending", "unit_price": "12.40", "list_unit_price": "12.40", "discount_pct": "0", "total": "12.40", "currency": "EUR", "tiers": [ { "min_quantity": null, "unit_price": null } ], "estimate": { "weight_grams": 31.2, "time_seconds": 5400, "unit_yield": 1, "printer_model": "Bambu Lab A2L", "nozzle": "0.4", "material": "PLA", "infill_pct": 15, "wall_loops": 3, "support": "auto", "warnings": [], "priced_at": "2026-10-24T09:30:00Z" }, "failure_code": null, "failure_message": null, "created_at": "2026-10-24T09:30:00Z", "priced_at": "2026-10-24T09:30:00Z" }, "file": { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "filename": "bracket-v3.stl", "format": "stl", "status": "pending", "size_bytes": 1048576, "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "rejection_code": null, "facts": { "triangles": 48210, "objects": 1, "bbox_mm": [ 60, 42.5, 18 ], "producer": "Bambu Studio 02.08.02.61", "sanitised": false }, "created_at": "2026-10-24T09:30:00Z", "ready_at": "2026-10-24T09:30:00Z", "purge_after": "2026-10-24T09:30:00Z" } } } ], "meta": { "next_after": 1234, "has_more": false } } ``` ```bash curl -X GET "https://engine.layercrew.com/v1/events?after=0&limit=50" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" ``` ## Webhooks Signed HTTP notifications and their delivery log. ### GET /v1/webhook-endpoints **List endpoints** Responses: - `200` — Endpoints. Response `200` (each item of `data`): | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | | | `url` | string | yes | | | `description` | string | no | | | `environment` | Environment: `live`, `test` | yes | | | `event_types` | array of string \| null | yes | | | `enabled` | boolean | yes | | | `disabled_reason` | string \| null | no | | | `consecutive_failures` | integer | no | | | `last_success_at` | string (date-time) \| null | no | | | `secret_rotated_at` | string (date-time) \| null | no | | | `created_at` | string (date-time) | yes | | ```json { "data": [ { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "url": "https://shop.example.com/webhooks/layercrew", "description": "Production events", "environment": "live", "event_types": [ "event types" ], "enabled": true, "disabled_reason": null, "consecutive_failures": 0, "last_success_at": "2026-10-24T09:30:00Z", "secret_rotated_at": "2026-10-24T09:30:00Z", "created_at": "2026-10-24T09:30:00Z" } ] } ``` ```bash curl -X GET "https://engine.layercrew.com/v1/webhook-endpoints" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" ``` ### POST /v1/webhook-endpoints **Register an endpoint** The URL must be HTTPS on port 443 (or 8443) on a public host. The signing `secret` is returned once; store it. Each delivery carries `webhook-id`, `webhook-timestamp` and `webhook-signature` (Standard Webhooks). Your plan caps the number of endpoints per environment. Request body: | Field | Type | Required | Description | | --- | --- | --- | --- | | `url` | string (uri) | yes | (up to 500 characters) | | `description` | string | no | (up to 200 characters) | | `event_types` | array of EventType \| null | no | null = every event. | ```json { "url": "https://shop.example.com/webhooks/layercrew", "description": "Production events", "event_types": [ "order.received" ] } ``` Responses: - `201` — Created; `secret` is shown once. - `409` — The request conflicts with the current state; `error.code` says how (`REFERENCE_TAKEN`, `FILE_IN_USE`, `FILE_NOT_READY`, `CANNOT_CANCEL`, `ENDPOINT_LIMIT`, `DELIVERY_IN_FLIGHT`, `IDEMPOTENCY_KEY_REUSED`, `IDEMPOTENCY_IN_PROGRESS`, `TERMS_REACCEPTANCE_REQUIRED`). - `422` — A field is wrong; `error.details` maps field names to messages (`VALIDATION_ERROR`, `IDEMPOTENCY_KEY_REQUIRED`, `MATERIAL_UNAVAILABLE`, `FILE_REJECTED`, `ENDPOINT_URL_INVALID`). Response `201`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | | | `url` | string | yes | | | `description` | string | no | | | `environment` | Environment: `live`, `test` | yes | | | `event_types` | array of string \| null | yes | | | `enabled` | boolean | yes | | | `disabled_reason` | string \| null | no | | | `consecutive_failures` | integer | no | | | `last_success_at` | string (date-time) \| null | no | | | `secret_rotated_at` | string (date-time) \| null | no | | | `created_at` | string (date-time) | yes | | | `secret` | string | yes | `whsec_` + base64. Shown once. | ```json { "data": { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "url": "https://shop.example.com/webhooks/layercrew", "description": "Production events", "environment": "live", "event_types": [ "event types" ], "enabled": true, "disabled_reason": null, "consecutive_failures": 0, "last_success_at": "2026-10-24T09:30:00Z", "secret_rotated_at": "2026-10-24T09:30:00Z", "created_at": "2026-10-24T09:30:00Z", "secret": "secret" } } ``` ```bash curl -X POST "https://engine.layercrew.com/v1/webhook-endpoints" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://shop.example.com/webhooks/layercrew", "description": "Production events", "event_types": [ "order.received" ] }' ``` ### GET /v1/webhook-endpoints/{endpoint_id} **Get an endpoint** Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `endpoint_id` | path | string (uuid) | yes | | Responses: - `200` — The endpoint. - `404` — No such object in this environment (`NOT_FOUND`). Response `200`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | | | `url` | string | yes | | | `description` | string | no | | | `environment` | Environment: `live`, `test` | yes | | | `event_types` | array of string \| null | yes | | | `enabled` | boolean | yes | | | `disabled_reason` | string \| null | no | | | `consecutive_failures` | integer | no | | | `last_success_at` | string (date-time) \| null | no | | | `secret_rotated_at` | string (date-time) \| null | no | | | `created_at` | string (date-time) | yes | | ```json { "data": { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "url": "https://shop.example.com/webhooks/layercrew", "description": "Production events", "environment": "live", "event_types": [ "event types" ], "enabled": true, "disabled_reason": null, "consecutive_failures": 0, "last_success_at": "2026-10-24T09:30:00Z", "secret_rotated_at": "2026-10-24T09:30:00Z", "created_at": "2026-10-24T09:30:00Z" } } ``` ```bash curl -X GET "https://engine.layercrew.com/v1/webhook-endpoints/4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" ``` ### PATCH /v1/webhook-endpoints/{endpoint_id} **Update an endpoint** Re-enabling a disabled endpoint resets its failure count and re-queues deliveries skipped in the last 7 days, in order. Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `endpoint_id` | path | string (uuid) | yes | | Request body: | Field | Type | Required | Description | | --- | --- | --- | --- | | `url` | string (uri) | no | | | `description` | string | no | | | `event_types` | array of EventType \| null | no | | | `enabled` | boolean | no | | ```json { "url": "https://shop.example.com/webhooks/layercrew", "description": "Production events", "event_types": [ "order.received" ], "enabled": true } ``` Responses: - `200` — Updated. - `404` — No such object in this environment (`NOT_FOUND`). - `422` — A field is wrong; `error.details` maps field names to messages (`VALIDATION_ERROR`, `IDEMPOTENCY_KEY_REQUIRED`, `MATERIAL_UNAVAILABLE`, `FILE_REJECTED`, `ENDPOINT_URL_INVALID`). Response `200`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | | | `url` | string | yes | | | `description` | string | no | | | `environment` | Environment: `live`, `test` | yes | | | `event_types` | array of string \| null | yes | | | `enabled` | boolean | yes | | | `disabled_reason` | string \| null | no | | | `consecutive_failures` | integer | no | | | `last_success_at` | string (date-time) \| null | no | | | `secret_rotated_at` | string (date-time) \| null | no | | | `created_at` | string (date-time) | yes | | ```json { "data": { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "url": "https://shop.example.com/webhooks/layercrew", "description": "Production events", "environment": "live", "event_types": [ "event types" ], "enabled": true, "disabled_reason": null, "consecutive_failures": 0, "last_success_at": "2026-10-24T09:30:00Z", "secret_rotated_at": "2026-10-24T09:30:00Z", "created_at": "2026-10-24T09:30:00Z" } } ``` ```bash curl -X PATCH "https://engine.layercrew.com/v1/webhook-endpoints/4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://shop.example.com/webhooks/layercrew", "description": "Production events", "event_types": [ "order.received" ], "enabled": true }' ``` ### DELETE /v1/webhook-endpoints/{endpoint_id} **Delete an endpoint** Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `endpoint_id` | path | string (uuid) | yes | | Responses: - `204` — Deleted. - `404` — No such object in this environment (`NOT_FOUND`). ```bash curl -X DELETE "https://engine.layercrew.com/v1/webhook-endpoints/4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" ``` ### POST /v1/webhook-endpoints/{endpoint_id}/rotate-secret **Rotate the signing secret** Returns a new secret once. For 24 hours every delivery is signed with both the new and the old secret, so you can switch without a gap. Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `endpoint_id` | path | string (uuid) | yes | | Responses: - `200` — Rotated. - `404` — No such object in this environment (`NOT_FOUND`). Response `200`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | | | `url` | string | yes | | | `description` | string | no | | | `environment` | Environment: `live`, `test` | yes | | | `event_types` | array of string \| null | yes | | | `enabled` | boolean | yes | | | `disabled_reason` | string \| null | no | | | `consecutive_failures` | integer | no | | | `last_success_at` | string (date-time) \| null | no | | | `secret_rotated_at` | string (date-time) \| null | no | | | `created_at` | string (date-time) | yes | | | `secret` | string | yes | `whsec_` + base64. Shown once. | ```json { "data": { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "url": "https://shop.example.com/webhooks/layercrew", "description": "Production events", "environment": "live", "event_types": [ "event types" ], "enabled": true, "disabled_reason": null, "consecutive_failures": 0, "last_success_at": "2026-10-24T09:30:00Z", "secret_rotated_at": "2026-10-24T09:30:00Z", "created_at": "2026-10-24T09:30:00Z", "secret": "secret" } } ``` ```bash curl -X POST "https://engine.layercrew.com/v1/webhook-endpoints/4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d/rotate-secret" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" ``` ### POST /v1/webhook-endpoints/{endpoint_id}/ping **Send a signed test event** Queues a `test.ping` delivery so you can verify your signature check before going live. Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `endpoint_id` | path | string (uuid) | yes | | Responses: - `202` — Queued. - `404` — No such object in this environment (`NOT_FOUND`). Response `202`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | | | `endpoint_id` | string (uuid) | yes | | | `event_id` | string (uuid) | yes | | | `event_type` | string | yes | | | `seq` | integer | yes | | | `status` | DeliveryStatus: `pending`, `in_flight`, `delivered`, `failed`, `skipped` | yes | | | `attempts` | integer | yes | | | `max_attempts` | integer | no | | | `next_attempt_at` | string (date-time) \| null | no | | | `last_status_code` | integer \| null | no | | | `last_error` | string \| null | no | | | `delivered_at` | string (date-time) \| null | no | | | `created_at` | string (date-time) | yes | | ```json { "data": { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "endpoint_id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "event_id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "event_type": "event type", "seq": 1234, "status": "pending", "attempts": 1, "max_attempts": 12, "next_attempt_at": "2026-10-24T09:30:00Z", "last_status_code": 200, "last_error": null, "delivered_at": "2026-10-24T09:30:00Z", "created_at": "2026-10-24T09:30:00Z" } } ``` ```bash curl -X POST "https://engine.layercrew.com/v1/webhook-endpoints/4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d/ping" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" ``` ### GET /v1/webhook-deliveries **Delivery log** Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `page` | query | integer | no | | | `per_page` | query | integer | no | | | `endpoint_id` | query | string (uuid) | no | | | `status` | query | DeliveryStatus | no | | | `event_id` | query | string (uuid) | no | | Responses: - `200` — Deliveries, newest first. Response `200` (each item of `data`): | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | | | `endpoint_id` | string (uuid) | yes | | | `event_id` | string (uuid) | yes | | | `event_type` | string | yes | | | `seq` | integer | yes | | | `status` | DeliveryStatus: `pending`, `in_flight`, `delivered`, `failed`, `skipped` | yes | | | `attempts` | integer | yes | | | `max_attempts` | integer | no | | | `next_attempt_at` | string (date-time) \| null | no | | | `last_status_code` | integer \| null | no | | | `last_error` | string \| null | no | | | `delivered_at` | string (date-time) \| null | no | | | `created_at` | string (date-time) | yes | | ```json { "data": [ { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "endpoint_id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "event_id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "event_type": "event type", "seq": 1234, "status": "pending", "attempts": 1, "max_attempts": 12, "next_attempt_at": "2026-10-24T09:30:00Z", "last_status_code": 200, "last_error": null, "delivered_at": "2026-10-24T09:30:00Z", "created_at": "2026-10-24T09:30:00Z" } ], "meta": { "page": 1, "per_page": 25, "total": 1, "total_pages": 1 } } ``` ```bash curl -X GET "https://engine.layercrew.com/v1/webhook-deliveries?page=1&per_page=25" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" ``` ### GET /v1/webhook-deliveries/{delivery_id} **A delivery with every attempt** Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `delivery_id` | path | string (uuid) | yes | | Responses: - `200` — The delivery. - `404` — No such object in this environment (`NOT_FOUND`). Response `200`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | | | `endpoint_id` | string (uuid) | yes | | | `event_id` | string (uuid) | yes | | | `event_type` | string | yes | | | `seq` | integer | yes | | | `status` | DeliveryStatus: `pending`, `in_flight`, `delivered`, `failed`, `skipped` | yes | | | `attempts` | integer | yes | | | `max_attempts` | integer | no | | | `next_attempt_at` | string (date-time) \| null | no | | | `last_status_code` | integer \| null | no | | | `last_error` | string \| null | no | | | `delivered_at` | string (date-time) \| null | no | | | `created_at` | string (date-time) | yes | | | `attempts_log` | array of WebhookAttempt | yes | | | `event` | Event | yes | | ```json { "data": { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "endpoint_id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "event_id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "event_type": "event type", "seq": 1234, "status": "pending", "attempts": 1, "max_attempts": 12, "next_attempt_at": "2026-10-24T09:30:00Z", "last_status_code": 200, "last_error": null, "delivered_at": "2026-10-24T09:30:00Z", "created_at": "2026-10-24T09:30:00Z", "attempts_log": [ { "attempt": 1, "at": "2026-10-24T09:30:00Z", "request_id": "01J9Z8Y7X6W5V4U3T2S1R0Q9P8", "status_code": 200, "duration_ms": 42, "error": "error", "response_snippet": "ok" } ], "event": { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "type": "order.received", "api_version": "2026-10-24", "created_at": "2026-10-24T09:30:00Z", "seq": 1234, "environment": "live", "reason_code": null, "data": { "order": { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "number": "PA-2026-0007", "reference": "SHOP-10231", "environment": "live", "status": "received", "pricing_status": "pending", "items": [ { "id": null, "position": null, "file": null, "quantity": null, "material": null, "colour": null, "nozzle": null, "notes": null, "pricing_status": null, "list_unit_price": null, "discount_pct": null, "unit_price": null, "line_total": null, "estimate": null, "failure_code": null, "failure_message": null } ], "pricing": { "currency": "EUR", "subtotal": "12.40", "discount_total": "12.40", "shipping_total": "12.40", "tax_total": "12.40", "tax_treatment": "standard", "grand_total": "12.40", "final": true }, "payment": { "method": "bank_transfer", "status": "unpaid", "ship_before_payment": false, "amount_due": "12.40", "instructions": { "beneficiary": null, "iban": null, "bank": null, "reference": null }, "paid_at": "2026-10-24T09:30:00Z" }, "delivery": { "name": "Claire Martin", "company": "Atelier Martin", "phone": "+33 6 12 34 56 78", "email": "claire@example.com", "line1": "12 rue de la Paix", "line2": "Bâtiment B", "city": "Lyon", "region": "Auvergne-Rhône-Alpes", "postal_code": "69001", "country_code": "FR" }, "white_label": { "enabled": true, "sender_name": "Studio NS", "card_text": "Thank you for your order — Studio NS", "packaging_preset": "neutral", "no_invoice_in_box": true, "language": "fr" }, "metadata": { "shop_order_id": "10231" }, "notes": "Fit check before the batch, please.", "shipments": [ { "carrier": null, "tracking_number": null, "tracking_url": null, "status": null, "last_tracking_status": null, "shipped_at": null, "delivered_at": null } ], "invoices": [ { "id": null, "number": null, "kind": null, "issued_at": null } ], "rejection": null, "hold": null, "created_at": "2026-10-24T09:30:00Z", "updated_at": "2026-10-24T09:30:00Z", "accepted_at": "2026-10-24T09:30:00Z", "shipped_at": "2026-10-24T09:30:00Z", "delivered_at": "2026-10-24T09:30:00Z", "cancelled_at": "2026-10-24T09:30:00Z", "links": { "self": "self", "events": "events" } }, "quote": { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "file_id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "material": "PLA", "nozzle": "0.4", "quantity": 2, "status": "pending", "unit_price": "12.40", "list_unit_price": "12.40", "discount_pct": "0", "total": "12.40", "currency": "EUR", "tiers": [ { "min_quantity": null, "unit_price": null } ], "estimate": { "weight_grams": 31.2, "time_seconds": 5400, "unit_yield": 1, "printer_model": "Bambu Lab A2L", "nozzle": "0.4", "material": "PLA", "infill_pct": 15, "wall_loops": 3, "support": "auto", "warnings": [], "priced_at": "2026-10-24T09:30:00Z" }, "failure_code": null, "failure_message": null, "created_at": "2026-10-24T09:30:00Z", "priced_at": "2026-10-24T09:30:00Z" }, "file": { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "filename": "bracket-v3.stl", "format": "stl", "status": "pending", "size_bytes": 1048576, "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "rejection_code": null, "facts": { "triangles": 48210, "objects": 1, "bbox_mm": [ 60, 42.5, 18 ], "producer": "Bambu Studio 02.08.02.61", "sanitised": false }, "created_at": "2026-10-24T09:30:00Z", "ready_at": "2026-10-24T09:30:00Z", "purge_after": "2026-10-24T09:30:00Z" } } } } } ``` ```bash curl -X GET "https://engine.layercrew.com/v1/webhook-deliveries/4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" ``` ### POST /v1/webhook-deliveries/{delivery_id}/redeliver **Send a delivery again** Allowed for delivered, failed and skipped deliveries; a delivery in flight answers 409. The exact original bytes are re-sent with a fresh signature and timestamp. Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `delivery_id` | path | string (uuid) | yes | | Responses: - `202` — Queued. - `404` — No such object in this environment (`NOT_FOUND`). - `409` — The request conflicts with the current state; `error.code` says how (`REFERENCE_TAKEN`, `FILE_IN_USE`, `FILE_NOT_READY`, `CANNOT_CANCEL`, `ENDPOINT_LIMIT`, `DELIVERY_IN_FLIGHT`, `IDEMPOTENCY_KEY_REUSED`, `IDEMPOTENCY_IN_PROGRESS`, `TERMS_REACCEPTANCE_REQUIRED`). Response `202`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | | | `endpoint_id` | string (uuid) | yes | | | `event_id` | string (uuid) | yes | | | `event_type` | string | yes | | | `seq` | integer | yes | | | `status` | DeliveryStatus: `pending`, `in_flight`, `delivered`, `failed`, `skipped` | yes | | | `attempts` | integer | yes | | | `max_attempts` | integer | no | | | `next_attempt_at` | string (date-time) \| null | no | | | `last_status_code` | integer \| null | no | | | `last_error` | string \| null | no | | | `delivered_at` | string (date-time) \| null | no | | | `created_at` | string (date-time) | yes | | ```json { "data": { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "endpoint_id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "event_id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "event_type": "event type", "seq": 1234, "status": "pending", "attempts": 1, "max_attempts": 12, "next_attempt_at": "2026-10-24T09:30:00Z", "last_status_code": 200, "last_error": null, "delivered_at": "2026-10-24T09:30:00Z", "created_at": "2026-10-24T09:30:00Z" } } ``` ```bash curl -X POST "https://engine.layercrew.com/v1/webhook-deliveries/4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d/redeliver" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" ``` ## Requests Your own request log, for debugging. ### GET /v1/requests **Your request log** Every call made with your keys in the last 90 days: when, which route, the status, the duration and the request id to quote to support. Bodies are never stored. Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `page` | query | integer | no | | | `per_page` | query | integer | no | | | `from` | query | string (date-time) | no | | | `to` | query | string (date-time) | no | | | `status` | query | integer | no | | | `route` | query | string | no | | Responses: - `200` — Requests, newest first. Response `200` (each item of `data`): | Field | Type | Required | Description | | --- | --- | --- | --- | | `request_id` | string | yes | | | `client_request_id` | string \| null | no | | | `key_id` | string | no | | | `method` | string | yes | | | `route` | string | yes | | | `path` | string | no | | | `status` | integer | yes | | | `error_code` | string \| null | no | | | `duration_ms` | integer | yes | | | `resource_ref` | string \| null | no | | | `created_at` | string (date-time) | yes | | ```json { "data": [ { "request_id": "01J9Z8Y7X6W5V4U3T2S1R0Q9P8", "client_request_id": null, "key_id": "k7m2p9q4r1s8t3u6", "method": "method", "route": "POST /v1/orders", "path": "/v1/orders", "status": 1, "error_code": null, "duration_ms": 42, "resource_ref": "order:5d0c…", "created_at": "2026-10-24T09:30:00Z" } ], "meta": { "page": 1, "per_page": 25, "total": 1, "total_pages": 1 } } ``` ```bash curl -X GET "https://engine.layercrew.com/v1/requests?page=1&per_page=25" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" ``` ### GET /v1/requests/{request_id} **One request by its id** Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `request_id` | path | string | yes | | Responses: - `200` — The request. - `404` — No such object in this environment (`NOT_FOUND`). Response `200`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `request_id` | string | yes | | | `client_request_id` | string \| null | no | | | `key_id` | string | no | | | `method` | string | yes | | | `route` | string | yes | | | `path` | string | no | | | `status` | integer | yes | | | `error_code` | string \| null | no | | | `duration_ms` | integer | yes | | | `resource_ref` | string \| null | no | | | `created_at` | string (date-time) | yes | | ```json { "data": { "request_id": "01J9Z8Y7X6W5V4U3T2S1R0Q9P8", "client_request_id": null, "key_id": "k7m2p9q4r1s8t3u6", "method": "method", "route": "POST /v1/orders", "path": "/v1/orders", "status": 1, "error_code": null, "duration_ms": 42, "resource_ref": "order:5d0c…", "created_at": "2026-10-24T09:30:00Z" } } ``` ```bash curl -X GET "https://engine.layercrew.com/v1/requests/01J9Z8Y7X6W5V4U3T2S1R0Q9P8" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" ``` ## Test mode Available with test keys only. ### POST /v1/test/orders/{order_id}/advance **Move a test order to its next state** Test keys only. Emits the same events and webhooks a live order would. Allowed moves: `pending_review` → `accepted` or `rejected`; `accepted` → `in_production` → `ready_to_ship` → `shipped` → `delivered`; any non-terminal state ↔ `on_hold`. Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `order_id` | path | string | yes | The order id (UUID) or number (`PA-YYYY-NNNN`). | Request body: | Field | Type | Required | Description | | --- | --- | --- | --- | | `to` | OrderStatus: `received`, `pending_review`, `accepting`, `accepted`, `in_production`, `ready_to_ship`, `shipped`, `delivered`, `on_hold`, `cancelled`, `rejected` | yes | received: pricing in progress · pending_review: priced, a LayerCrew engineer is looking at it · accepted: in the production queue · in_production · ready_to_ship: passed quality check and packed · shipped: handed to the courier · delivered (final) · on_hold: we need something, see `hold` · cancelled · rejected, see `rejection`. | | `reason` | string | no | (up to 500 characters) | ```json { "to": "received", "reason": "Cancelled by the customer before production." } ``` Responses: - `200` — Advanced. - `403` — Use a test key for this route (`TEST_ONLY`). - `404` — No such object in this environment (`NOT_FOUND`). - `409` — The request conflicts with the current state; `error.code` says how (`REFERENCE_TAKEN`, `FILE_IN_USE`, `FILE_NOT_READY`, `CANNOT_CANCEL`, `ENDPOINT_LIMIT`, `DELIVERY_IN_FLIGHT`, `IDEMPOTENCY_KEY_REUSED`, `IDEMPOTENCY_IN_PROGRESS`, `TERMS_REACCEPTANCE_REQUIRED`). Response `200`: | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | | | `number` | string | yes | | | `reference` | string | yes | | | `environment` | Environment: `live`, `test` | yes | | | `status` | OrderStatus: `received`, `pending_review`, `accepting`, `accepted`, `in_production`, `ready_to_ship`, `shipped`, `delivered`, `on_hold`, `cancelled`, `rejected` | yes | received: pricing in progress · pending_review: priced, a LayerCrew engineer is looking at it · accepted: in the production queue · in_production · ready_to_ship: passed quality check and packed · shipped: handed to the courier · delivered (final) · on_hold: we need something, see `hold` · cancelled · rejected, see `rejection`. | | `pricing_status` | enum: `pending`, `priced`, `failed` | yes | | | `items` | array of OrderItem | yes | | | `pricing` | Pricing | yes | | | `payment` | Payment | no | | | `delivery` | Delivery | yes | | | `white_label` | WhiteLabel | yes | White label is on by default: neutral packaging, no LayerCrew marks on parts, packaging or inserts, no invoice in the box. The courier label names LayerCrew as the shipping account (a courier requirement) and shows your sender name as the contact line; packaging and inserts carry your branding settings. | | `metadata` | object | yes | | | `notes` | string | no | | | `shipments` | array of Shipment | yes | | | `invoices` | array of Invoice | yes | | | `rejection` | Rejection \| null | no | | | `hold` | Hold \| null | no | | | `created_at` | string (date-time) | yes | | | `updated_at` | string (date-time) | yes | | | `accepted_at` | string (date-time) \| null | no | | | `shipped_at` | string (date-time) \| null | no | | | `delivered_at` | string (date-time) \| null | no | | | `cancelled_at` | string (date-time) \| null | no | | | `links` | object | yes | | ```json { "data": { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "number": "PA-2026-0007", "reference": "SHOP-10231", "environment": "live", "status": "received", "pricing_status": "pending", "items": [ { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "position": 1, "file": { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "filename": "bracket-v3.stl", "format": "stl" }, "quantity": 2, "material": "PLA", "colour": "black", "nozzle": "0.4", "notes": "Fit check before the batch, please.", "pricing_status": "pending", "list_unit_price": "12.40", "discount_pct": "0", "unit_price": "12.40", "line_total": "12.40", "estimate": { "weight_grams": 31.2, "time_seconds": 5400, "unit_yield": 1, "printer_model": "Bambu Lab A2L", "nozzle": "0.4", "material": "PLA", "infill_pct": 15, "wall_loops": 3, "support": "auto", "warnings": [ "warnings" ], "priced_at": "2026-10-24T09:30:00Z" }, "failure_code": null, "failure_message": null } ], "pricing": { "currency": "EUR", "subtotal": "12.40", "discount_total": "12.40", "shipping_total": "12.40", "tax_total": "12.40", "tax_treatment": "standard", "grand_total": "12.40", "final": true }, "payment": { "method": "bank_transfer", "status": "unpaid", "ship_before_payment": false, "amount_due": "12.40", "instructions": { "beneficiary": "LAYER CREW SRL", "iban": "RO49AAAA1B31007593840000", "bank": "Example Bank", "reference": "SHOP-10231" }, "paid_at": "2026-10-24T09:30:00Z" }, "delivery": { "name": "Claire Martin", "company": "Atelier Martin", "phone": "+33 6 12 34 56 78", "email": "claire@example.com", "line1": "12 rue de la Paix", "line2": "Bâtiment B", "city": "Lyon", "region": "Auvergne-Rhône-Alpes", "postal_code": "69001", "country_code": "FR" }, "white_label": { "enabled": true, "sender_name": "Studio NS", "card_text": "Thank you for your order — Studio NS", "packaging_preset": "neutral", "no_invoice_in_box": true, "language": "fr" }, "metadata": { "shop_order_id": "10231" }, "notes": "Fit check before the batch, please.", "shipments": [ { "carrier": "FAN Courier", "tracking_number": "2057100012345", "tracking_url": "https://www.fancourier.ro/awb-tracking/?awb=2057100012345", "status": "shipped", "last_tracking_status": "last tracking status", "shipped_at": "2026-10-24T09:30:00Z", "delivered_at": "2026-10-24T09:30:00Z" } ], "invoices": [ { "id": "4f1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "number": "number", "kind": "fiscal", "issued_at": "2026-10-24T09:30:00Z" } ], "rejection": null, "hold": null, "created_at": "2026-10-24T09:30:00Z", "updated_at": "2026-10-24T09:30:00Z", "accepted_at": "2026-10-24T09:30:00Z", "shipped_at": "2026-10-24T09:30:00Z", "delivered_at": "2026-10-24T09:30:00Z", "cancelled_at": "2026-10-24T09:30:00Z", "links": { "self": "self", "events": "events" } } } ``` ```bash curl -X POST "https://engine.layercrew.com/v1/test/orders/PA-2026-0007/advance" \ -H "Authorization: Bearer $LAYERCREW_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "received", "reason": "Cancelled by the customer before production." }' ``` --- # Changelog > Dated changes to the LayerCrew Partner API and its documentation. The API version is a date carried in every event; a breaking change is announced here first. Updated 2026-10-24 · HTML: https://layercrew.com/developers/changelog · Markdown: https://layercrew.com/developers/changelog.md · OpenAPI: https://layercrew.com/openapi.json Dated changes to the Partner API and these docs. The API version is a date, carried in every event's `api_version` and in the OpenAPI document's `info.version`. A breaking change gets a new version and a notice here before it ships; additive changes — a new field, a new event type, a new endpoint — appear under the current version. ## 2026-10-24 — first release - Partner API version `2026-10-24`: account, materials, files, quotes, orders, events, webhooks, requests and test mode. - Bearer API keys per environment (`lc_test_`, `lc_live_`), issued and revoked from the dashboard; revocation within 30 seconds. - Signed two-step file upload for STL, OBJ and 3MF with verification and sanitising on `complete`. - Idempotent order creation with `Idempotency-Key`, 24-hour replay and `Idempotent-Replayed: true`. - Standard Webhooks signatures, per-endpoint ordering, a 12-attempt retry ladder on the default plan and a 24-hour secret rotation. - A gap-free per-partner event feed as the authoritative order of everything. - Documentation on layercrew.com with an OpenAPI 3.1 document at `/openapi.json`, `/llms.txt`, `/llms-full.txt` and a Markdown twin of every page.