Two products on the same public API, for two different customers.

SDKWidgets (tier A)Widgets (tier B)
Consumerthe tenant's JS developera tenant with no developera tenant with no developer
Who writes the HTMLthe tenantusus
Installimport or one script tagone script tag + one elementone script tag + one element
Boundarynone — their pageopen shadow rootiframe to our origin
Holds a sessiononly if they build itneveryes

All of it ships in one file: packages/sdk builds an ESM module and an IIFE bundle, and the IIFE carries both widget tiers. A tenant's page loads exactly one script.

Why two tiers. Shadow DOM stops the host page's CSS breaking our layout, which is exactly what a product grid needs. It is the wrong tool the moment a customer session is involved: a shadow root is one JS context away from the page, so the tenant's XSS would own our customer's invoices. Anything holding a session, an invoice, a payment flow or hosting credentials goes in an **iframe to our origin** — a real origin boundary. Same split Stripe and Intercom make.

Install

<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>

One script host for every store. cdn.teculiar.net serves the SDK files and nothing else: no API, no cookies, the same for every store on every plan (the way every site loads Stripe.js from js.stripe.com). data-base-url says which store the page sells for: any address of the store that answers /api, such as its storefront, its client portal or <name>.teculiar.net. Loaded from the script host without data-base-url, the widgets show what is missing and call nothing.

A store with its own api.<domain> (Unbranded and Operator plans), added by the domain wizard (Admin → Domains) through the same pending → DNS-TXT → verify flow as every other host, may load the files from there instead and leave data-base-url out: https://api.acmehost.com/embed/latest/teculiar.js. That is the white-label option; the files are the same.

One script tag, widgets load when needed. teculiar.js (about 20 KB gzip) holds the client, the cart, the language and currency toggle and the price tag. Every other widget lives in a group file next to it: widgets-catalog.js (domain search, product grid, pricing table), widgets-content.js (blog list, knowledge-base search, contact form) and widgets-embed.js (account, login, checkout). The core finds the teculiar-* tags on the page (and ones added later) and loads a group once, the first time one of its widgets appears, from the same folder as the core, with an integrity hash it carries and crossorigin="anonymous". A page with only the toggle and prices downloads no group file. Nothing to change in the install snippet; if you restrict scripts with a CSP, allow the script host once and it covers all files. A pinned version (/embed/v<version>/) pins its group files too.

pk_… comes from Admin → Settings → API keys. It is an identifier, not a secret — it sits in page source by design — so the control is the key's origin allowlist. Add the tenant's website there or every call is a 403.

