Zwei Produkte auf derselben öffentlichen API, für zwei verschiedene Kunden.

SDKWidgets (Stufe A)Widgets (Stufe B)
Nutzerder JS-Entwickler des Tenantsein Tenant ohne Entwicklerein Tenant ohne Entwickler
Wer schreibt das HTMLder Tenantwirwir
Einbauimport oder ein Script-Tagein Script-Tag + ein Elementein Script-Tag + ein Element
Grenzekeine, es ist ihre Seiteoffener Shadow Rootiframe auf unsere Origin
Hält eine Sitzungnur wenn sie es bauennieja

Alles kommt in einer Datei: packages/sdk baut ein ESM-Modul und ein IIFE-Bundle, und das IIFE enthält beide Widget-Stufen. Die Seite eines Tenants lädt genau ein Script.

Warum zwei Stufen. Shadow DOM verhindert, dass das CSS der Host-Seite unser Layout zerstört, und genau das braucht ein Produktraster. Sobald eine Kundensitzung im Spiel ist, ist es das falsche Werkzeug: Ein Shadow Root ist nur einen JS-Kontext von der Seite entfernt, sodass ein XSS beim Tenant die Rechnungen unseres Kunden in die Hand bekäme. Alles, was eine Sitzung, eine Rechnung, einen Bezahlablauf oder Hosting-Zugangsdaten hält, gehört in einen iframe auf unsere Origin, eine echte Origin-Grenze. Dieselbe Trennung machen Stripe und Intercom.

Installation

<script src="https://cdn.teculiar.net/embed/latest/teculiar.js"
        data-key="pk_live_…" data-base-url="https://shop.acmehost.com"></script>

<teculiar-domain-search></teculiar-domain-search>
<teculiar-product-grid category="hosting"></teculiar-product-grid>

Ein Script-Host für jeden Shop. cdn.teculiar.net liefert die SDK-Dateien und sonst nichts: keine API, keine Cookies, für jeden Shop in jedem Tarif dasselbe (so wie jede Website Stripe.js von js.stripe.com lädt). data-base-url sagt, für welchen Shop die Seite verkauft: jede Adresse des Shops, die /api beantwortet, etwa sein Shop-Frontend, sein Kundenportal oder <name>.teculiar.net. Werden die Widgets vom Script-Host ohne data-base-url geladen, zeigen sie, was fehlt, und rufen nichts auf.

Ein Shop mit eigener api.<domain> (Tarife White Label und Mehrere Marken), hinzugefügt über den Domain-Assistenten (Admin → Domains) im selben Ablauf wie jeder andere Host (ausstehend → DNS-TXT → verifizieren), kann die Dateien stattdessen von dort laden und data-base-url weglassen: https://api.acmehost.com/embed/latest/teculiar.js. Das ist die White-Label-Variante; die Dateien sind dieselben.

Ein Script-Tag, Widgets laden bei Bedarf. teculiar.js (etwa 20 KB gzip) enthält den Client, den Warenkorb, den Schalter für Sprache und Währung und das Preisschild. Jedes andere Widget liegt in einer Gruppendatei daneben: widgets-catalog.js (Domainsuche, Produktraster, Preistabelle), widgets-content.js (Blogliste, Wissensdatenbank-Suche, Kontaktformular) und widgets-embed.js (Konto, Login, Checkout). Der Kern findet die teculiar-*-Tags auf der Seite (auch später hinzugefügte) und lädt eine Gruppe einmal, wenn zum ersten Mal eines ihrer Widgets erscheint, aus demselben Ordner wie der Kern, mit einem integrity-Hash, den er mitbringt, und crossorigin="anonymous". Eine Seite nur mit dem Schalter und Preisen lädt keine Gruppendatei. Am Einbau-Snippet ändert sich nichts; wenn du Scripts per CSP einschränkst, erlaube den Script-Host einmal, und es deckt alle Dateien ab. Eine festgelegte Version (/embed/v<version>/) legt auch ihre Gruppendateien fest.

pk_… bekommst du unter Admin → Einstellungen → API-Schlüssel. Es ist eine Kennung, kein Geheimnis (es steht absichtlich im Quelltext der Seite), die Kontrolle ist also die Origin-Allowlist des Schlüssels. Trage dort die Website des Tenants ein, sonst ist jeder Aufruf ein 403.

data-base-url benennt den Shop (ohne sie gilt der Ort, von dem das Script geladen wurde, was nur dann ein Shop ist, wenn es seine eigene api.<domain> ist), und data-locale überschreibt die Sprache (Standard ist <html lang>).

Die Widgets

ElementLiestScope
<teculiar-domain-search>GET /domains/searchdomains:search
<teculiar-product-grid category="…">GET /storefront/productscatalog:read
<teculiar-pricing-table>GET /storefront/productscatalog:read
<teculiar-blog-list limit="5" tag="…" base-href="/blog">GET /cms/postscontent:read
<teculiar-kb-search base-href="/knowledgebase">GET /knowledgebase/suggestcontent:read
<teculiar-contact-form>POST /storefront/inquiriesinquiries:write
<teculiar-preferences languages="en,de">GET /storefront/i18nkeiner
<teculiar-price amount="8">GET /storefront/i18n, GET /storefront/products (mit product)catalog:read (mit product)

Produktraster und Preistabelle zeigen Namen und Beschreibungen in der Sprache der Seite (den gespeicherten Namen, wenn es keine Übersetzung gibt), jeden Preis mit seinem Zeitraum („/mo“, „/yr“, „one-time“) und die Einrichtungsgebühr des angezeigten Preises. Verkauft ein Produkt sowohl monatlich als auch jährlich, erscheint über der Liste ein Schalter Monatlich/Jährlich (Part cycle-switch); cycle="YEAR_1" legt den Start fest. teculiar:product-select trägt productId, slug, productPriceId und billingCycle.

Gemeinsame Attribute: locale, heading, currency (legt die Preise des Widgets auf eine gelistete Währung fest; ohne sie folgen sie der Wahl des Besuchers und ändern sich sofort, wenn sie sich ändert, ohne neue Anfrage). Jedes Widget sendet teculiar:*-DOM-Ereignisse (domain-search, cart-add, product-select, catalog-loaded, inquiry-sent), die aufsteigen und die Shadow-Grenze überqueren, sodass die Host-Seite sie abgreifen kann.

