Einbau-Anleitung · Next.js-Shop
In sechs Schritten vom Shop in die Fertigung.
Diese Anleitung baut die Schnittstelle in die eigene Website ein, am Beispiel sla-bulk.de. Jede Datei ist vollständig und lässt sich direkt kopieren. Die Referenz aller Felder steht in der API-Dokumentation.
Schritt 1
Zugang anlegen
In der Leitstelle unter Stammdaten die Marke wählen, z. B. SLA-BULK.de. Dort erst einen Testschlüssel erzeugen, dann die Webhook-URL des Shops eintragen und speichern. Dabei entsteht das Signatur-Secret.
Schlüssel und Secret erscheinen genau einmal, schon als fertige Zeile für die .env. Gespeichert wird nur ein Hash. Geht ein Schlüssel verloren, erzeugen Sie einfach einen neuen.
# .env.local im Shop (z. B. sla-bulk.de)
BILTECH_API_URL=https://biltech3d.de
BILTECH_API_KEY=bt_test_… # erst Test, zum Livegang bt_live_…
BILTECH_WEBHOOK_SECRET=whsec_…Schritt 2
Client-Datei im Shop
Eine Datei kapselt alle Aufrufe. Sie läuft nur auf dem Server (server-only), der Schlüssel landet also nie im Browser.
Fehler kommen als BiltechError mit HTTP-Status und deutscher Meldung zurück. Die Meldung können Sie Ihrem Kunden direkt anzeigen. Shop-Bestellnummern dürfen Buchstaben, Ziffern, . _ - enthalten.
import "server-only"
const BASE = `${process.env.BILTECH_API_URL}/api/v1`
export class BiltechError extends Error {
constructor(public status: number, message: string, public details?: unknown) {
super(message)
}
}
async function api<T>(path: string, body?: unknown): Promise<T> {
const res = await fetch(BASE + path, {
method: body === undefined ? "GET" : "POST",
headers: {
Authorization: `Bearer ${process.env.BILTECH_API_KEY}`,
...(body === undefined ? {} : { "Content-Type": "application/json" }),
},
body: body === undefined ? undefined : JSON.stringify(body),
cache: "no-store",
})
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new BiltechError(res.status, data.error ?? `HTTP ${res.status}`, data.details)
return data as T
}
export type ProductType = "industrial-sla" | "full-color-mjp"
export type FileStatus = "pending" | "ready" | "invalid"
export type BiltechFile = { id: string; fileName: string; status: FileStatus; error: string | null }
export type Upload = { pathname: string; clientToken: string; access: "private"; expiresAt: string }
export type Quote = {
id: string
test: boolean
expiresAt: string
leadTimeWorkdays: number
retail: { positionsNetCents: number }
}
export type Customer = {
firstName: string
lastName: string
email: string
company?: string
street: string
zip: string
city: string
country: string // ISO, z. B. "DE"
}
export const biltech = {
catalog: () => api<{ test: boolean; markupPct: number }>("/catalog"),
registerFile: (fileName: string) =>
api<{ file: BiltechFile; upload: Upload }>("/files", { fileName }),
getFile: (fileId: string) => api<{ file: BiltechFile }>(`/files/${fileId}`),
// targetHeightMm: Pflicht für GLB/OBJ (z. B. 100 = 10 cm hoch), X/Y skalieren mit
quote: (productType: ProductType, items: { fileId: string; quantity: number; targetHeightMm?: number }[]) =>
api<{ quote: Quote }>("/quotes", { productType, items }),
placeOrder: (input: {
quoteId: string
externalOrderId: string
customer: Customer
dueDate?: string
note?: string
}) => api<{ duplicate: boolean }>("/orders", input),
getOrder: (externalOrderId: string) =>
api<{
cancellable: boolean
positions: {
status: string
statusLabel: string
shipments: { carrier: string; trackingNumber: string }[]
}[]
}>(
`/orders/${externalOrderId}`,
),
cancelOrder: (externalOrderId: string) =>
api<{ cancelled: boolean }>(`/orders/${externalOrderId}/cancel`, {}),
}Schritt 3
Upload direkt aus dem Browser
Ihr Server meldet die Datei an und reicht nur das Einmal-Token an den Browser weiter. Der Browser lädt die Datei (STEP, IGES, STL, GLB oder OBJ bis 200 MB) direkt in den privaten Speicher von BilTech3D. Große Dateien laufen also nie durch Ihren Server.
KI-Modelle (GLB/OBJ) direkt weitergeben: Liefert der Generator die Datei serverseitig, laden Sie sie mit demselben put() aus Ihrem Server hoch. Lassen Sie den Kunden die Höhe wählen (z. B. 100 mm) und übergeben Sie sie als targetHeightMm an priceCart. Die anderen Maße skalieren gleichmäßig mit, im Angebot stehen die fertigen Maße.
Danach misst BilTech3D die Geometrie. Der Browser fragt alle zwei Sekunden den Status ab, bis ready oder invalid kommt. Im Shop dafür @vercel/blob installieren.
import { biltech, BiltechError } from "@/lib/biltech"
export async function POST(request: Request) {
const { fileName } = await request.json()
try {
return Response.json(await biltech.registerFile(String(fileName)))
} catch (error) {
if (error instanceof BiltechError) {
return Response.json({ error: error.message }, { status: error.status })
}
throw error
}
}import { biltech } from "@/lib/biltech"
export async function GET(_: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
return Response.json(await biltech.getFile(id))
}"use client"
import { put } from "@vercel/blob/client"
export async function uploadCad(file: File) {
const res = await fetch("/api/biltech/datei", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fileName: file.name }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error)
await put(data.upload.pathname, file, {
access: "private",
token: data.upload.clientToken,
multipart: file.size > 50_000_000,
})
for (let attempt = 0; attempt < 60; attempt++) {
const { file: checked } = await fetch(`/api/biltech/datei/${data.file.id}`).then((r) => r.json())
if (checked.status === "ready") return checked
if (checked.status === "invalid") throw new Error(checked.error ?? "Datei nicht druckbar")
await new Promise((r) => setTimeout(r, 2000))
}
throw new Error("Die Prüfung dauert ungewöhnlich lange.")
}Schritt 4
Preis zeigen, nach Zahlung fertigen
priceCart holt das verbindliche Angebot. Zeigen Sie retail.positionsNetCents als Endkundenpreis und speichern Sie die quoteId mit dem Warenkorb. Das Angebot gilt 14 Tage.
Erst wenn die Zahlung bestätigt ist, rufen Sie sendToProduction auf. Der Aufruf ist sicher wiederholbar: Dieselbe Bestellnummer legt nie einen zweiten Auftrag an. Kommt der Zahlungs-Webhook also doppelt, ist das kein Problem.
"use server"
import { biltech } from "@/lib/biltech"
export async function priceCart(items: { fileId: string; quantity: number; targetHeightMm?: number }[]) {
const { quote } = await biltech.quote("industrial-sla", items)
// quote.id mit dem Warenkorb speichern, Preis kommt nie aus dem Browser
return {
quoteId: quote.id,
netCents: quote.retail.positionsNetCents,
expiresAt: quote.expiresAt,
leadTimeWorkdays: quote.leadTimeWorkdays,
}
}
// Aufrufen, sobald die Zahlung bestätigt ist (z. B. im Zahlungs-Webhook)
export async function sendToProduction(order: {
number: string
quoteId: string
customer: Parameters<typeof biltech.placeOrder>[0]["customer"]
}) {
await biltech.placeOrder({
quoteId: order.quoteId,
externalOrderId: order.number,
customer: order.customer,
})
}Schritt 5
Status empfangen und Kunden informieren
Jede Statusänderung kommt signiert an diese Route. Die Kunden-Mails verschickt Ihr Shop unter seiner eigenen Marke. BilTech3D schreibt Ihren Kunden selbst nie.
order.status_changed → printingMail: „Ihr Teil wird gerade gedruckt.“order.shippedVersandmail mit carrier + trackingNumber.order.rejectedKunden informieren und Zahlung erstatten.order.cancelledBestellung im Shop als storniert markieren.pingNichts tun, nur 200 antworten.
Lokal ohne öffentliche Adresse? Dann erreicht Sie kein Webhook. Fragen Sie in dem Fall biltech.getOrder(nummer) ab, etwa beim Öffnen der Bestellansicht. Das liefert denselben Stand.
import { createHmac, timingSafeEqual } from "node:crypto"
function isValidSignature(rawBody: string, header: string) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)))
const timestamp = Number(parts.t)
if (!parts.v1 || !Number.isFinite(timestamp)) return false
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false
const expected = createHmac("sha256", process.env.BILTECH_WEBHOOK_SECRET!)
.update(`${parts.t}.${rawBody}`)
.digest("hex")
return parts.v1.length === expected.length && timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected))
}
export async function POST(request: Request) {
const rawBody = await request.text()
if (!isValidSignature(rawBody, request.headers.get("x-biltech-signature") ?? "")) {
return new Response("invalid signature", { status: 401 })
}
const deliveryId = request.headers.get("x-biltech-delivery")
const event = request.headers.get("x-biltech-event")
const payload = JSON.parse(rawBody)
// 1. deliveryId schon verarbeitet? Dann sofort 200 zurückgeben.
// 2. Bestellung payload.externalOrderId, Position payload.position aktualisieren.
// 3. Eigene Kunden-Mail unter der Shop-Marke senden (siehe Tabelle).
console.log(event, deliveryId, payload.externalOrderId, payload.status)
return new Response("ok")
}Schritt 6
Livegang
- 1Testauftrag mit bt_test_… angelegt, im Shop als Bestellung sichtbar.
- 2Test-Webhook aus den Stammdaten kommt an und besteht die Signaturprüfung.
- 3Doppelter Aufruf von placeOrder legt keinen zweiten Auftrag an (duplicate: true).
- 4BILTECH_API_KEY auf bt_live_… getauscht und neu deployt.
- 5Webhook-URL in den Stammdaten zeigt auf die Live-Domain des Shops.
Testaufträge erscheinen nie auf dem Produktionsboard und werden nie berechnet. Live-Aufträge laufen wie jeder andere Auftrag in die Planung und werden über die monatliche Sammelrechnung abgerechnet.



