Documentation
Everything a page needs to run an EduFacturing widget: the script tag, the attributes, the Content-Security-Policy directives, and every error the widget can show a visitor.
<script type="module" src="https://cdn.edufacturing.com/embed.js" data-ef-api="https://developers.edufacturing.com"></script>
<ef-printer-advisor
ef-key="pk_ef_live_your_publishable_key"
ef-locale="en"
ef-theme="auto"></ef-printer-advisor>
Load embed.js once per page, anywhere — it is a module, so it does
not block rendering. Then place as many elements as you like. Each one fetches its own
configuration and renders inside its own Shadow DOM, so your CSS and ours never meet.
data-ef-api on the script tag is optional; it overrides the
compiled-in API host. An ef-api attribute on a single element overrides it
for that element only.
| Attribute | Values | Default |
|---|---|---|
ef-key | Your publishable key | required |
ef-locale | en, bg |
from <html lang>, else en |
ef-theme | auto, dark,
light | auto |
ef-api | Absolute URL of the portal | https://developers.edufacturing.com |
The widget reads CSS custom properties from its own host element. Set them from your stylesheet and nothing else changes:
ef-printer-advisor{
--ef-bg:#fff;
--ef-surface:#f4f6f8;
--ef-border:rgba(0,0,0,.12);
--ef-text:#1a2129;
--ef-text-strong:#000;
--ef-muted:#5a6b76;
--ef-accent:#e8752a;
--ef-accent-ink:#fff;
--ef-radius:12px;
--ef-font:"Inter",system-ui,sans-serif;
}
Everything inside is Shadow DOM: your page cannot accidentally restyle the widget, and the widget cannot restyle your page.
If your site sends a CSP header — and it should — these are the directives the widget needs. Nothing else is contacted.
| Directive | Add | Why |
|---|---|---|
script-src | https://cdn.edufacturing.com | embed.js and the lazily loaded advisor chunk |
connect-src | https://developers.edufacturing.com | The one call that validates your key and returns the widget config |
worker-src | blob: |
The advisor scores the catalogue in a Web Worker. A cross-origin worker has
to be started through a small blob shim; if blob: is refused, the
widget falls back to computing on the main thread — slower, still correct. |
frame-src |
the quote / agent host, if you use those elements | Only <ef-quote-calculator> and <ef-agent-chat>
use an iframe; the advisor does not. |
No style-src entry is normally needed: the widget’s
styles live inside its Shadow DOM and are installed as a constructed stylesheet
(adoptedStyleSheets), which style-src does not gate. On a
browser without that API the widget falls back to a <style> element
inside its shadow root, and that fallback does need
style-src 'unsafe-inline'.
GET https://developers.edufacturing.com/v1/widgets/printer-advisor/config?key=pk_ef_live_…
Origin: https://your-shop.example
200 {
"widget": "printer-advisor",
"plan": "pro",
"locale": "en",
"module": "https://cdn.edufacturing.com/w/printer-advisor.js?v=…",
"worker": "https://cdn.edufacturing.com/w/printer-advisor.worker.js?v=…",
"urls": { "quoteCalculator": null, "agentChat": null },
"quota": { "used": 41, "limit": 10000, "period": "2026-08" }
}
The key may also travel as an X-EF-Key header. The response
is never cached and never contains a secret.
| Status | Code | What went wrong |
|---|---|---|
| 401 | key_missing | No ef-key attribute. |
| 401 | key_invalid | The key does not exist. |
| 401 | key_revoked | You revoked it in the dashboard. |
| 401 | secret_key_in_browser |
A sk_ key reached the browser endpoint. Replace it with the
publishable key and rotate the secret. |
| 403 | origin_required |
The request carried no Origin. Widgets must run in a page. |
| 403 | origin_not_allowed |
This domain is not on the key. Add it in the dashboard. |
| 429 | rate_limited |
Burst limit for the plan. Retry-After says when. |
| 429 | quota_exceeded |
Monthly calls are used up. Upgrade or wait for the next period. |
| 404 | unknown_widget | No such element name. |
Refused requests do not count against your quota — a misconfigured page cannot spend your plan.
A key carries a list of hosts. Accepted forms:
shop.example.com — exactly that host*.example.com — any subdomain, but not
example.com itself; add it separately if you need itlocalhost:5173 — for local development; the port is part of the
origin, so :5174 is a different entryA pattern that would cover a whole suffix (*.com) is
refused when you save it, and the dashboard tells you which entries were dropped.
The advisor runs @edufacturing/reco-core against the EduFacturing
knowledge core — the same rule set as our other tools. Each machine card lists the
rule ids that supported or penalised it; hovering a rule shows its citation.
Catalogue prose (a machine’s pros and cons, a material’s
description) is written in Bulgarian and is shown only with
ef-locale="bg". The English widget explains from structured specifications
— price, technology, build volume, layer height, use-case tags — rather than
from machine translation.
Three independent limits sit in front of the configuration call:
| Limit | Scope | On the free plan |
|---|---|---|
| Monthly quota | Your whole account, across every key | 100 calls |
| Burst | Your whole account — adding a second key does not raise it | 30 / minute |
| Per-address burst | One visitor’s IP, whatever key it uses | 60 / minute |
Refused requests never touch the monthly quota, so a misconfigured page or a hostile visitor cannot spend your plan by being refused.
The publishable key is meant to be read. Anyone can open your page source, copy it and
send requests with a forged Origin header — the header is only enforced
by browsers, not by curl. That is the same trade-off every publishable-key
widget makes. What the allow-list buys you is that the key is useless inside another
website: a competitor cannot drop it into their own page and get a working widget.
What it does not buy you is protection from someone deliberately burning your quota
from a script. The per-address burst limit above is what makes that expensive; if that
is not enough for your case, write to us before you rely on it.
Rotate a key from the dashboard the moment it appears somewhere it should not; revocation takes effect on the next request, with no cache in between.