Domainvorschläge sind standardmäßig aus. Jede vorgeschlagene TLD kostet eine ausgehende RDAP-Abfrage von der IP-Adresse von Teculiar, und Registries sperren IPs, die sie bombardieren. Mit suggestions="true" schaltest du sie ein.

Stufe B: der Kontobereich (Phase 9.2.11)

<teculiar-account view="invoices"></teculiar-account>
<teculiar-login></teculiar-login>
ElementWas es istAttribute
<teculiar-account>Das Portal des angemeldeten Kunden, eingebettetview, locale, height, data-teculiar-*
<teculiar-login>Nur die Anmeldung, für eine Kopfzeile oder Seitenleistelocale, data-teculiar-*

view ist einer von overview (Standard), services, invoices, tickets, domains, billing, hosting, account. Das Element fragt mit GET /storefront/embed, wo die Einbettung des Tenants liegt (es rät nie einen Host), und bettet <client-host>/embed/<view> ein.

Im Frame steckt das Kundenportal selbst, ohne Rahmen dargestellt: dieselben Komponenten, die /client ausliefern, mit unterdrückter PageShell und jedem auf /embed/… umgeschriebenen /client/…-Link. Eine Funktion, die dem Portal hinzukommt, ist also am selben Tag einbettbar und navigierbar, ohne eine zweite Implementierung abgleichen zu müssen.

Ereignisse, als DOM-Ereignisse am Element: teculiar:ready, teculiar:resize, teculiar:authenticated, teculiar:signed-out, teculiar:navigate, teculiar:error. Das Element bietet außerdem show(view), um die Ansicht zu wechseln.

Drei Dinge zum Frame, die du vor dem Einbau wissen solltest:

  • Der Tenant muss deine Website freigeben. Admin → Einstellungen → Integrationen → Websites, die das Portal einbetten dürfen. Bis dahin weigert sich der Browser, den Frame darzustellen (frame-ancestors 'none'): Er scheitert geschlossen, denn eine einbettbare Zahlungsoberfläche ist ein Ziel für Clickjacking.
  • Er bestimmt seine Größe selbst. Der Frame misst seinen eigenen Inhalt und sagt es der Seite; eine übergeordnete Seite kann ein Dokument von einer anderen Origin nicht messen. Du musst nichts einstellen.
  • Manche Browser blockieren Speicher von Drittanbietern. Wo sie das tun, kann die Einbettung gar keine Sitzung halten; sie sagt das und verweist auf das vollständige Portal, statt ein Anmeldeformular zu zeigen, das still scheitert.

Der Frame akzeptiert keine Anweisung, die ändert, was angezeigt wird oder wer angemeldet ist: kein Styling, keine Tokens, keine URLs. Die einzige Nachricht, der er folgt, ist „zeige eine dieser acht Ansichten“, abgeglichen mit einer Whitelist. Aus demselben Grund wird das Theme serverseitig aus den gespeicherten Einstellungen des Tenants aufgelöst.

Der Checkout: <teculiar-checkout>

<teculiar-checkout product="starter" cycle="YEAR_1" config-subdomain="acme"></teculiar-checkout>

Das eigene Bestellformular des Shops für ein Produkt, eingebettet unter <client-host>/embed/checkout. Ein Produkt pro Checkout, mit seinen Zusatzoptionen, dem Abrechnungszeitraum, den Bestellfeldern und dem Domainangebot. Er bestimmt seine Größe selbst und nutzt die Sprache der Seite.

AttributBedeutung
productDer Slug des Produkts (Pflicht)
cycleAbrechnungszeitraum, mit dem gestartet wird (MONTHLY, YEAR_1, …)
domain, domain-actionDen Domainschritt mit diesem Namen starten (register oder transfer)
addonsaddOnId:quantity,addOnId:quantity, mit denen gestartet wird
config-<key>Eines der Bestellfelder des Produkts vorbelegen, zum Beispiel config-subdomain="acme". Es werden nur Schlüssel verwendet, die das Produkt hat; adminEmail wird nie von der Seite übernommen
locale, currencySprache und Anzeigewährung nur für diesen Frame; eine Währung, die der Shop nicht führt, wird ignoriert
return-url, cancel-urlWohin der Käufer nach dem Bezahlen oder Abbrechen geht; beide sind standardmäßig die aktuelle Seite
data-teculiar-*Die Theme-Felder des Konto-Widgets

Ereignisse am Element: teculiar:ready, teculiar:resize, teculiar:checkout-step (detail.step ist details, paying oder done), teculiar:checkout-created und teculiar:checkout-complete.

  • **teculiar:checkout-created** (orderId, statusToken) wird ausgelöst, sobald die Bestellung existiert, vor jedem Zahlungsschritt. Heb es auf (zum Beispiel in sessionStorage): Eine Zahlung mit Weiterleitung verlässt deine Seite, bevor checkout-complete ausgelöst werden könnte, und deine Rückkehrseite liest damit GET /orders/<orderId>/status?token=….
  • **teculiar:checkout-complete** (orderId, status) wird ausgelöst, wenn im Frame nichts mehr passiert: paid, pending (eine Überweisung oder SEPA-Lastschrift, die auf das Geld wartet) oder provisioning (eine kostenlose Bestellung wird eingerichtet). Der Frame schickt den Käufer nicht selbst ins Portal; deine Seite entscheidet, wohin er geht.

Zahlungsarten werden für die Währung des Checkouts und das Land des Käufers aufgelistet (SEPA nur für EUR). Karte, PayPal und die anderen Methoden mit Weiterleitung öffnen die Seite des Anbieters über das Element; SEPA, Überweisung und das Guthaben bleiben im Frame. Jede Übermittlung trägt einen Idempotency-Key, sodass ein Doppelklick oder eine Wiederholung nach einer verlorenen Antwort nie doppelt bestellt. return-url und cancel-url gehen an die Rückkehr-Behandlung des Zahlungsanbieters und werden gegen die verifizierten Hosts deines Shops geprüft.