data-base-url names the store (without it the default is wherever the script was served from, which is only a store when it is the store's own api.<domain>), and data-locale overrides the language (the default follows <html lang>).

The widgets

ElementReadsScope
<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/i18nnone
<teculiar-price amount="8">GET /storefront/i18n, GET /storefront/products (with product)catalog:read (with product)

The product grid and pricing table show names and descriptions in the page language (the stored name when there is no translation), every price with its period ("/mo", "/yr", "one-time"), and the setup fee of the price shown. When some product sells both monthly and yearly, a Monthly/Yearly switch (part cycle-switch) appears above the list; cycle="YEAR_1" sets the start. teculiar:product-select carries productId, slug, productPriceId and billingCycle.

Common attributes: locale, heading, currency (pins the widget's prices to one listed currency; without it they follow the visitor's choice and change at once when it changes, with no new request). Every widget emits teculiar:* DOM events (domain-search, cart-add, product-select, catalog-loaded, inquiry-sent) that bubble and cross the shadow boundary, so the host page can hook them.

Domain suggestions are off by default. Each suggested TLD costs one outbound RDAP lookup from Teculiar's IP, and registries block IPs that hammer them. Opt in with suggestions="true".

Tier B — the account area (Phase 9.2.11)

<teculiar-account view="invoices"></teculiar-account>
<teculiar-login></teculiar-login>
ElementWhat it isAttributes
<teculiar-account>The signed-in customer portal, framedview, locale, height, data-teculiar-*
<teculiar-login>Sign-in only, for a header or sidebarlocale, data-teculiar-*

view is one of overview (default), services, invoices, tickets, domains, billing, hosting, account. The element asks GET /storefront/embed where the tenant's embed lives — it never guesses a host — and frames <client-host>/embed/<view>.

What is inside the frame is the client portal itself, rendered chromeless: the same components that serve /client, with PageShell suppressed and every /client/… link rewritten to /embed/…. So a feature added to the portal is embeddable and navigable the day it ships, with no second implementation to keep in step.

Events, as DOM events on the element: teculiar:ready, teculiar:resize, teculiar:authenticated, teculiar:signed-out, teculiar:navigate, teculiar:error. The element also exposes show(view) to switch screens.

Three things about the frame worth knowing before you install it:

  • The tenant must list your site. Admin → Settings → Integrations → Websites that may embed the portal. Until then the browser refuses to render the frame (frame-ancestors 'none') — it fails closed, because a framable payment UI is a clickjacking target.
  • It sizes itself. The frame measures its own content and tells the page; a parent cannot measure a cross-origin document. Nothing to configure.
  • Some browsers block third-party storage. Where they do, the embed cannot hold a session at all; it says so and links out to the full portal rather than showing a sign-in form that silently fails.

The frame accepts no instruction that changes what is shown or who is signed in — no styling, no tokens, no URLs. The only message it honours is "show one of these eight views", matched against a whitelist. The theme is resolved server-side from the tenant's saved settings for the same reason.

The checkout: <teculiar-checkout>

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

The store's own order form for one product, framed at <client-host>/embed/checkout. One product per checkout, with its add-ons, billing cycle, order fields and the domain offer. It sizes itself and uses the page's language.

AttributeMeaning
productThe product's slug (required)
cycleBilling cycle to start on (MONTHLY, YEAR_1, …)
domain, domain-actionStart the domain step with this name (register or transfer)
addonsaddOnId:quantity,addOnId:quantity to start with
config-<key>Start one of the product's order fields, for example config-subdomain="acme". Only keys the product has are used; adminEmail is never taken from the page
locale, currencyLanguage and display currency for this frame only; a currency the store does not list is ignored
return-url, cancel-urlWhere the buyer goes after paying or cancelling; both default to the current page
data-teculiar-*The theme fields of the account widget

Events on the element: teculiar:ready, teculiar:resize, teculiar:checkout-step (detail.step is details, paying or done), teculiar:checkout-created and teculiar:checkout-complete.

  • **teculiar:checkout-created** (orderId, statusToken) fires as soon as the order exists, before any payment step. Keep it (for example in sessionStorage): a redirect payment leaves your page before checkout-complete could fire, and your return page reads GET /orders/<orderId>/status?token=… with it.
  • **teculiar:checkout-complete** (orderId, status) fires when nothing more happens in the frame: paid, pending (a bank transfer or SEPA debit waiting for the money) or provisioning (a free order being set up). The frame does not send the buyer to the portal itself; your page decides where they go.

Payment methods are listed for the checkout's currency and the buyer's country (SEPA only for EUR). Card, PayPal and the other redirect methods open the provider's page through the element; SEPA, bank transfer and the wallet stay in the frame. Every submit carries an Idempotency-Key, so a double click or a retry after a lost answer never orders twice. return-url and cancel-url go to the payment provider's return handling and are checked against your store's verified hosts.

A free plan (the order totals nothing) has no payment step: the button says Create account, the order settles at once and checkout-complete arrives with provisioning.

Language and currency. Every framed element (<teculiar-account>, <teculiar-login>, <teculiar-checkout>) shows the page's language and the visitor's currency; a locale or currency attribute on the element wins. A currency the store does not list is ignored, and neither is ever stored. An issued invoice always opens in the language it was issued in, whatever the page language.

A payment page cannot be framed and the frame cannot leave itself, so the frame asks the page to open it (teculiar:navigate-top); the element opens it only when the address is https: and the message came from its own frame. The tenant must list your site under Websites that may embed the portal, exactly as for the account widget.

From the catalogue to the checkout

Give the product grid, the pricing table or the domain search a checkout attribute: the address of your own checkout page.

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

"Select" then opens <checkout>?product=<slug>&cycle=<billingCycle> (after emitting teculiar:product-select as before); the domain search shows Order instead of Add to cart and opens <checkout>?product=<domain product slug>&domain=<name>&domainAction=register. Without checkout the widgets behave as before: the grid and table only emit the event, the domain search adds to the SDK cart.

On the checkout page, one line fills the <teculiar-checkout> element from the address:

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

It reads product, cycle, domain, domainAction, addons and every config.<key> from the page address and sets product, cycle, domain, domain-action, addons and config-<key> (one frame is built even though several attributes change). It takes an element or a selector, and returns false when the address names no product. config.adminEmail is never taken.

Knowledge base language. Articles have no language of their own, so <teculiar-kb-search> shows every article in the language it was written in, whatever the page language. Multilingual articles wait for the owner (see i18n-currency.md).

If your page builder strips unknown tags

Use a div instead — the script upgrades it in place, and data-* attributes carry across:

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

Theming

Each widget renders in an open shadow root, so the host page's CSS cannot reach in and ours cannot leak out. A purchased WordPress theme with * { box-sizing: content-box } or button { text-transform: uppercase !important } has no effect on the widget — that collision is the single most common way an embedded widget renders broken on someone else's site.

Two things cross the boundary deliberately, because they are the theming API.

CSS custom properties, inherited, so setting them anywhere above the widget works:

: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()**, for anything the properties do not cover:

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 tier B

Custom properties do not cross an iframe boundary — the framed document is a different origin, so none of the above reaches it. The embedded portal is themed from the tenant's saved settings instead, at Admin → Settings → Embed appearance: brand, accent, surfaces, text, lines, corner radius, font, density, light/dark and logo. A colour or font left unset there falls back to the tenant's brand (Admin → Settings → Brand). The server resolves them and inlines the token overrides into the response, so there is no flash of our palette, no postMessage race, and it still themes correctly when the parent page has JavaScript disabled.

A page may fill in what the tenant left unset, using data-teculiar-* on the element:

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

Two rules govern those, and both are deliberate:

  • A saved tenant value always wins — the embed appearance and the brand alike. An override fills a gap; it never displaces a choice. If the page could overrule the tenant, whoever compromised the page could restyle the money UI into something misleading while the URL bar still said our origin.
  • Unknown names and malformed values are dropped, not repaired. The accepted set is fixed and each value must match a tight pattern — a hex colour, a length up to 64px, one of a closed list. Fonts come from a curated list of stacks; there is no arbitrary font URL, which would be both an injection and a privacy leak from our origin.

Teculiar.matchSite() reads the host page's own colours and font and returns them, to be pasted into the settings form. It returns values and applies nothing — it is a convenience for filling the form, never a runtime channel, for the reason above.

The 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

Every method is **generated from docs/openapi/public-v1.json**, so a new @PublicRoute in the API appears here on the next build with no hand-written client to drift. scripts/api-contract-check.ts fails when the committed generated code falls behind the spec. Names come from the path: POST /services/{id}/renew-domain → tec.services.renewDomain(id). tec.operations lists what a given build knows about.

Path params are positional; the trailing argument is a query bag on GET and a JSON body otherwise. Both are typed from the spec: tec.orders.checkout(body) takes an OrdersCheckoutBody (its required fields are required, an enum field takes only its allowed values), tec.domains.search({ query }) a DomainsSearchQuery. The types are exported from src/generated/types.ts as <Group><Method>Body and <Group><Method>Query. A ticket attachment takes a FormData.

Retries

Reads (GET) are repeated once after a timeout or a 5xx. A write is repeated only when it carries an Idempotency-Key, which the SDK adds to checkout, the two pay calls, add-funds, inquiries and tickets (a fresh key per call, the same key on its retry): the server then returns the first answer instead of doing the work again. Every other write is sent once, because a timeout does not say whether it happened. retries: 0 in Teculiar.init switches repeating off.

Errors

TeculiarKeyError (the integration is wrong — bad key, or an origin not on the allowlist), TeculiarAuthError (sign the visitor in), TeculiarRequestError, TeculiarRateLimitError (retryAfterSeconds), TeculiarServerError. All extend TeculiarError and carry status.

The two 401s are separated on purpose: handling them identically ships a site that either logs people out silently or shows a login prompt when the key is misconfigured.

5xx and network faults are retried once (400ms, then 1200ms); 4xx and 429 never are.

Sessions, and the risk

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

The default is "memory": the token lives for one page load, so a refresh signs the visitor out. That is deliberate. A customer token in the tenant's page is readable by any XSS on that page — their bug, our customer's invoices. "local" persists it in localStorage and is opt-in by name so the risky choice is one someone typed rather than one they inherited.

This is why the recommended path for authenticated UI is the iframe tier (9.2.11), where the session lives on our origin and never enters their JS context. The SDK still exposes authenticated calls, for tenants who deliberately build their own portal.

Cart

Client-side, with server-authoritative pricing — there is no server Cart model.

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

toOrder() is { items, currency, locale }: the lines plus the visitor's display currency and the page language. Send it rather than toOrderItems() alone: without currency a guest is billed in the store's main currency, whatever the page showed. The payment methods the buyer can use for this basket (a method that cannot take their currency, or that the store's rules exclude, is left out):

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

locale and currency default to the page language and the display currency.

A domain is a line with no price id and a domain; the server prices it from the billing cycle in its configuration. The domain search widget adds one like this, and toOrderItems() sends domain as the API's domainName:

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

Two lines are the same line when product, price, domain and configuration are equal. A line has a key; cart.remove(key) and cart.setQuantity(key, n) take it (a price id also works for a line that has one).

cart.estimateCents() is named that way on purpose: it is for display. The amount charged comes from orders.preview / orders.checkout, because a total computed in the browser is a number the visitor can edit.

Delivery

PathCache
/embed/v<version>/teculiar.js (for example /embed/v0.1.0/)one year, immutable: the bytes of a released version never change — pin this with SRI
/embed/latest/teculiar.js60s — fixes arrive without touching the tenant's site
/embed/v1/teculiar.js60s — the old alias, kept for snippets already out there. It serves the current build, so it is not pinned

Every store loads these from the script host cdn.teculiar.net (see Install). The pinned 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>

Releasing a version (the owner, at a release): bump version in packages/sdk/package.json, run npm --workspace @teculiar/sdk run build, then npm --workspace @teculiar/sdk run release. That copies teculiar.js, the three widgets-*.js group files, their maps and integrity.json into packages/sdk/releases/<version>/. Commit the folder. The script refuses to overwrite a version that exists, and nothing may ever change inside a released folder. The API serves every folder under releases/ at /embed/v<version>/.

Released versions and their integrity values:

Versionintegrity
none yet

Served by the API itself (no CDN), mounted **before helmet()** with Cross-Origin-Resource-Policy: cross-origin. Helmet's default same-origin does not affect fetch() but does block a plain <script src> from another origin — the same reason /uploads is mounted early. Access-Control-Allow-Origin: * is what lets a browser validate an integrity attribute.

The SRI hash for the current build is written to packages/sdk/dist/integrity.json.

Widgets from a second store

One page can show widgets from more than one store. An element with its own data-key and data-base-url uses that store; the other widgets keep using the script tag's.

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

A second store is always named, never guessed: an element with a different data-key and no data-base-url says so and sends nothing. The language and currency choice stays one per page; each widget shows the chosen currency if its store lists it, otherwise its store's main currency. <teculiar-preferences> always belongs to the page's own store. The account and login frames accept the same two attributes.

Language and currency

The toggle: <teculiar-preferences>

One button (a globe and a summary such as EN · $) opens a dialog with the languages and currencies the store serves. Apply saves both choices. A currency change needs no reload (every widget hears teculiar:prefs). A language change opens the same page in the other language, found in this order: the page's own <link rel="alternate" hreflang> (exact tag, then the same primary subtag), the href-template, the first path segment when it is the page's language, else /<language>/. The query string is kept.

<teculiar-preferences languages="en,de" href-template="/{locale}/"></teculiar-preferences>
AttributeMeaning
languagesComma list of the languages this site has pages for. Shown = served by the store and listed here. Default: all served.
showboth (default), language or currency.
href-templateWhere a language lives when the page has no matching alternate, e.g. /{locale}/.

The widget hides itself when there is nothing to choose (one language and one currency). Parts for styling: button, summary, dialog, title, section, option, apply, close, flag. The dialog is a native <dialog>: it takes focus, closes on Esc and is fully keyboard-operable.

A live price: <teculiar-price>

One price in your own typography (no shadow root). Write the main-currency price inside it at build time; it stays until the live value is ready and on any error (the element then also gets 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>
AttributeMeaning
amountA main-currency amount as typed: 8, 29.90, 29,90.
product, cycle, fieldA catalogue price by product slug. cycle such as MONTHLY or YEAR_1 (default: the cheapest); field="setup" for the setup fee (default unit).
permonth or year: appends "/mo" or "/yr" in the page language.
currencyPin one listed currency instead of the visitor's choice.

Whole amounts drop .00; converted ones keep cents. The element's data-currency names the currency shown. It changes at once when the visitor switches currency, with no new request.

Price tokens in your own text

Type {{price:29}} in your CMS, the way the Customizer does, and mark the area that may contain them:

<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 on the script tag converts the marked areas once the page has loaded; call Teculiar.prices.render(root) yourself for content added later. Each token becomes a <teculiar-price> that follows the visitor's currency. Damaged tokens (a lost brace) are repaired; unreadable ones and tokens outside a marked area stay as written.

The script API

A page that uses only the script tag can read and change the visitor's language and currency the way a Teculiar theme does. The choice is one cookie, teculiar_prefs (language.currency.stamp, for example de.EUR.1760520000000), the same one the store's own storefront and client portal use.

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" }

The same methods exist on every client (const tec = Teculiar.init(...), then tec.prefs, tec.i18n, tec.money, tec.ready()). All clients on a page share one choice.

Rules, the same as on a Teculiar theme:

  • Page language is the primary part of <html lang> if the store serves it, else the store's main language. The address decides what the visitor reads; it never stores a preference.
  • Display currency is the cookie's currency if the store lists it, else the page language's default currency if listed, else the store's main currency. A currency without a valid exchange rate is never listed.
  • prefs.set ignores a language the store does not serve and a currency it does not list. Every choice is stamped with the server's time (the bootstrap's clock plus the time elapsed since it arrived), never the device's, so the later choice wins between the site and the portal.
  • First visit: with no cookie yet, when the page's language is the one the browser's languages resolve to, the language is remembered once. Nothing else writes on its own.
  • A signed-in customer's choice is also sent to their account (PATCH /users/me); a failure there is ignored.
  • teculiar:prefs is fired on document (bubbles, composed) with { locale, currency, previous }.
  • Before ready() resolves, money returns an empty string and i18n lists nothing: wait for it.

Your own website, step by step

How teculiar.com, a static site with its own pages and translated addresses, sells through the store. Each step is one thing from this page; do them in this order.

  1. Add the site as a host. Admin → Settings → Domains → Your own website: enter the site's domain, add the DNS TXT record, verify. This lets the language and currency choice be shared with the portal and lets the payment provider send buyers back to your pages. Create a publishable key under Admin → Settings → API keys and list the site's address as its origin.
  2. One script tag on every page (see Install); a page that needs only the toggle and prices loads the core file and nothing else.
  3. Put the language in the address (/en/…, /de/…) and set <html lang> and hreflang links to each translation. The address decides what the visitor reads; it never stores a choice.
  4. Add the toggle (<teculiar-preferences>). With the shared host it writes one cookie that the portal reads too. Prices follow the visitor's currency.
  5. Show prices with <teculiar-price product="…">, or the product grid or pricing table for a list. Give them checkout="/en/checkout/" and "Select" opens your checkout page with the product and cycle in the address.
  6. The checkout page. <teculiar-checkout> plus Teculiar.checkoutFromUrl(). Keep the statusToken from teculiar:checkout-created in sessionStorage.
  7. The return page. Set return-url to a page of yours. When the buyer arrives with order and result in the address, read GET /orders/<order>/status?token=<statusToken> until it says ready, then show the links it returns (the portal page of the new service).
  8. Keep the site fresh. Subscribe a webhook to content.*, catalog.updated and settings.updated (webhooks.md) and rebuild the site when one arrives.

Pages that read data at build time (the plans, the module library, posts) call the same public routes with the publishable key, for example GET /platform/plans?locale=de.

Caching

Anonymous reads are reused across page loads so a visitor moving between pages sees prices at once. The product list (and, from L2, the bootstrap) is kept in sessionStorage for five minutes: a stored answer is shown immediately and refreshed in the background, and widgets update only if the refresh differs. Two widgets that need the same answer make one request.

  • sessionStorage only, never localStorage: nothing outlives the tab.
  • A signed-in client never reads or writes it; an answer that depends on a customer token is not cached.
  • Without storage (private modes, blocked storage) the cache falls back to memory for the page.
  • cachedGet(client, path, query), cachedEntry (adds the time received) and onCacheUpdate are exported for tenants who build their own widgets.

Security notes

  • No markup, ever. Widgets build DOM with createElement/textContent only. A product name or blog excerpt interpolated into an HTML string would become script on the tenant's site — an XSS we shipped and they get blamed for. apps/../packages/sdk/test/no-html-injection.test.mjs fails the build on innerHTML, insertAdjacentHTML, document.write, eval and new Function, in both the source and the built bundle. (The plan called for an ESLint rule; this repo has no ESLint, so the guarantee is enforced in the test runner it already uses.)
  • Link hrefs are scheme-checked. textContent does not help with an attribute: a CMS field carrying javascript: is one click from executing on the tenant's origin. safeHref is exported for tenants rendering our data with their own markup.
  • No cookies. The public door is origin: * with credentials: false; the SDK sends credentials: "omit".

Methods

auth

CallRequestScopeSign-inSince
tec.auth.get2faSetup()GET /auth/2fa/setupcustomers:writeCustomer sign-in2026-09
tec.auth.get2faStatus()GET /auth/2fa/statuscustomers:readCustomer sign-in2026-09
tec.auth.login()POST /auth/loginauth:writeKey only2026-09
tec.auth.logout()POST /auth/logoutauth:writeCustomer sign-in2026-09
tec.auth.passwordResetConfirm()POST /auth/password-reset/confirmauth:writeKey only2026-09
tec.auth.passwordResetRequest()POST /auth/password-reset/requestauth:writeKey only2026-09
tec.auth.post2faDisable()POST /auth/2fa/disablecustomers:writeCustomer sign-in2026-09
tec.auth.post2faVerify()POST /auth/2fa/verifycustomers:writeCustomer sign-in2026-09
tec.auth.refresh()POST /auth/refreshauth:writeKey only2026-09
tec.auth.register()POST /auth/registerauth:writeKey only2026-09

billing

CallRequestScopeSign-inSince
tec.billing.addFunds() (safe to retry)POST /billing/add-fundspayments:writeCustomer sign-in2026-09
tec.billing.invoices()GET /billing/invoicesinvoices:readCustomer sign-in2026-09
tec.billing.invoicesById(id)GET /billing/invoices/{id}invoices:readCustomer sign-in2026-09
tec.billing.invoicesHtml(id)GET /billing/invoices/{id}/htmlinvoices:readCustomer sign-in2026-09
tec.billing.invoicesPay(id) (safe to retry)POST /billing/invoices/{id}/payinvoices:payCustomer sign-in2026-09
tec.billing.invoicesPaymentMethods(id)GET /billing/invoices/{id}/payment-methodsinvoices:payCustomer sign-in2026-09
tec.billing.invoicesPdf(id)GET /billing/invoices/{id}/pdfinvoices:readCustomer sign-in2026-09
tec.billing.paymentMethods()GET /billing/payment-methodspayment-methods:readCustomer sign-in2026-09

cms

CallRequestScopeSign-inSince
tec.cms.announcements()GET /cms/announcementscustomers:readCustomer sign-in2026-09
tec.cms.announcementsHide(id)POST /cms/announcements/{id}/hidecustomers:writeCustomer sign-in2026-09
tec.cms.announcementsRead(id)POST /cms/announcements/{id}/readcustomers:writeCustomer sign-in2026-09
tec.cms.blogCategories()GET /cms/blog-categoriescontent:readKey only2026-09
tec.cms.blogTags()GET /cms/blog-tagscontent:readKey only2026-09
tec.cms.pages(locale, slug)GET /cms/pages/{locale}/{slug}content:readKey only2026-09
tec.cms.posts()GET /cms/postscontent:readKey only2026-09
tec.cms.postsByLocaleSlug(locale, slug)GET /cms/posts/{locale}/{slug}content:readKey only2026-09
tec.cms.postTags()GET /cms/post-tagscontent:readKey only2026-09

domains

CallRequestScopeSign-inSince
tec.domains.search()GET /domains/searchdomains:searchKey only2026-09

knowledgebase

CallRequestScopeSign-inSince
tec.knowledgebase.get(slug)GET /knowledgebase/{slug}content:readKey only2026-09
tec.knowledgebase.list()GET /knowledgebasecontent:readKey only2026-09
tec.knowledgebase.suggest()GET /knowledgebase/suggestcontent:readKey only2026-09

orders

CallRequestScopeSign-inSince
tec.orders.checkout() (safe to retry)POST /orders/checkoutcheckout:writeKey only2026-09
tec.orders.get(id)GET /orders/{id}orders:readCustomer sign-in2026-09
tec.orders.pay(id) (safe to retry)POST /orders/{id}/paypayments:writeCustomer sign-in2026-09
tec.orders.preview()POST /orders/previewcheckout:writeKey only2026-09
tec.orders.status(id)GET /orders/{id}/statuscheckout:writeKey only2026-10

platform

CallRequestScopeSign-inSince
tec.platform.namesCheck()GET /platform/names/checkcatalog:readKey only2026-10
tec.platform.plans()GET /platform/planscatalog:readKey only2026-10

products

CallRequestScopeSign-inSince
tec.products.list()GET /productscatalog:readKey only2026-09

services

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

storefront

CallRequestScopeSign-inSince
tec.storefront.domainPrices()GET /storefront/domain-pricescatalog:readKey only2026-09
tec.storefront.embed()GET /storefront/embedcontent:readKey only2026-09
tec.storefront.i18n()GET /storefront/i18ncatalog:readKey only2026-09
tec.storefront.inquiries() (safe to retry)POST /storefront/inquiriesinquiries:writeKey only2026-09
tec.storefront.modules()GET /storefront/modulescatalog:readKey only2026-10
tec.storefront.modulesByName(name)GET /storefront/modules/{name}catalog:readKey only2026-10
tec.storefront.page(key)GET /storefront/page/{key}content:readKey only2026-09
tec.storefront.paymentGateways()GET /storefront/payment-gatewayspayment-methods:readKey only2026-09
tec.storefront.products()GET /storefront/productscatalog:readKey only2026-09
tec.storefront.redirects()GET /storefront/redirectscontent:readKey only2026-09
tec.storefront.settings()GET /storefront/settingscatalog:readKey only2026-09
tec.storefront.theme()GET /storefront/themecontent:readKey only2026-09

tickets

CallRequestScopeSign-inSince
tec.tickets.attachments(id)POST /tickets/{id}/attachmentstickets:writeCustomer sign-in2026-09
tec.tickets.close(id)POST /tickets/{id}/closetickets:writeCustomer sign-in2026-09
tec.tickets.create() (safe to retry)POST /ticketstickets:writeCustomer sign-in2026-09
tec.tickets.departments()GET /tickets/departmentstickets:readCustomer sign-in2026-09
tec.tickets.get(id)GET /tickets/{id}tickets:readCustomer sign-in2026-09
tec.tickets.list()GET /ticketstickets:readCustomer sign-in2026-09
tec.tickets.replies(id)POST /tickets/{id}/repliestickets:writeCustomer sign-in2026-09

users

CallRequestScopeSign-inSince
tec.users.me()GET /users/mecustomers:readCustomer sign-in2026-09
tec.users.meExport()GET /users/me/exportcustomers:readCustomer sign-in2026-09
tec.users.updateMe()PATCH /users/mecustomers:writeCustomer sign-in2026-09