# Embeddable Form Widget
Tenant-authored forms that other websites can embed via a single
`
```
Optional attributes:
- `data-host="https://forms.example.com"` — explicit iframe host. Defaults
to the origin of `widget.js` itself.
## Anti-spam layers
The submission endpoint applies four layers in order. Each layer
either rejects the request or quietly drops it (honeypot).
| Layer | Mechanism | Configurable |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------- | ---------------------- |
| Origin allowlist | `Origin` header matched against `Form.allowedOrigins`. Empty allowlist = no submissions. Supports `*.example.com` wildcards. | per-form |
| Honeypot | A hidden field with key `hp_extra_data`. When filled, the API returns 204 silently. | always on |
| Rate-limit | Sliding-window via `@toolctrl/shared-store`: 10/min/IP, 100/min/origin. | static |
| Turnstile | Cloudflare CAPTCHA, opt-in per form (`config.turnstileEnabled=true`) + feature flag `form-widget-turnstile`. | per-form + global flag |
## Submission targets
`Form.targetType` chooses how a submission is processed:
- `generic` (default) — the submission is stored immediately; the
routing handler marks it `processed`.
- `crm-contact` — the routing handler maps submitted fields through
`Form.fieldMapping`, calls the injected CRM adapter, and marks the
submission `routed-to-crm` with the created contact id.
- `entity-row` — the routing handler maps submitted fields through
`Form.fieldMapping`, calls the injected entity-row adapter for
`targetEntityId`, and marks the submission `routed-to-entity` with
the created row id.
Routing is asynchronous and idempotent. The handler short-circuits rows
that are no longer `received`, rejects tenant mismatches between the
outbox envelope, payload, and stored submission row, writes
`route-failed` plus a redacted error on failure, and rethrows so BullMQ
retries and error reporting still see the issue. `apps/worker/src/index.ts`
wires `bootstrapFormSubmissionRoute(...)` on `WORKER_QUEUE_GROUP=general`
with the Prisma store, CRM/entity adapters, Redis worker factory, and
follow-up publisher.
Webhook targets are delivered through a second queue hop: routing emits
`webhook.form-submission.deliver`; `WORKER_QUEUE_GROUP=mail` subscribes
to `outbox.webhook`, validates the tenant-scoped payload, then enqueues
the existing `toolctrl.email` `deliver-webhook` job with an idempotency key
based on the outbox event id, dispatch key, or submission id. Notification
email follow-ups use the same pattern: routing emits
`mail.transactional.form-submission`; `WORKER_QUEUE_GROUP=mail`
subscribes to `outbox.mail`, validates the tenant-scoped payload, renders
a compact operator notification, then enqueues the existing
`toolctrl.email` `send-transactional-email` job.
The PII-sensitive submitter IP is never stored raw; only the
`sha256(ip).slice(0, 32)` lives in `FormSubmission.ipHash`.
## Theming
`Form.theme` accepts a partial of:
```json
{
"primaryColor": "#2563eb",
"backgroundColor": "#ffffff",
"textColor": "#111111",
"fontFamily": "system-ui, sans-serif",
"borderRadius": "6px"
}
```
The renderer exposes these as CSS variables `--tcform-primary`,
`--tcform-bg`, `--tcform-text`, `--tcform-font`, `--tcform-radius`.
Empty theme inherits browser defaults; future work will pull defaults
from `TenantSettingsRow` brand tokens.
## postMessage protocol
The iframe emits the following messages; the bootstrap loader
re-broadcasts them on the host document as `CustomEvent`s of the same name:
| Type | Payload |
| ------------------ | --------------------------------------------------------------------------------------------------------------- |
| `tcform:resize` | `{ height, formKey }` — fires whenever the iframe content reflows. The loader clamps height to `[100, 8000]px`. |
| `tcform:submitted` | `{ formKey }` — fires once the submission API returns `ok=true`. |
| `tcform:error` | `{ formKey, error }` — fires on validation / 4xx / 5xx. |
Listen on the parent page:
```js
document.addEventListener("tcform:submitted", (event) => {
console.log("submitted!", event.detail);
});
```
## Webhook payload
When `Form.webhookUrl` is set, the follow-up planner emits a
`webhook.form-submission.deliver` outbox event after successful routing.
The payload shape is:
```json
{
"submissionId": "ckxxxxxxxxxxxxxxxxxxxxxxx",
"formId": "ckxxxxxxxxxxxxxxxxxxxxxxx",
"tenantId": "...",
"webhookUrl": "https://www.example.com/hooks/form",
"targetType": "crm-contact",
"targetId": "ckxxxxxxxxxxxxxxxxxxxxxxx",
"values": { "email": "alice@example.com", "...": "..." }
}
```
When `notifyEmails` is non-empty, the same follow-up planner emits
`mail.transactional.form-submission` with `recipients`, `targetType`,
`targetId` and `values`. The worker host publishes the returned outbox
events transactionally during routing, and `worker-mail` bridges them
from `outbox.mail` into `toolctrl.email`. Operator-owned delivery
concerns include provider credentials, queue health and any
tenant-facing delivery-status UX.
URLs are validated against an internal-host blocklist (`localhost`,
`127.0.0.1`, `10.x`, `192.168.x`, `169.254.x`, `*.internal`) before
they're allowed to save (SSRF defense).
## CLI
```
toolctrl-cli forms list --tenant
toolctrl-cli forms create --tenant --name "Contact" --origin https://www.example.com
toolctrl-cli forms delete --id
```
## Environment variables
Add to `.env`:
| Name | Purpose |
| ---------------------- | ----------------------------------------------------------------------------------- |
| `FORM_WIDGET_CDN_URL` | Public CDN/host that serves `/embed/widget.js`. Optional — falls back to `APP_URL`. |
| `TURNSTILE_SITE_KEY` | Cloudflare Turnstile site key (front-end). Required when Turnstile is enabled. |
| `TURNSTILE_SECRET_KEY` | Cloudflare Turnstile server secret. Required when Turnstile is enabled. |
## Troubleshooting
- **403 "Origin not allowed"** — add the embedding site's origin to
`Form.allowedOrigins`. Browsers send only scheme+host+port; no path.
- **204 with no DB row** — the honeypot triggered. Check that automation
scripts skip the `hp_extra_data` field.
- **429** — the per-IP or per-origin sliding window kicked in. Each
window is 60 seconds.
- **iframe height stuck** — the parent page is blocking the loader's
message listener. Confirm `widget.js` is loaded with `async` and not
filtered by a strict CSP.
## Security notes
- iframe `sandbox="allow-scripts allow-forms allow-same-origin"` — no
storage/popups/top-navigation.
- Form definitions are Zod-validated on read; React escapes every text
node so XSS via crafted labels is not possible.
- IPs are stored as truncated SHA-256 hashes.
- Webhook URLs are validated against an internal-host blocklist.