Ein kostenloser Tarif (die Bestellung ergibt nichts) hat keinen Zahlungsschritt: Der Button sagt Konto erstellen, die Bestellung wird sofort abgeschlossen, und checkout-complete kommt mit provisioning.

Sprache und Währung. Jedes eingebettete Element (<teculiar-account>, <teculiar-login>, <teculiar-checkout>) zeigt die Sprache der Seite und die Währung des Besuchers; ein Attribut locale oder currency am Element gewinnt. Eine Währung, die der Shop nicht führt, wird ignoriert, und keine von beiden wird je gespeichert. Eine ausgestellte Rechnung öffnet sich immer in der Sprache, in der sie ausgestellt wurde, egal welche Sprache die Seite hat.

Eine Zahlungsseite kann nicht eingebettet werden, und der Frame kann sich nicht selbst verlassen, daher bittet der Frame die Seite, sie zu öffnen (teculiar:navigate-top); das Element öffnet sie nur, wenn die Adresse https: ist und die Nachricht von seinem eigenen Frame kam. Der Tenant muss deine Website unter Websites, die das Portal einbetten dürfen freigeben, genau wie beim Konto-Widget.

Vom Katalog zum Checkout

Gib Produktraster, Preistabelle oder Domainsuche ein Attribut checkout: die Adresse deiner eigenen Checkout-Seite.

<teculiar-pricing-table checkout="/en/checkout/"></teculiar-pricing-table>
<teculiar-domain-search checkout="/en/checkout/"></teculiar-domain-search>

„Auswählen“ öffnet dann <checkout>?product=<slug>&cycle=<billingCycle> (nachdem wie bisher teculiar:product-select gesendet wurde); die Domainsuche zeigt Bestellen statt *In den Warenkorb* und öffnet <checkout>?product=<domain product slug>&domain=<name>&domainAction=register. Ohne checkout verhalten sich die Widgets wie bisher: Raster und Tabelle senden nur das Ereignis, die Domainsuche legt in den Warenkorb des SDK.

Auf der Checkout-Seite füllt eine Zeile das Element <teculiar-checkout> aus der Adresse:

<teculiar-checkout></teculiar-checkout>
<script>Teculiar.checkoutFromUrl();</script>

Sie liest product, cycle, domain, domainAction, addons und jedes config.<key> aus der Adresse der Seite und setzt product, cycle, domain, domain-action, addons und config-<key> (es wird ein einziger Frame gebaut, obwohl sich mehrere Attribute ändern). Sie nimmt ein Element oder einen Selektor und gibt false zurück, wenn die Adresse kein Produkt nennt. config.adminEmail wird nie übernommen.

Sprache der Wissensdatenbank. Artikel haben keine eigene Sprache, daher zeigt <teculiar-kb-search> jeden Artikel in der Sprache, in der er geschrieben wurde, egal welche Sprache die Seite hat. Mehrsprachige Artikel warten auf den Inhaber (siehe i18n-currency.md).

Wenn dein Page Builder unbekannte Tags entfernt

Nimm stattdessen ein div: Das Script rüstet es an Ort und Stelle auf, und data-*-Attribute werden übernommen:

<div data-teculiar-widget="product-grid" data-category="hosting"></div>

Theming

Jedes Widget wird in einem offenen Shadow Root dargestellt, sodass das CSS der Host-Seite nicht hineinreicht und unseres nicht hinauslecken kann. Ein gekauftes WordPress-Theme mit * { box-sizing: content-box } oder button { text-transform: uppercase !important } hat keinen Einfluss auf das Widget. Diese Kollision ist der häufigste Grund, warum ein eingebettetes Widget auf der Seite eines anderen kaputt aussieht.

Zwei Dinge überqueren die Grenze mit Absicht, denn sie sind die Theming-API.

CSS Custom Properties, vererbt, sodass es genügt, sie irgendwo oberhalb des Widgets zu setzen:

:root {
  --teculiar-accent: #ff6600;
  --teculiar-on-accent: #ffffff;
  --teculiar-radius: 4px;
  --teculiar-font: "Inter", sans-serif;
  --teculiar-bg: #fff;
  --teculiar-surface: #f7f7f7;
  --teculiar-line: #e2e2e2;
  --teculiar-text: #111;
  --teculiar-muted: #666;
  --teculiar-card-min: 240px;
}

**::part()** für alles, was die Properties nicht abdecken:

teculiar-product-grid::part(card)  { box-shadow: none; }
teculiar-product-grid::part(button){ letter-spacing: .04em; }

Parts: root, heading, form, input, button, card, title, description, price, status, error, loading, list, results, table, row, excerpt.

Theming der Stufe B

Custom Properties überqueren eine iframe-Grenze nicht: Das eingebettete Dokument hat eine andere Origin, nichts von oben erreicht es also. Das eingebettete Portal wird stattdessen aus den gespeicherten Einstellungen des Tenants gestaltet, unter **Admin → Einstellungen → Aussehen der Einbettung**: Marke, Akzent, Flächen, Text, Linien, Eckenradius, Schrift, Dichte, hell/dunkel und Logo. Eine eigene Überschriften-Schrift (Standard: dieselbe wie beim Text) gestaltet die Überschriften in den Rahmen von Checkout und Portal. Eine dort nicht gesetzte Farbe oder Schrift fällt auf die Marke des Tenants zurück (Admin → Einstellungen → Marke). Der Server löst sie auf und schreibt die Token-Überschreibungen in die Antwort, sodass es kein Aufblitzen unserer Palette gibt, kein postMessage-Rennen, und es auch dann richtig gestaltet ist, wenn auf der übergeordneten Seite JavaScript abgeschaltet ist.

Eine Seite darf ergänzen, was der Tenant offen gelassen hat, mit data-teculiar-* am Element:

<teculiar-account view="invoices" data-teculiar-accent="#ff6600" data-teculiar-density="comfortable"></teculiar-account>

