Skip to content
BetaDocs

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:

  1. aud equals your Extension id;
  2. developer_org_id equals 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.

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.

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/token
Content-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.

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.

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.

Typeroll signs the exact raw body with HMAC-SHA256:

X-Typeroll-Event
X-Typeroll-Event-Id
X-Typeroll-Timestamp
X-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.

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/self returns the own enabled installation’s resolved version, validated non-secret config and verified site origins.
  • GET/PUT /pages/{pageId}/owner-fields reads typed owner-writable fields and a revision. PUT creates an isolated pending proposal using {changes,base_revision,request_id} and a server-derived X-Typeroll-Owner-Subject digest. Core 0.2.99 also supports independent review groups with group_id and group_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/email sends { 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}/actions accepts { 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.

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.

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.

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.

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.

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.

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.

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.

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_use is form_action or form_options;
  • Organization, Site, installation and immutable provider version are bound;
  • body_sha256 authenticates the exact JSON request bytes;
  • scopes includes forms: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.

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.