# Jule documentation > Every page of https://docs.jule.ai as Markdown. The index is https://docs.jule.ai/llms.txt. # Introduction > Jule is a visual widget builder with deep Iterable integration. Build pop-ups, inline forms, preference centers, and quizzes. Every submission syncs to Iterable in real time. Jule is a visual widget builder with deep Iterable integration. Build pop-ups, inline forms, preference centers, and quizzes. Every submission syncs to Iterable in real time. ## What is Jule? Most widget tools treat Iterable as an afterthought — you export a CSV and import it manually, or write custom code to hit the Iterable API. Jule is different: every form submission is a native Iterable API call, fired in real time, as soon as the user clicks Submit. You design the widget in Jule’s visual editor, choose which Iterable fields to update, and click Publish. The widget embeds on your site via a single `
``` ```typescript React import { useEffect } from 'react'; export function JuleWidget({ projectId }: { projectId: string }) { useEffect(() => { if (!document.querySelector('script[data-api-key]')) { const s = document.createElement('script'); s.src = 'https://assets.jule.ai/widget.js'; s.async = true; s.dataset.apiKey = 'YOUR_API_KEY'; document.head.appendChild(s); } }, []); // Only needed for Inline mode — omit for Pop-up or Banner return
; } ``` ```typescript Next.js // app/layout.tsx import Script from 'next/script'; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ``` ## Placement Modes For **Inline** mode, place a `
` exactly where you want the widget to render. It collapses to zero height until loaded. For **Pop-up** and **Banner** mode, no container div is needed. The widget script loads all active pop-up and banner projects automatically and renders them as overlays or anchored bars. The display trigger controls when they appear. ## Publish and Verify Click **Publish** in the Jule editor, then reload your page. The widget renders and the impression appears in the Jule dashboard’s Analytics within a few seconds. > Note > > **Verify in Iterable:** submit a test response, then check **Users** for the test email and **Activity → Custom Events** for your configured event. If they appear, the integration is live. # Your First Widget > Build, style, and publish a lead-capture widget end to end. This walkthrough creates a simple email + phone opt-in that pushes subscribers to Iterable. Build, style, and publish a lead-capture widget end to end. This walkthrough creates a simple email + phone opt-in that pushes subscribers to Iterable and fires a Journey trigger. ## Create a New Project From the **Projects** page, click **Create New Project** in the top right. A three-step modal appears. Name it something descriptive — *Summer Newsletter Signup*, for example. This name appears in your project list and as the label in Analytics. ## Pick a Template Choose **Widgets** to build pop-ups, inline forms, and banners — or **Preference Center** for a full-page subscription preferences flow. Click **Next** to choose a template, then name the project and create it — Jule opens the editor for you. ![Create New Project — Select Project Type modal](https://docs.jule.ai/docs/getting-started/your-first-widget/step-3.png) *Select a project type — Widgets for pop-ups and forms, Preference Center for subscription management.* ## Add Elements In the editor, open the **Pages** tab and click **+ Add Element**. The element picker shows all available inputs and content blocks. For a basic opt-in, add: - **Email** — set Field Name to `email`. This is the Iterable user identity key. - **Phone** — set Field Name to `phoneNumber`. - **Button** — label it “Subscribe”. ![Element picker in the editor](https://docs.jule.ai/docs/getting-started/your-first-widget/step-14.png) *The element picker — Form Elements on top, Content Elements below. Click an element to add it.* > Note > > **Field names matter.** Jule sends each element’s Field Name as a key in the Iterable `dataFields` payload. Match your Iterable user profile keys exactly. ## Style the Widget Switch to the **Style** tab. Set Display Mode to **Pop-up**, **Inline**, or **Banner**, then adjust card colors, border radius, and padding. The live canvas on the right updates in real time. ![Style tab in the editor](https://docs.jule.ai/docs/getting-started/your-first-widget/step-17.png) *Style tab — Display settings, Teaser, Card styles, Effect, and Close button controls.* If you set Inline mode, the Style tab shows a `
` snippet to copy once the project is saved. ## Set a Display Trigger Switch to the **Triggers** tab. Select one of your saved triggers from the dropdown — for a newsletter pop-up, an **Exit Intent** trigger or a **Conditional Behavior** trigger with a 5-second delay both work well. Click **Manage triggers** to create one on the Triggers page if needed. ![Triggers tab showing no trigger selected](https://docs.jule.ai/docs/getting-started/your-first-widget/step-19.png) *Triggers tab — choose an existing trigger or manage triggers to create URL-match and scroll-depth rules.* > Note > > Triggers don’t fire in **Inline** mode — time delay, scroll depth, exit intent, and conditional behavior work for pop-ups and banners; inline widgets render with the page. A warning banner appears in the editor if there’s a mismatch. ## Publish Click **Publish** in the top right, then copy the embed script from **Settings → Integrations → Embed Widget** (and, for Inline mode, the `
` snippet from the Style tab) into your site’s HTML. On the next page load, the widget renders and starts syncing submissions to Iterable. > Note > > Submit a test response from your site. Within seconds you should see the user created or updated in Iterable — and, if the Track Custom Event action is enabled, the event you selected in their activity timeline. # Connect Iterable > Link Jule to your Iterable workspace with an API key. Every widget submission updates Iterable users and fires custom events in real time. Link Jule to your Iterable workspace with an API key. Once connected, every widget submission updates Iterable users and fires custom events in real time. ## Get Your Iterable API Key In Iterable, go to **Settings → API Keys → Create API Key**. Choose type **Server-Side** — Jule uses it to look up and update user profiles, subscribe users to lists, and track events, so it needs permission to do all three. > Note > > Keep this key private. Jule encrypts it at rest with AES-256-GCM. Never expose a server-side Iterable key in client-side code. ## Choosing a Region Iterable runs two separate data centers — **United States** and **Europe (EU)** — each with its own API keys. Pick the region that matches where your Iterable account was created; picking the wrong one fails the connection test even with a valid key. The region you pick also changes where Jule points you to find your key: `app.iterable.com` for US accounts, or `app.eu.iterable.com` for EU accounts. ## Add the Key to Jule Go to **Settings → Integrations** in Jule. Paste your API key into the **Iterable API Key** field, choose your region, then click **Test Connection**. Jule sends the key and region to Iterable to confirm they work before anything is saved. Once the test succeeds — *Your API has connected successfully* — a **Save** button appears; click it to store the key. The status badge turns green (*Active*) once saved. ![Iterable API Key field in Settings → Integrations](https://docs.jule.ai/docs/getting-started/installation/step-8.png) *Paste the key, pick your region, then Test Connection.* ## HMAC Signing Secret (Required) Every preference center needs an HMAC signing secret. When a subscriber opens your Iterable unsubscribe link, Jule verifies an identity hash before it shows or saves any preferences, which is what stops one person from unsubscribing another. A preference center cannot be published until the secret is set. 1. In Jule, enter a secret under **Settings → Integrations → HMAC Secret** and click Save. 2. Paste the exact same secret into Iterable under **Project Settings → HMAC Secret**. 3. Copy the Hosted Unsubscribe URL from Jule — it already includes `&hash={{hmacSHA256 email}}` — and paste it into Iterable, so Iterable computes and sends the verification hash along with the link. Until both sides hold the same secret, publishing a preference center is rejected, and any request to an already-published one answers 403 — the page loads nothing and saves nothing. There is no email-only fallback. The secrets must match exactly; a mismatch fails verification the same way a missing one does. ![HMAC Secret field in Settings → Integrations](https://docs.jule.ai/docs/iterable/connect/step-3.png) *The HMAC Secret field, with a link straight to the matching Iterable setting.* ## Hosted Unsubscribe URL Jule provides a branded, fully-hosted preference page for each preference-center project in your workspace. To activate it: 1. In Jule, copy the project’s **Hosted Unsubscribe URL** shown in Settings → Integrations. 2. In Iterable, go to **Settings → Project Settings** and paste the URL into the **Hosted Unsubscribe URL** field. 3. Check **Use as the “List-Unsubscribe” mail header value** — without it, one-click unsubscribes from Gmail and Yahoo never reach Jule. 4. Use `{{hostedUnsubscribeUrl}}` in your email templates for the unsubscribe link. Subscribers who click Unsubscribe in your Iterable emails will land on your Jule preference center instead of the generic Iterable page — letting them manage channel preferences rather than opting out entirely. ## Verify the Connection Submit a test response from any published widget. Open Iterable and check: - **Users** — the test email should appear as a new or updated user. - **Activity → Custom Events** — if the widget has the Track Custom Event action enabled, an event with the name you selected in the editor should be visible with the mapped fields as properties. # Audience Sync > When a user submits a Jule widget, Jule calls the Iterable API to create or update a user profile, subscribe them to lists, and fire a custom event — depending on the actions you enable. When a user submits a Jule widget, Jule calls the Iterable API server-side to create or update a user profile, subscribe them to lists, and fire a custom event — depending on which actions you enable in the editor’s Iterable tab. ## How It Works On submission, Jule’s backend runs the Iterable actions you configured for the project: 1. **users/update** — creates or updates the user profile with your mapped fields as `dataFields` (Update User Profile action). 2. **users/updateSubscriptions** — sets the user’s list, channel, and message-type subscriptions from the Iterable tab’s Subscribe action. Lists selected for unsubscribe additionally go through `lists/unsubscribe`. 3. **events/track** — fires your configured custom event with the mapped field values as the event payload (Track Custom Event action). All calls identify the user by email, or by `userId` when no email is available. If the user doesn’t exist in Iterable yet, the profile update creates them. ## Sync Actions Open the **Iterable** tab in the widget editor to turn on any combination of actions under **Iterable Action Types**. Enabling an action reveals its own configuration fields inline. **Subscribe** exposes separate list, channel, and message-type selectors for subscribing and unsubscribing, plus SMS double opt-in settings. **Track Custom Event** exposes an event picker and its own field mapping. **Update User Profile** exposes field mapping into your Iterable user profile fields. ## The email input is the identity key Add an **Email** input element from the element panel — the widget uses its value as the visitor’s Iterable identity for all three calls. (Naming its Field Name `email` as well silences the editor’s preflight warning.) The identity is resolved in this order: 1. `window.JULE_USER_DATA.email`, when set before the widget script loaded. 2. The Email-type input’s value. 3. The email stored (encrypted) in the browser from a previous submission. `window.JULE_USER_DATA.id` is sent as `user_id` and is on its own enough to sync. Without any identity, the submission is recorded in Jule analytics but **not** synced to Iterable. ## Field Name Mapping Fields you enable in the Update User Profile mapping become keys in the Iterable `dataFields` object, named by their **Send as** value (defaulting to the Field Name). Match them to existing Iterable user profile keys to avoid creating duplicates. Jule also stamps `last_project` and `last_submission_date` on every profile write. When the Subscribe action is enabled and the email doesn’t yet exist in Iterable, Jule creates the profile before subscribing and stamps `signupSource`, `signupDate`, and `last_submission_project` — profiles created by Update User Profile alone don’t get these. ```json Iterable payload // What Jule sends to POST /api/users/update { "email": "alex@example.com", "dataFields": { "phoneNumber": "+14155551234", "smsOptIn": "true", "last_project": "Summer Newsletter Signup", "last_submission_date": "2026-08-01T17:03:12.000Z" }, "mergeNestedObjects": true } ``` ## List Subscriptions Open the **Iterable** tab in the widget editor to configure lists to subscribe the user to on submission and, separately, lists to unsubscribe them from. The subscribe selection **replaces** the user’s existing email-list membership in Iterable rather than adding to it, so include every list the visitor should stay on. ![Iterable tab in the widget editor showing action types](https://docs.jule.ai/docs/iterable/audience-sync/step-19.png) *The Iterable tab — configure Subscribe, Track Custom Event, and Update User Profile actions that run on each submission.* ## The Track Custom Event Action When the **Track Custom Event** action is enabled, Jule fires a custom event to Iterable on each submission. Pick the event from a searchable dropdown of the events defined in your Jule workspace (managed on the Events page) — it isn’t free text, and the action won’t fire until an event is selected. The event’s name is what Jule sends to Iterable as `eventName`. The payload includes the project name and the field values you mapped in the Track Event mappings: ```json Event payload { "email": "alex@example.com", "eventName": "form_submission", "id": ":ae", "createdAt": 1767225600, "dataFields": { "project": "Summer Newsletter Signup", "fields": { "phoneNumber": "+14155551234" } } } ``` Use `dataFields.project` in your Iterable Journey filter to trigger only on submissions from a specific widget, or give each widget its own event name. ## Field Mapping The **Track Custom Event** and **Update User Profile** actions each have their own field-mapping list — one row per field in your project, with a toggle to include it and a `Send as` input for the target Iterable field name. Use the **All** / **None** toggle above the list to enable or disable every field at once. For **Update User Profile**, the field input opens a dropdown of your existing Iterable user profile fields to pick from. For **Track Custom Event**, it’s a free-text property name — Iterable event properties aren’t fixed to a schema, so you can enter any name you want. Fields can be nested by assigning them to a **group**. Create a group, then assign any mapped field to it from its **Group** dropdown — a field assigned to a group named `contact` is sent as a nested object (`contact.email`) instead of a top-level key. Below the field list, **Custom fields** let you send static key/value pairs that aren’t tied to any project field, also assignable to a group. `email` and `userId` are reserved at the top level — Jule ignores a custom field using one of those keys unless it’s placed inside a group. # Triggered Campaigns > Jule widget submissions can fire custom events to Iterable. Use your configured event as a Journey entry trigger to send an immediate follow-up. With the Track Custom Event action enabled, every widget submission fires your selected custom event to Iterable. Use it as a Journey entry trigger to send an immediate follow-up email or SMS. ## Events Jule Fires to Iterable | Event name | When fired | Key payload fields | | --- | --- | --- | | Your selected event name | Form submitted, with the Track Custom Event action enabled | `project` (project name), `fields` (your mapped field values) | | Per-page event fires (custom names) | A page configured to fire Iterable events is shown, once per session — held until an email identity is known, then flushed | The properties you configure on the page | | `couponAssigned`, `couponRedeemed`, `couponExpired` (names configurable per coupon) | Coupon lifecycle, when coupon Iterable sync is enabled | `coupon_code`, `coupon_status`, discount and expiry details (prefix configurable) | ## Set Up an Iterable Journey 1. In Iterable, go to **Journeys → Create Journey**. 2. Add an entry step: **Custom Event** trigger, with the event name you selected in the editor’s Iterable tab. 3. Add a filter on `dataFields.project` equals your widget’s project name to scope the Journey to a specific widget — or give each widget a unique event name. 4. Add a **Send Email** or **Send SMS** action immediately after the entry step. 5. Activate the Journey. > Note > > The `project` field carries the project name exactly as shown in the Jule dashboard — match it verbatim in your Journey filter. ## Widget Display Triggers Display triggers control *when* the widget appears on screen — separate from the Iterable campaign triggers above. Create and configure them on the **Triggers** page of the dashboard, then attach one to the project from the **Triggers** tab in the editor. | Trigger type | Description | | --- | --- | | Conditional Behavior | Show when the trigger’s targeting conditions are met — time on page, scroll depth, past visits, Iterable list membership, and more. | | Exit Intent | Show when the cursor moves toward the browser chrome, or the visitor switches tabs. | | Manual | Show when you call `window.Jule.show(’project-id’)` from your own JS. | No trigger type gates an inline widget — inline mode renders immediately with the page. > Note > > For the full set of controls over who sees a widget and when, see [Triggers](https://docs.jule.ai/display/triggers), [Audience Targeting](https://docs.jule.ai/display/targeting), and [Frequency & Scheduling](https://docs.jule.ai/display/frequency). Targeting includes Iterable audience conditions — show a widget only to visitors on a specific Iterable list, or only to known Iterable profiles. ## Manually Trigger a Widget Call `window.Jule.show(’project-id’)` from any JavaScript to display a specific widget programmatically — useful for showing a widget after a checkout step, a button click, or an app event. ```javascript JavaScript // Show a widget after a successful purchase document.querySelector('#checkout-btn') .addEventListener('click', async () => { await processPayment(); window.Jule.show('post-purchase-nps'); }); ``` # Templates > Jule ships with a library of pre-built templates organised by goal. Each template comes wired with sensible element defaults and Iterable field names. Jule ships with a library of pre-built templates organised by goal. Each template comes wired with element defaults and Iterable field names so you can publish without starting from scratch. ## Choosing a Template Click **Create New Project** and choose the project type, then select a category on the left of the template picker and choose a template. Click **Next**, name the project, and create it to open it in the editor. ![Template picker modal](https://docs.jule.ai/docs-frames/create-project.png) *The template picker — categories on the left, templates on the right. Select From Scratch for a blank canvas.* ## Template Categories Templates are grouped into eight categories: Lead Generation, Feedback & Surveys, Promotions, Onboarding, Announcements, Email Collection, Exit Intent, and Preference Center. ## Starting From Scratch Choose **From Scratch** to open the editor with an empty canvas. ## Your Own Templates Any project you can edit can be saved as a workspace template. From the **Projects** list, open a project’s actions menu and choose **Create template**. Pick the categories it should appear under; it then shows up in the picker alongside the built-in templates, available to everyone in your workspace. # Elements > The building blocks of a Jule widget — form fields, content blocks, containers, and special blocks, plus per-device visibility. Everything on a widget page is an element — a form field, a piece of content, a layout container, or a special block like a coupon. You add elements from the editor palette and arrange them on the canvas, then style and configure each one in the properties panel. ## Form Fields Form fields collect a visitor’s answer. Each one has a field name — the key its value is stored and sent under — and the palette offers a dedicated element for each kind of answer: | Element | Collects | | --- | --- | | Input | A single line of free text. | | Text Area | Multiple lines of free text. | | Email | An email address, with an optional typo checker (below). | | Phone | A phone number. | | Address | A postal address. | | Date | A date. | | Time | A time of day, with a configurable format. Timezone and submit-format options belong to the Date element with Include Time on. | | Dropdown | One option chosen from a menu. | | Single Select | One option chosen from a row of buttons. | | Checkbox | A single opt-in checkbox, with a formatted label that supports links. | Each field can be marked **required**. Single Select and Rating elements also offer **Advance on select**: picking an option moves straight to the next page (submitting only if it’s the last page). The toggle is unavailable when the page already has a page-advancing button. > Note > > Email typo checker: turn it on for an email field and Jule shows a non-blocking “Did you mean …?” suggestion when a visitor enters an address with a likely typo (for example `gmial.com`). It is off by default. ## Content Blocks Content blocks display information rather than collecting it: - **Headline** and **Paragraph** — text content, from section headings down to body copy. - **Image** and **Logo** — two palette entries for the same uploaded-image element (with alt text); Logo is the shortcut for your workspace mark. - **HTML Block** — custom HTML with its own CSS and JavaScript, for anything the standard elements do not cover. - **Form Message** — the message shown after submission, with separate success and error copy. ## Layout With Containers A **Container** groups other elements so you can arrange them together — it has its own direction (row or column), alignment, wrapping, and gap, so you can build rows, columns, and nested layouts. In a two-column layout, an element’s side (left or right) decides which column it belongs to. ## Special Blocks - **Coupon** — reveals a discount code, optionally behind an interactive reveal like a spin wheel, gift boxes, or pick-a-ball. See Coupons for the full setup. - **Rating** — a rating scale with a choice of icon (star, heart, thumbs-up, smiley, and more) and a configurable maximum. - **Channels & Types** — checkbox groups wired to Iterable channels and message types, used to build preference centers. ## Device Visibility Any element can be hidden per device. Toggle visibility independently for **Mobile**, **Tablet**, and **Desktop** to tailor the layout to each screen — for example, hiding a large hero image on mobile while keeping the form. # Pages & Buttons > How multi-page widgets move between steps, every button action in the editor, and when to reach for logic instead. A widget isn’t limited to a single screen. Add pages to build a multi-step flow, and use each button’s action to control how visitors move between pages, submit their responses, or leave the widget. ## Multi-Page Widgets A project holds an ordered list of pages, and each page holds its own title and its own list of elements. The widget renders one page at a time and tracks which page the visitor is currently on, so a flow can walk someone through several steps — a welcome screen, a question, a form, and a thank-you screen. Pages advance in the order they’re listed in the editor. **Next Page** and **Previous Page** buttons move one step at a time along that order. Jumping straight to a specific page out of order — skipping the pages in between — isn’t a button action: switch to the **Logic** view and give a rule a **Page** destination (or **Submit & Page**, if the response should be recorded before the jump). ## Button Actions Every button element fires exactly one action on click. These are the options available when creating or editing a button: | Action | What it does | | --- | --- | | Next Page | Advances to the next page in order without submitting. On the last page it falls back to submitting the form. Check **Sync Data to Iterable** on the button to send the answers collected so far to Iterable when the visitor moves on. The form still submits later, on its Submit button. | | Previous Page | Returns to the page immediately before this one in page order — not necessarily the page the visitor came from, if logic skipped pages. Does nothing on the first page, and logic rules never override it. | | Submit & Next Page / Submit & Close | Submits every answer collected so far, then continues to the next page, or closes the widget if it’s the last page. The label adapts automatically to the button’s position. A form submits once per visit, so answers on later pages are never saved. | | Submit & Redirect | Submits the current page, then redirects the browser to a URL once the submission completes. | | Close | Closes the widget without submitting. | | Redirect to URL | Redirects the browser to a URL immediately, without submitting the current page’s responses. | Preference centers only offer Submit and Submit & Redirect. Older projects may still show an action marked `(legacy)` — re-pick a current action to migrate the button. ## Submit Once, at the End A form submits once per visit. After the first submit, any later Submit button sends nothing and only moves the visitor forward. So put the Submit on the last page that collects answers, and use Next Page on every page before it. Take a popup that asks for an email on page 1 and a phone number for SMS consent on page 2. Set the page 1 button to Next Page and check Sync Data to Iterable, so the email reaches Iterable even if the visitor leaves on page 2. Then submit on page 2, which saves the phone number and sends the SMS consent. Sync only works on or after the page with the email field, and publishing is blocked if it comes earlier. > Note > > Logic rules always win. If a page has a matching logic rule, its destination overrides whatever action the clicked button declares — see [When to Use Logic Instead](https://docs.jule.ai/widgets/logic) below. ## Success & Redirect Patterns Two independent mechanisms send a visitor to a “success” state after they finish a widget: - **A thank-you page inside the widget.** Make your last page a simple message — a thank-you note and, if you like, a Close button. - **An off-widget redirect.** Any button can use **Submit & Redirect** to submit the response and send the visitor straight to an external URL — checkout, a landing page, and so on — instead of landing on an in-widget thank-you page. Use plain **Redirect to URL** instead when the visitor should leave without their response being recorded, such as a “No thanks” exit link. ## When to Use Logic Instead A button action is fixed — the same button always does the same thing, regardless of how the visitor answered anything on the page. Reach for [Logic & Branching](https://docs.jule.ai/widgets/logic) when the next step should depend on the visitor’s input instead — routing a developer to a technical page and a marketer to a different one from the same question, for example. # Logic & Branching > Route visitors between pages based on their answers — condition rules, operators, first-match ordering, fallback, and scoring. Page logic decides what happens when a visitor finishes a page. Every page can carry an ordered list of rules — each rule compares the visitor’s answers to a value and, if it matches, routes them to a different page or ends the flow. This is the mechanism behind conditional branching: skipping irrelevant pages, showing different follow-up questions per audience, or ending early for a disqualified respondent. ## How Branching Works Click **Logic** in the editor top bar to switch from the content canvas to the branching canvas. (Preference centers have no Logic view.) Each page can carry a list of rules; a rule pairs one or more conditions with a **destination** — where the visitor goes the moment those conditions are satisfied. A destination is one of: - **Page** — jump to a specific page, skipping everything in between. - **Submit & Close** — record the response and end the flow. - **Close** — end the flow without submitting. - **Redirect** — navigate the browser to a URL. - **Submit & Page** — record the response and continue into a specific page rather than closing. - **Submit & Redirect** — record the response, then navigate the browser to a URL. (On canvas edges and rule pills, the submit destinations read as `Submit → …` — same actions, shorter labels.) A page with no rules and no explicit fallback just advances to the next page in sequence, or submits & closes if it’s the last page. ## Condition Rules & Operators Each condition compares one field — an input or rating’s answer, or the running Total Score (see Scoring below) — against a value using an operator. A rule only fires once every one of its conditions matches; conditions inside a rule are always combined with AND. The operators offered depend on the field you pick — text fields get Contains, multi-select fields get Is any of / Is none of; number, rating, date, and time fields get the numeric comparisons; and checkboxes get only Equals / Does not equal. | Operator | Meaning | | --- | --- | | Equals | Value equals the given value (numeric-aware — "4" matches 4). | | Does not equal | Value does not equal the given value. | | Contains | Value contains the given text, case-insensitive. For multi-select answers, matches when one of the selected options equals the value exactly. | | Does not contain | Value does not contain the given text. | | Greater than | Value is numerically greater than the given value. | | Less than | Value is numerically less than the given value. | | Greater than or equal | Value is numerically greater than or equal to the given value. | | Less than or equal | Value is numerically less than or equal to the given value. | | Is any of | Value matches any entry in a given list. | | Is none of | Value matches none of the entries in a given list. | | Is empty | Field has no answer yet. Takes no value. Not offered on rating, checkbox, or date/time fields. | | Is not empty | Field has an answer. Takes no value. Not offered on rating, checkbox, or date/time fields. | ## First-Match-Wins & Fallback A page’s rules evaluate top to bottom. The first rule whose conditions all match wins, and every rule below it is skipped for that visitor. Rules evaluate in the order they were added — to change priority, delete and re-add a rule in the position you want. If no rule matches, the page uses its fallback destination, labeled **Otherwise** once at least one rule exists (or just **Destination** when there are none). Leave the fallback unset and it mirrors the page’s own primary button action — a Submit & Redirect button becomes that redirect, a plain Submit becomes submit-then-next-page — falling back to the next page in sequence, or Submit & Close on the last page, so a page without any explicit fallback still behaves like a normal linear flow. ```javascript Logic example // Page 1 ends with a Single Select: "What describes you best?" // Options: "I'm a marketer", "I'm a developer", "I'm an executive" If role === "I'm a developer" → Jump to Page 3 (technical setup) If role === "I'm an executive" → Jump to Page 4 (ROI overview) Default → Next Page (Page 2, marketer flow) ``` ## Scoring Rules — and the fallback — can each carry an optional **Score**. When a rule fires, its points are added to a running Total Score, exposed back to later conditions as the synthetic `__score__` field (labeled **Total Score** in the field picker). This lets a lead-qualification flow accumulate points across several pages and branch on the total near the end, without the visitor ever seeing a score field. Each page’s contribution is tracked separately and replaced — not appended — every time that page’s Next runs, so navigating back and forward again never double-counts a page’s points; the Total Score is always the sum of the current per-page contributions. ## Working on the Canvas The Logic view renders every page and terminal destination (Submit & Close, Close, Redirect, Submit → Redirect) as nodes on a canvas, connected by rule edges. Drag from a page’s bottom handle to any other node to create a new rule between them. Drag the endpoint of an existing edge to reconnect it to a different node. Click an edge to open its rule in the panel on the right; select an edge and press Backspace or Delete to remove the rule it represents (or clear a fallback). Clicking a page node opens its Rules panel, where rules can be edited or deleted, and the Otherwise destination configured. A **Reset Layout** control re-runs the automatic layout if the canvas gets tangled after edits. # Styling & Themes > The Style tab controls how your widget looks and where it sits — layout, position, fonts, overlay, mobile overrides, SEO, and custom CSS. Changes preview live in the canvas without publishing. ![Editor Style tab open](https://docs.jule.ai/docs-frames/build-05.png) *The Style tab — layout, position, card styles, and effects in one panel.* ## The Style Tab Every setting on this page lives in the editor’s **Style** tab and applies to the whole widget. Element- level styling (a single button or heading) is edited on the element itself; this tab sets the shared theme around it. ## Position & Placement A pop-up can be anchored to any of nine positions on the screen: - Corners — `top-left`, `top-right`, `bottom-left`, `bottom-right`. - Edges — `top-center`, `bottom-center`, `center-left`, `center-right`. - `center` — centered over the page. Inline widgets ignore position — they render wherever their container sits in your HTML. ## Layout & Columns Choose a single-column card or a two-column layout. Two columns are ideal for image-plus-form designs — a hero image on the left and the opt-in form on the right. When two columns are on, each column has its own controls: - **Column setting ratio** and **Column gap** — the percentage split and the space between them, in the Two column settings panel. - **Left column** / **Right column** sections — padding and a background (a solid color or an image with a cover or contain fit), set independently for each column. ## Card Styles Controls the outer card that wraps the widget content: | Property | Options | | --- | --- | | Background | A solid color or a background image with an object fit. | | Border radius | Corner rounding, from square to pill-like. | | Border | Width, color, and style (solid, dashed, dotted, double). | | Padding & size | Inner spacing plus card width and height. | | Card drop shadow | None, Small, Medium, or Large presets — set in the Effect section. | ## Fonts & Typography Set the widget’s font family to match your brand. The **Font family** picker is searchable and lists two groups: *Imported to Jule* — the built-in Default, Sans Serif, Serif, and Monospace options plus any font your workspace has uploaded in the Branding section — and the full *Google Fonts* catalog. Per-element size, color, and alignment are edited on the element itself in the properties panel. ## Overlay & Close Button - **Overlay** — Pop-up mode only. Set the overlay color and its transparency in one control, plus an optional background blur. - **Close Button** — Pop-up and Banner modes. Style the dismiss control’s size, color, background, corner radius, and padding; Banner mode adds a **Show close button** toggle. ## Mobile Overrides Two-column layouts can be tuned for small screens without changing the desktop design: - Hide the left or right column on mobile so the form stands alone. - Set the mobile column order — whether the left or right column stacks first. > Note > > You can also hide individual elements per device from the element itself. See Elements → Device Visibility. ## SEO Settings Preference centers are served on their own hosted page, so they get an SEO section the other display modes don’t. Set the **Page title**, **Meta description**, **Favicon**, and **OG image** (the social share preview) so the page looks right in search results and link previews. ## Custom CSS When you need something the controls do not expose, the Card styles section ends with a **Custom CSS** box. Enter plain CSS declarations, one per line (`opacity: 0.9;`) — they are applied directly to the widget card. Selectors and full rule blocks aren’t supported. > Note > > Custom CSS is an escape hatch — prefer the built-in controls where they exist, and keep custom rules scoped so a future template change does not fight them. # Localization > Translate a widget into multiple languages, override individual elements per locale, and preview each translation before publishing. Multi-language widgets keep one layout and one set of A/B tests — only the text changes per locale. Translations live inline on each element, so a widget with no translations renders exactly as it did before you turned localization on. ## Adding Locales Open the language menu next to the A/B Test control in the editor header. It reads **Translate** until localization is enabled. Click it and choose your **default language** — the language your existing content already speaks. Nothing moves; that language just becomes the base. Use **Add a language** to search the curated locale list and enable more. The picker offers 31 locales, from major European and Asian languages to four right-to-left scripts. A few notable ones: | Code | Language | | --- | --- | | `en` | English | | `es` | Spanish | | `pt-BR` | Portuguese (Brazil) | | `zh-TW` | Chinese (Traditional) | | `ar` | Arabic (RTL) | ## Translating Content Once a second language is enabled, the language menu doubles as a lens: pick a locale from the list and the canvas and inspector rebind to it. Edit any text field — button labels, headlines, input placeholders, coupon copy — and the value is written to that locale only. The default locale keeps rendering its own scalar values untouched. Each non-default locale shows a translation indicator next to its name: a check when every translatable element across the whole widget is translated, or a `?` when only some are — hover it for the exact count. Clearing a field’s text for a locale removes that translation and the element falls back to the default language. ## Per-Element Overrides Translations are scoped to a single element, not the whole page. Selecting an element while a non-default locale is active edits only that element’s translation — its layout, styling, and any A/B variants stay shared across every language. Style properties can be overridden the same way: switch the lens to a locale, restyle just that element, and the default locale’s styling is left as-is. > Note > > Removing a language from the menu deletes every translation tied to it across all pages. This can’t be undone, so the language menu asks for confirmation before deleting. Any style overrides you made for that locale are not cleaned up — they’re simply orphaned and stop being applied. ## RTL Languages Four locales in the picker render right-to-left and are flagged accordingly: - `ar` — Arabic - `he` — Hebrew - `fa` — Persian - `ur` — Urdu Enabling one of these as the default language, or adding it as an additional language, automatically records its reading direction against that locale code. The widget applies the direction when that locale is active, so text alignment and layout mirror correctly without any manual configuration. ## Previewing Locales Selecting a locale in the language menu is also how you preview it — the canvas immediately re-renders with that language’s translations (falling back to the default for anything untranslated), so you can check line lengths, wrapping, and RTL mirroring before publishing. On the live embed, visitors don’t pick a language manually: the widget matches the browser’s language preferences against your enabled locales — trying an exact match first, then the base language of a region variant (e.g. a visitor set to `es-MX` matches an enabled `es`) — and falls back to the default locale when nothing matches. If your site already knows the visitor’s language, force it with a `data-jule-locale` attribute on the embed script (or `window.JULE_LOCALE`), which takes precedence over the browser’s preferences. # Versions > Every save snapshots your widget as a version. Browse the history, compare details, and restore any of the last 30. Jule keeps a running history of your widget so you can experiment freely and roll back if a change does not work out. Every time you save, the current design is captured as a version you can return to later. ## Save vs Publish Saving and publishing do two different things: - **Save** stores your latest edits and snapshots them as a new version. It does not change what visitors see — it is your private working copy and your rollback history. - **Publish** pushes the current design live to the embed on your site and records its own version entry (named `Published `), so you can always roll back to exactly what visitors saw. Visitors only ever see the published version, so you can keep saving work-in-progress without affecting them. > Note > > Save often. Each save is a restore point, so frequent saves give you a finer-grained history to fall back on. ## Version History Open a project’s actions menu in the **Projects** list and choose **Changelog** to browse every saved version, newest first. Jule retains the **30 most recent versions** — older ones are deleted automatically as new saves push them out, except the currently published version, which is always kept. ## What Each Version Shows Each row in the changelog carries enough context to identify a version without opening it: - Its **name** — `Version N` unless you renamed it — and its **version number**. - Who **changed** it and **when**. Versions can’t be previewed — restoring is how you load one. In the editor, Undo steps into the previous saved version once the in-session undo stack is exhausted, asking you to confirm first. ## Restoring a Version **Open the Changelog.** From the Projects list, open the project’s actions menu and choose **Changelog**. **Restore it.** Find the version, open its row menu, and choose **Restore**. Jule replaces the project’s current pages and settings with the snapshot and drops you into the editor with the restored design. > Note > > Restoring does not publish on its own — review the restored design, then publish when you are ready for visitors to see it. # Display Modes > Choose how your widget appears — pop-up, inline, or banner — and add a teaser to invite visitors in. Display mode controls how a widget is presented on the page — a centered overlay, embedded content, or a full-width bar. Set it on the **Style** tab, under **Display settings**; each mode unlocks its own position and behavior controls. ## Pop-up A pop-up is an overlay that appears above the page content, centered or anchored to one of nine positions (see Position & Placement on the Styling page). It dims the background behind it — the overlay color, its transparency, and an optional blur are set on the Style tab — which makes it the right choice when the widget should interrupt the page, such as a welcome offer, an exit-intent capture, or a survey. Pop-ups can open immediately on page load or wait for any of the triggers described in Triggers, and they close via the close button or any button with a Close action — clicking the dimmed background does not dismiss them. ## Inline Inline mode renders the widget directly inside a container `
`, wherever that container sits in your HTML — no overlay, no fixed position. Use it to embed a form into a landing page, drop a preference center into an existing page layout, or place a widget inside a blog post. Because inline widgets follow the page’s normal flow, they resize with their container and never dim or cover surrounding content. Position and overlay settings on the Style tab do not apply. ## Banner A banner is a full-width bar pinned to the top or bottom of the page. It suits announcements, cookie notices, or promo strips that should stay visible without blocking the rest of the page. An optional close button lets visitors dismiss it. ## Teasers A teaser is a small floating preview that invites the visitor to open the widget — it turns a pop-up into a click-to-open launcher for a lower-commitment first touch. The Style tab’s Teaser section controls its size, text, colors, background image, and position. | Setting | Options | | --- | --- | | Style | **Rectangle** for a card-shaped preview, or **Circle** for a round badge. | | Position | One of eight anchors: the four corners or the four edge midpoints. Center is not available for teasers. | | Display settings | **Show after form is closed** displays it once the widget has been dismissed, **Show before displaying form** shows it before the widget has been opened, and **Show before and after** shows it in both states. | > Note > > **Show after form is closed** works well on a pop-up you don’t want to re-interrupt visitors with — it stays as a quiet reminder instead of reopening the overlay. ## After Submit Whichever mode you pick, what happens after submit is controlled by the button’s own action — end on an in-widget thank-you page, or use Submit & Redirect to send the visitor to an external URL. That’s independent of display mode, so you can pair any after-submit pattern with any mode. See [Pages & Buttons](https://docs.jule.ai/widgets/pages-and-buttons) for the full set of button actions. # Triggers > How trigger types — conditional behavior, exit intent, and manual — decide when a widget appears, plus reuse across projects. A trigger decides when a widget appears. A project points at at most one trigger — you can also leave it unset — and in an A/B test each variant can override the project’s trigger with its own. In the trigger editor you pick one of three trigger types — each a different way of deciding the widget’s timing. ## Trigger Types | Type | When it fires | | --- | --- | | Conditional Behavior | Fires based on how the visitor behaves on the page — after a set time delay, once they scroll past a chosen depth, or when other engagement conditions are met. | | Exit Intent | Fires when the cursor leaves the top of the viewport, or when the visitor switches tabs or backgrounds the page. The mouse path is desktop-only; the tab-switch path also fires on mobile. | | Manual (JavaScript API) | Fires only when your own code calls `window.Jule.show()`, or when a custom JavaScript condition you write evaluates to true. | > Note > > **Touchscreens:** the mouse path has nothing to detect on a touchscreen, but the tab-switch path still fires when a mobile visitor backgrounds the page. The trigger editor pins the device chips to Desktop as guidance only — to actually keep the widget off touch devices, add the Device Type targeting condition, which on an exit-intent trigger is fixed to **Desktop**. ## Manual Triggers A manual trigger doesn’t fire on its own — you decide when. There are two ways to open a manual-trigger widget: call `window.Jule.show(’project-id’)` from your own JavaScript, or enter a custom JavaScript condition on the trigger that the widget script evaluates for you. Writing that condition needs the **Manage Custom Code** permission. ```javascript JavaScript window.Jule.show('project-id'); ``` > Note > > **Only one public method.** `window.Jule.show()` is the entire public API — there is no `Jule.trigger()`, `Jule.track()`, or `Jule.hide()`. See [JavaScript API](https://docs.jule.ai/developers/javascript-api) for the full reference. ## Reusing Triggers Across Projects Triggers belong to the workspace (`workspace_id`), not to any single project. Build one trigger — say, a Conditional Behavior trigger whose Scroll Depth condition is set to 50% — and point as many projects at it as you like. Editing that trigger’s conditions updates every project that uses it — trigger settings are resolved at request time rather than copied into the project. If you want to tune the condition for just one project, create a separate trigger instead of editing a shared one — otherwise the change lands everywhere the trigger is used. ## Combining Triggers with Targeting A trigger only controls timing — whether now is the right moment to show a widget. Two more layers decide the rest: targeting conditions filter which visitors are eligible at all, and a display frequency caps how often the same visitor sees the widget once they’re eligible. They run in order: the frequency cap is checked before anything renders, then the schedule, and the trigger fires only once its targeting conditions pass. See [Audience Targeting](https://docs.jule.ai/display/targeting) and [Display Frequency](https://docs.jule.ai/display/frequency) for how each one works. # Audience Targeting > Narrow who sees a widget with visitor, behavior, technical, and Iterable audience conditions, combined with AND/OR rule logic. Targeting determines who sees a widget once its trigger fires. A trigger decides when a widget is eligible to show; targeting narrows that down to which visitors actually see it, based on who they are, how they’re behaving, and what your integrations know about them. ## How Conditions Evaluate Targeting is built from rules, and rules are built from conditions. Each condition checks one thing — for example, that the visitor is on a mobile device, or that they’ve visited the site more than once — and evaluates to true or false. Conditions inside a rule share a single AND/OR switch: choose **AND** to require every condition in the rule to match, or **OR** to match if any one of them does — switching it applies to every condition in that rule, evaluated left to right with no operator precedence. Rules combine through a second AND/OR switch shown between the rule groups, which defaults to **OR**. For example: one rule matching **Device Type** equals mobile **OR** equals tablet, joined with **AND** to a second rule matching **Country** equals US, produces mobile or tablet visitors from the US. Each condition also carries a comparison operator that governs how its value is checked: **Equals**, **Not Equals**, **Contains**, **Not Contains**, **Greater than**, **Less than**, **Any of**, **On any of**, and **On none of**. Not every operator applies to every condition — a numeric condition like **Scroll Depth** pairs naturally with **Greater than** or **Less than**, while a list-style condition like **Website URL** is a better fit for **Contains** or **Not Contains**. ## Visitor Conditions Visitor conditions describe who’s looking at the page, independent of anything they’ve done during this session. | Condition | What it matches | | --- | --- | | Device Type | Desktop, mobile, or tablet. | | Country | The visitor’s country, resolved from their IP address and cached in the browser. If the lookup is unavailable, Jule uses the browser’s language region for that page only. | | First Time Visitor | True for every page of the visitor’s first visit, tracked in local storage. A visit ends after 30 minutes without a page view. | | Past Visits | How many visits the visitor has made, counted in local storage and including the current one. | ## Behavior Conditions Behavior conditions look at what the visitor is doing on the current page. | Condition | What it matches | | --- | --- | | Total seconds on page | How long the visitor has spent on the current page. | | Scroll Depth | How far down the current page the visitor has scrolled. | | Website URL | The URL of the page the visitor is on right now, for every operator. Equals ignores www, a trailing slash and a missing https://. | | Referring URL | The URL the visitor arrived from, read from the browser referrer. It is empty on direct visits and whenever the referring site withholds it, so a rule on this condition won’t match that traffic. | Total seconds on page and Scroll Depth aren’t offered on Manual triggers, and inline widgets ignore behavior conditions entirely — they render with the page, so there’s nothing to wait for. ## Technical Conditions Technical conditions read values already stored in the browser, useful for targeting based on your own site’s state. | Condition | What it matches | | --- | --- | | Cookie Value | Enter a cookie name to check it merely exists, or `name=value` to compare its contents with **Equals** or **Contains**. | | Local Storage Value | Enter a key to check it merely exists, or `key=value` to compare its contents with **Equals** or **Contains**. | ## Iterable Conditions Iterable conditions check the visitor against your connected Iterable account. | Condition | What it matches | | --- | --- | | Iterable List | Whether the visitor is on a given Iterable list. | | Existing Iterable User | Whether the visitor’s email belongs to a known Iterable profile. | > Note > > **Evaluated server-side:** Iterable conditions can’t be checked in the browser — they need the workspace’s server-only Iterable API key. When a targeting rule contains an Iterable condition, the widget calls `POST /projects/:id/audience/evaluate` to get a server-side verdict before deciding whether to show. The call is made only when the visitor’s identity (email or user id) is known — without one, **on any of** hides the widget and **on none of** shows it — and if the server is unreachable the check fails open, so the widget still displays. Every other condition type evaluates entirely client-side. ## Single-Page Sites Some sites change the URL without reloading the page, such as single-page apps built with Next.js and headless Shopify stores. When a widget’s targeting uses Website URL, Cookie Value or Local Storage Value, or the widget has a schedule, Jule notices the new URL within about a second and checks targeting and the schedule again for that page, until the widget shows. Moving to a new URL this way isn’t a new visit. Past Visits and First Time Visitor stay the same, and Total seconds on page keeps counting from the first page load. Display frequency is checked again on each new URL, and a trigger fires at most once until the page fully reloads. Manual triggers run their code once per page load, and an inline widget is checked when its placeholder is added to the page. # Frequency & Scheduling > Control how often a widget shows and schedule the dates and hours it runs. Frequency and scheduling decide how often the widget may appear and during which dates and hours. They are checked before anything renders — a widget that’s capped or out of schedule never arms its trigger at all. Both live on the trigger, alongside its targeting, so every project pointing at that trigger shares them. ## Frequency Options Choose how often a visitor can see the widget: | Option | Behavior | | --- | --- | | Once | Show only once — dismissing or completing it blocks it forever. | | Always | Show on every eligible page view. | | Multiple times | Show up to a set number of times (a maximum display count). | | Once per session | Show at most once per visit, across tabs. A visit ends after 30 minutes without a page view. | | Once every N days | Show, then wait a set number of days before it can show again. | A newly created trigger starts on **Once**. Projects with no trigger at all — or a trigger saved before frequency existed — fall back to a built-in *until completed* behavior: the widget keeps showing on every page view until the visitor submits, and dismissing it does not stop it. > Note > > **Multiple times** takes a maximum display count (1–100, default 3) and **Once every N days** takes a number of days (1–365, default 1) — the other options need no extra value. > > Display counts advance when the visitor dismisses or submits the widget, not when it renders — an abandoned view isn’t counted, so the widget can appear again on the next page. Frequency applies to pop-up, banner and teaser widgets; inline widgets and preference centers ignore it. A visitor who completed the widget never sees it again unless the frequency is Always. ## Scheduling A schedule limits when the widget is eligible to appear at all. When enabled, you can set: - **Start and end date & time** — the campaign window. Before the start or after the end, the widget never shows. Leave a side empty for an open-ended range; a date with no time defaults to 00:00 (start) or 23:59 (end). - **Active hours** — a daily time window (for example 9am–5pm), so the widget only runs during business hours. Overnight windows like 10pm–6am span midnight, and the hours after midnight count toward the day the window started. - **Active days** — the days of the week the widget may run. At least one day must stay selected. - **Timezone** — the timezone the dates and hours are measured in. # Custom Events > Named events represent milestones in a visitor journey. Define them once and fire them to Iterable from any widget. A custom event is a named milestone in a visitor’s journey — something like *subscribed*, *coupon redeemed*, or *demo booked*. You define these events once for your workspace, then fire them to Iterable from any widget’s **Track Custom Event** action. ## What Custom Events Are Each event is a workspace-level record with a **name** and an optional **description**. They are shared across every project in the workspace, so a milestone you define once can be reused by any widget. ## Defining Events Manage your events from the **Events** area of the dashboard. Keep names consistent — the same event can drive behavior across several widgets. > Note > > Name events for the milestone, not the widget — for example *newsletter subscribed* rather than *pop-up 3 done*. A milestone-based name stays meaningful as you add more widgets. ## Using Events The main use of a custom event is the **Track Custom Event** action on a widget’s **Iterable** tab. Its event picker is a searchable dropdown of your workspace’s existing custom events — pick one, and Jule fires it to Iterable when the widget is submitted. Events can also be attached to a single page from the page’s **Track Page View on Iterable** section in Page settings — those fire as soon as the visitor reaches the page, or as soon as their email becomes known. See [Audience Sync](https://docs.jule.ai/iterable/audience-sync) for the full payload shape. > Note > > Custom events pair naturally with [triggered campaigns](https://docs.jule.ai/iterable/triggered-campaigns): the same milestones you track can mark where a visitor is in an Iterable journey. # Coupons Overview > Coupon types, discounts, expiration, usage limits, CSV uploads, and low-stock alerts in Jule. A coupon is a discount code Jule hands out to visitors — through a widget block, a QR code, or an API call. Every coupon has a type that controls where its codes come from, a discount, an optional expiration, and usage limits. ## Coupon Types The coupon type decides how Jule sources the codes it distributes. | Type | How codes are sourced | | --- | --- | | Static | One shared code is handed to every visitor who redeems the coupon. | | Unique | Jule generates a unique code per visitor from a prefix, a length, and a character set. | | Uploaded | You upload your own list of codes and Jule assigns one per visitor from that pool. | > Note > > For **Unique** coupons, choose the character set generated codes draw from — **Alphanumeric**, **Numeric Only**, or **Alphabetic Only** — alongside the prefix and length. ## Discounts Independently of its type, every coupon records one discount — **Percentage**, **Fixed Amount**, or **Free Shipping**. Jule uses it for display and passes it to Iterable — it does not enforce the discount at checkout. For a code that actually discounts an order, attach a Stripe or Shopify provider (see Stripe & Shopify). Each coupon also has a **Discount Label** — the customer-facing text shown wherever the discount is surfaced, such as “20% off” or “Free shipping”. ## Expiration Expiration controls how long an assigned code stays redeemable: **No Expiration**, **Fixed Date**, or **Rolling (after assignment)** — with rolling, the code expires a set number of hours after it is assigned to the visitor. ## Usage Limits & Fallback Two limits bound how far a coupon’s codes can spread. The **Total Usage Limit** caps how many codes the coupon can hand out across everyone, while the **Per Visitor Limit** caps how many a single visitor (identified by email or phone) can be given. Both count assignments, not redemptions, and anonymous session-only visitors aren’t subject to the per-visitor limit. > Note > > Turn on fallback to set a single **Fallback Code**, handed out whenever Jule can’t issue a real one — the pool is empty, the coupon is paused, or a usage limit has been reached. A coupon past its expiry hands out nothing, fallback included. ## Uploading Codes by CSV For **Uploaded** coupons, add codes to the pool by uploading a CSV file — up to 10,000 rows per upload. Each row is one code, optionally followed by a comma and a per-code QR payload (`SAVE10,https://shop.example.com/redeem?c=SAVE10`) that overrides the coupon’s QR Content template for that code. Jule reports back the total number of codes uploaded, how many were skipped as duplicates already in the pool, and how many were skipped because they are blacklisted — codes blocked platform-wide by Jule. Matching for duplicates and blacklisting is case-insensitive, so `SAVE10` and `save10` are treated as the same code. ## Low-Stock Alerts Set a **low-stock threshold** to be emailed when the available-code count reaches it. It applies to uploaded pools and Stripe/Shopify-backed pools — Jule-generated Unique coupons mint codes on demand and have no pool. Provider pools also top themselves up automatically, but the alert still sends when the threshold is crossed. It sends once per depletion and re-arms after an upload or a successful provider top-up. Every coupon tracks running stats: total codes, available, assigned, redeemed, and expired, plus a redemption rate. The coupon's assignments list shows who got each code: the visitor's email in full, and a phone number with only its last two digits (for example `•••• 71`). Searching by the full number still finds the row. # Coupon Blocks & QR > Add a Coupon block to a widget page, pick a display type and expiry treatment, and configure a QR code for the revealed code. A coupon only reaches visitors once it’s placed on a widget page as a Coupon block. The block decides how that specific placement reveals the code — plain or gamified — and how an expiry countdown appears once the code is showing. A coupon’s QR code is delivered through Iterable for email and SMS — the block itself shows the text code, never the QR image. ## Adding a Coupon Block Add a Coupon block from the element panel on any page except the first — the first page collects the form, so the editor blocks a coupon there and offers the next page instead. Then open its settings and select which coupon it points to. The coupon itself has to already exist in your workspace — see [Coupons Overview](https://docs.jule.ai/coupons/overview) for coupon types, discounts, and expiration before wiring one up here. A block holds exactly one coupon; add a second block to offer a different coupon on the same page, or to show the same coupon a second time with a different reveal. Display type, expiry display, and any gamified reveal configuration all live on the block’s **Display Type** and **Expiry Display** settings — not on the coupon itself — so the same coupon can be revealed differently across multiple blocks or pages without touching the coupon. Swapping which coupon a block points to, on the other hand, never changes that block’s display settings — only which codes get handed out. ## Display Types Set the block’s **Display Type** to control how the code is revealed to the visitor. This is independent of the coupon’s own type — a **Static**, **Unique**, or **Uploaded** coupon can use any display type below: | Type | Behavior | | --- | --- | | Always | Reveals the code as soon as the form is submitted — no extra interaction. The editor canvas shows the revealed state for styling. | | Label | Reveals your **Label Template** after submit — a single line built from smart tags like `{{coupon_code}}` and `{{discount_label}}` instead of the standard code box. | | Wheel | The visitor spins a wheel that always lands on a winning segment, then the code reveals. Segments — including any “Try again” — are cosmetic; every spin awards the coupon’s code. | | Gift | The visitor picks one of several gift boxes to reveal the code. | | Ball | The visitor picks a ball from a set to reveal the code. | **Wheel**, **Gift**, and **Ball** gamify the reveal, and each carries its own configuration on the block: the wheel by segment (label and color), spin duration, and pointer color; the gift boxes by box count, color set, and icon; the balls by ball count and color set. ## Expiry Displays When the coupon carries an expiry, turn on **Show expiration** and pick one of five styles for how the remaining time is shown once a code is revealed: **Label** (a static expiry line), **Countdown**, **Progress** (a depleting bar), **Flip** (flip-clock digits), or **Badge** (a compact time-remaining badge). > Note > > An expiry display only appears once a code has been revealed — it has no effect on a **Label** line. ## QR Codes A coupon can carry a QR code — useful for in-store or offline redemption. Jule renders it as a public PNG and hands the URL to Iterable two ways. Journey sync writes it to the user profile as `_qr_code_url` — the field prefix defaults to a slug of the coupon name — referenced in templates with `{{...}}`. The data feed returns it as `coupon_qr_code_url`, referenced with `[[coupon_qr_code_url]]`. QR codes are not rendered inside the widget block — the block shows the text code only. The **QR content / URL** field, set on the coupon in the create/edit wizard, is the payload encoded into the QR image. Include the placeholder `{{coupon_code}}` anywhere in the template and it’s substituted with the specific code assigned to that visitor when the QR renders — a template like `https://shop.example.com/redeem?code={{coupon_code}}` produces a working per-visitor redemption link. The **QR Content Description** is a caption explaining what scanning does — for example, “Scan to redeem in-store” — so recipients understand the code before they act on it. > Note > > Leave the **QR content / URL** field empty and no QR is generated from the template — Iterable receives the text code only. Uploaded codes that carry their own per-row QR payload in the CSV still get one. # Iterable Sync > Push coupon assignments and redemptions to Iterable — as journey events or a send-time data feed — and track every sync. Coupon sync connects a coupon to Iterable so the codes your widgets hand out are available in your email and SMS campaigns. Jule offers two sync methods depending on how you want Iterable to get a code. ## Journey vs Data Feed - **Journey** — on each lifecycle step Jule writes the coupon’s prefixed fields onto the Iterable user profile and fires a custom event (`couponAssigned`, `couponRedeemed`, `couponExpired`). Use those events to trigger or branch an Iterable Journey — for example, send a reminder if a code is assigned but not redeemed. - **Data Feed** — Iterable pulls a code from Jule at send time. When a campaign renders, Jule assigns (or reuses) a code for that recipient and returns it to the template, so each email carries its own unique code. > Note > > Choose Journey when the coupon reveal happens in the widget and you want to react to it. Choose Data Feed when Iterable itself is where the recipient first receives the code. ## Setting Up Sync On the coupon, enable Iterable sync and pick a method. You can customize: - A **field prefix** so the coupon’s Iterable data fields do not collide with your other fields. - The **event names** Jule uses for the assigned, redeemed, and expired events, so they match your Iterable naming. - For **Data Feed**, also copy the generated **Data Feed URL** and **Authorization Token** into Iterable under Content → Data Feeds → Create New Feed. The token is shown only once — copy it immediately, or regenerate it later. > Note > > Connect Iterable at the workspace level first — see [Connect Iterable](https://docs.jule.ai/iterable/connect). Coupon sync uses that same connection. ## Sync Activity Log Every sync attempt is logged so you can confirm codes are reaching Iterable. Each entry records the event type, the retry count, any error message, the timestamp, and a status of **Success**, **Failure**, **Pending**, or **Retrying** — visitor emails are deliberately not stored on log rows. A summary shows totals and the last time a sync succeeded. ## Troubleshooting - **Failures retry automatically** — a transient error moves to **Retrying** before it is marked **Failure**. Check the error on the failed row. - **Nothing syncing** — confirm Iterable is connected for the workspace and that sync is enabled on the coupon. - **Data-feed rows show an outcome** — an assigned, reused, skipped, or preview result explains what a send-time pull did for that recipient. # Stripe & Shopify > Connect Stripe or Shopify so revealed coupon codes are real, redeemable discounts in your store, and how Jule provisions and verifies them. By default, a coupon’s codes are Jule-only — they live in Jule’s database and unlock content in the widget, but they aren’t a discount anywhere else. Connect Stripe or Shopify instead, and Jule provisions the underlying discount in your store, so the code a visitor reveals is one they can actually redeem at checkout. ## Connect Stripe Open **Settings → Integrations → Stripe** from your workspace. Integrations are workspace-level, so connecting once makes Stripe available to every coupon in the workspace. Generate a **restricted API key** in your Stripe dashboard with write access to **Coupons**, **Promotion Codes**, and **Webhook Endpoints** (the last enables automatic redemption tracking), and paste it into Jule. Jule stores the key encrypted, only ever displays a masked version, and detects whether it is a livemode or test key, so you can confirm you connected the right Stripe account before turning on live coupons. To replace the key, generate a new restricted key in Stripe and reconnect — the full key is never retrievable once pasted. ## Connect Shopify Sign in to your Shopify admin in this browser, then open **Settings → Integrations → Shopify** and enter your store handle (Jule appends `.myshopify.com`). ```text Shop domain your-store.myshopify.com ``` Click **Connect Shopify**. Jule sends you to Shopify’s consent screen to approve the `write_discounts` and `read_orders` scopes — no tokens to copy. Approve the scopes and you land back in Jule with the integration connected. The `orders/paid` and `refunds/create` webhooks that power redemption tracking are registered automatically. > Note > > **Credential security:** the access token Shopify grants is stored encrypted. The status card shows the shop domain and the granted scopes — the token itself is never displayed in any form. ## How Provisioning Works When a coupon uses a provider, Jule doesn’t call Stripe or Shopify one code at a time. It provisions a bank of codes ahead of demand, up to a **pool target** you set, and tops the bank back up as codes are handed out. The pool applies to Unique provider-backed coupons — Uploaded isn’t offered with a provider, since the provider mints its own codes, and a Static provider-backed coupon gets a single shared promotion code instead, with no pool. The provider status on the coupon reflects where that bank stands: | Provider status | Meaning | | --- | --- | | None | The coupon isn’t attached to a provider. | | Provisioning | Jule is creating codes with the provider. | | Ready | The bank is stocked and codes are being handed out. | | Error | The last provisioning attempt failed — check the operation log and reconnect the integration if credentials are stale. | Behind the scenes, provisioning runs as discrete provider operations — provision bank, provision codes, deactivate code, deactivate bank, flag a refund, and reconcile drift — each moving through `pending`, `running`, `polling`, `succeeded`, or `failed` states. Reconcile keeps Jule’s records lined up with what the provider actually has. What Jule keeps a reference to depends on the provider: | Provider | Stored reference | | --- | --- | | Stripe | A `stripe_coupon_id` for the underlying Stripe coupon. | | Shopify | A discount node ID, plus a discount class of `basic` or `free_shipping`. | ## Verifying Codes Before trusting a provider connection, check its status card in **Settings → Integrations**. It reports: | Field | What it tells you | | --- | --- | | Connected | Whether credentials have been saved for the provider. | | Livemode | Whether Stripe is in live or test mode. | | Shop domain / masked key | Which Shopify store or Stripe account is connected. | | Webhook registered | Whether the Shopify webhook is set up. | | `healthy` | A live health check — Jule pings the provider directly rather than trusting cached credentials. | If `healthy` comes back false, the stored credentials are failing the provider’s own check — a revoked key or an expired token, for example — even if everything else on the card looks fine. Reconnect from **Settings → Integrations**: paste a fresh restricted API key for Stripe, or re-enter your store domain and click **Reconnect Shopify** to run the approval again. # Preference Center Overview > How preference center projects work in Jule — creating one, the Domain tab, header and footer customization, and publishing. A preference center is a hosted page where subscribers manage which emails and SMS messages they receive from you, instead of unsubscribing from everything at once. In Jule it is its own project type — **Preference Center** — distinct from the **Widgets** projects used for widgets like pop-ups, banners, and inline forms. ## What a Preference Center Is Where a widget project renders inline or floats over your site, a preference center project renders as a standalone full page. The subscriber lands on it directly — from an email footer link, for example — rather than having it appear over other content. Which channels and message types show up on that page, and whether each one is opt-in or opt-out by default, is covered in [Channels & Message Types](https://docs.jule.ai/preference-center/channels). ## Creating One When you create a new project, pick **Preference Center** — *full-page subscription preferences* — instead of **Widgets** — *pop-ups, inline forms, banners, and floating bubbles*. This sets the project’s type and cannot be changed afterward. The editor toolbar adapts to the type you picked. Preference center projects keep **Pages**, **Style**, and **Iterable**, but drop the **Triggers** tab — a hosted page has nothing to trigger — in favor of a **Domain** tab. Build the page itself on the **Pages** tab, then configure which channels and message types subscribers see — see [Channels & Message Types](https://docs.jule.ai/preference-center/channels) — before publishing. ## The Domain Tab The Domain tab shows the **Current Preference Center URL** — copyable with one click — and a **Custom Domain** field for pointing the page at your own branded URL (e.g. `preferences.yourdomain.com`) instead of Jule’s default. Saving a domain with **Save Domain** puts it in a **Pending Verification** state and shows the CNAME record to add in your DNS settings, pointing to `cname.jule.ai`. Once that record propagates, **Verify Domain** flips the status to **Verified**, and the page becomes reachable at your own domain. If the domain is already connected to another account, verification returns **Ownership Verification Required** and shows an extra TXT record to add before you verify again. A domain can be removed at any time with **Remove Custom Domain**. > Note > > Until a custom domain is verified, the current URL is a preview link scoped to the project and workspace. Once verified, it becomes your domain on its own — `https://preferences.yourdomain.com`. Full DNS walkthrough: [Custom Domain](https://docs.jule.ai/preference-center/custom-domain). ## Header & Footer Customization Beyond the page content built on the Pages tab, every preference center project carries its own **Header** and **Footer** configuration — each with separate HTML, CSS, and JS fields — that wrap around the hosted page. Use these for elements that should appear on every page of the preference center, such as your logo and navigation in the header or legal links in the footer. If your workspace branding defines a header or footer, the region shows a **Relink** action that replaces the project’s Header or Footer with the workspace version and keeps it in sync — while linked, the region updates automatically whenever workspace branding changes. Editing it here (or clicking **Unlink**) detaches the project copy. > Note > > Relinking overwrites whatever is currently in the project’s Header or Footer fields for that region, including any per-locale translations of that region’s HTML — relink before you start customizing, not after. ## Publishing Preference center projects publish the same way widget projects do: the **Publish** button in the editor header opens a confirmation dialog, and confirming pushes your current draft live. After a save, a hint under the Publish button reminds you that your changes are saved but not yet live. A preference center serves traffic once it has been published at least once — an unpublished project resolves to a not-found page, no matter how complete the page or how healthy the domain. # Channels & Message Types > Configure which Iterable channels and message types appear in your preference center, plus subscribe-all controls and opt-in vs opt-out wiring. The **Channels & Types** block is what turns a plain page into a working preference center. Drop it onto a page and it pulls your Iterable channels and message types live, letting visitors subscribe or unsubscribe at whatever granularity you choose — a whole channel, or an individual message type. ## Wiring Iterable Channels & Message Types Adding the block opens a panel titled **Iterable Channels** (it reads **Edit Channels** when you reopen an existing block). It lists every channel on your connected Iterable account as an expandable row with its own checkbox; expanding a row reveals that channel’s message types, each with a checkbox of its own. Checking a channel selects all of its message types at once, and unchecking it clears them. A bar above the list gives you **Select All** and **Unselect All** for the whole channel/type tree, plus **Expand All** / **Collapse All** for the rows. The first time you add the block, every channel and message type comes pre-selected. Confirming the block stores only the channels and message types you picked — not your whole Iterable account — so the block stays exactly as curated even if your account grows new channels later. ## Preference Categories Preference-center pages can carry **Category** blocks — a title plus a description used to label a group of channels, for example “Marketing Emails” with a line of supporting copy underneath. They come from preference-center templates rather than the Add Element panel. Both fields support per-locale overrides, so a translated preference center can show a translated category title without changing the default text. ## Subscribe & Unsubscribe Actions Below the channel list, the block offers two optional helper rows: **Subscribe All** and **Unsubscribe All**. Each is independent — turn on either one, both, or neither, depending on which one-tap shortcuts you want to offer. Those two helper rows are the only select-all / unselect-all controls: checking Subscribe All mirrors its state onto every other subscription checkbox on the page, and checking Unsubscribe All unchecks them — plain checkboxes not wired to Iterable are untouched. Both re-sync automatically when the visitor toggles individual boxes. ## Opt-In vs Opt-Out A checked box does not always mean the same thing — it depends on which Iterable field you wired it to in the checkbox’s **Iterable Integration** settings. | Field (UI label) | Checked | Unchecked | | --- | --- | --- | | Subscribe to Lists | Adds to the list(s) | Removes from the list(s) | | Subscribe to Types | Subscribes to the type(s) | Unsubscribes from the type(s) | | Unsubscribe from Channels | Unsubscribes from the channel(s) | Resubscribes to the channel(s) | | Unsubscribe from Types | Unsubscribes from the type(s) | Resubscribes to the type(s) | The first two fields are opt-in framing — checked means subscribed. The two “Unsubscribe from…” fields are opt-out framing — checked means the visitor asked to leave. Mixing both styles on the same page is valid, but pick one framing per checkbox and label it clearly so “checked” always reads the same way to the visitor. The Channels & Types block always uses opt-in framing: a channel stays subscribed if its own toggle is checked, or if any one of its message types is still subscribed — it only drops to unsubscribed once every message type underneath it, and the channel toggle itself, are unchecked. > Note > > **Checked = subscribe, unchecked = unsubscribe** is the rule of thumb printed at the bottom of the Channels & Types panel itself — keep it in mind when you add opt-out fields elsewhere on the same page, since those invert it. # Custom Domain > Host your preference center on your own branded domain with a CNAME record and one-click verification (plus a one-time TXT record if the domain is already connected to another account). By default your preference center is served from a Jule URL. Add a custom domain to host it on your own branded address — for example `preferences.yourdomain.com` — so the page matches the rest of your site. ## Adding Your Domain Open the **Domain** tab in the preference center editor. It shows the current preference center URL and a field to set a custom one. **Enter your subdomain.** Type the subdomain you want to use, such as `preferences.yourdomain.com`. **Save the domain.** Choose **Save Domain**. The status moves to **Pending Verification** and the DNS instructions appear. ## DNS Verification Add this CNAME record with your DNS provider, then come back and choose **Verify Domain**: | Type | Name | Value | | --- | --- | --- | | `CNAME` | your custom domain | `cname.jule.ai` | The domain status reflects where you are in the process: - **Pending Verification** — saved, waiting for the DNS record to be found. - **Ownership Verification Required** — the CNAME was found, but the domain is already attached to another account; add the TXT record shown and verify again. - **Verified** — the record was found and your preference center is served on your domain. If verification fails, the status stays Pending and the exact DNS problem appears as an error under the field. > Note > > DNS changes can take up to 48 hours to propagate. Add the record first, then click Verify Domain — you can re-run verification as many times as you need. ## Troubleshooting - **Still pending or failing** — confirm the CNAME name matches your domain exactly and its value is `cname.jule.ai` (a trailing dot is fine) with no extra subdomain added by your DNS provider. - **Just added the record** — give it time to propagate and verify again; propagation is often quick but can take hours. - **Starting over** — you can remove the custom domain at any time to fall back to the default Jule URL. # Unsubscribe > Configure one-click unsubscribe, the hosted unsubscribe page, and SMS double opt-in for your Iterable-connected workspace. Jule’s Iterable integration configures three parts of the unsubscribe experience: how a recipient’s native inbox Unsubscribe control behaves, where the hosted unsubscribe page lives, and whether SMS subscribers must confirm their opt-in with a reply. The first two live in **Settings → Integrations → Iterable** and appear once an Iterable API key is connected to the workspace; SMS double opt-in is configured per widget project, in that editor’s **Iterable** tab under the Subscribe action. ## One-Click Unsubscribe Most inbox providers show a native **Unsubscribe** control next to the sender name — separate from any unsubscribe link inside the email itself. This is the one-click unsubscribe flow defined by RFC 8058. The One-Click Unsubscribe card controls what happens when a recipient uses that inbox control, not the footer link to your preference center. Choose one of three behaviors: - **Campaign’s channel** — unsubscribes the recipient from the channel that sent the email. Falls back to a global unsubscribe if the campaign can’t be identified. - **Global email unsubscribe** — unsubscribes the recipient from all email; they won’t receive anything further. - **Specific channels** — unsubscribes from a set of channels you pick from a list pulled from Iterable. The choice is saved on the Iterable integration, along with the specific channels you pick when Specific channels is active. ## Hosted Unsubscribe Page Each preference-center project has a hosted unsubscribe URL. Paste it into Iterable’s **Settings → Project Settings → Hosted Unsubscribe URL** field — Iterable fills in the recipient’s email, campaign ID, and template ID automatically for every send. If your workspace has an HMAC secret configured, the URL also carries a signed hash parameter. Finish the setup in Iterable: - Paste the URL into the Hosted Unsubscribe URL field. - Check **“Use as the ’List-Unsubscribe’ mail header value”** below that field. - Optionally, add a visible unsubscribe link in your templates using Iterable’s `{{hostedUnsubscribeUrl}}` variable. > Important > > The List-Unsubscribe mail header is what inbox providers read to show the native Unsubscribe control. If the checkbox above isn’t enabled in Iterable, that header points at Iterable’s own hosted page instead of your Jule preference center. The card lists every existing preference-center project in the workspace — create one first if none appear. ## SMS Double Opt-In In a widget project’s Subscribe action, you can require double opt-in for specific SMS message types — preference center editors don’t offer the Subscribe action, so this is set on the widget that collects the number. The picker only lists message types whose Iterable subscription policy is `DoubleOptIn` — other message types aren’t eligible here. Once a message type is selected, a new subscriber receives a confirmation SMS and must reply `Y` before they’re subscribed. You can also override the brand name shown in that confirmation SMS, up to 50 characters — leave it blank to use the default configured in Iterable. ## Compliance Notes These settings configure technical behavior in your Iterable send pipeline — they don’t determine which regulations apply to your program. Confirm requirements for your audience and channels with your own compliance team. # A/B Test Setup > Create variants, pick a goal, set winner gates, and configure auto-declare and per-variant trigger overrides for an A/B test. Setting up a test means defining its variants, picking a goal, and deciding how — and when — a winner gets declared. ## Creating a Test & Variants Start a new test on a project. Give it a name and, optionally, a hypothesis — a short note on what you expect to happen and why. The test begins in `draft` status, so nothing is shown to visitors yet. **Add variants.** Every test needs at least two variants. Exactly one is the **control** — the existing experience you’re testing against. Each of the other variants gets its own snapshot of the project, built in the editor after the test is created: open the editor’s A/B menu and click **Duplicate live** on the empty variant to copy the live project into it as a starting point. You’re free to test copy, layout, or an entirely different flow without touching the control. The test can’t start until every variant has content. **Set the traffic split.** Assign each variant a share of traffic. An even split is the default, but you can weight it — sending, say, 80% of visitors to the control and 10% each to two challengers — if you want to limit exposure to unproven variants early on. **Review and launch.** Once the variants, traffic split, goal, and gates are set, launching moves the test from `draft` to `running`. From there it can be `paused` and resumed, and it ends in `concluded` once a winner is settled. ## Choosing a Goal Every test picks one goal metric that decides the winner. Jule supports seven goals, covering the pop-up funnel from trigger to completion — see the full list, with how each one is measured, on the [Reference](https://docs.jule.ai/ab-testing/reference) page. If you’re not ready to commit to a metric — or you just want an even split for a structural or design change — choose **No goal**. It follows the same 2–4 variant rule as any other goal, but no winner is ever calculated — the test runs an even split until you conclude it manually. ## Holdout & Winner Gates A **holdout** reserves a percentage of visitors for whom the pop-up is suppressed entirely — they see nothing, which gives the test a no-pop-up baseline for measuring incremental lift. It is currently available through the API only (`holdout_percent`, 0–50); the create wizard doesn’t expose it, so tests created in the dashboard run with no holdout. For everyone else, Jule won’t call a winner until three gates all clear: | Gate | What it checks | | --- | --- | | Minimum sample size | Each variant has collected enough visitors — and at least 25 conversions — for the result to be meaningful, not noise from a handful of early hits. | | Minimum runtime | The test has run long enough to smooth out day-of-week and time-of-day swings in traffic and behavior. | | Confidence threshold | Jule’s Bayesian model is confident enough that the leading variant is actually better, not just ahead by chance. | ## Auto-Declare vs Manual **Auto-Declare Winner** decides what happens the moment all three gates clear. It is on by default: Jule concludes the test on its own and promotes the leading variant to be the project’s new default — no one has to be watching for it to happen. Switched off, clearing the gates doesn’t end anything by itself — an operator decides when to conclude the test and promote a variant. (The **Likely winner emerging** banner appears as soon as a variant crosses the confidence threshold, before the sample-size and runtime gates clear, whatever the toggle.) Switch auto-declare off if you want a human check before the control changes for every visitor. > Locked at Launch > > Auto-declare winner can’t be changed once a test starts running — decide before launch whether you want Jule to conclude automatically or leave that call to you. ## Per-Variant Trigger Overrides Variants don’t just have to differ in content or layout — each one can also carry its own trigger, overriding when it appears to visitors. A variant left without one simply inherits the project’s regular trigger. This is what lets you A/B-test the trigger itself, not only what it shows: for example, running the control on an exit-intent trigger while a challenger fires after a 10-second delay, to see which timing converts better for the exact same offer. # A/B Test Results > Read variant exposures, conversions, and funnel rates, then use probability to beat control and the SRM check to judge when Jule can declare a winner. Every running or concluded test has a Results view that turns raw event counts into a clear read: which variant is ahead, how confident Jule is, and whether it’s safe to call the test. ## The Results View The Results view shows one card per variant, including the control. Each card reports the same four numbers against the test’s goal: exposures, conversions, conversion rate, and lift vs control. | Metric | What it shows | | --- | --- | | Exposures | The goal’s denominator — page views or pop-ups shown to that variant. | | Conversions | The goal’s numerator — how many exposures converted. | | Conversion Rate | Conversions divided by exposures, as a percentage. | | Lift vs Control | How much better, or worse, a variant’s rate is than the control’s. | The control has no lift of its own (`lift_vs_control` is `null` and the card omits the figure) — every other variant is measured against it. ## Funnel Rates & Lift Beyond the headline conversion rate, each variant has a full funnel breakdown for the same goal: open rate, trigger non-fire rate, submission rate, CTA click rate, completion rate, drop-off rate, and end-to-end rate. Every rate is shown with its numerator and denominator next to the percentage — for example, 12.3% (148 / 1,200) — so you can see the volume behind the ratio, not just the ratio itself. | Funnel stage | Measures | | --- | --- | | Open Rate | Exposures that resulted in the pop-up or page opening. | | Trigger Non-Fire Rate | Page views where the pop-up never showed. | | Submission Rate | Opens that ended in a form submission. | | CTA Click Rate | Opens that ended in a CTA click. | | Completion Rate | Opens where the visitor finished the flow. | | Drop-Off Rate | Pop-ups shown that never reached a completion. | | End-to-End Rate | Exposures that ended in a form submission. | Lift vs control is reported for the goal metric only — the other funnel rates are shown as raw rates with their counts. For a drop-off goal the winner math is inverted, since a lower rate is the desired outcome, but the lift figure itself is displayed as a raw signed percentage: a negative lift is the improvement. ## Probability & Credible Intervals Alongside each rate, Jule reports three statistics that describe how confident it is in the result. | Statistic | What it tells you | | --- | --- | | Probability to Beat Control | The Bayesian probability (`prob_beats_control`) that a variant’s true rate is better than the control’s. | | 95% Credible Interval | The range (`credible_interval_95`) the variant’s true lift over control most likely falls within. Null for the control. | | p-value | A frequentist significance value (`p_value`) returned by the results API for anyone reading it programmatically — it isn’t shown in the dashboard and Jule does not use it to pick a winner. | Jule’s winner logic relies on probability to beat control, gated by the sample-size and runtime checks — the credible interval and the p-value are shown for context only. See [A/B Testing Reference](https://docs.jule.ai/ab-testing/reference) for the exact statistical definitions behind each figure. ## SRM Check Every test also runs a sample ratio mismatch (`srm`) check, comparing the traffic split you configured against what each variant actually received. A mismatch means visitors weren’t split the way you expect — a targeting rule, a caching layer, or a bug in the trigger can all skew traffic toward one variant. > Note > > **SRM flagged:** treat the results with caution. Skewed traffic can inflate or hide a lift that isn’t real — fix the underlying split before trusting the numbers. ## Declaring a Winner Jule only offers a winner once three conditions are all met: every variant has cleared the minimum sample size for the goal (with at least 25 conversions each), the test has run its configured minimum runtime, and at least one variant has cleared the Bayesian confidence threshold for probability to beat control. Until the sample gate clears, the Results view shows **Keep running — we need more data.** A variant that crosses the confidence threshold early is flagged as a likely winner, but no winner is declared until all three conditions hold. Once a winner is available, you decide what happens next. From the Results view you can conclude the test and, optionally, make the winning variant the project’s default going forward. With auto-declare turned on, Jule skips that manual step and concludes the test itself as soon as all three conditions are met — keep it off for tests where a false positive would be costly. # A/B Testing Reference > Every goal metric and its formula, plus a glossary of the Bayesian and frequentist statistics shown on A/B test results. A lookup for every goal metric an A/B test can optimize for, and the statistics terms shown on the results page. ## Goal Definitions The goal you pick when creating a test decides which rate the winner gate watches. All rates except **Drop-off rate** are higher-is-better. | Goal | Formula | Measures | | --- | --- | --- | | Open Rate | Opens / page views | Trigger effectiveness | | Submission Rate | Submissions / pop-ups shown | Form persuasiveness | | Completion Rate | Completions / pop-ups shown | Multi-step flow strength | | CTA Click Rate | Primary button clicks / pop-ups shown | CTA copy strength | | End-to-end conversion | Submissions / page views | Headline business metric (open × submission) | | Drop-off rate | (Pop-ups shown − completions) / pop-ups shown | Which variant loses the fewest visitors | | No goal | Pure traffic split | No winner, no auto-conclude | ## Statistics Glossary Terms shown on the results page. Probability, credible interval, and lift are hidden for **No goal** tests; the SRM check runs on every test, No goal included. | Term | Definition | | --- | --- | | Bayesian probability to beat control (`prob_beats_control`) | The probability a variant’s true rate is higher than the control’s. The winner gate combines the Bayesian threshold with minimum sample size (at least 25 conversions per variant) and minimum runtime. | | Credible interval (95%) | The Bayesian range the variant’s true lift over control most likely falls in. Null for the control row. | | p-value | A frequentist two-sided significance value versus control, returned by the results API for users who prefer it — not shown in the dashboard and not used by the winner gate. | | SRM (sample ratio mismatch) | A check that the observed traffic split matches the expected split. A mismatch (low p-value) means bucketing is skewed and results may be untrustworthy. | # Analytics Overview > The Analytics dashboard tracks impressions, responses, response rate, and completion rate across your pop-up and survey widgets. The Analytics dashboard shows six metrics for your pop-up and survey widgets. Preference centers have their own view. Filter by date range and compare against a prior period. ![Pop-up Analytics dashboard](https://docs.jule.ai/docs-frames/measure-01.png) *Analytics → All Pop-ups — six metric cards, date range picker, and Compare mode.* ## Metrics Explained | Metric | Definition | | --- | --- | | Active Pop-ups | Number of distinct widget projects that recorded at least one tracked event in the selected period. | | Responses Collected | Visitor sessions whose answers were accepted by the server — one per session, so a multi-step widget that submits several pages still counts one response per visitor. Submissions the server rejected are not counted. | | Starts | Sessions where the visitor interacted at least once: typed in a field, selected an answer, or clicked a button. Interaction tracking began May 2026; earlier ranges show a dash. | | Conversion Rate | Sessions that submitted / sessions that saw the widget — the views-to-leads number (what Klaviyo and Jotform call conversion or submit rate). | | Completion Rate | Sessions that reached a completion — a submit on the final page, a redirect button, or arriving on a final page with no further step (a thank-you or result page) — / sessions that started, i.e. what Typeform and Interact call completion rate. Every rate on the dashboard (cards, daily charts, funnel, top projects) uses this same per-session definition. A drop here often indicates friction on a specific page. | | Total Impressions | Total number of impression events — one per widget per page load, counted every time rather than deduplicated by visitor. | | Avg. Completion Time | Mean session length of sessions that reached a completion — how long finishing the widget actually takes. Sessions that bounced or abandoned are excluded. | | Average Time | Mean session length, from when the widget loads on the page until the session ends, with trailing idle time capped at 5 minutes past the last interaction so abandoned tabs don’t inflate it. Shown as e.g. 45s or 2m 5s. | ## Date Range The date picker in the top right takes a preset or any custom range up to today. The default is the past 7 days, and your last selection is remembered. ## Compare Mode Click **Compare** to overlay a prior period. Jule seeds an equal-length prior period ending the day before your range starts (comparing Jun 4–11 seeds May 27–Jun 3). Each metric card then shows the comparison period’s value in grey next to the current one. The arrow and percentage in the card footer always compare against the immediately preceding equal-length period, independent of your compare selection. ## Per-Project Analytics Go to **Analytics → Projects** for a searchable table of every project, and click a row to open that project’s own analytics view with six per-project metrics: Total Views, Total Starts, Project Responses, Conversion Rate, Avg. Completion Time, and Completion Rate. From the main Projects page, a row’s ⋮ menu → **See Analytics** goes to the same view. ## Analytics Sections - **All Pop-ups** — aggregate metrics across pop-up and survey projects. - **All Preferences** — metrics for preference center widgets specifically. - **Projects** — per-project breakdown table: impressions, opens, responses, closes, completion rate, and average time per widget. # Events Reference > Jule fires configurable custom events to Iterable. This page documents each event source, when it fires, and its payload shape. Jule fires custom events to Iterable for campaign triggering. This page documents each event source and its payload. ## Iterable Events These fire server-side from Jule’s backend via the Iterable `events/track` API. They appear in the user’s Iterable activity timeline and can be used as Journey entry triggers. | Event source | When | dataFields | | --- | --- | --- | | Track Custom Event action — the event you selected | A form or preference center is submitted, with the Track Custom Event action enabled in the editor’s Iterable tab | `project` (project name), `fields` (the field values you mapped in the Track Event mappings) | | Per-page event fires — custom names set per page | A page configured to fire Iterable events is shown, once per session. Fires without a known email are held and sent as soon as a later page captures one; if no identity is ever captured they are skipped. | The properties you configure on the page | | Coupon lifecycle — `couponAssigned`, `couponRedeemed`, `couponExpired` (names configurable per coupon) | A coupon is assigned, redeemed, or expires, with coupon Iterable sync enabled and the workspace Iterable integration active. The assigned event fires only for Journey-method coupons — data-feed coupons deliver the code in the feed response instead | `coupon_code`, discount label/type/value, assigned and expiry dates, and QR URL on the assigned event; `redeemed_at`, `order_id`, and `order_value` on redeemed; `expired_at` on expired. The `coupon_` prefix is configurable. The assigned event includes `coupon_status`; on redeemed and expired, status is updated on the Iterable profile instead of sent in the event | > Note > > Events tied to a submission require an identity — an email address (or user ID) captured by the form. Submissions without one are recorded in Jule analytics but not sent to Iterable. ## Dashboard Analytics Widget interactions — impressions, opens, closes, page views, submissions, completions, CTA and redirect clicks, preference changes, and experiment exposures — are tracked separately in Jule’s own analytics and power the charts in your dashboard’s Analytics section. They are not sent to Iterable. # Workspace Settings > Create and switch workspaces, find your public API key, manage the domain allow-list, and invite people to your organization. A workspace is where your projects, coupons, and triggers live. ## Creating & Switching Workspaces Most accounts only need one workspace, but agencies and larger teams often create one per brand or client. To create a new workspace, go to **Settings → Workspace** and select **Create New Workspace**. The dialog only asks for a **Workspace Name** — everything else, like the domain allow-list and integrations, is configured afterward. To switch workspaces, use the dropdown in the top header. If you belong to more than one organization, the list groups workspaces by organization so you can move between clients without leaving the page. Whatever you had open reloads scoped to the workspace you just selected. ## Your Public API Key Every workspace has its own public API key. It’s the same key that goes into the embed script’s `data-api-key` attribute — Jule generates it automatically the first time you open the embed guide, if the workspace doesn’t already have one. > Safe to Expose > > Despite the name, the public API key is not a secret. It ships inside your site’s HTML, visible to anyone who views source. It only tells the widget bundle which workspace to talk to — it can’t be used to sign in, read data, or change anything in Jule. It’s fine to commit it to a public repository or paste it into a support ticket. ## Whitelisted Domains Whitelisted domains are the workspace’s domain allow-list, managed from **Settings → Security** under **Widget Security**. By default the list is empty, and an empty list means widgets can load on any domain — the same public API key, embedded anywhere, will render. Add a domain without the protocol (for example `example.com`) to restrict loading to just that domain and its subdomains — whitelisting `example.com` automatically covers `app.example.com`. Add every domain you embed on, including staging and preview hosts, or the widget will silently refuse to render there. Domains can be removed from the same list at any time, and removal takes effect immediately. ## Organization Invitations Inviting someone to your organization starts in **Settings → Users**: select **Add User**, enter their email, choose which workspace(s) they should have access to, and pick the landing workspace they’ll land in right after accepting. Submitting always shows an **Invitation Sent** screen with a copyable invitation link, on top of the invitation email Jule sends automatically. If the person already has a Jule account, they’re invited only to the workspaces they aren’t in yet (the rest are listed as skipped), and they get access when they accept — if they’re already in every workspace you picked, the invite is rejected. Invitation links expire after 7 days. Role assignment happens in the same form, but what each role can actually do is covered separately in [Team & Roles](https://docs.jule.ai/workspace/team-and-roles). # Team & Roles > Invite users, understand system vs custom roles, and grant access with the permission matrix. Every workspace has its own team. Who can see what, and who can change what, is controlled by roles — each teammate is assigned exactly one role, and that role determines which parts of Jule they can read, create, update, or delete. ## Inviting Users Go to **Settings → Users** and select **Add User**. Enter their email, pick the workspaces they should have access to and the one they’ll land in after accepting, and assign a role — they’ll appear in the users table, showing as **Active** once they accept. Only teammates whose role includes the User category’s Create permission see the **Add User** button (super admins always see it, regardless of their role’s permissions). ## System vs Custom Roles Roles come in two kinds. **System roles** — **Admin** (every permission) and **Member** (everything except user and role management, the SMS security settings and custom code) — are created once per organization, shared by all of its workspaces, marked with a **System** badge in **Settings → Roles**, and can’t be edited or deleted — the row simply shows *“Cannot be modified”* instead of the usual edit and delete actions. **Custom roles** are yours to define. Select **Create New Role**, give it a name and description, then check off exactly the permissions it should carry — grouped by category, with a **Select All** toggle to grant an entire category at once. Custom roles can be renamed and re-scoped at any time; deleting one requires reassigning everyone who holds it first — Jule blocks the delete while the role still has members. In the roles table, the **Permissions** column shows a count plus a preview of the first couple of grants, and system rows are tinted so they stand out from the roles your team has authored. > Note > > A role’s permissions can be changed after teammates are already assigned to it — changes take effect on their next request; the buttons and tabs they see update the next time they reload. ## The Permission Matrix Every permission in Jule belongs to one of ten categories. Most categories expose **Create**, **Read**, **Update**, and **Delete**, and a role can be granted any combination of them — so a role might read triggers but never delete them, or manage coupons without touching workspace settings. A few are narrower: Analytics is read-only, Event has no Update, Project adds **Manage Custom Code**, and Integration adds **Manage SMS Settings**. | Category | Covers | | --- | --- | | Project | Widget projects — creation, edits, publishing. **Manage Custom Code** covers adding or changing custom JavaScript: scripts in HTML blocks, manual trigger conditions, and the header and footer code of preference centers and branding. Only the Admin role has it by default. | | Trigger | Display triggers attached to projects. | | Workspace | Workspace-level settings. | | User | Team members, their role assignments, and the roles themselves. | | Analytics | Analytics dashboards and reporting. | | Event | Custom events used for targeting. | | Integration | Third-party connections, including Iterable, Stripe, and Shopify. **Manage SMS Settings** covers revealing and rotating the SMS webhook secret and changing Phone Verification (profile, countries, daily text limit); only the Admin role has it by default. | | Coupon | Coupons and reward blocks. | | Experiment | A/B tests and experiment results. | | Webhook | Outbound webhook destinations and their delivery history. | To audit the full list without opening a role, **Settings → Permissions** shows every permission in the system, organized into one tab per category — useful when you’re deciding what a new custom role actually needs before you start checking boxes. ## Managing Members From the users table, use the row actions to **Edit user** — reassign their role — or **Remove from workspace** to revoke access entirely. Both actions respect the same permission model: they only appear for teammates whose role grants the User category’s Update or Delete permission (super admins always see them), and if neither applies the row reads *“No permissions”* instead. As a safeguard, nobody can edit or remove their own account from this screen — your own row reads *“Cannot modify yourself”*, preventing self-escalation and accidental lockout. Self-service changes live in profile settings. # Security > Set up two-factor authentication (2FA), manage backup codes, and recover access if you lose your device. Two-factor authentication (2FA) adds an extra layer of security to your Jule account on top of your password, using an authenticator app like Google Authenticator or Authy. ## Enabling Two-Factor Authentication Turning on 2FA takes a minute and requires an authenticator app already installed on your phone. In your account settings, find the **Two-Factor Authentication** section and click **Enable 2FA**. Scan the QR code with your authenticator app — or enter the key shown below it manually — then type the 6-digit code into the **Verification Code** field and click **Verify & Enable**. Two-factor authentication is now enabled, and a set of backup codes appears — save them before closing the dialog. See **Backup Codes** below for what to do with them. ## Backup Codes Backup codes are one-time codes you can use to sign in if you ever lose access to your authenticator app. They’re shown once — when you first enable 2FA, and again any time you click **Regenerate Backup Codes** in account settings — so save them somewhere safe as soon as they appear. When you first enable 2FA you can **Copy Codes** or **Download** them as a text file; a regenerated set offers **Copy Codes** only, so paste them somewhere safe before closing the dialog. Each code can only be used once. > Note > > Regenerating backup codes invalidates your previous set. Only keep the most recently generated codes. ## Recovering Access If you lose your device at login, click **Use a backup code** on the two-factor authentication screen. This swaps the authentication code field for a **Backup Code** field — enter one of your saved codes there instead of a code from your authenticator app. Click **Use authenticator app instead** to switch back if you find your device. Once you’re back in, generate a fresh set of backup codes from account settings so you’re not left with fewer codes than you started with. ## Account Settings Disabling 2FA — from the same account-settings section — requires confirming your password and a current authentication code. # Branding > Configure your workspace's logo, colors, fonts, and custom CSS, and see how it cascades into widgets. Workspace branding controls how Jule looks across every widget you publish — your logo, brand colors, and custom fonts. Configure it once from your workspace’s **Branding** settings and it becomes available everywhere a widget is embedded. ## Logo, Colors & Fonts Upload one or more logos; each gets an alt text field for accessibility. **Brand Colors** let you organize a reusable palette into named color groups, each holding up to 20 named hex swatches (up to 20 groups per workspace), so the same palette is available globally in color pickers across your projects. Upload custom font files. Give each font a name — Jule automatically detects whether it’s a variable font (and its supported weight range) or a static font with a single weight, and disables unsupported weights in the editor accordingly. | Asset | Accepted formats | Size limit | | --- | --- | --- | | Logo | JPEG, PNG | 10 MB | | Font | TTF, WOFF, WOFF2, OTF | 10 MB | ## Custom CSS Custom CSS lives alongside your workspace’s **Header & Footer** configuration, which is shared across projects. Header and footer each get their own CSS editor, so you can style the surrounding preference center chrome without touching individual widget elements. CSS variables (`--var-name`) declared in the header’s CSS are automatically available in the footer’s CSS, so shared values only need to be declared once. ## How Branding Cascades to Widgets When a widget initializes, it fetches your workspace’s brand color groups from the API — keyed by the workspace’s public API key from the embed script — and builds an internal map from each color’s name to its hex value. Any color field on a widget element can point at that palette with a reference like `brand:primary` instead of a literal hex value. At render time, Jule resolves the reference to the matching hex from your workspace’s palette. > Note > > Setting a literal hex value on the same field instead of a `brand:` reference bypasses the workspace palette entirely for that element — an explicit color always wins over the shared palette, which itself falls back to Jule’s built-in defaults if nothing else is set. Fonts and logos cascade the same way. Fonts uploaded to the workspace appear as selectable options in every widget’s Font Family picker, alongside the built-in system fonts, and logos uploaded to the workspace appear in the Logo element’s asset picker. Either can still be overridden per element — by choosing a different font, or by giving the Logo element its own image: upload a file or pick one from the assets library instead of a workspace logo. # Assets > Upload, browse, and edit the images in your workspace image library, and reuse them across widgets. Every workspace has its own image library — a shared pool that combines images you upload directly with images already used inside the workspace’s projects. Upload a logo or product photo once and drop it into as many widgets as you need, instead of re-uploading the same file every time. ## The Image Library Open the library from your workspace’s **Assets** page. Click **Upload** to add a PNG, JPEG, GIF, or WebP file — it appears in the grid alongside every other image already uploaded to the workspace. Use the search box to filter by name, and the sort dropdown to order the grid by **Newest first**, **Oldest first**, **A–Z**, or **Z–A**. The grid is paginated at 20 images per page, and search and sort apply to the page you’re viewing — page through the library if you don’t find what you expect. Each image card has its own menu for **Edit Image**, **Edit Name & Alt Text**, and **Delete**. **Edit Name & Alt Text** changes how the asset is labelled — the stored file is never moved, so anything already pointing at it keeps working. Alt text starts as a copy of the name and follows later renames until you type your own; after that it stays as you wrote it. Leave alt text empty for decorative images. > Note > > The image library is scoped to a workspace. Switching workspaces in the top-left switcher shows a different set of assets — an image uploaded in one workspace isn’t available in another. ## Editing Images Click **Edit Image** on any asset card to open the built-in image editor. It has a focused toolbar of editing tools: | Tool | Shortcut | What it does | | --- | --- | --- | | Crop | `C` | Crop to a free or fixed aspect ratio. | | Resize | `R` | Scale the image to new dimensions. | | Text | `T` | Add and style editable text layers. | | Shapes | `S` | Draw rectangles, circles, lines, arrows, triangles, and stars. | | Brush | `B` | Paint freehand strokes directly on the image. | | Adjustments | `A` | Tune brightness, contrast, saturation, hue, blur, grayscale, sepia, and invert. | | Layers | — | Manage every image, text, shape, and brush stroke as its own layer — reorder, hide, or lock it independently. | Saving in the editor replaces the asset: Jule stores the edited file, repoints every project in the workspace to it, and deletes the old file. Widgets pick up the new version automatically, but the image’s URL changes — anything referencing the old URL from outside Jule will break. ## Deleting Images **Delete** first checks which projects use the image. If an active project uses it, Jule won’t delete it. **View Projects** lists those projects. **Replace All** lets you pick another image from the library, puts it into every project that used this one, live versions included, and then deletes the old image. If only inactive projects use it, you get a warning with the count. Deleting removes the image from them and leaves an empty image block in its place. The file is removed from storage too, so the image stops loading and can’t be restored. Jule keeps the file only when something else still uses it, such as your branding, another workspace or a template, or when it can’t confirm the image was uploaded in this workspace. ## Using Assets in Widgets To place a library image into a widget, add an **Image** element to a page in the widget editor, then choose **Assets** instead of browsing for a file. Pick any asset from the **Select from Assets** modal and it’s placed on the canvas. (The separate **Logo** element picks from your workspace’s branding logos, not the assets library.) See [Elements](https://docs.jule.ai/widgets/elements) for everything the Image element supports, including alt text and sizing. # Widget Script > Reference for the Jule embed script: the async loader, its data attributes, framework snippets, and the security checks that gate where widgets load. Every Jule widget on your site — pop-ups, banners, teasers, and inline forms — is delivered by one script served from `https://assets.jule.ai/widget.js`. ## How the Script Loads Add the embed script once per page — paste it anywhere in your HTML, head or body. The canonical embed is a small async loader: ```html HTML ``` The loader is `async`, so it never blocks your page from rendering. Once loaded, the script reads its data attributes, fetches your workspace’s active projects and brand colors, and renders whichever widgets match their display triggers. Your snippet ships pre-filled with your key in the **Embed Widget** card on the **Integrations** page — Jule generates the API key automatically if your workspace doesn’t have one yet. ## Script Attributes | Attribute | Required | What it does | | --- | --- | --- | | `data-api-key` | Required* | Your workspace’s public API key. Identifies which workspace’s active projects the script loads. *If `data-project-id` is set the script initializes without it, but only that one project loads — the key is required for the normal workspace embed. | | `data-project-id` | Optional | Pins the script to a single project. When set, only that project loads instead of every active project in the workspace. | | `data-debug` | Optional | Set to `"true"` to log the widget lifecycle to the browser console. Appending `?jule_debug=true` to the page URL does the same. | | `data-container-selector` | Optional | CSS selector of an element to mount widgets into instead of the page overlay. If no element matches, the widget falls back to the overlay. | | `data-jule-locale` | Optional | Forces the widget locale and skips browser detection. Setting `window.JULE_LOCALE` or appending `?jule_locale=` to the page URL does the same. | > Note > > **Write attributes in kebab-case.** The runtime reads `dataset.apiKey`, which the browser derives from `data-api-key`. A literal `data-apiKey` attribute in raw HTML maps to `dataset.apikey` and is never read — the widget won’t initialize. ## Framework Snippets The Integrations page generates ready-to-paste snippets for Vanilla JS, React, Next.js, Vue 3, Nuxt 3, Angular, Svelte, SvelteKit, Solid.js, and Astro, plus a video walkthrough for Shopify. The three most common are below. In single-page apps, load the script from your root component or layout so it persists across client-side navigations — the React hook also removes the script on unmount. Projects set to inline display render inside your page content instead of floating over it: drop a `
` placeholder where the form should appear and the script mounts the project into it, even when the placeholder is added after the initial page load. ```html HTML My App ``` ```jsx React // useJuleWidget.js (custom hook) import { useEffect } from 'react'; export const useJuleWidget = (apiKey) => { useEffect(() => { const script = document.createElement('script'); script.src = 'https://assets.jule.ai/widget.js'; script.dataset.apiKey = apiKey; script.async = true; (document.body || document.head).appendChild(script); return () => { const existingScript = document.querySelector( 'script[src="https://assets.jule.ai/widget.js"]' ); if (existingScript) existingScript.remove(); }; }, [apiKey]); }; // Usage in component function App() { useJuleWidget('YOUR_API_KEY'); return
Your app content
; } ``` ```jsx Next.js // _app.js or layout.js import Script from 'next/script'; export default function App({ Component, pageProps }) { return ( <> ``` ## URL Parameters The script also fills `window.JULE_USER_DATA` from the page URL, so an email campaign can link to a preference center with identity already attached. On initialization it reads these query parameters: | Parameter | Populates | | --- | --- | | `email` | `JULE_USER_DATA.email` | | `userId` | `JULE_USER_DATA.id` | | `user_id` | `JULE_USER_DATA.id` (fallback when `userId` is absent) | | `user_email` or `userEmail` | The personalization profile lookup — not `JULE_USER_DATA` | | `hash` | HMAC token sent along with the profile lookup to verify the identity in the URL | URL values win: when a parameter is present, it overwrites the matching field even if your own script already set it. ```text URL https://example.com/preferences?email=ada%40example.com&userId=84213 ``` ## Troubleshooting Everything `Jule.show()` reports lands in the browser console, prefixed with `[Jule]`. | Console message | What it means | | --- | --- | | `[Jule] No widgets registered. Nothing to show.` | Every widget that had registered has since been torn down — its instance was explicitly destroyed — leaving the registry empty when `Jule.show()` ran. Rare on production sites: client-side navigation does not tear widgets down. | | `[Jule] No widget registered for project "…". Registered IDs:` | The ID you passed doesn’t match a registered widget. The warning lists every registered ID — copy the right one from there. Also fires when that project’s trigger type isn’t Manual. | | `[Jule] Error showing widget:` | The widget threw while opening. The error object is logged alongside the message. | If `window.Jule` itself is undefined, no manual-trigger widget has registered yet — the global is created on first registration. The script may still be loading or initializing, no active project on the page uses the Manual trigger, or the project was filtered out first — by targeting (including device type), schedule, display frequency, or the audience gate. > Note > > **Debug mode:** add `?jule_debug=true` to the page URL to turn on `[Widget]` console logs. They show exactly why a project was skipped — for example `[Widget] Skipping project … (already seen)` or `[Widget] Skipping project … (audience gate)`. # Webhooks > Send Jule form and preference-center events to your own HTTPS endpoints with JSON body templates, HMAC-SHA256 signatures, project routing, and a delivery log. Webhooks deliver form, preference-center, and subscription events to your own HTTPS endpoints. They live in **Settings → Integrations** under **Third-Party APIs**: each destination you add with **Add Destination** is a URL that receives the events it subscribes to as `POST https://your-endpoint` requests. The wizard walks four steps — Basics, Authentication, Body Template, Review — and new destinations are created disabled, so nothing is sent until you enable them from the list, connect them to at least one project, and turn on Third-Party APIs for the workspace. ## Webhook Events A destination subscribes to any combination of five event types. Every event carries a stable event id — the `X-Jule-Event-Id` header — you can use as an idempotency key. `webhook-id` identifies the delivery instead: it is stable across automatic retries but changes when a delivery is manually resent. | Event | Fires when | | --- | --- | | `form_submit` | A visitor submits a widget form. The payload includes the submission id and the raw answers, plus the subscribe outcome, tracked event, and mapped profile fields when the form’s Iterable actions are configured. | | `preference_change` | A visitor updates their choices in a preference center. The payload lists the subscribed and unsubscribed list, channel, and message-type ids. | | `subscribe` | A visitor subscribes through a form or a preference center. SMS double opt-in does not fire this event; see the next row. | | `sms_opt_in_initiated` | Iterable has been asked to send an SMS confirmation message and the visitor has not replied Y yet, so this is a requested opt-in rather than a completed one. The payload carries`confirmed: false`, a`data.source` of`sms_double_opt_in`, and the`messageTypeIds` the confirmation was requested for. Iterable handles the reply itself and exposes no callback for it, so there is no matching confirmed event. Count these as attempted opt-ins, not subscribers. | | `unsubscribe` | A visitor unsubscribes via a preference center or one-click unsubscribe. One-click unsubscribes carry `data.behavior`, which distinguishes `global_email`, `campaign_channel`, and `specific_channels`; preference-center unsubscribes omit it and describe the change through `data.unsubscribed`. | ## Payloads & Headers You control the request body per destination with a JSON body template. Placeholders resolve against the event at delivery time: a bare placeholder like `"{{data.emailListIds}}"` injects the real typed value, while a placeholder embedded in a longer string interpolates as text. Variables come in four groups — `event.*`, `context.*`, `identity.*`, and event-specific `data.*` — and **Show Variables** in the Body Template step lists them all. The Rendered Preview shows your template filled with a sample event of whichever type you pick, before you save. ```json Default Template { "email": "{{identity.email}}", "event": "{{event.type}}", "listIds": "{{data.emailListIds}}" } ``` ```json Delivered Body { "email": "sample@jule.ai", "event": "subscribe", "listIds": [42, 88] } ``` ```http Request Headers Content-Type: application/json User-Agent: Jule-Webhooks/1.0 webhook-id: 018f6d3e-4b6a-7c1d-9e2f-3a4b5c6d7e8f webhook-timestamp: 1767963000 webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pCsRcvFT4tbBAY= X-Jule-Event: subscribe X-Jule-Event-Id: 3f2c8a90-1b2c-4d5e-8f90-a1b2c3d4e5f6 ``` Every request is sent with `Content-Type: application/json`, a User-Agent of `Jule-Webhooks/1.0`, the signature headers `webhook-id`, `webhook-timestamp`, and `webhook-signature`, plus `X-Jule-Event` (the event type) and `X-Jule-Event-Id`. In the Authentication step you can add custom headers (an API key header, for example) and named secrets referenced in the template as `{{secrets.name}}` — secrets are stored encrypted and never returned. Custom headers can’t shadow the reserved names Jule sets itself. ## Verifying Signatures Each destination gets a signing secret (prefixed `whsec_`) when it’s created. It is shown once and can’t be retrieved later, so copy it right away. If you lose it, use **Rotate Secret** in the destination’s row menu — for 24 hours Jule signs with both the old and the new secret, so you can update your server without dropping events. Signing follows the Standard Webhooks convention: the `webhook-signature` header carries an HMAC-SHA256 of `{id}.{timestamp}.{raw body}` keyed with your secret, base64-encoded and prefixed with `v1,`. The timestamp is Unix seconds; during a rotation window the header holds two space-delimited signatures, and a request is valid if any of them matches. Also reject old timestamps (more than 5 minutes, for example) so captured requests can’t be replayed. ```javascript Node.js const { createHmac, timingSafeEqual } = require('crypto'); function verifyJuleWebhook(headers, rawBody, secret) { const id = headers['webhook-id']; const timestamp = headers['webhook-timestamp']; const signatureHeader = headers['webhook-signature'] ?? ''; const expected = createHmac('sha256', secret) .update(`${id}.${timestamp}.${rawBody}`) .digest('base64'); return signatureHeader.split(' ').some(part => { const [version, sig] = part.split(','); if (version !== 'v1' || !sig || sig.length !== expected.length) { return false; } return timingSafeEqual(Buffer.from(sig), Buffer.from(expected)); }); } ``` > Note > > **Verification is optional:** Jule sends events whether or not you check the signature. Skipping the check means your server will trust any request sent to its URL, so verifying is strongly recommended for production. ## Project Routing Destinations belong to the workspace; routing decides which projects feed them. In the project editor’s Integrations tab, the **Third-Party APIs** section lets you choose which workspace destinations receive events from that project — only enabled destinations are listed, and your selection is saved with the project. You can also route from the other side: a destination’s row menu → **Connected Projects** picks the projects for that destination and saves on its own. Third-Party APIs start off for a new workspace, behind a workspace-wide master switch: the **Enable** / **Disable** button on the Third-Party APIs section in Settings → Integrations must be turned on before any delivery is attempted, and turning it off pauses all deliveries at once. While off, the editor shows a **Paused** badge — routing choices are saved but no events will be sent until you enable deliveries. ## Delivery Log & Testing Every attempt is recorded. Open **Recent Deliveries** from a destination’s row menu to see its log, filterable by status and event type. Each row shows the event, a status of Success, Failure, Retrying, or Pending, the HTTP response code, the retry count, and when it ran; click a row for the Delivery Detail. Failed and retrying deliveries show the request payload (secrets redacted), the request headers, and the response body; failed deliveries also get a **Resend** button — only failures can be resent. Successful deliveries keep only metadata — the payload and response are removed right after success under Jule’s PII retention policy, and failed copies are removed after 24 hours. Outcomes follow standard HTTP status semantics: any 2xx is recorded as success even if the response body reports an error. Responses of 429 or 5xx — and transport errors like timeouts and connection failures — are retried automatically with exponential backoff (roughly 1s, 2s, 4s) for up to three attempts. Everything else fails permanently: other 4xx responses, 3xx (redirects are never followed), and URLs blocked by Jule’s SSRF check. When your API expects different values than Jule’s — its own list ids, its own status enums — add optional **Value Mappings** on the destination’s Body Template step. A mapping names a field (e.g. `data.emailListIds`) and a set of From → To pairs (e.g. `42 → 7`); if the field is an array, each item is translated. Matching is type-strict (`41` the number is not `"41"` the string) and whole-value pairs like `[] → [{}]` are supported. Values with no pair are sent unchanged unless the mapping sets a fallback. For APIs that return 2xx with an error in the body, add an optional **Response Success Check** on the destination: a JSON path (e.g. `d.Success`) and an expected value (e.g. `true`). A 2xx response then counts as success only when the body field strictly matches; a mismatch is recorded as a permanent failure — no retries — with the expected path and value in the delivery log’s error message. The received value is withheld there because it may contain PII; the stored response body on the failed delivery has it. To try a destination without real traffic, choose **Send Test Event** from its row menu, pick an event type, and Jule delivers a synthetic event of that type immediately — signed and templated exactly like production traffic, with two differences: the send is delivered inline with no retries, so a 429 or 5xx response is logged as a failure rather than retried; and it skips routing, so the destination doesn’t need to be enabled or connected to a project. The workspace master switch must still be on, as for any delivery. # Connect Your AI > Connect Claude, ChatGPT, Cursor, Claude Code, Codex, VS Code, Gemini CLI or any MCP-capable AI assistant to Jule - or use an access token from a script - so it can build, preview and publish pop-ups, preference centers and landing pages in your workspace. Jule runs a remote **MCP server** (Model Context Protocol), the open standard AI assistants use to work with other products. Any assistant that supports remote MCP servers - Claude, ChatGPT, Cursor, Claude Code and others - can connect to Jule and build, preview and publish pop-ups, preference centers and landing pages in your workspace. It works with your own Jule login - nothing to copy and paste - and the assistant gets exactly the access you have. Every client needs the same single value, the server URL: `https://api.jule.ai/mcp` To let an assistant read these docs instead, point it at [llms.txt](https://docs.jule.ai/llms.txt) - the index of every page - or [llms-full.txt](https://docs.jule.ai/llms-full.txt), all pages in one file. Any page is also available as Markdown: add `.md` to its URL. ## Before You Start - A Jule account whose role includes the **Connect AI Tools** permission. The default Admin and Member roles have it; custom roles get it in **Settings → Roles**. - An AI assistant that supports remote MCP servers with OAuth sign-in. The sections below cover the clients we test against; most others accept the same URL. - For a script, a CI job or an automation platform that cannot open a browser, an access token instead of a sign-in - see [Automations & Scripts](#automations). - For Claude Team and Enterprise plans, and for ChatGPT Team and Enterprise workspaces, an owner or admin may have to add the connector or enable developer mode before members can connect. - A Jule plan that covers what you want built. Preference centers work on every plan; pop-ups, landing pages and A/B tests need an upgraded plan, and coupons are switched on per organization. The assistant reads your plan as soon as it connects and tells you what is unavailable instead of failing halfway through. ## How Connecting Works The flow is the same in every client. You give it the server URL, it opens Jule in your browser, and Jule asks you to sign in (if you are not already), pick the organization to connect when you belong to more than one, and review the permissions grouped as **Read**, **Write** and **Publish**. Tick **Read-only access** if the tool should only look and never change anything; you can connect again later to allow more. Click **Allow Access** and the browser hands you back to the assistant. Nothing is copied or pasted: the assistant registers itself with Jule and receives its own credentials. A connection is tied to you and to one organization. The assistant can only see the workspaces you can see and only do what your role allows; if you lack a permission in the dashboard, the assistant lacks it too. Connections stay active as long as you use them at least once every 90 days, or until you revoke them (see [Managing Connections](#managing-connections)). > More than one organization > > A connection serves exactly one organization. If you work in several - an agency, or a group with one Jule organization per brand - add the connector once per organization and name each after it (most clients let you rename a connector), and keep one per chat: an assistant with two Jule connections in the same conversation cannot reliably tell which organization a request is about. Start a new chat when you switch. For read-only work on a client account, tick **Read-only access** when you connect. ## Claude (Web & Desktop) Connectors added at claude.ai are shared with the Claude desktop app. Open **Settings → Connectors** and choose **Add custom connector**. Name it **Jule**, paste `https://api.jule.ai/mcp` as the remote MCP server URL and leave the OAuth client ID and secret empty. Click **Add**. Click **Connect** next to Jule. Sign in, choose your organization and click **Allow Access**. In a new chat, open the tools menu and enable Jule. Ask *“What can Jule do?”* to confirm the connection. > Team and Enterprise plans > > On a Team or Enterprise plan the connector is added once for the whole organization: an **Owner** or **Primary owner** opens **Settings → Organization settings → Connectors**, adds the URL above, and Claude then lists Jule for every member. > > Each member still clicks **Connect** once and signs in with their own Jule account, so every member acts with their own permissions. Owners cannot connect on someone else’s behalf. On the free plan one custom connector is allowed, which is enough for Jule. ## Claude Code Claude Code (the terminal agent) adds the server from the command line and signs in through your browser. ```bash Terminal claude mcp add --transport http jule https://api.jule.ai/mcp claude /mcp # choose "jule" → Authenticate ``` The browser opens Jule’s consent page; after **Allow Access** the terminal reports “Authentication successful”. Claude Code caches the tool list per connection, so when Jule announces new capabilities run `/mcp` and reconnect. Jule’s guided prompts show up as slash commands such as `/mcp__jule__build-popup`. ## ChatGPT ChatGPT connects to custom MCP servers through **developer mode**, available on paid plans. Team and Enterprise admins may need to enable it for the workspace first, and on Plus and Pro plans ChatGPT limits what a custom connector may write - reads always work. Open **Settings → Apps & Connectors → Advanced settings** and turn on **Developer mode**. Choose **Create**. Name it **Jule**, enter `https://api.jule.ai/mcp` as the MCP server URL, pick **OAuth** as the authentication method and save. Click **Connect**, sign in to Jule, choose your organization and click **Allow Access**. In a chat, open the **+** menu, choose **Developer mode** and enable Jule for that conversation. ## Cursor One click does it: [Add Jule to Cursor](cursor://anysphere.cursor-deeplink/mcp/install?name=jule&config=eyJ1cmwiOiJodHRwczovL2FwaS5qdWxlLmFpL21jcCJ9) opens Cursor’s install dialog with the server filled in. To do it by hand, open **Settings → Tools & MCP → Add Custom MCP**, which opens `~/.cursor/mcp.json`, and add the Jule entry: ```json mcp.json { "mcpServers": { "jule": { "url": "https://api.jule.ai/mcp" } } } ``` Back in Settings the server shows **Needs login**. Click it, sign in to Jule and click **Allow Access**. The tool list appears once the status turns green. Cursor renders Jule’s draft preview inline in the chat. ## VS Code Jule works in VS Code’s agent mode (Copilot Chat). [Add Jule to VS Code](vscode:mcp/install?%7B%22name%22%3A%22jule%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fapi.jule.ai%2Fmcp%22%7D) asks VS Code to install the server for you; the same entry can be typed into `.vscode/mcp.json` for one project or into your user `mcp.json` for all of them. ```json mcp.json { "servers": { "jule": { "type": "http", "url": "https://api.jule.ai/mcp" } } } ``` Press **Start** on the server, sign in to Jule in the browser window VS Code opens, then pick Jule’s tools from the agent mode tool list. VS Code renders Jule’s draft preview inline. ## Codex Codex adds remote servers from its CLI and signs in through your browser. The same connection then serves the Codex desktop app. ```bash Terminal codex mcp add jule --url https://api.jule.ai/mcp codex mcp login jule ``` `codex mcp list` shows the connection once sign-in finished. For a CI job that cannot open a browser, use an access token instead ([Automations & Scripts](#automations)). ## Gemini CLI Gemini CLI reads MCP servers from `~/.gemini/settings.json` (or `.gemini/settings.json` inside a project). Note the key is `httpUrl`, not `url`. ```json settings.json { "mcpServers": { "jule": { "httpUrl": "https://api.jule.ai/mcp" } } } ``` Start `gemini` and run `/mcp auth jule`. Sign in to Jule in the browser and click **Allow Access**. `/mcp` lists Jule’s tools once the status reads connected. The consumer Gemini app on the web cannot add custom MCP servers; the CLI and Antigravity can. ## Other Clients Any client that supports remote MCP servers over HTTP with OAuth works the same way: give it `https://api.jule.ai/mcp` and sign in when it asks. Windsurf, Zed, Cline, Kiro, Warp, Raycast and OpenCode all use the `mcpServers` shape below (Windsurf calls the field `serverUrl`). ```json mcpServers JSON { "mcpServers": { "jule": { "url": "https://api.jule.ai/mcp" } } } ``` Clients that read a server card can discover Jule from `GET https://api.jule.ai/.well-known/mcp.json`. A client that only speaks stdio can wrap the remote server with `npx mcp-remote https://api.jule.ai/mcp`. Microsoft 365 Copilot and Gemini Enterprise reach Jule after an administrator registers the URL in their own console. ## Automations & Scripts Some clients cannot open a browser: a CI job, a server-side agent, an automation platform, or the MCP connector of an AI API. Those use a **personal access token** - a long-lived bearer token you create in the dashboard and send as an `Authorization` header. Everything else is identical: same server URL, same tools, same permissions. Open **Settings → Connected Apps** in Jule and click **Create Token**. Name it after the job that will use it, pick the organization, tick only the permissions that job needs, and choose an expiry (up to one year). Copy the token from the dialog. It is shown once - Jule keeps only a hash - so store it in your secret manager before closing. > What a token can do > > Exactly what you can do, and no more: the permissions you tick are narrowed by your own role in each workspace. A token is bound to one organization and never refreshes itself - when it expires, create a new one and swap the secret. > > Creating tokens needs the **Connect AI Tools** permission, the same one an interactive connection needs. Treat a token like a password: one per job, never in version control, revoked as soon as the job is retired. Two calls prove a token works: `initialize` and `tools/list`. The accept header is required by the protocol. ```bash initialize curl -sS https://api.jule.ai/mcp \ -H "Authorization: Bearer $JULE_TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{ "protocolVersion":"2025-06-18","capabilities":{}, "clientInfo":{"name":"curl","version":"1"}}}' ``` ```bash tools/list curl -sS https://api.jule.ai/mcp \ -H "Authorization: Bearer $JULE_TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' ``` For an AI platform that asks for an MCP server URL and a bearer token, give it `https://api.jule.ai/mcp` and the token. The token appears in **Connected Apps** as a **Token** row next to your AI connections, with its expiry, last use and call count, and **Revoke access** stops it immediately. ## What Your AI Can Do Once connected, describe what you want in plain words. The assistant reads your workspace branding and integrations, drafts the project, validates it against Jule’s rules, saves it as a draft, and hands you a preview before anything goes live. Clients that offer tools but no MCP resources (ChatGPT, Codex, Windsurf) read the same building guides through the tools, in small parts, so nothing is lost there. ```text Example Build a two-step newsletter pop-up with our branding, preview it, then publish. ``` ### Things to ask - Build an exit-intent email pop-up for the spring sale with a 10% code, in our brand colors. - Turn this Figma frame into a hosted landing page and show me the phone preview. - Add a Spanish translation to the Welcome Offer pop-up and check nothing is missing. - Create a preference center where subscribers choose newsletter topics and frequency. - How did the Welcome Offer pop-up do in the last 30 days, and where do people drop off? - Set up an A/B test of two headlines on the Welcome Offer pop-up; do not start it yet. The complete list of tools, prompts and resources - with what each one needs and whether it asks before touching live traffic - is on the [MCP Tool Reference](https://docs.jule.ai/developers/mcp/tools) page, generated from the live server. - **Read** your workspaces, brand colors, fonts, logos and legal text, Iterable and coupon status, projects and templates. - **Build and edit** pop-ups, preference centers and landing pages: pages, elements, styling, settings and translations. Every change lands as a draft version in the same editor you use, so you can keep working on it by hand. - **Preview** the draft in the real widget at desktop, tablet and phone width. The preview link lasts ten minutes; Claude, ChatGPT and Cursor render it inline in the conversation, other clients show the links. An assistant that can open or screenshot a web page itself is asked to check its own work against your brief, every page and on the phone preview, before it says it is done. - **Check** a draft the way the editor does before publishing: the same warnings (missing email field, phone without an SMS disclaimer, no submit button, Iterable not connected), plus responsive advice and translation completeness per language. - **Translate** a project into more languages, design quiz and branching **logic**, and **connect Iterable** from the live list of your profile fields, lists, channels and custom events. - **Run** A/B tests end to end (create, edit, link variants and triggers, start, promote the winner), manage coupons and codes, show-rule triggers, webhook subscriptions and branding (palette, theme colors, icon library, logos, fonts, images). - **Report** on your analytics: the dashboard numbers and any breakdown you ask for (submissions by week and project, conversion by country, drop-off by page), shown as a chart and a table in the chat without asking, and turned into an Excel, Word, PDF or HTML report by the assistant when you want a file. - **Publish, archive or delete** only after asking you. The assistant shows a summary of what will change to live traffic and waits for your explicit yes. - **Duplicate** projects, **restore** an earlier version and **save** a project as a template. ### What stays in the dashboard Some things are deliberately out of reach. The assistant says so and points you at Jule rather than guessing: - Inviting teammates, and creating or editing roles and permissions. - Keys and secrets: the Iterable API key and a webhook destination’s signing secret. - Setting up or verifying a custom domain. It can read whether a project’s domain is verified; it cannot change it. - Creating or editing webhook destinations, and the One-Click Unsubscribe setting. It can point a project at a destination that already exists. - Billing and plan changes. > Guided prompts > > Jule ships ready-made prompts - *build-popup*, *build-preference-center*, *build-landing-page*, *figma-to-jule*, *analyze-project*, *translate-project*, *responsive-review*, *design-logic*, *set-up-experiment*, *connect-iterable* and *export-report* - that walk the assistant through each flow. Clients that list server prompts show them in their prompt or slash-command menu; in others, simply describe the project and the assistant follows the same steps. ## Managing Connections **Settings → Connected Apps** lists every AI tool connected to your account and every access token you created, with the name, the permissions granted, when it was created, when it was last used and, for a token, when it expires. **Revoke access** in a row’s menu cuts it off immediately; an AI tool then asks you to connect again and a token stops working for good. Users with the **Manage AI Connections** permission (Admins by default) can switch to the organization view to see and revoke everyone’s connections and tokens. Disconnecting inside the AI tool removes Jule from that tool but does not notify Jule, so the row stays in Connected Apps until it expires. To be sure access is gone, revoke it in Jule. ## Limits & Errors > There is no sandbox > > A connection works on your real account: a publish goes live, a delete is gone, a coupon code handed out is spent. Live-traffic actions always ask you first, but drafts, coupons and tests are created for real. Use a test workspace while you learn what the assistant does with your prompts. ### Limits - **Calls:** 120 a minute per connection and 1,200 a minute per organization; over that, a call answers `rate_limited` with the seconds to wait. - **Uploads:** an image sent inline may be 2 MB; by URL or one-time upload link, 10 MB. - **Time-boxed values:** preview links last ten minutes, confirmation tokens five minutes (single use), a retried create with the same `idempotency_key` replays the first result for 24 hours. - **Custom analytics queries:** 500 a day per organization; a query covers at most one year, two breakdowns and 1,000 rows. The fixed reports and the overview are not counted. - **Plan:** `whoami` lists the features your plan includes; a call for one it does not answers `plan_required` instead of failing halfway. ### Retries A create that times out on the way back may still have happened. The create tools (project, A/B test, coupon, coupon codes) accept an optional `idempotency_key`: repeating the call with the same key and arguments returns the first result, marked `idempotent_replay`, instead of a duplicate; the same key with different arguments is refused. Assistants that follow the tool descriptions use it on their own. ### Error codes A failed call answers with a code the assistant can act on, a sentence for you, and sometimes detail such as the field or the current version: | Code | Meaning | What to do | | --- | --- | --- | | `invalid_arguments` | A field is missing, malformed or not allowed; document errors carry the JSON path and a help entry with the allowed values. | Fix the arguments and call again. | | `not_found` | No project, workspace, coupon or page with that id in this organization. | Read the list tool again for current ids. | | `forbidden` | Your role lacks the permission in that workspace, or the workspace belongs to another organization. | Ask an admin for the permission, or reconnect to the right organization. | | `plan_required` | The feature is not in your plan (pop-ups, landing pages, A/B tests or coupons on the Essential plan). | Change plans in the dashboard; the assistant cannot. | | `insufficient_scope` | The connection was granted fewer permissions than the tool needs. | Reconnect and allow it (a read-only connection stays read-only until you reconnect); a token has to be recreated. | | `conflict` | The project changed since it was read (a stale expected_version), a name is taken, or an identical call with the same idempotency_key is still running. | Read again and retry; for a running call, wait a few seconds and repeat it. | | `rate_limited` | More than 120 calls a minute on one connection, or 1,200 across the organization. | Wait the retry_after_seconds it reports. | | `requires_confirmation` | Not an error: a live-traffic action (publish, archive, delete, promote a winner, delete an asset) returns a summary and a confirmation token first. | The assistant shows the summary and calls again with the token only after you agree; tokens last five minutes and work once. | | `unavailable` | Jule could not complete the call right now. | Retry in a minute or use the dashboard. | | `internal_error` | Something failed unexpectedly. The answer carries a reference_id. | Retry once; if it persists, send the reference_id to support@jule.ai. | ## Troubleshooting - **The client keeps asking you to sign in.** Check the URL is exactly `https://api.jule.ai/mcp` with no trailing slash or path, then remove the connector and add it again so the client registers itself from scratch. A loop that survives that is usually a browser blocking third-party cookies for claude.ai or chatgpt.com during the redirect. - **The consent page says your role lacks Connect AI Tools.** Ask an administrator to add that permission to your role in Settings → Roles, then start the connection again from the assistant. - **The assistant says it needs a permission it was not granted.** The AI tool decides which permissions to ask Jule for, and the consent screen shows you that list to allow or deny - there is nothing to tick there. Web clients re-run consent by themselves; in a terminal client, disconnect and connect again so it asks from scratch. For a token, the permissions are the ones you ticked when you created it: create a new token with the missing one, since scopes cannot be added afterwards. - **Everything returns “sign in again” after a revoke.** That is expected: revoking in Connected Apps kills the connection at once. Connect again from the AI tool, or create a new token. - **The assistant cannot find a Jule capability you expect.** The client is holding an old tool list. In Claude Code run `/mcp` and reconnect, in Cursor and VS Code restart the server, elsewhere disconnect and connect again. - **You are asked to sign in after a quiet period.** A connection unused for 90 days expires, as does a token on its expiry date. Sign in and allow access again, or create a new token. - **The preview link has expired.** Preview links last ten minutes. Ask the assistant for a fresh preview. - **The assistant reports too many requests.** Each connection is limited to 120 requests a minute, and 1,200 a minute across the whole organization. Wait a minute and continue. - **Something was published, archived or deleted without asking.** That should never happen: those actions require a confirmation step. Report it to support with the chat transcript. # MCP Tool Reference > Every tool, prompt and resource the Jule MCP server exposes to an AI assistant - what each one does, whether it reads or writes, whether it asks before touching live traffic, and the arguments it takes - generated from the live server. ## What This Is Everything the Jule MCP server offers an AI assistant once it is [connected](https://docs.jule.ai/developers/mcp): the tools it can call, the prompts it can start from and the resources it can read. This page is rendered from the server's own reference, [mcp-tools.json](https://api.jule.ai/.well-known/mcp-tools.json), so it always matches what the server serves (version 0.13.5, 32 tools). **Access** says what a tool may change. *Read* tools change nothing. *Write* tools create or edit drafts, tests, coupons and settings - nothing a visitor sees. *Asks first* marks the tools that affect live traffic: they return a summary and a confirmation token, and run only when called again with that token after you agreed. The scope under it is the permission the connection must have been granted; a tool whose scope depends on its action argument says so. ## Read Tools | Tool | What it does | Access | | --- | --- | --- | | `whoami` Who Am I and Guides | Start here, once per conversation. Who this connection runs as: the user, the connected organization with its plan tier and coupons flag, the features available to it, the OAuth scopes granted, the workspaces the user can access (id, name, status — every workspace_id other tools take comes from this list) and the permission ids the user holds in each. When more than one workspace comes back and the user has not named one, list them by name and ask which to use; never choose for them (one workspace: use it and say so). Do not call tools for a feature listed as unavailable — tell the user what is missing and how to get it (feature_notes). With doc (and section for one part) the answer also carries that reference guide, bounded, for clients that cannot read jule://docs resources. docs points at the public product documentation, readable as Markdown, for questions about how a feature works. Arguments: `doc`, `section` (* required) | Read `account:read` | | `list_projects` List Projects | The user's Jule projects (pop-ups, inline and banner forms, quizzes, preference centers, hosted landing pages) in one workspace with the dashboard's filters: kind (popup, inline, banner, preference_center, landing_page), status (active, inactive; archived shows only archived ones) and a name/description search. Use it to find a project id and its version; use get_project for the content. Arguments: `workspace_id`*, `type`, `status`, `search`, `page`, `limit` (* required) | Read `projects:read` | | `get_project` Get Project | One project in full: a summary (kind, status, version, publish state) and the editable document — pages with items, style_config, settings_config — in exactly the shape save_project_document accepts, with expected_version filled in. Read it before every save so you hold the current version and the real page and item ids. Large project? view "outline" lists pages and items without config or styles, and page_id returns one page in full. selected_item: the item clicked in the preview. Archived projects are refused: unarchive first. Arguments: `project_id`*, `view`, `page_id` (* required) | Read `projects:read` | | `list_templates` List Templates | Templates a workspace can start a pop-up, form, preference center or landing page from (its own plus global ones), with the page and item types each contains. Pass a template id to create_project to copy it; get_project on the new project shows the copied document. Arguments: `workspace_id`* (* required) | Read `templates:read` | | `preview_project` Preview Project | A fresh link to the current draft in the real Jule widget. device: desktop (default), tablet (820px), mobile (390px) or all (preview_urls). review: true renders all three widths and returns a picture per device plus the problems it measured: call it after every save and fix what it lists. When nothing renders here, follow render_it_yourself and send its measure_script output back as measurements. Arguments: `project_id`*, `device`, `page`, `locale`, `review`, `section`, `variant`, `measurements` (* required) | Read `projects:read` | | `validate_project_document` Validate Project Document | Checks a project document against the schema and save rules without writing anything. Returns field-level errors with JSON paths (each with its description, allowed values and where to read more) and warnings for what passes but looks wrong: the dashboard's publish checks, controls built as Boxes instead of button items, html-block items holding what Jule items express, responsive and layout advice, language completeness and the design rules of jule://docs/best-practices. Fix errors and warnings alike. Pass project_id for the type- and workspace-dependent checks. render: true returns a page that renders this document through the widget: open it, look at the part, send its measure_script output back as measurements (as in preview_project) — a section is checked before it reaches the project. Call it until valid: true before save_project_document. Arguments: `document`*, `project_id`, `render`, `measurements` (* required) | Read `projects:read` | | `list_triggers` List Triggers | Workspace triggers: reusable rules for when and to whom a popup shows (type, delay or scroll depth, devices, targeting, schedule, display frequency). Use it to find a trigger_id for a project's document or the id to pass to save_trigger. A project's own trigger_* fields come from get_project, not from here. Arguments: `workspace_id`* (* required) | Read `triggers:read` | | `list_experiments` List Experiments | A/B tests of a workspace with their variants and status (draft, running, paused, concluded); archived ones only when archived is true. Use it to find an experiment_id or to see whether a project already has a live test (a project can run one at a time). Results and statistics come from get_experiment. Arguments: `workspace_id`*, `project_id`, `status`, `archived` (* required) | Read `experiments:read` | | `get_experiment` Get Experiment | One experiment, its variants and results (zeros before the start): per-variant exposures, conversions, rates, P(beats control), winner (winner_variant_id; null until sample and confidence gates pass), SRM check, daily series. Read before concluding or promoting. Arguments: `experiment_id`*, `country`, `locale` (* required) | Read `experiments:read` | | `list_coupons` List Coupons | Coupons of a workspace with their type (static, unique, uploaded), status, discount and code/assignment counts. Use it to find a coupon_id for a coupon page item (config.couponId) or before creating one with the same name. Details, assignments and daily analytics: get_coupon. Arguments: `workspace_id`* (* required) | Read `coupons:read` | | `get_coupon` Get Coupon | One coupon with code counts (total, available), assignment totals (assigned, redeemed, expired, fallback, redemption rate), the most recent assignments, assignments/redemptions per day and the recent Iterable sync log (iterable_sync: reached Iterable or not, with the error). Use it to answer "how is this coupon doing" or to check the code pool before a campaign. Nothing here changes data. Arguments: `coupon_id`*, `workspace_id`, `days` (* required) | Read `coupons:read` | | `list_assets` List Assets | The workspace's Images library (uploaded files plus images already used by its projects) with names, alt text and folders — paginated, filter by folder or search. Each image has url (the stored key, what a logo item's imageUrl accepts) and public_url (the full https address — for fields the widget uses verbatim: bubble_bg_image_url, bubble_custom_icon_url, seo_og_image_url, seo_favicon_url, html-block markup; background_image, the column images, gift_box_icon_url and logo items accept the key too). Use it before uploading (the picture may exist). Logos, fonts and icons are in jule://workspaces/{id}/branding instead. Arguments: `workspace_id`*, `folder_id`, `search`, `page`, `limit` (* required) | Read `branding:read` | | `list_webhook_destinations` List Webhook Destinations | Outbound webhook destinations of a workspace: name, URL, subscribed event types, enabled flag, body template, response check, how many projects send to each, and the NAMES of custom headers and secrets (never their values). Also whether webhooks are switched on for the workspace at all. Use it to pick destination ids for set_project_webhook_subscriptions or to explain where form submissions go. Destinations are created and edited in the dashboard (Settings → Webhooks), not here. Arguments: `workspace_id`* (* required) | Read `webhooks:read` | | `analytics_overview` Analytics Overview | The dashboard numbers for a workspace or one project over a period — the answer to "how did we do", "how is this popup performing", "how many leads/sign-ups last week", "is conversion up or down". Returns metrics (views, opens, starts, submissions = leads, completions, open/conversion/completion rates, average times) with trends against the previous period, the daily series, cumulative growth, and — for a workspace — the top projects ranked by submissions. Same definitions and date arithmetic as the dashboard, so quoting the range (range.start_date … end_date) makes the numbers reproducible there. Uses the workspace of project_id when workspace_id is omitted. For where visitors drop off, countries, individual sessions or a status history use analytics_report. Arguments: `workspace_id`, `project_id`, `project_type`, `range`, `start_date`, `end_date`, `limit` (* required) | Read `analytics:read` | | `analytics_report` Analytics Report | One analytics table for a workspace or a project over a period. report: funnel (daily views → submissions → completions), page_dropoff (sessions per page), engagement (daily rates), countries, submissions_by_popup, preference_fields, sessions (individual visits, paginated), activity_log (status history; project_id required) — or custom: any table from a query (1–6 measures such as submissions or conversion_rate, up to two of date, week, month, project, country, page_index, variant; filters, order, limit) — call report "schema" first. Dashboard definitions and period arithmetic. Hosts that show MCP Apps render the result inline as tiles, chart and table; elsewhere show a table, and a chart for anything over time, without being asked. Headline numbers and trends: analytics_overview. Arguments: `report`*, `workspace_id`, `project_id`, `project_type`, `range`, `start_date`, `end_date`, `limit`, `offset`, `query` (* required) | Read `analytics:read` | ## Write Tools Drafts and settings only; nothing here changes what visitors see. The create tools accept an optional `idempotency_key` so a retry after a timeout returns the first result instead of a duplicate. | Tool | What it does | Access | | --- | --- | --- | | `create_project` Create Project | Creates a Jule project — a pop-up, inline or banner sign-up form, quiz, preference center or hosted landing page (also when the source is a Figma design) — empty or copied from a template, and returns its id and version for save_project_document. New projects are inactive drafts. Refuses when a non-archived project with the same name already exists in the workspace and returns those projects: ask the user whether to update one of them (save_project_document / patch_project) or create another, and only then call again with allow_duplicate_name: true. Not for editing content: that is save_project_document. Arguments: `workspace_id`*, `name`*, `type`, `description`, `display_type`, `template_id`, `allow_duplicate_name`, `idempotency_key` (* required) | Write `projects:write` | | `save_project_document` Save Project Document | Saves a project document — the editor path for every content change, translations included (jule://docs/languages). Building a long page: send one reviewed section at a time in append and the stored document keeps the rest; document instead replaces every page and item. style_config replaces; settings_config merges its top-level keys; trigger_id and the scalar fields update when present. Placeholder ids such as "box-1" become UUIDs (parent_id, linkedPhoneInputId and targetPageId follow); UUIDs from get_project keep their item. A new track_event name becomes a custom event; the result carries the usual warnings. Read get_project first, validate, then save; a stale expected_version is refused with error "conflict" and nothing is written. Saving changes the draft only: visitors keep the published version until set_project_status publish. Arguments: `project_id`*, `expected_version`*, `document`, `append` (* required) | Write `projects:write` | | `patch_project` Patch Project | Small edits that leave the pages alone: rename, change the description, the legacy primary color, thank-you message or redirect URL, replace style_config whole, or merge top-level keys into settings_config — the place for "show it after 10 seconds", "only on mobile", "once per visitor" (trigger_*, display_frequency, targeting_conditions, schedule_config; shapes in jule://docs/settings). Does not change the version. For pages, items and translations use save_project_document; for publishing or on/off use set_project_status. Arguments: `project_id`*, `name`, `description`, `primary_color`, `thank_you_message`, `redirect_url`, `style_config`, `settings_config` (* required) | Write `projects:write` | | `duplicate_project` Duplicate Project | Copies a project (pages, items, styles, settings) into a new inactive draft in the same workspace and returns the copy's id — "make a copy", "clone this popup for the Black Friday version". Not for A/B tests: give the other variant to create_experiment as a document instead. To reuse a design in another workspace use save_as_template. Arguments: `project_id`* (* required) | Write `projects:write` | | `project_versions` Project Versions | History of a project: "list" returns the saved snapshots (every save and publish makes one, the published one is flagged); "restore" replaces the current draft with a snapshot and bumps the version — the live version is untouched until you publish. Use it for "undo", "go back to yesterday's version" and to pick project_version_id values for create_experiment. Undoing a publish is restore + publish. Arguments: `project_id`*, `action`*, `version_id` (* required) | Write `depends on the action argument` | | `save_as_template` Save as Template | Saves a copy of the project as a reusable template of its workspace (list_templates shows it; create_project with template_id uses it). The original project is unchanged. Not for copying within the workspace: that is duplicate_project. Arguments: `project_id`*, `name`*, `description`, `category_ids`, `with_placeholders` (* required) | Write `templates:write` | | `save_trigger` Save Trigger | Creates a workspace trigger, or updates one when trigger.id is set (only the fields you send change). Use it when the user wants a reusable rule such as "show after 10 seconds, mobile only, returning visitors", then point the project at it with trigger_id (save_project_document or patch_project). Updating a trigger changes every project that uses it. For one-off behaviour on a single project set the trigger_* fields in the document instead. Arguments: `workspace_id`*, `trigger`* (* required) | Write `triggers:write` | | `create_experiment` Create Experiment | Creates an A/B test on a project as a draft — nothing changes for visitors until experiment_action start. 2–4 variants, exactly one control. Give each non-control variant its content as a document (the save shape: the project's document with the change under test); Jule stores it for the test, so no publishing and no extra steps are needed and the live project is untouched. A design or brief that labels alternatives (Variant A / Variant B) is one project plus one test: save variant A as the project, pass variant B as a document here. A project runs one test at a time. Needs a plan that includes A/B testing. Arguments: `project_id`*, `name`*, `hypothesis`, `goal_metric`, `variants`*, `holdout_percent`, `min_sample_size`, `min_runtime_hours`, `confidence_threshold`, `auto_declare_winner`, `idempotency_key` (* required) | Write `experiments:write` | | `create_coupon` Create Coupon | Creates a coupon in a workspace: a shared static code, per-visitor unique codes, an uploaded pool (add codes next with add_coupon_codes) or a Stripe/Shopify-provisioned pool. Use it when the user wants a discount to hand out from a popup; then reference the id from a coupon item. Call list_coupons first to avoid a duplicate. Needs coupons enabled for the organization and a plan that includes Coupons & QR. Arguments: `workspace_id`*, `coupon`*, `idempotency_key` (* required) | Write `coupons:write` | | `add_coupon_codes` Add Coupon Codes | Adds codes to a coupon. codes: your own list for an uploaded coupon (one code per entry, optionally "CODE,qr-content"; duplicates and blacklisted codes are skipped and counted). generate: how many new codes a Stripe/Shopify coupon should mint now (queued; get_coupon shows the pool growing). Pass exactly one of the two. Unique coupons need neither. Arguments: `coupon_id`*, `workspace_id`, `codes`, `generate`, `idempotency_key` (* required) | Write `coupons:write` | | `update_branding` Update Branding | Changes workspace branding: the brand color palette (brand_colors), the three workspace theme colors (colors: primary, secondary, accent — merged field by field; primary is the default for new projects), the icon library's groups (icon_groups), the style classes (element_presets), legal text (organization name, privacy and terms URLs, SMS disclaimer; merged field by field) and the landing-page header/footer HTML, CSS and JS (merged per side; publish: true also republishes landing pages that use them). brand_colors, icon_groups and element_presets replace the whole list, so read jule://workspaces/{id}/branding first, edit what it returns and send it back — keeping ids and names so brand: colors and config.presetIds bindings keep resolving. Logos, fonts and icon files are uploads (upload_branding_asset) and cannot be changed here. Not for one project: use its style_config. Arguments: `workspace_id`*, `brand_colors`, `colors`, `icon_groups`, `legal`, `header_footer`, `element_presets` (* required) | Write `branding:write` | | `upload_branding_asset` Upload Branding Asset | Stores a file in the workspace: a logo or font into branding, an SVG into the icon library, or a picture into the Images library — and returns how to reference it in a document. Use it whenever a project needs the user's real images, logo or typeface instead of a substitute. Three ways in, pick one per call: source.url for anything already on the web, source.base64 (+ filename) for small files, source.upload_link: true for a file on the user's machine (returns a one-time link and the curl to run instead of storing). Check list_assets and the branding resource first so nothing is uploaded twice. Arguments: `workspace_id`*, `kind`*, `source`*, `name`, `alt_text`, `folder_id`, `icon_group` (* required) | Write `branding:write` | | `set_project_webhook_subscriptions` Set Project Webhook Subscriptions | Sets which webhook destinations a project sends its events (form submissions, page views, coupon assignments, …) to. destination_ids is the complete new set: ids left out are unsubscribed, an empty list unsubscribes everything. Destinations must belong to the project's workspace (list_webhook_destinations). Use it when the user wants a project's leads in their CRM or tool; the destination itself must already exist. Arguments: `project_id`*, `destination_ids`* (* required) | Write `webhooks:write` | | `report_capability_gap` Report Capability Gap | Record a capability the user needed in this conversation that these tools could not deliver, so the Jule team hears about it. Before calling, re-check the tool list and jule://docs/*: call only for a real need of the user in this conversation that stayed blocked or degraded, once per gap; never for your own mistake, a fixable validation error or a cosmetic wish. Describe the capability, not the person: no names, e-mail addresses, phone numbers, customer URLs or text copied from the user. After the call tell the user, in this order: what cannot be done today, then user_message verbatim, then what you did instead (the closest alternative or workaround). Arguments: `area`*, `requested`*, `blocked_by`*, `impact`*, `workaround`, `workspace_id` (* required) | Write `account:read` | ## Live-Traffic Tools Publishing, archiving and deleting a project, concluding a test or promoting its winner, and deleting a branding asset. Each asks first, every time. | Tool | What it does | Access | | --- | --- | --- | | `set_project_status` Publish or Set Project Status | Everything that changes what visitors see, each behind the confirmation step. publish (and a first activate) runs the publish check: a blocker is refused with its fix; warnings show in the summary. publish: snapshots the current draft and makes it the version the widget and hosted page serve (the editor's Publish) — the answer to "publish", "push my changes live", "release the new copy"; an inactive project stays hidden after publishing. activate / deactivate: the dashboard on/off toggle — "turn it on", "go live" for a project that has never been shown (a first activation also publishes), "pause it", "take it down"; the draft and history stay. archive / unarchive: hide from or return to the projects list. delete: permanent. Only after the user has seen the preview and asked. For content changes use save_project_document; for undoing a publish use project_versions restore, then publish. Arguments: `project_id`*, `action`*, `confirmation_token` (* required) | Write - asks first `depends on the action argument` | | `experiment_action` Experiment Action | Runs and edits an A/B test; see the action argument for what each one does and needs. Lifecycle: start, pause, resume, conclude, promote_winner, archive, unarchive, duplicate. Editing (drafts only, except name, hypothesis, min_sample_size and min_runtime_hours): update, link_variant, set_variant_trigger. conclude and promote_winner answer with requires_confirmation first: show the summary and call again with confirmation_token after the user agrees. Read get_experiment before recommending a winner. Arguments: `experiment_id`*, `action`*, `variant_id`, `project_version_id`, `trigger_id`, `conclude`, `update`, `confirmation_token` (* required) | Write - asks first `experiments:write` | | `manage_branding_asset` Manage Branding Asset | Delete a logo, font or icon (id from the branding resource); delete, rename or set alt text on an image (url from list_assets); move an image into a folder; create or delete a folder. Uploading is upload_branding_asset; changing colors or legal text is update_branding. Arguments: `workspace_id`*, `action`*, `id`, `url`, `name`, `alt_text`, `folder_id`, `replace_with`, `confirmation_token` (* required) | Write - asks first `branding:write` | ## Prompts Ready-made starting points; clients that list server prompts show them in their prompt or slash-command menu. - `build-popup` - Build a pop-up (modal, inline, banner or bubble) from a brief: read the guides and workspace branding, draft, validate, save, preview, then ask before publishing. - `build-preference-center` - Build an Iterable preference center from a brief: channels group from the live Iterable connection, branding header/footer, validate, save, preview, then ask before publishing. - `build-landing-page` - Build a landing page as a Jule project (hosted by Jule, not a static site) from a brief: sections as Boxes, SEO, lead form, validate, save, preview, then ask before publishing. - `figma-to-jule` - Turn a Figma design (read through the Figma MCP server) into a Jule pop-up, preference center or landing page using the mapping guide — instead of writing HTML/CSS — then validate, save, preview and ask before publishing. - `analyze-project` - Read-only review of an existing project: its analytics (views, submissions, drop-off, trends) plus the document against the catalogs and best practices — prioritized findings with the exact fields to change, ending in one recommendation grounded in the numbers. Never saves or publishes. - `export-report` - Produce an analytics report as Excel, Word, PDF or HTML — or as charts and tables in the chat — from analytics_overview and analytics_report (fixed reports and custom queries), with the period, definitions and source on every sheet. Read-only. - `translate-project` - Add languages to an existing project: enable the locales (RTL directions included), translate every text-bearing field of every item plus the project-level strings into the i18n maps, validate until no translation is missing, save and preview each language. - `responsive-review` - Open the desktop, tablet and mobile previews of a project, run the mobile checklist (grids, card width, text sizes, hidden items, images, two-column stacking, teaser), fix the document with responsive overrides, validate, save and preview again. - `design-logic` - Branching, quiz and score logic for a project: draw the flow, write logic_rules with fallbacks and scores keyed by real page ids, give every page a way forward, validate for dead ends, save and walk each path in the preview. - `set-up-experiment` - From hypothesis to result: goal metric, variants as documents or linked versions (duplicate + link_variant), per-variant triggers, traffic split and gates, start, what to watch in get_experiment, then conclude or promote the winner with confirmation. - `connect-iterable` - Wire a project to Iterable from the live integration state: actions, field mappings taken from the profile-field list (email, phoneNumber), lists, channels and message types, event names from the events resource, SMS disclaimer and double opt-in, preference-center unsubscribe behaviour; validate against the connection warnings and save. ## Resources Building guides, the document schema in parts, and the live branding, integrations, events and install snippet of a workspace or project. Clients without resource support read the guides through `whoami` instead. - `jule://docs/how-to-work` - How to Work with Jule - `jule://docs/item-catalog` - Item Catalog - `jule://docs/project-style` - Project Style Guide - `jule://docs/settings` - Settings Guide - `jule://docs/layout` - Layout with Boxes - `jule://docs/responsive` - Responsive Guide - `jule://docs/languages` - Languages Guide - `jule://docs/best-practices` - Best Practices - `jule://docs/examples/popup` - Example: Pop-up Document - `jule://docs/examples/preference-center` - Example: Preference Center Document - `jule://docs/examples/landing-page` - Example: Landing Page Document - `jule://docs/examples/landing-page-classes` - Example: landing page with style classes - `jule://docs/product-docs` - Jule Product Docs - `jule://docs/figma-mapping` - Figma to Jule Mapping - `jule://docs/schema/document` - Schema: document - `jule://docs/schema/style_config` - Schema: style_config - `jule://docs/schema/settings_config` - Schema: settings_config - `jule://docs/schema/items/text` - Schema: text item - `jule://docs/schema/items/button` - Schema: button item - `jule://docs/schema/items/input` - Schema: input item - `jule://docs/schema/items/logo` - Schema: logo item - `jule://docs/schema/items/group` - Schema: group item - `jule://docs/schema/items/checkbox-group` - Schema: checkbox-group item - `jule://docs/schema/items/preference-category` - Schema: preference-category item - `jule://docs/schema/items/html-block` - Schema: html-block item - `jule://docs/schema/items/rating` - Schema: rating item - `jule://docs/schema/items/success_message` - Schema: success_message item - `jule://docs/schema/items/flex` - Schema: flex item - `jule://docs/schema/items/coupon` - Schema: coupon item - `jule://docs/schema/items/icon` - Schema: icon item - `jule://docs/document-schema` - Project Document Schema (whole) - `jule://workspaces/{id}/branding` - Workspace Branding - `jule://workspaces/{id}/integrations` - Workspace Integrations - `jule://workspaces/{id}/events` - Workspace Events - `jule://projects/{id}/embed` - Project Install Snippet and Domain # Exit-Intent Email Capture > Capture emails when a visitor is about to leave and subscribe them in Iterable. This recipe combines four already-documented Jule features into one flow: a widget with an email field, an exit-intent trigger that decides when to show it, targeting that narrows who sees it, and an Iterable sync action that subscribes whoever submits. ## What You’ll Build A single-step pop-up that stays hidden until a visitor’s cursor moves toward the browser chrome — the signal that they’re about to close the tab or navigate away. At that moment the widget appears with a headline, an email field, and a call-to-action button. Submitting subscribes the visitor to an Iterable list, so a departing visitor becomes a lead you can follow up with instead of a visit you never hear from again. ## Build the Widget Open the widget editor and add a **Headline** element — something like “Wait — don’t miss out”. Below it, add an **Email** element and set its **Field Name** to exactly `email` — this is the value Iterable identifies the visitor by once they subscribe, so the name has to match exactly. Mark the field required, and turn on the email typo checker if you want a non-blocking “Did you mean …?” suggestion for addresses like `gmial.com`. Finish with a Button element for the call-to-action and set its action to submit — this fires the visitor’s response the moment they click, with no second page required for a single-step sign-up form. For every element type, validation rule, and per-device visibility option, see [Elements](https://docs.jule.ai/widgets/elements). ## Set the Exit-Intent Trigger Create a trigger and set its type to **Exit Intent**, then point this project at it. Because a trigger belongs to the workspace rather than the project, the same exit-intent trigger can be reused across other sign-up widgets later without rebuilding it. > Note > > **Exit intent and mobile:** the mouse path has nothing to detect on a touchscreen, but the tab-switch path still fires when a mobile visitor backgrounds the page. To keep the pop-up strictly off touch devices, add the Device Type targeting condition — fixed to Desktop on exit-intent triggers. For dedicated mobile coverage, use a **Conditional Behavior** trigger (time on page or scroll depth) on a separate project. See [Triggers](https://docs.jule.ai/display/triggers) for the full set of trigger types. ## Target the Right Visitors A trigger only decides timing — targeting decides who actually sees the pop-up once it fires. Build a rule with two conditions joined by **AND** so both have to match: a **First Time Visitor** condition set to true, so you’re not interrupting someone who already knows the site, and an **Iterable List** condition using the **On none of** operator against the list you plan to subscribe them to in the next step — so visitors who are already on that list never see the pop-up again. Because the Iterable condition needs a server-side check, the widget calls `POST /projects/:id/audience/evaluate` before showing — the **First Time Visitor** condition still resolves instantly in the browser. See [Audience Targeting](https://docs.jule.ai/display/targeting) for every condition type and how rules combine. ## Subscribe Them in Iterable Open the **Iterable** tab in the widget editor and turn on the **Subscribe** action, then choose the same list you excluded in the targeting rule above. Because the email field’s **Field Name** is exactly `email`, Jule uses that value as the visitor’s Iterable identity for the sync. On submit, Jule calls Iterable’s `users/updateSubscriptions` with the list in `emailListIds`. That call replaces the user’s email-list membership with the lists you configure, so include every list the visitor should stay on. Enabling **Update User Profile** alongside Subscribe lets you map the field to your Iterable profile schema, and enabling **Track Custom Event** gives you an event you can use in Iterable Journeys. To stop the pop-up re-appearing, leave the trigger’s frequency on **Once** — the default — which blocks it after a dismiss or a submit. For the full set of sync actions, field mapping, and the payloads Jule sends, see [Audience Sync](https://docs.jule.ai/iterable/audience-sync). Related docs - [Triggers](https://docs.jule.ai/display/triggers)— every trigger type and how triggers combine with targeting. - [Audience Targeting](https://docs.jule.ai/display/targeting) — visitor, behavior, technical, and Iterable conditions. - [Audience Sync](https://docs.jule.ai/iterable/audience-sync) — Subscribe, Track Custom Event, and Update User Profile actions. - [Elements](https://docs.jule.ai/widgets/elements) — form fields, content blocks, containers, and special blocks. # Spin-to-Win Coupon > Build a spin-to-win coupon: unique codes, a spin wheel reveal, and synced assignments and redemptions in Iterable. This recipe combines three already-documented Jule features into one flow: a coupon that hands out unique codes, a spin wheel that gamifies the reveal, and an Iterable sync that keeps every assignment and redemption visible in your campaigns. ## What You’ll Build A widget page with a Coupon block rendered as a spin wheel. Visitors spin, land on a segment, and reveal a unique code drawn from a pool you configure once. Every assignment and redemption fires to Iterable so you can follow up by email or SMS. The pieces are a coupon (the codes and their rules), a block (how the code is revealed on the page), and a sync (how the event reaches Iterable) — each configured independently but wired together for this one flow. ## Create the Coupon Create a new coupon and set its type to **Unique** so Jule generates a distinct code per visitor from a prefix, length, and character set, rather than handing everyone the same code. Pick the discount the wheel awards — **Percentage**, **Fixed Amount**, or **Free Shipping** — and write a **Discount Label** that matches the copy you want on the winning segment, such as “20% off”. Set a **Total Usage Limit** and **Per Visitor Limit** so the pool can’t be drained by repeat spins, and turn on a **Fallback Code** so a visitor still gets something if the pool runs dry mid-campaign. For the full breakdown of coupon types, discounts, expiration, and limits, see [Coupons Overview](https://docs.jule.ai/coupons/overview). > Note > > A low-stock threshold on the coupon warns you before the pool of unique codes runs out — useful for a spin-to-win promotion that can spike in traffic. ## Add the Spin Wheel Open the widget editor and add a Coupon block to the page where the wheel should appear, then point it at the coupon you just created. On the block, set the display type to **Wheel** — this is independent of the coupon’s Unique type, so the same coupon could power a different reveal on another block without any change here. Configure the wheel’s segments — label and color for each one — along with the spin duration and pointer color. Only one segment needs to correspond to the real discount; the rest are decoys purely for the reveal experience — the code is the same whichever segment shows. The wheel always stops on the first segment whose label isn’t a losing one (Try again, No luck, Sorry, Oops, Next time, Better luck, No win), so put your real discount segment first. For every display type and how expiry is shown once the code appears, see [Coupon Blocks & QR](https://docs.jule.ai/coupons/blocks-and-qr). ## Sync to Iterable On the coupon, enable Iterable sync and choose **Journey** so Jule pushes `couponAssigned`, `couponRedeemed`, and `couponExpired` events to Iterable the moment a visitor spins and later redeems the code. That lets you branch a Journey on the reveal — for example, a reminder email if a spin-to-win code is assigned but never redeemed. Set a field prefix so the coupon’s data doesn’t collide with other synced fields, and check the sync activity log after a test spin to confirm the event reached Iterable. For Journey vs. Data feed, setup, and troubleshooting, see [Iterable Sync](https://docs.jule.ai/coupons/iterable-sync). ## Go Live Before publishing, spin the wheel yourself in preview: confirm the winning segment’s label matches the coupon’s discount label, the code reveals correctly, and the assignment event shows up in the sync activity log. Then publish the widget page so visitors see the live wheel. > Note > > Watch the coupon’s stats — available, assigned, redeemed, and redemption rate — after launch. A sudden drop in available codes is your cue to top up the pool before the fallback code starts going out instead. Related docs - [Coupons Overview](https://docs.jule.ai/coupons/overview) — coupon types, discounts, expiration, and usage limits. - [Coupon Blocks & QR](https://docs.jule.ai/coupons/blocks-and-qr) — display types, expiry displays, and QR codes. - [Iterable Sync](https://docs.jule.ai/coupons/iterable-sync) — Journey and Data feed sync, plus the activity log. # SMS Double Opt-In > Collect a phone number, wire it to an SMS message type, and require a reply-to-confirm double opt-in before the subscription completes. This recipe combines three already-documented Jule features into one flow: a phone input field that collects the number, a Channels & Types wiring that subscribes it to an SMS message type, and the Iterable integration’s double opt-in setting that holds the subscription until the visitor confirms by reply. ## What You’ll Build A widget page with a phone number field and an SMS checkbox that, together, submit a visitor’s number to Iterable and subscribe it to a chosen message type. Because that message type’s subscription policy in Iterable is `DoubleOptIn`, the visitor isn’t subscribed the moment they submit the form — Iterable sends a confirmation SMS first, and the subscription only completes once they reply `Y`. The pieces are a field (the tel input), a wire (the checkbox tied to that message type), and a setting (double opt-in on the Iterable integration) — each configured independently but combined for this one flow. ## Collect the Phone Number In the widget editor, add a **Phone** element. Give it a field name — the key its value is stored and sent under — and mark it **required** so the visitor can’t submit the form without a number. Validation rules such as a pattern or a minimum length are optional and work the same way as on any other input type. The form must also identify the visitor: add an **Email** element, or pre-identify logged-in visitors with `window.JULE_USER_DATA`. The double opt-in request requires an email or user ID alongside the phone number — without one, no confirmation SMS is sent. Then map that field to Iterable. In the project's Iterable settings, add an **Update Profile** mapping from this field to Iterable's `phoneNumber` field, with no group set. That is the number the confirmation SMS is sent to, so without the mapping the rest of the flow looks correct end to end and no SMS is ever sent. For the full set of input types, validation options, and how elements are added and arranged, see [Elements](https://docs.jule.ai/widgets/elements). ## Wire SMS Channels & Message Types Add a **Channels & Types** block to the page and expand your SMS channel to reveal its message types. Checking a message type there selects it, and confirming the block stores only the channels and message types you picked — not your whole Iterable account. A standalone checkbox works too: point its **Iterable Integration** setting at **Subscribe to Types** and pick the same SMS message type. Either way, checked means subscribed — the opt-in framing used throughout the Channels & Types block. For the panel walkthrough, Select All / Unselect All controls, and how opt-in versus opt-out framing works, see [Channels & Message Types](https://docs.jule.ai/preference-center/channels). ## Enable Double Opt-In In the widget editor, open the **Iterable** tab and tick the **Subscribe** action. Its **Double Opt-In Message Types** picker only lists message types whose subscription policy in Iterable is `DoubleOptIn` — select the same SMS message type you wired in the previous step. From then on, a new subscriber receives a confirmation SMS and must reply `Y` before they’re actually subscribed. You can also override the brand name shown in that confirmation SMS, up to 50 characters — leave it blank to use the default configured in Iterable. > Note > > Only message types already set to `DoubleOptIn` in Iterable show up in the picker. If the message type you wired isn’t listed, set its subscription policy in Iterable first. For the full unsubscribe and opt-in configuration, including one-click unsubscribe and the hosted unsubscribe page, see [Unsubscribe](https://docs.jule.ai/preference-center/unsubscribe). ## Compliance Notes The phone field and the Channels & Types wiring only decide what gets submitted and which message type it targets — the confirm-by-reply behavior itself lives entirely in Iterable’s double opt-in setting. Jule doesn’t determine which regulations apply to your program; confirm requirements for your audience and channels with your own compliance team. Related docs - [Elements](https://docs.jule.ai/widgets/elements) — input types, validation, and the tel field. - [Channels & Message Types](https://docs.jule.ai/preference-center/channels) — wiring channels and message types, and opt-in vs. opt-out framing. - [Unsubscribe](https://docs.jule.ai/preference-center/unsubscribe) — one-click unsubscribe, the hosted unsubscribe page, and SMS double opt-in. # SMS Sign-Up with Iterable > Confirm a visitor’s phone number before they get the offer: Reply Y or Code, the waiting screen, the confirmation webhook, and the welcome journey that texts the code to late repliers. SMS confirmation holds the offer until the visitor proves the phone number is theirs. The coupon is assigned, and `couponAssigned` is sent to Iterable, only after they confirm, so a typo or a fake number never burns a code. It is a setting on the pop-up's Iterable tab and works with the Iterable SMS program you already have. ## Choose a Mode - **Off**: the offer appears as soon as the form is sent. The number is not checked. - **Reply Y** (reply by text): Iterable texts "Reply Y". The pop-up shows a waiting screen and reveals the code the moment the reply arrives. Visitors who reply after leaving get the code from your welcome journey. Works with or without a coupon (newsletters and waitlists too). - **Code** (enter a code): Jule texts a short code through Iterable; the visitor types it (phones usually fill it in for them) and sees the offer at once. Needs Phone Verification in the workspace; it works with or without a coupon (without one the form ends on "You're subscribed ✓"). > Note > > Reply Y is the classic double opt-in. Code keeps the visitor on your page and converts better on mobile; Iterable still records the SMS subscription after the code is verified. > > A pop-up with SMS confirmation can't run an A/B test. Turn the mode off before you start a test on it. ## What the Visitor Sees 1. **The form**: phone field with your SMS disclosure under it, plus an email field. 2. **Waiting screen** (Reply Y): "Check your texts. We sent you a text. Reply Y to confirm and unlock your code." with an *Edit number* link. 3. **Code screen** (Code): "Enter your code", a resend timer and *Edit number*. 4. **Confirmed**: the coupon page shows the code, and a floating pill says "SMS verified · use code … · tap to copy", even if the visitor closed the pop-up. Without a coupon the pop-up says "You're subscribed ✓". 5. **After the wait**: if no reply arrives within the window you set (2 minutes by default), the pop-up says "Still waiting? Reply Y whenever you're ready and we'll text you your code." ![Reply Y waiting screen: Check your texts, Reply Y to confirm and unlock your code, with an Edit number link](https://docs.jule.ai/docs/guides/sms-sign-up-with-iterable/waiting-screen.png) *Reply Y: the waiting screen.* ![Code screen: Enter your code, six digit boxes, the masked number, a resend timer and Edit number](https://docs.jule.ai/docs/guides/sms-sign-up-with-iterable/code-screen.png) *Code: the visitor types the texted code.* ![Confirmed state: You are confirmed! Here is your code, with the coupon code, a copy button and Shop now](https://docs.jule.ai/docs/guides/sms-sign-up-with-iterable/confirmed.png) *Confirmed: the code appears on the page.* Every text is editable and translatable under the Style tab → **Text confirmation**. Preview each Reply Y state from the SMS confirmation section of the Iterable tab without sending a text. The floating pill is set up in the same section, under **Teaser After Confirmation**: switch it on or off, change its text, what a tap does, how long it stays, its position, colors and font. It is separate from the pop-up's own teaser (Style tab → **Teaser**): it shows whether or not that teaser is on, and changing one never changes the other. It is on by default when the pop-up has a coupon. Without a coupon it is off by default; switched on, it tells the visitor "You're subscribed ✓", which helps when they reply Y after closing the pop-up. Use **Insert** to add the code, the discount or when it expires; these stay empty until the pop-up has a coupon element. **Tap** copies the code or opens the coupon page; without a coupon, tapping the pill does nothing. ![Style tab, Text confirmation panel: the Show Teaser After Confirmation switch with its text, tap and position settings, and the floating code pill on the canvas](https://docs.jule.ai/docs/guides/sms-sign-up-with-iterable/teaser-after-confirmation.png) *Text confirmation → Teaser After Confirmation: the floating pill and its settings.* ![Style tab, Text confirmation panel: editable title and text with their styles, next to the waiting screen on the canvas](https://docs.jule.ai/docs/guides/sms-sign-up-with-iterable/text-confirmation-style.png) *Style tab → Text confirmation: edit each screen’s copy and style.* ## Set Up the Pop-Up 1. Add a **Phone** field (required), the **SMS disclaimer** element under it, and an **Email** field. Map the phone to Iterable's `phoneNumber`. 2. Open the **Iterable** tab. Under the action types, in **SMS confirmation**, pick *Reply Y* or *Code*. It applies whichever actions are ticked. 3. Choose the double opt-in SMS message type. Only message types set to `DoubleOptIn` in Iterable are listed. 4. Set how long the page waits for the reply (30 seconds to 5 minutes). 5. Work through the **Before Publishing** list. Every warning has a link to the fix, including a coupon that is not sent to Iterable. ![Iterable tab, SMS confirmation section: Reply Y selected, an opt-in message type, brand name and a 2-minute wait](https://docs.jule.ai/docs/guides/sms-sign-up-with-iterable/mode-picker.png) *Iterable tab → SMS confirmation: pick the mode, message type and wait.* ![Before Publishing list flagging a phone input with no SMS disclaimer and a missing double opt-in message type](https://docs.jule.ai/docs/guides/sms-sign-up-with-iterable/before-publishing.png) *Before Publishing names what is missing, with a link to each fix.* Jule refuses to publish an SMS confirmation pop-up that is missing any of these: a double opt-in message type, an email field (or user ID), a phone field mapped to `phoneNumber`, the SMS disclaimer, and for *Code* a saved Phone Verification profile. The same rules apply when an AI assistant publishes through Jule. ## Connect the Confirmation Webhook Reply Y needs Iterable to tell Jule when a visitor replies. Do this once per workspace: Settings → Integrations → Iterable → **SMS Phone Verification** → **Reply by Text** → *Create*, then copy the URL and value. In Iterable go to Integrations → Webhooks → New webhook: ![Settings, Iterable, Reply by Text card: webhook URL, masked Authorization value with Reveal and Rotate, and a Receiving badge](https://docs.jule.ai/docs/guides/sms-sign-up-with-iterable/reply-by-text-settings.png) *Reply by Text: the webhook URL and the Authorization value to paste into Iterable.* 1. Paste the URL. 2. Auth: choose **OAuth 2.0** and paste the value as the token, with no "Bearer" prefix (Iterable adds it). 3. Events: tick **Subscribe**, **Unsubscribe** and **SMS Bounce** if it is listed. 4. Tick **Exclude list update events**. 5. Save, reply Y from a test phone, and watch the badge turn *Receiving*. Only workspace admins can create, reveal or rotate the value (the **Manage SMS Settings** permission); everyone else sees the URL and the status badge. If the value leaks, rotate it twice: the old value stops working within a minute. Without the webhook, confirmations still arrive (Jule checks Iterable every few hours), but too late to show the code on the page. ## Set Up Phone Verification For *Code* (Enter a code), ask your Iterable contact to enable Phone Verification, then paste its profile ID under Settings → Integrations → Iterable → **SMS Phone Verification** → **Enter a Code**. Pick the code length that matches the profile, then press *Save* and *Verify* (Verify checks your key and that a profile ID is saved). Under **Limits**, choose the countries you text and a daily text limit. Use codes of 6 digits or more; a daily limit of 0 pauses every text for the workspace. These settings, like the webhook value, can only be changed by workspace admins. Jule also limits how often one number is texted (3 an hour and 5 a day per workspace), and a code allows 5 tries before it stops working. ![Enter a Code card with the saved verification profile ID and code length, and the Limits card with allowed countries and the daily text limit](https://docs.jule.ai/docs/guides/sms-sign-up-with-iterable/phone-verification-settings.png) *Enter a Code and Limits, under Settings → Integrations → Iterable.* ## Build the Welcome Journey Visitors who reply after leaving the page get the code from Iterable. With the **Journey** delivery method: 1. Trigger the journey on the `couponAssigned` event (or your custom event name). For forms without a coupon, trigger on "Subscribed to message type" instead. 2. Branch on `revealed_in_session`: `true` means the visitor already saw the code; `false` means a late reply that still needs it. 3. Add an SMS tile: "Thanks for subscribing! Your code is `{{coupon_code}}` for `{{coupon_discount_label}}`." (use your coupon's field prefix). The event also carries `assignment_source`: `sms_confirmation` for these codes. The same values are on the profile as `{prefix}_revealed_in_session` and `{prefix}_assignment_source`. With the **Data Feed** method, trigger the welcome email on "Subscribed to message type" only. The feed itself is not held back until the reply, so an email that pulls it earlier issues a code; sent after the subscription, it returns the same code the page showed. Data feeds are keyed by email, so visitors without an email need the Journey method. See [Coupon Iterable Sync](https://docs.jule.ai/coupons/iterable-sync). ## Read the Numbers The pop-up's analytics page gains an **SMS Confirmation** card when texts were sent in the selected range: sent for confirmation, confirmed (on the page and later), median time to confirm, codes copied, still waiting and expired. Your conversion rate is calculated exactly as before. ## Compliance Notes - Show the disclosure under the phone field: who is texting, recurring automated marketing texts, consent is not a condition of purchase, message frequency varies, message and data rates may apply, reply STOP to cancel and HELP for help, and links to your terms and privacy policy. - Never pre-check an SMS consent box. - Jule stores consent evidence for every sign-up: the disclosure text, the time, the page address (without its query string), the browser, and for Reply Y the moment the reply arrived. Without a disclosure on the form, Jule sends no text. - Quiet hours and the "You're subscribed" text are handled by Iterable. - STOP always works. A visitor who texted STOP before must reply START first; the waiting screen says so. Jule doesn't determine which regulations apply to your program; confirm requirements with your own compliance team. ## FAQ **What if the pool runs out before they reply?** They get the fallback code if you set one, otherwise "You're confirmed, but this offer has run out." **The visitor already has a code.** They get the same code again, never a second one. **They closed the pop-up.** The floating pill shows the code until dismissed, for up to 24 hours, on every page, as long as *Teaser After Confirmation* is on (it is by default when the pop-up has a coupon). **They came back the next day.** The same browser shows the code once on the next visit. Another device shows nothing; the welcome journey covers it. **I never built a welcome journey.** Late repliers only see the code on their next visit. The publish checklist warns you. **They submit again after confirming.** They are already subscribed to that number, so the code appears at once with no new text (Reply Y only; Code always asks for a code). **The form has no email.** There is nothing to confirm against, so no text is sent and no code is shown. Related docs - [SMS Double Opt-In](https://docs.jule.ai/guides/sms-double-opt-in) — the phone field and message type wiring. - [Coupon Iterable Sync](https://docs.jule.ai/coupons/iterable-sync) — Journey and Data Feed delivery. - [Webhooks](https://docs.jule.ai/developers/webhooks) — send sign-ups to your own systems. # Embedded Newsletter Form > Embed a Jule signup form directly in a page with Inline mode, instead of a pop-up, using a plain HTML or framework snippet. Not every signup form needs to interrupt the page. This recipe embeds a Jule form directly into your layout — a footer, a blog post, a landing page section — using **Inline** display mode instead of a pop-up or banner. The form itself and the Iterable subscription it creates work exactly like any other widget; only how it’s placed on the page changes. ## What You’ll Build A single email field and a submit button, rendered inside a container `
` that lives wherever you put it in your HTML — no overlay, no fixed position, no dimmed background. It resizes with its container and follows the page’s normal flow, so it reads as part of the page rather than something layered on top of it. ## Set Inline Mode On the **Style** tab, under **Display settings**, set the display mode to **Inline**. Position and overlay settings on the Style tab stop applying once you do — inline widgets only care about where their container div sits in your HTML. For the other display modes and when each one fits, see [Display Modes](https://docs.jule.ai/display/modes). > Note > > Skip the Triggers tab for this recipe. Inline widgets render as soon as their container div is on the page — triggers like delay or scroll depth are for pop-ups and banners deciding when to interrupt, not for content that’s already part of the page. ## Build the Form Add an **Email** element and turn on **Required field**. Turning on the email typo checker catches an address like `gmial.com` before it’s submitted — worth enabling here since a mistyped email on a newsletter form is a subscriber you silently lose. Finish with a submit button and a **Form Message** block for the confirmation. The full set of field types, validation, and content blocks is in [Elements](https://docs.jule.ai/widgets/elements). ## Embed in Your Framework Every embed needs the same two pieces: the widget script tag, added once per page, and a container div for this specific form. In plain HTML that’s two lines; in a component-based framework, load the script from an effect so it only runs once on the client. ```html HTML
``` ```typescript React import { useEffect } from 'react'; export function NewsletterForm({ projectId }: { projectId: string }) { useEffect(() => { if (!document.querySelector('script[data-api-key]')) { const s = document.createElement('script'); s.src = 'https://assets.jule.ai/widget.js'; s.async = true; s.dataset.apiKey = 'YOUR_API_KEY'; document.head.appendChild(s); } }, []); return
; } ``` If the same page already embeds another Jule widget, don’t add the script tag twice — one script per page serves every container div on it. See [Embed Your Widget](https://docs.jule.ai/getting-started/embed-your-widget) for the Next.js snippet and pre-identifying logged-in visitors. ## Go Live Publish the project, then reload the page the container div lives on. The form should render in place, with no flash of an overlay or shift in the surrounding layout. Submit a test entry and confirm the email appears in Iterable before sending traffic to the page. Related docs - [Display Modes](https://docs.jule.ai/display/modes) — pop-up, inline, and banner, and when to use each. - [Elements](https://docs.jule.ai/widgets/elements) — form fields, content blocks, and validation. - [Embed Your Widget](https://docs.jule.ai/getting-started/embed-your-widget) — the script tag, framework snippets, and pre-identifying visitors. # Hosted Preference Center > Stand up a full subscription preference center on your own custom domain, from creating the project to verifying DNS. Instead of an all-or-nothing unsubscribe link, this recipe gives subscribers a full page to manage exactly which emails and SMS messages they get from you — served on your own domain so it reads as part of your site rather than a third-party page. It combines a preference center project, its channel configuration, and a custom domain into one end-to-end setup. ## What You’ll Build A standalone, full-page project — not a pop-up or inline widget — that subscribers land on directly, typically from a link in an email footer. It will list the channels and message types they can toggle, and it will be reachable at a URL on your own domain instead of a Jule-hosted one. ## Create the Project When creating a new project, choose **Preference Center** — *full-page subscription preferences* — rather than **Widgets**. This locks the project as a **Preference Center** and can’t be changed afterward. The editor toolbar adapts accordingly: it keeps Pages, Style, and Iterable, but swaps the Triggers tab — nothing to trigger on a hosted page — for a **Domain** tab. Build the page content on the **Pages** tab. Your workspace’s header and footer branding is linked onto a new preference center automatically and stays in sync — unlink or remove it in the editor if you don’t want it (editing the region also unlinks it). Full walkthrough: [Preference Center Overview](https://docs.jule.ai/preference-center/overview). ## Configure Channels & Message Types Add **Channels & Types** blocks to the page — checkbox groups wired to your Iterable channels and message types — and offer the optional **Subscribe All** and **Unsubscribe All** shortcuts so subscribers can set everything at once or narrow down from there, rather than opting out of everything. See [Channels & Message Types](https://docs.jule.ai/preference-center/channels) for every checkbox behavior and how it maps to Iterable. ## Add Your Own Domain On the **Domain** tab, enter the subdomain you want to use — for example `preferences.yourdomain.com` — and choose **Save Domain**. The status moves to **Pending Verification** and shows a CNAME record to add with your DNS provider (verification may also ask for a one-time TXT record to prove you own the domain), pointing to `cname.jule.ai`. Once that record propagates, choose **Verify Domain** to flip the status to **Verified**, then **Active**. Full DNS steps and troubleshooting: [Custom Domain](https://docs.jule.ai/preference-center/custom-domain). > Note > > DNS changes can take up to 48 hours to propagate. Add the CNAME record first, then verify — you can re-run verification as many times as you need while you wait. ## Go Live Make sure the project is **Active**, then publish — an unpublished preference center resolves to a not-found page no matter how complete it is. Open the page on your custom domain, toggle a channel as a test subscriber, and confirm the change reaches Iterable before linking to it from a real email footer. Related docs - [Preference Center Overview](https://docs.jule.ai/preference-center/overview) — creating a project, the Domain tab, and publishing. - [Channels & Message Types](https://docs.jule.ai/preference-center/channels) — checkbox behaviors and Iterable mapping. - [Custom Domain](https://docs.jule.ai/preference-center/custom-domain) — CNAME setup, verification, and troubleshooting. # A/B Test a Welcome Offer > Test two versions of a welcome offer against each other and let Jule declare the winner based on real visitor traffic. Instead of guessing whether a 10% or 20% welcome discount converts better, this recipe runs both as variants of the same project and lets real traffic decide. It walks through creating the test, picking a goal and winner gates, and reading the Results view once it’s live. ## What You’ll Build A test on your existing welcome-offer project with a **control** — the offer as it runs today — and one challenger variant with a different discount or copy. Traffic splits between them, Jule tracks a goal for each, and once enough data comes in it tells you which one is actually winning rather than which one looks better after a day. ## Create the Test & Variants Start a new test on the welcome-offer project and give it a name — the test begins in **Draft**, so nothing changes for visitors yet. Name your challenger variant in the wizard, then click **Duplicate live** on the empty variant in the editor’s A/B menu to snapshot the alternate discount into it, leaving the existing experience as the **control** — the test can’t start until every variant has content. An even traffic split is the default; weight it toward the control if you’d rather limit early exposure to the unproven offer. Full walkthrough of variants and traffic splits: [A/B Test Setup](https://docs.jule.ai/ab-testing/setup). ## Pick a Goal & Winner Gates For a welcome offer, **Submission rate** or **CTA click rate** is usually the goal that matters — how many visitors who saw the offer actually acted on it. Before launch, also decide **auto-declare winner**: on, Jule concludes the test itself and promotes the winner the moment minimum sample size, minimum runtime, and the Bayesian confidence threshold all clear; off, it surfaces a likely winner instead and waits for you to conclude it. This can’t be changed once the test is running, so decide up front. > Note > > A welcome offer usually gets a lot of exposure fast, so the runtime gate — not sample size — is often what makes you wait. Give it enough days to smooth out day-of-week swings before trusting an early lead. ## Launch & Monitor Launching moves the test from **Draft** to **Running**. From the Results view, watch each variant’s exposures, conversions, conversion rate, and lift vs. control, plus the probability to beat control and its 95% credible interval — the statistics behind the headline number. Check the SRM check too: if the traffic split you configured doesn’t match what each variant actually received, treat the numbers with caution until you find and fix the cause. Full breakdown of every metric: [A/B Test Results](https://docs.jule.ai/ab-testing/results). ## Declare a Winner Jule only offers a winner once every variant has cleared the minimum sample size (and at least 25 conversions each), the test has run its minimum runtime, and one variant has cleared the confidence threshold — until the sample gate clears, the Results view says to keep running, and an early confident variant is flagged only as a likely winner. Once a winner is available, conclude the test from the Results view and, if you’re confident, make the winning variant the project’s new default. With auto-declare on, Jule has already done this for you the moment the gates cleared. Related docs - [A/B Test Setup](https://docs.jule.ai/ab-testing/setup) — variants, goals, holdout, and winner gates. - [A/B Test Results](https://docs.jule.ai/ab-testing/results) — reading the Results view, statistics, and the SRM check. # Guides > Step-by-step recipes that combine Jule features to ship a complete use case end to end. Guides are end-to-end recipes. Each one combines several Jule features — widgets, triggers, targeting, coupons, Iterable sync — to ship a complete use case, and links to the reference pages for the details. - [Exit-Intent Email Capture](https://docs.jule.ai/guides/exit-intent-email-capture): Capture emails when a visitor is about to leave and subscribe them in Iterable. - [Spin-to-Win Coupon](https://docs.jule.ai/guides/spin-to-win-coupon): Reveal a unique discount code with a spin-to-win wheel and sync it to Iterable. - [SMS Double Opt-In](https://docs.jule.ai/guides/sms-double-opt-in): Collect a phone number and subscribe to SMS with a compliant double opt-in. - [SMS Sign-Up with Iterable](https://docs.jule.ai/guides/sms-sign-up-with-iterable): Hold the offer until the visitor confirms by text, and text the code to late repliers. - [Embedded Newsletter Form](https://docs.jule.ai/guides/embedded-newsletter-form): Embed an inline signup form directly in a page instead of a pop-up. - [Hosted Preference Center](https://docs.jule.ai/guides/hosted-preference-center): Run a full subscription preference center on your own custom domain. - [A/B Test a Welcome Offer](https://docs.jule.ai/guides/ab-test-welcome-offer): Test two versions of a welcome offer and let Jule declare the winner. # API References > Technical reference for the Jule widget script, the window.Jule JavaScript API, and webhooks that deliver widget events to your backend. If you’re integrating Jule for the first time, start with the widget script. - [Widget Script](https://docs.jule.ai/developers/widget-script): Install the embed script on your site and configure it with data attributes. - [JavaScript API](https://docs.jule.ai/developers/javascript-api): Show a widget on demand with Jule.show() and identify visitors with window.JULE_USER_DATA. - [Webhooks](https://docs.jule.ai/developers/webhooks): Receive signed deliveries for form submissions, subscribe and unsubscribe events, and preference changes. - [Connect Your AI](https://docs.jule.ai/developers/mcp): Let Claude, ChatGPT, Cursor or any MCP client build, preview and publish projects in your workspace. - [MCP Tool Reference](https://docs.jule.ai/developers/mcp/tools): Every tool, prompt and resource the Jule MCP server exposes, with its access level and arguments - generated from the live server. # Changelog > Latest updates and releases for the Jule platform. This section is coming soon. Check back for updates.