Zwei Regeln gelten dafür, beide mit Absicht:

  • Ein gespeicherter Wert des Tenants gewinnt immer, bei Einbettungs-Aussehen und Marke gleichermaßen. Eine Überschreibung füllt eine Lücke; sie verdrängt nie eine Entscheidung. Könnte die Seite den Tenant überstimmen, könnte jeder, der die Seite kompromittiert, die Oberfläche für Geld so umgestalten, dass sie täuscht, während die Adressleiste weiter unsere Origin zeigt.
  • Unbekannte Namen und fehlerhafte Werte werden verworfen, nicht repariert. Die akzeptierte Menge ist fest, und jeder Wert muss einem engen Muster entsprechen: eine Hex-Farbe, eine Länge bis 64px, einer aus einer geschlossenen Liste. Schriften kommen aus einer kuratierten Liste von Stacks; es gibt keine beliebige Schrift-URL, die zugleich eine Einschleusung und ein Datenschutzleck von unserer Origin wäre.

Teculiar.matchSite() liest die Farben und die Schrift der Host-Seite und gibt sie zurück, damit du sie in das Einstellungsformular einfügen kannst. Es gibt Werte zurück und wendet nichts an: eine Hilfe beim Ausfüllen des Formulars, nie ein Laufzeitkanal, aus dem oben genannten Grund.

Das SDK

const tec = Teculiar.init({ publicKey: "pk_live_…" });

const products = await tec.storefront.products();
const hit      = await tec.domains.search({ query: { domain: "example.com" } });

await tec.auth.login("a@b.test", "hunter2");   // stores the token
const invoices = await tec.billing.invoices(); // sent with it automatically

Jede Methode wird **aus docs/openapi/public-v1.json erzeugt**, eine neue @PublicRoute in der API erscheint hier also beim nächsten Build, ohne dass ein von Hand geschriebener Client abdriften kann. scripts/api-contract-check.ts schlägt fehl, wenn der eingecheckte erzeugte Code hinter der Spezifikation zurückliegt. Die Namen kommen aus dem Pfad: POST /services/{id}/renew-domain → tec.services.renewDomain(id). tec.operations listet auf, was ein gegebener Build kennt.

Pfadparameter sind positionell; das letzte Argument ist bei GET ein Query-Objekt und sonst ein JSON-Body. Beide sind aus der Spezifikation typisiert: tec.orders.checkout(body) nimmt einen OrdersCheckoutBody (seine Pflichtfelder sind Pflicht, ein Enum-Feld nimmt nur seine erlaubten Werte), tec.domains.search({ query }) eine DomainsSearchQuery. Die Typen werden aus src/generated/types.ts als <Group><Method>Body und <Group><Method>Query exportiert. Ein Ticket-Anhang nimmt ein FormData.

Wiederholungen

Lesezugriffe (GET) werden nach einem Timeout oder einer 5xx-Antwort einmal wiederholt. Ein Schreibzugriff wird nur wiederholt, wenn er einen Idempotency-Key trägt, den das SDK bei Checkout, den beiden Bezahlaufrufen, Guthaben aufladen, Anfragen und Tickets ergänzt (pro Aufruf ein neuer Schlüssel, bei der Wiederholung derselbe): Der Server gibt dann die erste Antwort zurück, statt die Arbeit noch einmal zu tun. Jeder andere Schreibzugriff wird nur einmal gesendet, denn ein Timeout sagt nicht, ob er passiert ist. retries: 0 in Teculiar.init schaltet das Wiederholen ab.

Fehler

TeculiarKeyError (die Integration ist falsch: ungültiger Schlüssel oder eine Origin, die nicht auf der Allowlist steht), TeculiarAuthError (den Besucher anmelden), TeculiarRequestError, TeculiarRateLimitError (retryAfterSeconds), TeculiarServerError. Alle erweitern TeculiarError und tragen status.

Die beiden 401 sind mit Absicht getrennt: Würde man sie gleich behandeln, entstünde eine Website, die entweder Leute still abmeldet oder eine Anmeldeaufforderung zeigt, obwohl der Schlüssel falsch eingerichtet ist.

5xx und Netzwerkfehler werden einmal wiederholt (400 ms, dann 1200 ms); 4xx und 429 nie.

Sitzungen und das Risiko

Teculiar.init({ publicKey: "pk_…", tokenStore: "local" }); // opt-in

Der Standard ist "memory": Das Token lebt für einen Seitenaufruf, ein Neuladen meldet den Besucher also ab. Das ist Absicht. **Ein Kunden-Token auf der Seite des Tenants kann von jedem XSS auf dieser Seite gelesen werden**: ihr Fehler, die Rechnungen unseres Kunden. "local" speichert es in localStorage und ist nur auf ausdrücklichen Namen aktiv, damit die riskante Wahl eine ist, die jemand getippt hat, und keine, die er geerbt hat.

Deshalb ist der empfohlene Weg für angemeldete Oberflächen die iframe-Stufe (9.2.11), bei der die Sitzung auf unserer Origin liegt und nie in ihren JS-Kontext gelangt. Das SDK bietet authentifizierte Aufrufe trotzdem, für Tenants, die bewusst ihr eigenes Portal bauen.

Warenkorb

Clientseitig, mit serverseitig maßgeblicher Preisberechnung: Es gibt kein Cart-Modell auf dem Server.

tec.cart.add({ productId, productPriceId, quantity: 1, unitAmountCents: 499 });
await tec.ready();
const quote = await tec.orders.preview(tec.cart.toOrder());

toOrder() ist { items, currency, locale }: die Positionen plus die Anzeigewährung des Besuchers und die Sprache der Seite. Sende das statt nur toOrderItems(): Ohne currency wird ein Gast in der Hauptwährung des Shops abgerechnet, egal was die Seite gezeigt hat. Die Zahlungsarten, die der Käufer für diesen Warenkorb nutzen kann (eine Methode, die seine Währung nicht annehmen kann oder die die Regeln des Shops ausschließen, fehlt):

const methods = await tec.payments.methods({ country: "DE", baseTotalCents: 4900, families: ["hosting"] });

locale und currency sind standardmäßig die Sprache der Seite und die Anzeigewährung.

