Provider backend & admin SSO
The provider backend owns business data and authorization. Typeroll supplies two distinct identity types; they must not be interchangeable:
- a short-lived delegated user token for an admin launch;
- a public Extension token attached to a direct frontend API request.
Always validate the token issuer dynamically. A hosted portal and a self-hosted portal publish discovery and JWKS from different domains.
Bind every token to your developer organization
Section titled “Bind every token to your developer organization”Every token Typeroll issues for an installation (admin launch and portal admin
tokens, public Extension tokens, documentation and build derivation
assertions, and issuer pairing assertions) has aud set to the Extension id
and, from Core 0.2.113, a developer_org_id claim naming the developer
Organization that registered the Extension. Check both:
audequals your Extension id;developer_org_idequals your own developer Organization id.
Extension ids are reserved host-wide from Core 0.2.113: registering an id
that another developer Organization holds returns 409. Registrations made
before that release keep working without a reservation until their owner
updates them or the host operator backfills reservations, so two developer
Organizations can already hold the same id and both receive tokens with the
same aud. The developer_org_id claim is what tells them
apart. A provider that checks only iss and aud can accept a token issued
for another Organization’s Extension with the same id.
Your developer Organization id is the developer_org_id field of the
Extension record: it is returned when you register the Extension, by
GET /api/developer/extensions/{extensionId} and by the
read_developer_extension and list_developer_extensions MCP tools. The same
read returns id_reservation: reserved (your Organization holds the id),
unreserved (a registration from before reservations; your next update or
publish claims it), or held_by_other (another developer Organization holds
the id; your existing installations keep working, but contact the host
operator).
Store the expected value as configuration next to your Extension id and the
trusted issuer. Treat a token without developer_org_id as issued by a
portal older than Core 0.2.113: accept it only from an issuer you know runs
such a release, and stop accepting it once every issuer you trust has
upgraded.
Planned audience change
Section titled “Planned audience change”aud remains the bare Extension id for compatibility. A later protocol
version is planned to change it to
typeroll:ext:{developer_org_id}:{extension_id}, which binds the
developer Organization into the audience itself. Before that change ships,
it will be announced in the changelog and in issuer discovery, with a
deprecation window in which providers can accept both forms. Providers that
already require developer_org_id need only to accept the new aud value
when it arrives.
Admin launch
Section titled “Admin launch”Typeroll posts a 60-second launch code, issuer, installation ID, and page ID to
the manifest’s exact launch_url. Exchange the code server-to-server:
POST {issuer}/api/extensions/tokenContent-Type: application/json
{ "grant_type": "authorization_code", "code": "…", "client_id": "…", "client_secret": "…"}Validate the returned ES256 JWT against the issuer’s discovery document and
JWKS. Check at least iss, aud, developer_org_id, sub, org_id,
site_id, installation_id, permission, scopes, jti, iat, and
exp. The client
secret and access token must never reach frontend code or logs.
Direct frontend requests
Section titled “Direct frontend requests”Frontend code calls a declared relative path with context.api.fetch(). The
browser sends the request directly to api.base_url; Typeroll does not receive
or forward its payload. Configure CORS for each installed site origin and
accept the X-Typeroll-Extension-Token request header.
When api.authentication is signed_installation, verify that token’s ES256
signature and iss, aud, developer_org_id, sub,
token_use=public_extension, org_id, site_id, installation_id,
origin, jti, iat, and exp. Fetch keys
from the issuer’s discovery/JWKS endpoints. The token proves installation
identity only. Authenticate and authorize the visitor, recipient token and
business action separately.
For bundled components, require the verified origin claim to equal the
request’s browser Origin header. Embedded apps use Typeroll’s validated
browser-side message bridge, so the provider request is likewise made by the
published site origin. Never use the token claim to relax CORS dynamically
before the signature and issuer have been verified.
Editor previews are the exception to ordinary site-origin binding: their
sandbox has an opaque origin, so both the browser Origin header and the
signed preview token’s origin claim are the literal string null. That
equality does not identify a site. Require the token’s preview: true claim
and enforce its exact preview_routes method/path allowlist server-side.
Do not accept a site or organization ID supplied by browser input when a verified Extension token already contains that identity. Do not put durable provider credentials in frontend code. Typeroll’s route declaration is a client contract, not a provider-side access-control substitute.
There is no supported architecture where Typeroll or a generated customer Function reverse-proxies this request. Third-party and bespoke backends remain in the app developer’s accounts. Typeroll Apps use the same direct contract, but their backends are operated separately in Typeroll-controlled accounts.
Pair a self-hosted issuer
Section titled “Pair a self-hosted issuer”If the manifest declares auth.pairing_url, an administrator can explicitly
pair a portal. The provider receives an issuer, discovery URL, nonce, JWKS
fingerprint, and signed pairing assertion. Fetch discovery and JWKS from the
declared issuer, compare the canonical JWKS fingerprint, verify the assertion,
then store the trusted issuer. The assertion has token_use: "issuer_pairing",
your Extension id as aud and your developer Organization as
developer_org_id; check both. Never trust keys included only in the inbound
request body.
Lifecycle webhooks
Section titled “Lifecycle webhooks”Typeroll signs the exact raw body with HMAC-SHA256:
X-Typeroll-EventX-Typeroll-Event-IdX-Typeroll-TimestampX-Typeroll-Signature: v1=<hex hmac>Verify the signature over <timestamp>.<raw-body>, reject stale timestamps,
and deduplicate X-Typeroll-Event-Id. Return a success response for an event
already processed. Delivery retries network failures, 408, 429, and 5xx.
Service credentials
Section titled “Service credentials”Server-to-server calls into Typeroll use an installation credential, not an admin launch token. Send it as Bearer along with the owner organization and installation headers required by the site API. Store the secret outside source control and rotate it from the Extension installation settings. Only the Organization that owns the Site issues credentials; an Organization the Site is shared with cannot. A credential carries the scopes granted when it was issued, limited on every request to the installation’s current grant (see available scopes).
See the manifest reference for the declared surfaces.
Owner proposals, delivery and form actions
Section titled “Owner proposals, delivery and form actions”A provider may request content:owner, email:send and forms:execute scopes
with explicit reasons. All require administrator approval. Use an installation
server credential with the organization and installation headers; never put it
in a component, public snapshot or guide.
Relative to /api/v1/sites/{siteId}:
GET /extensions/selfreturns the own enabled installation’s resolved version, validated non-secret config and verified site origins.GET/PUT /pages/{pageId}/owner-fieldsreads typed owner-writable fields and a revision. PUT creates an isolated pending proposal using{changes,base_revision,request_id}and a server-derivedX-Typeroll-Owner-Subjectdigest. Core 0.2.99 also supports independent review groups withgroup_idandgroup_revision; the subject-authenticated descriptor returns their current review state. It never writes accepted Page content directly. Authenticate the visitor and bind its record in the provider. See Owner answers and review. This replaces the direct-write contract in Core 0.2.24 and earlier.POST /delivery/emailsends{ idempotency_key, to, subject, text }through the site’s configured connector. It supports one recipient and returns delivery errors without exposing connector credentials.POST /forms/{formId}/actionsaccepts{ phase: "before" | "after", data, page_id? }only when the form belongs to the calling installation.
An administrator can bind an existing or new Form using
target: { installation_id, path, hydrate?, session_param?, context_params? }. The path must be
a declared POST route. Both API and MCP support this target. Preserve the
form’s steps, actions and styling when changing its destination.
With forms:write an installation can bind only to itself, and only a Form it
creates or an unbound Form without configured actions; binding a Form the site
administrator gave email or webhook actions is the administrator’s decision. A
Form bound to another installation cannot be changed or deleted by it. An
installation may delete a Form bound to itself, but never with
delete_submissions=true: erasing submitted data needs a site administrator.
A component’s form_bindings never receive a Core submit token for a Form bound
to an app installation.
Authenticated product guides (Core 0.2.9)
Section titled “Authenticated product guides (Core 0.2.9)”Declare documentation.access: "installation" and a HTTPS guide URL on the
approved provider origin. Omit agent_instructions for private guides. Core
fetches Markdown with a 60-second ES256 assertion: verify issuer, app audience,
developer_org_id, token_use: "documentation", organization, site,
installation and app version.
Recheck current enabled status against the CMS or a securely synchronized
installation record. Do not follow an arbitrary caller-supplied issuer URL.
Return Cache-Control: private,no-store; reject unauthorized, expired,
disabled, cross-site and wrong-version requests.
Authorized users and agents discover these guides with read_app_documentation.
An installation credential reading /apps/documentation with content:read
receives only its own installation’s entry, never another provider’s guide.
Core limits the document to 256 KiB and reports unavailable guides explicitly.
No private guide text belongs in a public manifest or build asset.
Native portal administration (runtime 0.41.0)
Section titled “Native portal administration (runtime 0.41.0)”An explicitly trusted app can render a self-contained ES module directly inside Typeroll’s portal. This is an alternative to the existing isolated iframe admin launch. It does not require moving the CMS, authentication or app backend to the same provider. A backend can be deployed independently on Cloudflare or another HTTPS service.
Native modules have the portal’s browser privileges. They are not sandboxed. A site administrator installing an app is not sufficient authorization to run its JavaScript in the portal. The host operator must approve its exact published manifest digest, extension ID and developer organization. Every new release needs new approval. Untrusted apps should use the iframe launch contract. Typeroll Cloud controls this approval; self-host operators own their own trust policy. Private app implementations and guides remain with their providers.
Declare a native admin entry in the manifest:
{ "admin": { "pages": [ { "id": "settings", "label": "App settings", "minimum_permission": "admin", "launch_url": "https://app.example.com/admin", "native": { "sdk_version": 1, "script_url": "https://app.example.com/assets/1.0.0/admin.js", "script_sha256": "<SHA-256 of the exact module bytes>", "api_base_url": "https://app.example.com/v1" } } ] }}Use runtime_compatibility: ">=0.41.0 <1.0.0". launch_url remains required by
the manifest contract, but approved native pages do not perform an iframe launch.
Serve the immutable module with CORS permission for the portal origin. It must
contain its dependencies: relative imports do not work from the verified Blob
module. The portal checks SHA-256 and a 1 MiB limit before evaluating it.
export const sdkVersion = 1;export async function mount(root, sdk) { const config = await sdk.configuration.read(); const button = document.createElement("button"); button.textContent = "Enable feature"; button.addEventListener( "click", async () => { try { await sdk.configuration.save({ enabled: true }); sdk.notify("Settings saved"); } catch (error) { sdk.notify(error.message, "error"); } }, { signal: sdk.signal }, ); root.append(button); return () => root.replaceChildren();}The version 1 SDK provides:
| Member | Contract |
|---|---|
context |
Read-only issuer, organization, site, installation, app version and permission. |
signal |
Abort signal for listeners and in-flight work when the page closes. |
request('/path', { method, body }) |
JSON request beneath the approved API base; automatically supplies a short-lived user token. |
configuration.read() / .save(patch) |
Masked installation config and schema-validated patch; requires site administrator access. Secret values are never returned. |
navigate(path) |
Navigate within the current site’s portal routes. |
notify(message, 'success' | 'error') |
Accessible portal feedback using plain text. |
setDirty(boolean) |
Warn before leaving with unsaved changes; a successful SDK save clears it. |
Mount into the supplied root, use responsive widths, and return a cleanup function. Use text nodes for untrusted values. Portal theme variables can style the content; the module must avoid global styles and navigation outside the SDK contract.
Each protected SDK operation rechecks the portal session, current permissions,
enabled installation and exact host approval. An app version or organization
change requires reloading the app. API tokens expire after five minutes; their
claims include token_use: "portal_admin", page_id, version, sub, org_id,
site_id, installation_id, developer_org_id, permission and effective
scopes. Verify the ES256 signature, trusted issuer, audience, developer
Organization and expiration, bind the claims to the
requested resource, and recheck the installation. User revocation has at most
the token lifetime at a backend unless the backend performs an additional live
user check. Do not confuse these tokens with installation credentials or guide
assertions. Never persist them, put them in URLs or forward them on redirects.
Public site components use the existing frontend contract and the version frozen at publication. Updating portal administration does not automatically update already-published websites. Apps own their operational data; CMS Pages remain in Typeroll. Share data through explicit authenticated APIs or events instead of writing another app’s database.
For self-host operators, approval is a host-owned datastore record at
extension_admin_approvals/<manifest_sha256> with developer_org_id,
extension_id, manifest_sha256 and enabled: true. Read the digest from the
published registry record, verify the release and write through the host’s
administrative datastore access. Set enabled: false to revoke it. Do not expose
this collection through a developer or site-admin API. An ordinary installation
or organization administrator cannot grant this host-level trust.
Approved app administration through API/MCP (Core 0.2.25)
Section titled “Approved app administration through API/MCP (Core 0.2.25)”call_extension_admin provides the same native admin integration to an ordinary
site administrator API key. Read the enabled installation’s own guide for its
operation paths and effects. Core POST endpoint:
/api/v1/sites/{site}/extensions/{installation}/admin-request.
The body has page_id (manifest admin page), path (relative to that page’s
approved native.api_base_url), method (GET or POST), optional string-map
query, and optional JSON body for POST. Core requires an enabled installation
and host-approved current manifest, signs the administrator identity, and calls
only the approved HTTPS host. Redirects, path traversal and caller-supplied hosts
are rejected; credentials remain server-side. Installation credentials cannot
invoke this operation. A call does not automatically publish the site. Follow the
app’s documented idempotency rules; inspect status before retrying an uncertain write.
Transactional mail
Section titled “Transactional mail”An enabled installation with email:send can POST
/api/v1/sites/{siteId}/delivery/email using its installation credential and
organization/installation headers. This is transactional delivery, not permission
to send unsolicited campaigns. The site administrator configures the provider,
verified sender and Reply-To; apps never receive provider credentials or choose
an arbitrary From address.
{ "idempotency_key": "request-0000000001", "to": "owner@example.com", "subject": "Your requested link", "text": "The requested message"}Reuse the same 16–100 character key and identical message for a transport retry.
A different payload with that key returns 409. Keys and receipt reads are scoped
to the installation and site. Read /delivery/email/{message_id} with the same
credential for status and normalized events. The response distinguishes
accepted (provider accepted it) from delivered (confirmed delivery), failed,
unknown, bounced, complained and dispatching. An accepted send returns
202; other states return 409 with its receipt. Retrying a pending/unknown receipt
does not send another message. Never generate a new key to bypass an uncertain
send or a quota.
Reservations and quotas are atomic: 100 messages per installation/day and 500 per site/day, counted in UTC. Accepted duplicates consume no additional quota. Only explicit provider rate-limit rejections get bounded retries (three attempts); network failures or interrupted dispatches require investigation, never automatic timed replay. Receipt bodies contain no recipient, subject, message body or link secret. Deduplication metadata is retained until the site is deleted; operators must not prune it while callers can reuse old keys.
Amazon SES is an optional provider for Cloud and self-hosting. Its connector
requires region, encrypted access keys and a configuration set. The sender must
be verified in that SES region; sandbox accounts also restrict recipients.
Configure SNS delivery, bounce, complaint, rejection and rendering-failure events
to /api/webhooks/email/ses, and explicitly allow its exact topic ARN through
EMAIL_SES_SNS_TOPICS. The endpoint verifies the SNS signature and regional AWS
certificate URL; configured subscriptions are confirmed automatically. It is
unavailable when no topics are configured. Keep topic publishing restricted to
the intended SES account/configuration. Provider acceptance alone does not prove
this event wiring works.
Permanent bounces and complaints suppress that destination for all apps on the site. This suppression protects delivery; it does not replace campaign opt-outs. SMTP/Postmark still work through their existing connectors, but their delivery events are not normalized by this SES adapter. Without events, status stays accepted. Core owner-review notifications use the same delivery ledger and show transport status in the review queue. Email acceptance never approves a proposal.
Incoming aliases and text forwarding are available from Core 0.2.29 when enabled
by the host; see
Incoming email for setup, limits and the separate
email:inbound:status permission. Body/attachment reads and inbound app workflow
subscriptions remain unavailable. A normal monitored Reply-To still works without
changing a site’s main MX records. Incoming mail is never verified ownership or
authorization to approve changes.
Build-time derived data
Section titled “Build-time derived data”Core 0.2.91 / Extension runtime 0.44.0 adds frozen_publication_v1.
An installed provider can compute public datasets during normal publication,
without storing editable copies of the original Page fields. It requires
content:read and a signed installation API:
{ "build_derivation": { "input_format": "frozen_publication_v1", "path": "/derive", "sources": ["example.facts", "example.memberships"], "required": true }}This is part of the Extension manifest, not a separate publication endpoint.
Core sends org_id, site_id, version_id, provider_version, content,
config, content_digest and config_digest. Content contains the selected
version’s projected public pages and content_types; drafts, working copies,
pending proposals, private fields and provenance are excluded. Configuration is
only this installation’s frozen public configuration. Authenticate the short-lived
installation assertion with purpose build_derivation and verify the audience,
developer Organization, installation, site and version. Recheck authorization, but do not reread live Pages or live
configuration to calculate output. An approval made during a build belongs to the
next publication.
Digests are the first 32 hex characters of SHA-256 of canonical JSON: recursively
sort object keys lexically, preserve array order, omit undefined object values.
Return input_digest, sources: [{id, records}] and dependency receipts: [{kind, id, count}]. The input digest covers
{config: config_digest, content: content_digest, org: org_id, provider: provider_version, site: site_id, version: version_id} using the same
canonical encoding. Return exactly the declared sources, including empty ones.
Counts are nonnegative integers; zero-result dependencies are meaningful.
Requests are bounded to 8 MiB, responses to 16 MiB, with a 15-second timeout. At most 64 sources and 50,000 records per source are accepted. The provider must bound its own request handling. There are no redirects to another API origin. A failed required provider stops the publication before activation; the current static website stays online. Marking a provider optional means its failure may omit its output and records an explicit skipped-provider result.
A core/repeater uses source_type: "derived", derived_source, and optional
filter_field, filter_value and limit. For example, filter on id with
filter_value: "{{page.id}}" to render one profile’s derived facts. Bind custom
item blocks to {{item.*}}; an AI agent can create an appropriate reusable item
block. Empty results remain empty. Partial builds track the selected records,
including membership changes with unchanged counts, rather than invalidating
unrelated pages whenever any derived value changes.
For browser filters, a custom block can place {{site.derived_data_url}} in a
data-* attribute. Fetch that URL to read the source-id → records JSON object.
The address contains the dataset’s content hash, and it is shipped in the same
static artifact as the HTML. Do not hardcode the hash or fetch live CMS records
from a public filter. The dataset contains public output only. Changing it
invalidates pages that reference its URL, while unrelated pages retain their
render cache. Preview does not run external derivations: this URL and derived
output are publication features; verify them in a test publication.
The provider owns its domain rules; the project owns its field map, taxonomy and presentation. Approval and publication remain separate actions. Freeform editorial copy is not automatically rewritten when a structured answer changes; review claims based on those answers before publishing.
Private questionnaire intake
Section titled “Private questionnaire intake”An enabled installation with forms:execute can collect private responses for a
site-owned Form through POST /api/v1/sites/{siteId}/forms/{formId}/intake.
The Form’s target.installation_id must match the caller. This does not grant
access to unrelated Forms or permission to publish content.
The provider verifies its respondent and completes its own primary workflow.
It sends action: "describe", a stable SHA-256 subject_id for this interview,
trusted context, string-valued reference and optional step conditions.
The browser must never choose identity, trusted context, branching rules or
references. Use a trusted page_id and review_id to associate private answers
with one Page and review. Answers remain in Forms, never copied into Page fields.
{ "action": "describe", "subject_id": "<64 lowercase hexadecimal characters>", "context": { "uses_online_orders": true }, "reference": { "page_id": "company-example", "review_id": "review-123" }, "conditions": { "pilot": { "source": "answers", "field": "interested", "equals": true } }}Partial saves, private admin reads and notifications below are candidate
functionality for Core 0.2.97 / MCP 0.45.75, not available in Core 0.2.95. describe without answers resumes
saved responses from this interview. A partial response returns ok, done:false,
submission_id, revision, saved_at, visible steps and answers. Save with
action:"save", that revision, a fresh request_id (16–100 letters, digits,
underscores or hyphens) and answers containing the complete current answer
snapshot. Retry an uncertain response with the exact same payload and request ID.
A stale revision or changed payload reusing an ID returns 409. The latest exact
request is idempotent; an older retry after a newer save conflicts instead of
rewinding answers. Never overwrite a conflict without reloading.
Conditions support exact equals or array includes; answer conditions refer
only to earlier fields. Hidden answers are removed on the server on every save,
including when a prior Yes becomes No or is cleared. false, null and omission
are distinct. Answers are committed before notifications; a mail failure does
not undo a saved response. A deliberate save has no partial-expiry deadline.
Only descriptors that have never saved an answer expire after 30 days.
Finish with action:"complete", the current revision, a stable request ID and
the answer snapshot. Required fields and formats are validated. Completed owner
responses, including subsequent describe and completion retries, return only
ok, done:true, submission_id and saved_at: no answers, steps or history.
Only the active interview may be resumed; a new interview has a new subject.
Questions, trusted context, references and notification configuration are frozen
at the first describe. Form edits affect new interviews. The endpoint supports
up to 30 ordered static steps and 100 fields: text, textarea, email, phone, URL,
date, number, boolean, select, radio and checkbox lists. Arbitrary block HTML,
dynamic steps, explicit next branches and Form/step action hooks are rejected.
Existing describe/complete clients remain supported; new clients must use save
for persistence and omit answers when resuming.
Milestone notifications
Section titled “Milestone notifications”An installation additionally approved for email:send may supply this private
server-side configuration alongside the same intake identity on every request:
{ "notifications": { "recipient": "research@example.com", "first_answer_field": "interested" }}The first nonempty answer (including No/false) creates a durable first-answer notification in the same transaction as the response. Completion creates a second summary only if answers differ from that first snapshot. Completely unanswered completion (including null/blank values) sends nothing. Text autosaves do not create more messages. Recipient settings never enter the owner descriptor or public configuration. Messages use the site’s existing Typeroll email connector; configure that connector before enabling notifications.
Delivery is reserved once and uses the existing idempotent transactional mail service. Definite provider rejections receive its bounded retries. Administrators may explicitly retry pending or definitely failed notifications, up to three dispatch attempts. Accepted, in-flight and uncertain deliveries are not resent; uncertain delivery requires checking the provider. There is no periodic timeout that silently reissues a message. Pending outbox events survive request failure and remain visible for administrator recovery.
Private administrative access
Section titled “Private administrative access”GET /api/v1/sites/{siteId}/forms/{formId}/intake-responses?page_id=<id> lists
profile-linked responses, labels, saved timestamps and notification status.
Use after_id/next_after_id to paginate (50 records). Ordinary callers must be
site administrators. An installation needs forms:intake:read and can read only
its own target Form and installation-bound responses. This permission does not
grant the broader submissions:read scope. Owner session tokens cannot use this
administrative API. Ordinary Forms inbox list/read endpoints also hide intake
records from non-administrators, including installations with broad
submissions:read permission.
Explicit retry uses POST on that URL with {submission_id,event} where event
is first_answer or complete. Installation callers also need email:send.
MCP exposes list_form_intake_responses and retry_form_intake_notification;
the latter has an admin effect and may send mail. Private app interfaces should
put these responses in an administrator panel, never a public profile or an
owner response-history screen.
Explicit link exchange
Section titled “Explicit link exchange”For native Forms backed by an extension, set
target.session_exchange: "confirm_post" alongside session_param. The browser
removes the token from its URL, waits for Start editing, and sends
{ "action": "redeem", "token": "…" } by POST. The provider must refuse token
redemption by GET. This avoids consuming single-use links during ordinary link
previews. Existing targets without this option retain their previous exchange
contract; migrate the provider and published form together.
Ordinary forms with provider actions
Section titled “Ordinary forms with provider actions”Extension runtime 0.45.0 adds generic completed-form actions and live
select/radio choices. This contract is available in the next Core release;
providers must require >=0.45.0 <1.0.0 until all target installations upgrade.
It works in Typeroll Cloud and self-hosted installations with an approved provider
origin and installation signing configured.
Declare forms:actions permission and a forms manifest contribution. The Site
administrator chooses its types using get_form_capabilities, the Forms editor,
create_form or update_form. Types are scoped to an installation:
extension:<installation-id>:<contribution-id>. Core remains the submission owner;
an app provides additional effects, not a second form collector.
For each action, config.field_map maps provider field names to declared Form
field names. Only these accepted fields are sent. Raw answers, IP addresses,
other form actions and mail credentials are not included. Core calls the
manifest’s approved API origin and forms.path with a short-lived ES256 assertion:
- audience is the Extension ID;
token_useisform_actionorform_options;- Organization, Site, installation and immutable provider version are bound;
body_sha256authenticates the exact JSON request bytes;scopesincludesforms:actions.
Verify all claims and the active installation before performing an effect. Never
use an unsigned request’s issuer as a discovery or fetch destination. Form action
requests contain operation, configuration, form_id, submission_id, submitted_at
and mapped data. Treat submission_id + operation as an idempotency key. Reusing
that identity with different input must fail instead of applying another effect.
Required app effects run before optional email/webhook notifications. Core saves a frozen completion snapshot and supports explicit retry of the same answers. A failed effect is not a successful registration. A known retryable failure may be resumed; an operation with an unknown running outcome is not reclaimed on a timer. Submission reads expose action_status but never the frozen configuration.
An option source is configured as
{field, type, config, notice_field?} in Form.option_sources. Its field must be
a declared select/radio field. notice_field, if used, must be a declared hidden
field. The provider returns options: [{value,label}] (max 200, unique stable
values), optionally notice: {version,text}. Core displays that notice and submits
its version. Return current choices; the provider must still enforce capacity,
availability and other business rules when it receives the completed action.
No customer-site rebuild is required for changed choices. Failure to load choices
blocks submission rather than using stale static labels.
An action may return a small public_result object containing only safe public
identifiers and outcomes. Never return recipient identity, private answers or
credentials there: it is delivered to the browser. Core emits a bubbling
typeroll:form-success CustomEvent with {form_id, results: [{type,result}]} only
after successful completion, before any success redirect. Previews do not emit
real success events. This event does not send analytics; callers must enforce
consent before invoking external advertising or analytics services.
Calendar confirmations
Section titled “Calendar confirmations”The existing email:send installation API accepts an optional
calendar: {filename: "event.ics", content: "BEGIN:VCALENDAR\r\n…"} alongside its
plain-text message. Content is inline UTF-8, limited to 64 KiB; remote URLs and
file paths are never accepted. SMTP, SES and Postmark receive a calendar attachment.
The exact attachment bytes participate in the idempotency fingerprint. Freeze
message and calendar content before the first attempt and reuse the same key
on network retry. An uncertain delivery is not permission to send again under a
new key. Imports and background data reconciliation must not send confirmations
unless explicitly designed and authorized to do so.