Eine Domain ist eine Position ohne Preis-ID und mit einer domain; der Server berechnet sie aus dem Abrechnungszeitraum in ihrer configuration. Das Domainsuche-Widget fügt eine so hinzu, und toOrderItems() sendet domain als domainName der API:

tec.cart.add({ productId: hit.productId, productPriceId: "", quantity: 1, domain: "example.com",
  configuration: { domainAction: "register", billingCycle: "YEAR_1" } });

Zwei Positionen sind dieselbe Position, wenn Produkt, Preis, Domain und Konfiguration gleich sind. Eine Position hat einen key; cart.remove(key) und cart.setQuantity(key, n) nehmen ihn (bei einer Position mit Preis-ID geht auch diese).

cart.estimateCents() heißt mit Absicht so: Es dient der Anzeige. Der abgebuchte Betrag kommt aus orders.preview / orders.checkout, denn eine im Browser berechnete Summe ist eine Zahl, die der Besucher ändern kann.

Auslieferung

PfadCache
/embed/v<version>/teculiar.js (zum Beispiel /embed/v0.1.0/)ein Jahr, immutable: Die Bytes einer veröffentlichten Version ändern sich nie, hier mit SRI festlegen
/embed/latest/teculiar.js60 s: Korrekturen kommen an, ohne die Website des Tenants anzufassen
/embed/v1/teculiar.js60 s: der alte Alias, für Snippets, die schon draußen sind, beibehalten. Er liefert den aktuellen Build, ist also nicht festgelegt

Jeder Shop lädt diese vom Script-Host cdn.teculiar.net (siehe Installation). Das festgelegte Snippet:

<script src="https://cdn.teculiar.net/embed/v<version>/teculiar.js"
        integrity="sha384-…" crossorigin="anonymous"
        data-key="pk_live_…" data-base-url="https://shop.acmehost.com"></script>

Eine Version veröffentlichen (der Inhaber, bei einem Release): version in packages/sdk/package.json erhöhen, npm --workspace @teculiar/sdk run build ausführen, dann npm --workspace @teculiar/sdk run release. Das kopiert teculiar.js, die drei widgets-*.js Gruppendateien, ihre Maps und integrity.json nach packages/sdk/releases/<version>/. Committe den Ordner. Das Script weigert sich, eine vorhandene Version zu überschreiben, und in einem veröffentlichten Ordner darf sich nie etwas ändern. Die API liefert jeden Ordner unter releases/ unter /embed/v<version>/ aus.

Veröffentlichte Versionen und ihre integrity-Werte:

Versionintegrity
noch keine

Von der API selbst ausgeliefert (kein CDN), eingebunden **vor helmet()** mit Cross-Origin-Resource-Policy: cross-origin. Der Standard same-origin von Helmet betrifft fetch() nicht, blockiert aber ein einfaches <script src> von einer anderen Origin; aus demselben Grund ist /uploads früh eingebunden. Access-Control-Allow-Origin: * erlaubt es einem Browser, ein integrity-Attribut zu prüfen.

Der SRI-Hash für den aktuellen Build steht in packages/sdk/dist/integrity.json.

Widgets aus einem zweiten Shop

Eine Seite kann Widgets aus mehr als einem Shop zeigen. Ein Element mit eigenem data-key und data-base-url nutzt diesen Shop; die anderen Widgets bleiben bei dem des Script-Tags.

<teculiar-product-grid data-key="pk_live_other" data-base-url="https://api.other-store.com"></teculiar-product-grid>

Ein zweiter Shop wird immer benannt, nie geraten: Ein Element mit anderem data-key und ohne data-base-url sagt das und sendet nichts. Die Wahl von Sprache und Währung bleibt eine pro Seite; jedes Widget zeigt die gewählte Währung, wenn sein Shop sie führt, sonst die Hauptwährung seines Shops. <teculiar-preferences> gehört immer zum eigenen Shop der Seite. Die Frames für Konto und Login akzeptieren dieselben zwei Attribute.

Sprache und Währung

Der Schalter: <teculiar-preferences>

Ein Button (ein Globus und eine Zusammenfassung wie EN · $) öffnet einen Dialog mit den Sprachen und Währungen, die der Shop anbietet. Übernehmen speichert beide Wahlen. Eine Währungsänderung braucht kein Neuladen (jedes Widget hört teculiar:prefs). Eine Sprachänderung öffnet dieselbe Seite in der anderen Sprache, gefunden in dieser Reihenfolge: das eigene <link rel="alternate" hreflang> der Seite (genauer Tag, dann derselbe primäre Subtag), das href-template, das erste Pfadsegment, wenn es die Sprache der Seite ist, sonst /<language>/. Der Query-String bleibt erhalten.

<teculiar-preferences languages="en,de" href-template="/{locale}/"></teculiar-preferences>
AttributBedeutung
languagesKommagetrennte Liste der Sprachen, für die diese Website Seiten hat. Gezeigt wird, was der Shop anbietet und hier steht. Standard: alle angebotenen.
showboth (Standard), language oder currency.
href-templateWo eine Sprache liegt, wenn die Seite keine passende Alternative hat, z. B. /{locale}/.

Das Widget blendet sich aus, wenn es nichts zu wählen gibt (eine Sprache und eine Währung). Parts zum Gestalten: button, summary, dialog, title, section, option, apply, close, flag. Der Dialog ist ein natives <dialog>: Er nimmt den Fokus, schließt mit Esc und ist vollständig per Tastatur bedienbar.

Ein Live-Preis: <teculiar-price>

Ein Preis in deiner eigenen Typografie (kein Shadow Root). Schreibe den Preis in der Hauptwährung zur Build-Zeit hinein; er bleibt stehen, bis der Live-Wert bereit ist und bei jedem Fehler (das Element bekommt dann außerdem data-error).

<teculiar-price amount="8" per="month">$8/mo</teculiar-price>
<teculiar-price product="pro" cycle="YEAR_1" per="year">$117/yr</teculiar-price>
AttributBedeutung
amountEin Betrag in der Hauptwährung, wie getippt: 8, 29.90, 29,90.
product, cycle, fieldEin Katalogpreis über den Produkt-Slug. cycle wie MONTHLY oder YEAR_1 (Standard: der günstigste); field="setup" für die Einrichtungsgebühr (Standard unit).
permonth oder year: hängt „/mo“ oder „/yr“ in der Sprache der Seite an.
currencyEine gelistete Währung festlegen statt der Wahl des Besuchers.

Ganze Beträge verlieren das .00; umgerechnete behalten Cent. Das data-currency des Elements nennt die angezeigte Währung. Es ändert sich sofort, wenn der Besucher die Währung wechselt, ohne neue Anfrage.

Preis-Token in deinem eigenen Text

Tippe {{price:29}} in dein CMS, so wie es der Customizer macht, und markiere den Bereich, der sie enthalten darf:

<p data-teculiar-prices>from {{price:8}}/month</p>
<script src="…/teculiar.js" data-key="pk_live_…" data-base-url="https://…" data-prices></script>

data-prices am Script-Tag wandelt die markierten Bereiche um, sobald die Seite geladen ist; rufe Teculiar.prices.render(root) selbst auf für Inhalte, die später hinzukommen. Jedes Token wird zu einem <teculiar-price>, das der Währung des Besuchers folgt. Beschädigte Token (eine verlorene Klammer) werden repariert; unlesbare und Token außerhalb eines markierten Bereichs bleiben, wie sie geschrieben sind.

Die Script-API

Eine Seite, die nur das Script-Tag nutzt, kann Sprache und Währung des Besuchers lesen und ändern, so wie ein Teculiar-Theme es tut. Die Wahl ist ein Cookie, teculiar_prefs (language.currency.stamp, zum Beispiel de.EUR.1760520000000), dasselbe, das das Shop-Frontend und das Kundenportal des Shops verwenden.

await Teculiar.ready();                       // the store's bootstrap is loaded
Teculiar.prefs.get();                         // { locale, currency, pageLocale }
Teculiar.prefs.set({ currency: "EUR" });      // writes the cookie, fires teculiar:prefs
Teculiar.prefs.subscribe((state) => render(state));
Teculiar.i18n.languages();                    // [{ code, nativeName, flag, bcp47 }]
Teculiar.i18n.currencies();                   // ["USD", "EUR"]
Teculiar.money.format(800);                   // "€7.36": the number the checkout charges
Teculiar.money.format(800, { authored: true });  // whole amounts drop ".00"
Teculiar.money.convert(800);                  // { cents: 736, currency: "EUR" }

Dieselben Methoden gibt es an jedem Client (const tec = Teculiar.init(...), dann tec.prefs, tec.i18n, tec.money, tec.ready()). Alle Clients auf einer Seite teilen sich eine Wahl.

Die Regeln, dieselben wie bei einem Teculiar-Theme:

  • Seitensprache ist der primäre Teil von <html lang>, wenn der Shop sie anbietet, sonst die Hauptsprache des Shops. Die Adresse entscheidet, was der Besucher liest; sie speichert nie eine Einstellung.
  • Anzeigewährung ist die Währung des Cookies, wenn der Shop sie führt, sonst die Standardwährung der Seitensprache, wenn sie geführt wird, sonst die Hauptwährung des Shops. Eine Währung ohne gültigen Wechselkurs wird nie geführt.
  • prefs.set ignoriert eine Sprache, die der Shop nicht anbietet, und eine Währung, die er nicht führt. Jede Wahl wird mit der Zeit des Servers gestempelt (die Uhr des Bootstraps plus die seit seinem Eintreffen vergangene Zeit), nie des Geräts, sodass zwischen Website und Portal die spätere Wahl gewinnt.
  • Erster Besuch: Ohne Cookie wird die Sprache einmal gemerkt, wenn die Sprache der Seite die ist, zu der die Sprachen des Browsers aufgelöst werden. Sonst schreibt nichts von selbst.
  • Die Wahl eines angemeldeten Kunden wird außerdem an sein Konto gesendet (PATCH /users/me); ein Fehler dabei wird ignoriert.
  • teculiar:prefs wird auf document ausgelöst (bubbles, composed) mit { locale, currency, previous }.
  • Bevor ready() aufgelöst ist, gibt money einen leeren String zurück und i18n listet nichts: warte darauf.

Deine eigene Website, Schritt für Schritt

Wie teculiar.com, eine statische Website mit eigenen Seiten und übersetzten Adressen, über den Shop verkauft. Jeder Schritt ist eine Sache von dieser Seite; geh sie in dieser Reihenfolge durch.

  1. Die Website als Host hinzufügen. Admin → Einstellungen → Domains → Deine eigene Website: Domain der Website eingeben, den DNS-TXT-Eintrag setzen, verifizieren. So lässt sich die Wahl von Sprache und Währung mit dem Portal teilen, und der Zahlungsanbieter kann Käufer auf deine Seiten zurückschicken. Lege unter Admin → Einstellungen → API-Schlüssel einen öffentlichen Schlüssel an und trage die Adresse der Website als Origin ein.
  2. Ein Script-Tag auf jeder Seite (siehe Installation); eine Seite, die nur den Schalter und Preise braucht, lädt die Kerndatei und sonst nichts.
  3. Die Sprache in die Adresse legen (/en/…, /de/…) und <html lang> sowie hreflang-Links zu jeder Übersetzung setzen. Die Adresse entscheidet, was der Besucher liest; sie speichert nie eine Wahl.
  4. Den Schalter einbauen (<teculiar-preferences>). Mit dem gemeinsamen Host schreibt er ein Cookie, das auch das Portal liest. Preise folgen der Währung des Besuchers.
  5. Preise zeigen mit <teculiar-price product="…">, oder Produktraster beziehungsweise Preistabelle für eine Liste. Gib ihnen checkout="/en/checkout/", und „Auswählen“ öffnet deine Checkout-Seite mit Produkt und Zeitraum in der Adresse.
  6. Die Checkout-Seite. <teculiar-checkout> plus Teculiar.checkoutFromUrl(). Heb das statusToken aus teculiar:checkout-created in sessionStorage auf.
  7. Die Rückkehrseite. Setze return-url auf eine Seite von dir. Kommt der Käufer mit order und result in der Adresse an, lies GET /orders/<order>/status?token=<statusToken>, bis es ready sagt, und zeige dann die Links, die es zurückgibt (die Portalseite des neuen Dienstes).
  8. Die Website aktuell halten. Abonniere einen Webhook auf content.*, catalog.updated und settings.updated (webhooks.md) und baue die Website neu, wenn einer eintrifft.

Seiten, die Daten zur Build-Zeit lesen (die Tarife, die Modulbibliothek, Beiträge), rufen mit dem öffentlichen Schlüssel dieselben öffentlichen Routen auf, zum Beispiel GET /platform/plans?locale=de.

Caching

Anonyme Lesezugriffe werden über Seitenaufrufe hinweg wiederverwendet, damit ein Besucher, der zwischen Seiten wechselt, Preise sofort sieht. Die Produktliste (und ab L2 der Bootstrap) wird fünf Minuten in sessionStorage gehalten: Eine gespeicherte Antwort wird sofort gezeigt und im Hintergrund aktualisiert, und Widgets ändern sich nur, wenn die Aktualisierung abweicht. Zwei Widgets, die dieselbe Antwort brauchen, stellen eine Anfrage.

  • Nur sessionStorage, nie localStorage: Nichts überdauert den Tab.
  • Ein angemeldeter Client liest oder schreibt ihn nie; eine Antwort, die von einem Kunden-Token abhängt, wird nicht gecacht.
  • Ohne Speicher (private Modi, blockierter Speicher) fällt der Cache für die Seite auf den Arbeitsspeicher zurück.
  • cachedGet(client, path, query), cachedEntry (ergänzt die Empfangszeit) und onCacheUpdate werden für Tenants exportiert, die eigene Widgets bauen.

Sicherheitshinweise

  • Nie Markup. Widgets bauen das DOM nur mit createElement/textContent. Ein Produktname oder Blog-Auszug, der in einen HTML-String eingesetzt wird, würde auf der Website des Tenants zu Script: ein XSS, das wir ausgeliefert haben und für das sie verantwortlich gemacht werden. apps/../packages/sdk/test/no-html-injection.test.mjs lässt den Build bei innerHTML, insertAdjacentHTML, document.write, eval und new Function scheitern, sowohl im Quelltext als auch im gebauten Bundle. (Der Plan sah eine ESLint-Regel vor; dieses Repository hat kein ESLint, die Garantie wird also in dem Test-Runner durchgesetzt, den es schon nutzt.)
  • Link-Hrefs werden auf das Schema geprüft. textContent hilft bei einem Attribut nicht: Ein CMS-Feld mit javascript: ist einen Klick davon entfernt, auf der Origin des Tenants ausgeführt zu werden. safeHref wird für Tenants exportiert, die unsere Daten mit eigenem Markup darstellen.
  • Keine Cookies. Die öffentliche Tür ist origin: * mit credentials: false; das SDK sendet credentials: "omit".

Methoden

auth

AufrufAnfrageScopeAnmeldungSeit
tec.auth.get2faSetup()GET /auth/2fa/setupcustomers:writeKundenanmeldung2026-09
tec.auth.get2faStatus()GET /auth/2fa/statuscustomers:readKundenanmeldung2026-09
tec.auth.login()POST /auth/loginauth:writeNur Schlüssel2026-09
tec.auth.logout()POST /auth/logoutauth:writeKundenanmeldung2026-09
tec.auth.passwordResetConfirm()POST /auth/password-reset/confirmauth:writeNur Schlüssel2026-09
tec.auth.passwordResetRequest()POST /auth/password-reset/requestauth:writeNur Schlüssel2026-09
tec.auth.post2faDisable()POST /auth/2fa/disablecustomers:writeKundenanmeldung2026-09
tec.auth.post2faVerify()POST /auth/2fa/verifycustomers:writeKundenanmeldung2026-09
tec.auth.refresh()POST /auth/refreshauth:writeNur Schlüssel2026-09
tec.auth.register()POST /auth/registerauth:writeNur Schlüssel2026-09

billing

AufrufAnfrageScopeAnmeldungSeit
tec.billing.addFunds() (sicher wiederholbar)POST /billing/add-fundspayments:writeKundenanmeldung2026-09
tec.billing.invoices()GET /billing/invoicesinvoices:readKundenanmeldung2026-09
tec.billing.invoicesById(id)GET /billing/invoices/{id}invoices:readKundenanmeldung2026-09
tec.billing.invoicesHtml(id)GET /billing/invoices/{id}/htmlinvoices:readKundenanmeldung2026-09
tec.billing.invoicesPay(id) (sicher wiederholbar)POST /billing/invoices/{id}/payinvoices:payKundenanmeldung2026-09
tec.billing.invoicesPaymentMethods(id)GET /billing/invoices/{id}/payment-methodsinvoices:payKundenanmeldung2026-09
tec.billing.invoicesPdf(id)GET /billing/invoices/{id}/pdfinvoices:readKundenanmeldung2026-09
tec.billing.paymentMethods()GET /billing/payment-methodspayment-methods:readKundenanmeldung2026-09

cms

AufrufAnfrageScopeAnmeldungSeit
tec.cms.announcements()GET /cms/announcementscustomers:readKundenanmeldung2026-09
tec.cms.announcementsHide(id)POST /cms/announcements/{id}/hidecustomers:writeKundenanmeldung2026-09
tec.cms.announcementsRead(id)POST /cms/announcements/{id}/readcustomers:writeKundenanmeldung2026-09
tec.cms.blogCategories()GET /cms/blog-categoriescontent:readNur Schlüssel2026-09
tec.cms.blogTags()GET /cms/blog-tagscontent:readNur Schlüssel2026-09
tec.cms.pages(locale, slug)GET /cms/pages/{locale}/{slug}content:readNur Schlüssel2026-09
tec.cms.posts()GET /cms/postscontent:readNur Schlüssel2026-09
tec.cms.postsByLocaleSlug(locale, slug)GET /cms/posts/{locale}/{slug}content:readNur Schlüssel2026-09
tec.cms.postTags()GET /cms/post-tagscontent:readNur Schlüssel2026-09

domains

AufrufAnfrageScopeAnmeldungSeit
tec.domains.search()GET /domains/searchdomains:searchNur Schlüssel2026-09

knowledgebase

AufrufAnfrageScopeAnmeldungSeit
tec.knowledgebase.get(slug)GET /knowledgebase/{slug}content:readNur Schlüssel2026-09
tec.knowledgebase.list()GET /knowledgebasecontent:readNur Schlüssel2026-09
tec.knowledgebase.suggest()GET /knowledgebase/suggestcontent:readNur Schlüssel2026-09

orders

AufrufAnfrageScopeAnmeldungSeit
tec.orders.checkout() (sicher wiederholbar)POST /orders/checkoutcheckout:writeNur Schlüssel2026-09
tec.orders.get(id)GET /orders/{id}orders:readKundenanmeldung2026-09
tec.orders.pay(id) (sicher wiederholbar)POST /orders/{id}/paypayments:writeKundenanmeldung2026-09
tec.orders.preview()POST /orders/previewcheckout:writeNur Schlüssel2026-09
tec.orders.status(id)GET /orders/{id}/statuscheckout:writeNur Schlüssel2026-10

platform

AufrufAnfrageScopeAnmeldungSeit
tec.platform.domainsCheck()GET /platform/domains/checkcatalog:readNur Schlüssel2026-10
tec.platform.namesCheck()GET /platform/names/checkcatalog:readNur Schlüssel2026-10
tec.platform.plans()GET /platform/planscatalog:readNur Schlüssel2026-10

products

AufrufAnfrageScopeAnmeldungSeit
tec.products.list()GET /productscatalog:readNur Schlüssel2026-09

services

AufrufAnfrageScopeAnmeldungSeit
tec.services.actions(id)GET /services/{id}/actionsservices:readKundenanmeldung2026-09
tec.services.actionsByIdAction(id, action)GET /services/{id}/actions/{action}services:readKundenanmeldung2026-09
tec.services.autoRenew(id)PATCH /services/{id}/auto-renewservices:manageKundenanmeldung2026-09
tec.services.cancel(id)POST /services/{id}/cancelservices:manageKundenanmeldung2026-09
tec.services.cancellationRequest(id)GET /services/{id}/cancellation-requestservices:readKundenanmeldung2026-09
tec.services.changePlan(id)POST /services/{id}/change-planservices:manageKundenanmeldung2026-09
tec.services.createActions(id, action)POST /services/{id}/actions/{action}services:manageKundenanmeldung2026-09
tec.services.domainActions(id, action)POST /services/{id}/domain-actions/{action}domains:manageKundenanmeldung2026-09
tec.services.domainLock(id)GET /services/{id}/domain-lockservices:readKundenanmeldung2026-09
tec.services.domainLockById(id)POST /services/{id}/domain-lockdomains:manageKundenanmeldung2026-09
tec.services.get(id)GET /services/{id}services:readKundenanmeldung2026-09
tec.services.list()GET /servicesservices:readKundenanmeldung2026-09
tec.services.planOptions(id)GET /services/{id}/plan-optionsservices:readKundenanmeldung2026-09
tec.services.renewDomain(id)POST /services/{id}/renew-domaindomains:manageKundenanmeldung2026-09
tec.services.restart(id)POST /services/{id}/restartservices:manageKundenanmeldung2026-09
tec.services.resume(id)POST /services/{id}/resumeservices:manageKundenanmeldung2026-09
tec.services.transferCode(id)GET /services/{id}/transfer-codedomains:manageKundenanmeldung2026-09

storefront

AufrufAnfrageScopeAnmeldungSeit
tec.storefront.domainPrices()GET /storefront/domain-pricescatalog:readNur Schlüssel2026-09
tec.storefront.embed()GET /storefront/embedcontent:readNur Schlüssel2026-09
tec.storefront.i18n()GET /storefront/i18ncatalog:readNur Schlüssel2026-09
tec.storefront.inquiries() (sicher wiederholbar)POST /storefront/inquiriesinquiries:writeNur Schlüssel2026-09
tec.storefront.modules()GET /storefront/modulescatalog:readNur Schlüssel2026-10
tec.storefront.modulesByName(name)GET /storefront/modules/{name}catalog:readNur Schlüssel2026-10
tec.storefront.page(key)GET /storefront/page/{key}content:readNur Schlüssel2026-09
tec.storefront.paymentGateways()GET /storefront/payment-gatewayspayment-methods:readNur Schlüssel2026-09
tec.storefront.products()GET /storefront/productscatalog:readNur Schlüssel2026-09
tec.storefront.redirects()GET /storefront/redirectscontent:readNur Schlüssel2026-09
tec.storefront.settings()GET /storefront/settingscatalog:readNur Schlüssel2026-09
tec.storefront.theme()GET /storefront/themecontent:readNur Schlüssel2026-09

tickets

AufrufAnfrageScopeAnmeldungSeit
tec.tickets.attachments(id)POST /tickets/{id}/attachmentstickets:writeKundenanmeldung2026-09
tec.tickets.close(id)POST /tickets/{id}/closetickets:writeKundenanmeldung2026-09
tec.tickets.create() (sicher wiederholbar)POST /ticketstickets:writeKundenanmeldung2026-09
tec.tickets.departments()GET /tickets/departmentstickets:readKundenanmeldung2026-09
tec.tickets.get(id)GET /tickets/{id}tickets:readKundenanmeldung2026-09
tec.tickets.list()GET /ticketstickets:readKundenanmeldung2026-09
tec.tickets.replies(id)POST /tickets/{id}/repliestickets:writeKundenanmeldung2026-09

users

AufrufAnfrageScopeAnmeldungSeit
tec.users.me()GET /users/mecustomers:readKundenanmeldung2026-09
tec.users.meExport()GET /users/me/exportcustomers:readKundenanmeldung2026-09
tec.users.updateMe()PATCH /users/mecustomers:writeKundenanmeldung2026-09