# API keys Source: https://developers.tally.so/api-reference/api-keys Learn how API keys work on Tally API keys on Tally allow you to access your account programmatically. This is useful for integrating Tally into your application or with other tools and services. For now, each API key is tied to a specific user - meaning that it will inherit the permissions of the user, also when they change. With the key you will be able to access all of the user's resources. ## Creating an API key You can create an API key by following these steps: Go to **Settings** > [**API keys**](https://tally.so/settings/api-keys). API keys overview Click on the "Create API key" button. Currently, you can only create an API key in the context of your own user. While we might add fine-grained permissions in the future, it's not option right now. Creating an API key Once your API key is created, make sure to copy and store it in a safe place. You won't be able to see it again for security reasons. If it gets lost, you can create a new one. API key created Now that you have your API key, you can use it to access your account's resources programmatically via any API request as a bearer token. ``` Authorization: Bearer tly-xxxx ``` When you remove a user from your organization (or they leave your organization), all API keys associated with that user will stop working as well. Keep this in mind when managing users. # Changelog Source: https://developers.tally.so/api-reference/changelog Changes made to the Tally API worth knowing about We try to version the API using dates to maintain a highly backwards compatible API. You can specify a version using the `tally-version` header, e.g. `tally-version: 2025-02-01`. #### Breaking changes * `POST /forms` and `PATCH /forms/{formId}` no longer accept `DELETED` as a `status`. Requests using it will be rejected with a `400` error. Previously the form was marked as deleted without being moved to the trash, leaving it unrecoverable. Use `DELETE /forms/{formId}` to delete a form. #### New endpoints * `GET /workspaces/{workspaceId}/folders`
List a workspace's folders * `POST /workspaces/{workspaceId}/folders`
Create a folder * `PATCH /workspaces/{workspaceId}/folders/{id}`
Rename a folder * `DELETE /workspaces/{workspaceId}/folders/{id}`
Delete a folder and its subfolders, moving any forms they contain to trash #### Changed endpoints * `GET /workspaces`
Each workspace now includes a `folders` array * `GET /workspaces/{workspaceId}`
The response now includes a `folders` array
#### New endpoints * `GET /forms/{formId}/analytics/metrics`
Get aggregate form metrics * `GET /forms/{formId}/analytics/visits`
Get form visits over time * `GET /forms/{formId}/analytics/submissions`
Get completed and partial submissions over time * `GET /forms/{formId}/analytics/dimensions`
Get visitor breakdowns by source, browser, OS, device and location * `GET /forms/{formId}/analytics/drop-off`
Get per-question drop-off statistics
#### Changed endpoints * `POST /forms`
Form settings accept rich text (`html` with `mentions`) for email and redirect fields * `GET /forms/{formId}`
Form settings now return rich text (`html` with `mentions`) for email and redirect fields * `PATCH /forms/{formId}`
Form settings accept rich text (`html` with `mentions`) for email and redirect fields
#### Changed endpoints * `GET /forms/{formId}/submissions`
Each submission now includes `previewUrl` and `pdfUrl` * `GET /forms/{formId}/submissions/{submissionId}`
The response now includes `previewUrl` and `pdfUrl`
#### Changed endpoints * `GET /forms/{formId}/questions`
Field references (used for default answers and mentions) can now resolve to utility fields, such as `utility::today()`
#### Breaking changes * Block payloads are now validated against the schema. Requests with invalid block payload structures will be rejected with a `400` error. Previously, malformed block payloads were accepted silently. #### New endpoints * `GET /forms/{formId}/questions`
Get form questions * `GET /forms/{formId}/submissions/{submissionId}`
Get a specific form submission * `GET /organizations/{organizationId}/users`
View organization users * `DELETE /organizations/{organizationId}/users/{userId}`
Remove users from organization * `GET /organizations/{organizationId}/invites`
View pending invites * `POST /organizations/{organizationId}/invites`
Invite users to organization * `DELETE /organizations/{organizationId}/invites/{inviteId}`
Cancel pending invites * `GET /webhooks`
List configured webhooks * `GET /webhooks/{webhookId}/events`
List webhook events * `POST /webhooks/{webhookId}/events/{eventId}`
Retry webhook events #### Changed endpoints * `GET /forms/{formId}/submissions`
Added new filtering options: `startDate`, `endDate` & `afterId`
#### New endpoints * `GET /users/me` #### Changed endpoints * `GET /forms`
Now returns a paginated response instead of a flat list. * `GET /workspaces`
Now returns a paginated response instead of a flat list. #### Deprecated endpoints * `GET /me`
Hello world! 🎉 # Dimensions Source: https://developers.tally.so/api-reference/endpoint/forms/analytics/dimensions GET /forms/{formId}/analytics/dimensions Returns visitor breakdowns by source, browser, OS, device, and location. # Drop-off Source: https://developers.tally.so/api-reference/endpoint/forms/analytics/drop-off GET /forms/{formId}/analytics/drop-off Returns per-question drop-off statistics for a form. # Metrics Source: https://developers.tally.so/api-reference/endpoint/forms/analytics/metrics GET /forms/{formId}/analytics/metrics Returns aggregate metrics for a form (visits, submissions, completion rate, and more). # Submissions Source: https://developers.tally.so/api-reference/endpoint/forms/analytics/submissions GET /forms/{formId}/analytics/submissions Returns completed and partial submission counts for a form over time. # Visits Source: https://developers.tally.so/api-reference/endpoint/forms/analytics/visits GET /forms/{formId}/analytics/visits Returns visit counts for a form over time. # Deleting forms Source: https://developers.tally.so/api-reference/endpoint/forms/delete DELETE /forms/{formId} Deletes a form by its ID and moves it to the trash. # Fetching forms Source: https://developers.tally.so/api-reference/endpoint/forms/get GET /forms/{formId} Returns a single form by its ID with all its blocks and settings. # Listing forms Source: https://developers.tally.so/api-reference/endpoint/forms/list GET /forms Returns a paginated array of form objects. # Updating forms Source: https://developers.tally.so/api-reference/endpoint/forms/patch PATCH /forms/{formId} Updates a form's settings, blocks, or status. # Creating forms Source: https://developers.tally.so/api-reference/endpoint/forms/post POST /forms Creates a new form, optionally based on a template or within a specific workspace. # Listing questions Source: https://developers.tally.so/api-reference/endpoint/forms/questions/list GET /forms/{formId}/questions Returns a list of all questions in a form. # Deleting submissions Source: https://developers.tally.so/api-reference/endpoint/forms/submissions/delete DELETE /forms/{formId}/submissions/{submissionId} Deletes a specific submission from a form. # Fetching submissions Source: https://developers.tally.so/api-reference/endpoint/forms/submissions/get GET /forms/{formId}/submissions/{submissionId} Returns a specific form submission with all its responses and the form questions. # Listing submissions Source: https://developers.tally.so/api-reference/endpoint/forms/submissions/list GET /forms/{formId}/submissions Returns a paginated list of form submissions with their responses. Looking for real-time submissions? The most efficient way to instantly retrieve new submissions is by using a [webhook](https://tally.so/help/webhooks). This allows you to receive data as soon as a form is submitted, without needing to poll the API. # Cancelling invites Source: https://developers.tally.so/api-reference/endpoint/organizations/invites/delete DELETE /organizations/{organizationId}/invites/{inviteId} Cancels a pending invitation to join workspaces within your organization. Only the user who created the invite can cancel it. # Listing invites Source: https://developers.tally.so/api-reference/endpoint/organizations/invites/get GET /organizations/{organizationId}/invites Returns a list of all invites in your organization. # Creating invites Source: https://developers.tally.so/api-reference/endpoint/organizations/invites/post POST /organizations/{organizationId}/invites Invites users to join specific workspaces within your organization. # Removing users Source: https://developers.tally.so/api-reference/endpoint/organizations/users/delete DELETE /organizations/{organizationId}/users/{userId} Removes a user from your organization. Only the organization creator can remove other members, or users can remove themselves. # Listing users Source: https://developers.tally.so/api-reference/endpoint/organizations/users/get GET /organizations/{organizationId}/users Returns a list of all users in your organization. # Fetching user info Source: https://developers.tally.so/api-reference/endpoint/users/me/get GET /users/me Returns information about the current authenticated user. # Deleting webhooks Source: https://developers.tally.so/api-reference/endpoint/webhooks/delete DELETE /webhooks/{webhookId} Deletes a webhook. If this is the last webhook for a form, the webhooks integration will also be marked as deleted. # Listing webhook events Source: https://developers.tally.so/api-reference/endpoint/webhooks/events/get GET /webhooks/{webhookId}/events Returns a paginated list of webhook delivery events for a specific webhook, including delivery status, response codes, and retry information. # Retrying webhook events Source: https://developers.tally.so/api-reference/endpoint/webhooks/events/retry POST /webhooks/{webhookId}/events/{eventId} Retries sending a failed webhook event. This will attempt to deliver the webhook payload again to the configured endpoint. Failed webhook events are automatically retried up to 5 times with exponential backoff intervals (5 minutes, 30 minutes, 1 hour, 6 hours, 24 hours) before being permanently dropped. # Listing webhooks Source: https://developers.tally.so/api-reference/endpoint/webhooks/get GET /webhooks Returns a paginated list of all webhooks across your accessible forms and workspaces. # Updating webhooks Source: https://developers.tally.so/api-reference/endpoint/webhooks/patch PATCH /webhooks/{webhookId} Updates an existing webhook configuration. # Creating webhooks Source: https://developers.tally.so/api-reference/endpoint/webhooks/post POST /webhooks Creates a new webhook for a form to receive form events. # Deleting workspaces Source: https://developers.tally.so/api-reference/endpoint/workspaces/delete DELETE /workspaces/{workspaceId} Deletes a workspace and all its associated forms. The workspace and forms are moved to trash and can be restored later. Forms in DRAFT or PUBLISHED state will be marked as DELETED. # Deleting folders Source: https://developers.tally.so/api-reference/endpoint/workspaces/folders/delete DELETE /workspaces/{workspaceId}/folders/{id} Deletes a folder and its entire subtree by its ID, moving any contained forms to trash. Requires a Pro subscription. # Listing folders Source: https://developers.tally.so/api-reference/endpoint/workspaces/folders/list GET /workspaces/{workspaceId}/folders Returns the folders in a workspace. Requires a Pro subscription. Folders are also included inline on each workspace in the [workspace list response](/api-reference/endpoint/workspaces/list), under the `folders` array. # Updating folders Source: https://developers.tally.so/api-reference/endpoint/workspaces/folders/patch PATCH /workspaces/{workspaceId}/folders/{id} Renames a folder by its ID. Requires a Pro subscription. # Creating folders Source: https://developers.tally.so/api-reference/endpoint/workspaces/folders/post POST /workspaces/{workspaceId}/folders Creates a folder in a workspace. Pass parentId to nest it inside another folder. Requires a Pro subscription. # Fetching workspaces Source: https://developers.tally.so/api-reference/endpoint/workspaces/get GET /workspaces/{workspaceId} Returns a single workspace by its ID with associated members. # Listing workspaces Source: https://developers.tally.so/api-reference/endpoint/workspaces/list GET /workspaces Returns a paginated array of workspace objects with associated users and pending invites. # Updating workspaces Source: https://developers.tally.so/api-reference/endpoint/workspaces/patch PATCH /workspaces/{workspaceId} Updates a workspace's information by its ID. # Creating workspaces Source: https://developers.tally.so/api-reference/endpoint/workspaces/post POST /workspaces Creates a new workspace and assigns the authenticated user as a member. Requires a Pro subscription. # Introduction Source: https://developers.tally.so/api-reference/introduction Fundamental concepts of Tally's API. ## Base URL The Tally API follows REST principles and is accessible only via HTTPS. For security reasons, unencrypted HTTP requests are not allowed. The Base URL for all API endpoints is: ```bash Terminal theme={null} https://api.tally.so ``` ## Authentication Authentication to the Tally API requires an Authorization header with a Bearer token. Include the Authorization header in your requests as shown below: ```bash Terminal theme={null} Authorization: Bearer ``` Learn more about [how to get your API key](/api-reference/api-keys). ## Response codes The API returns standard HTTP response codes to indicate the success or failure of an API request. Here are a few examples: | Code | Description | | ----- | ----------------------------------------------------------------------- | | `200` | Success - The request completed successfully | | `400` | Bad Request - The request was malformed or contained invalid parameters | | `401` | Unauthorized - Authentication credentials are missing or invalid | | `403` | Forbidden - You don't have permission to access this resource | | `404` | Not Found - The requested resource doesn't exist | | `429` | Rate Limited - You've exceeded the allowed number of requests | | `500` | Server Error - Something went wrong on our end | ## Rate Limits To ensure fair usage and maintain service quality, the Tally API limits requests to 100 per minute. Avoid rate limits with webhooks! Instead of polling for new form submissions, use [webhooks](https://tally.so/help/webhooks) to receive data instantly when forms are submitted. This is more efficient and won't count against your rate limit. # MCP server Source: https://developers.tally.so/api-reference/mcp Build Tally forms and list forms or submissions using AI assistants via the Model Context Protocol The Tally MCP server is currently in beta and subject to change. ## What is MCP? The Model Context Protocol (MCP) lets you build Tally forms and retrieve forms or submissions using natural language through AI assistants like Claude. ## Server URL The Tally MCP server is available at: ```bash theme={null} https://api.tally.so/mcp ``` ## Authentication The Tally MCP server supports two authentication methods: ### Option 1: OAuth (Recommended) The easiest way to connect is through OAuth. Many AI assistants support OAuth authentication for MCP servers, allowing you to connect with just a few clicks without manually managing API keys. When your AI assistant prompts for OAuth authentication: 1. Click the connect button or authorization link 2. Log in to your Tally account (if not already logged in) 3. Authorize the connection 4. The AI assistant will automatically configure the connection ### Option 2: API Key You can also authenticate using an API key in the Authorization header: ```bash theme={null} Authorization: Bearer tly-xxxx ``` Learn more about [how to get your API key](/api-reference/api-keys). ## Configuration ### Claude Desktop This method works for Claude Desktop and [claude.ai](https://claude.ai), and applies across all Claude apps that support connectors, including Claude Chat, Claude Code, and Claude Cowork. 1. Open Claude Desktop (or [claude.ai](https://claude.ai) in your browser) and navigate to **Customize > Connectors > Add custom connector** from the sidebar on the left. Claude connectors 2. Give the connector a name and the Tally MCP server URL. ```bash theme={null} https://api.tally.so/mcp ``` 3. Click "Add" to save the connector. Add Tally MCP connector 4. Restart Claude Desktop 5. The first time you use the Tally MCP server, Claude will open an OAuth flow to authenticate with your Tally account. ### Claude Code CLI To connect Claude Code to the Tally MCP server, run the following command in your terminal: ```bash theme={null} claude mcp add tally --transport http https://api.tally.so/mcp ``` Claude Code will handle the OAuth flow automatically when you first use the Tally MCP server. You can verify the connection by running the `/mcp` command inside Claude Code. ### Cursor 1. Open **Cursor Settings > Tools & MCP** 2. Click **New MCP Server** 3. Paste the following JSON to configure the Tally MCP server: ```json theme={null} { "mcpServers": { "tally": { "url": "https://api.tally.so/mcp", "headers": { "Authorization": "Bearer YOUR_API_KEY_HERE" } } } } ``` ## Example Usage Once configured, you can ask your AI assistant to create forms using natural language. Here are some examples: ### Creating a Simple Contact Form ``` Create a simple contact form ``` The AI assistant will automatically create a contact form with commonly expected fields based on its understanding of what a typical contact form should include. ### Updating the Contact Form If the generated form isn't exactly what you wanted, you can easily modify it by following up: ``` We don't need the Company field, remove the consent checkbox and maybe add a captcha ``` The AI assisant should correctly modify the form and save it. ### Creating a form with specific fields Alternatively, you can be explicit about exactly what fields you want: ``` Create a contact form with these specific fields: - Name (required) - Email (required) - Phone (optional) - Message (required) - Captcha (required) ``` The AI assistant should create a form with the specified fields and save it. ### Updating an existing form ``` Update this form https://tally.so/r/a1B2c3 - Remove the phone number field - Also add a captcha - Make company field optional ``` The AI assistant will use the MCP tools to load your form, make the requested changes, and save the updated version. ### Fetching form submissions You can fetch submissions for any form, with optional filters for status and date range: ``` Show me all completed submissions for https://tally.so/r/a1B2c3 from January 2025 ``` The AI assistant will fetch and display the matching submissions for your form, including question labels and response data. ### Listing forms You can list forms across your accessible workspaces and ask for filtering by name, status, or recency: ``` Show my 10 most recently updated published forms ``` The AI assistant will call the MCP list forms tool and return matching forms with key details such as form name, URL, status, and last updated date. ## Example usage with Claude ``` ## Tally.loadEmbeds() Embeds aren't loaded by the script alone — call `Tally.loadEmbeds()` after the script has loaded: ```javascript theme={null} Tally.loadEmbeds(); ``` Embeds in the viewport (or within 500px of its edges) are loaded immediately. The rest are loaded lazily via an `IntersectionObserver` as they scroll into view. If you're rendering embeds in a SPA, see the [React](/widgets/examples/react) and [Next.js](/widgets/examples/nextjs) examples for the correct loading pattern. ## Save page URL and query parameters The widget automatically forwards the host page's path and query parameters to the embed. To capture them in your submissions, add matching [hidden fields](https://tally.so/help/hidden-fields) to your form. For example, given the page URL: ``` https://company.com/register?ref=downloads&email=alice@example.com ``` Hidden fields named `originPage`, `ref`, and `email` will receive the following values: | Hidden field | Value | | ------------ | --------------------------------------------- | | originPage | /register | | ref | downloads | | email | [alice@example.com](mailto:alice@example.com) | ## Events Embeds emit JavaScript events when the form is loaded, when the respondent navigates between pages, and when the form is submitted. See [Events](/widgets/events) for the full payload reference. # JavaScript events Source: https://developers.tally.so/widgets/events React to form lifecycle events from your own JavaScript — for embeds, popups, and code injection. Every Tally widget emits the same set of events. The transport changes depending on the integration method: * **Embeds and popups** send events as `postMessage` payloads. Listen for them on the `message` event on `window`. * **Code injection** (running inside the form via your custom domain) receives them as `CustomEvent`s dispatched directly on `window`. Add your event listeners to **the page hosting the embed or popup** — not to the form itself. For code injection, add them to the form via your custom domain settings. ## Tally.FormLoaded Fires when the form is rendered. Because embeds and popups are lazy-loaded, you receive this event each time the form is actually shown. ```typescript theme={null} interface LoadedPayload { formId: string; } window.addEventListener('message', (e) => { if (e?.data?.includes('Tally.FormLoaded')) { const payload = JSON.parse(e.data).payload as LoadedPayload; // ... } }); ``` ## Tally.FormPageView Fires every time the respondent navigates to a page of the form — handy for multi-page forms. ```typescript theme={null} interface PageViewPayload { formId: string; page: number; } // For embeds and popups window.addEventListener('message', (e) => { if (e?.data?.includes('Tally.FormPageView')) { const payload = JSON.parse(e.data).payload as PageViewPayload; // ... } }); // For code injection via a custom domain window.addEventListener('Tally.FormPageView', (e) => { const payload = e.detail as PageViewPayload; // ... }); ``` ## Tally.FormSubmitted Fires when the form is submitted. The payload contains the submission metadata **and** the full set of answers. ```typescript theme={null} interface SubmissionPayload { id: string; // submission ID respondentId: string; formId: string; formName: string; createdAt: Date; // submission date fields: Array<{ id: string; title: string; type: | 'INPUT_TEXT' | 'INPUT_NUMBER' | 'INPUT_EMAIL' | 'INPUT_PHONE_NUMBER' | 'INPUT_LINK' | 'INPUT_DATE' | 'INPUT_TIME' | 'TEXTAREA' | 'MULTIPLE_CHOICE' | 'DROPDOWN' | 'CHECKBOXES' | 'LINEAR_SCALE' | 'FILE_UPLOAD' | 'HIDDEN_FIELDS' | 'CALCULATED_FIELDS' | 'RATING' | 'MULTI_SELECT' | 'MATRIX' | 'RANKING' | 'SIGNATURE' | 'PAYMENT'; answer: { value: any; raw: any }; }>; } // For embeds and popups window.addEventListener('message', (e) => { if (e?.data?.includes('Tally.FormSubmitted')) { const payload = JSON.parse(e.data).payload as SubmissionPayload; // ... } }); // For code injection via a custom domain window.addEventListener('Tally.FormSubmitted', (e) => { const payload = e.detail as SubmissionPayload; // ... }); ``` ## Tally.PopupClosed Fires only for popups, when the popup is closed. ```typescript theme={null} interface PopupClosedPayload { formId: string; } window.addEventListener('message', (e) => { if (e?.data?.includes('Tally.PopupClosed')) { const payload = JSON.parse(e.data).payload as PopupClosedPayload; // ... } }); ``` Popups also accept lifecycle callbacks (`onOpen`, `onClose`, `onPageView`, `onSubmit`) directly on the `Tally.openPopup()` options argument. See [Popups](/widgets/popups) for the full reference. # Collect the respondent's IP address Source: https://developers.tally.so/widgets/examples/collect-ip-address Capture the respondent IP address into a hidden field using custom domain code injection. **Privacy and security:** capturing the visitor's IP address has privacy implications. Make sure to inform users and obtain consent where it's required. This example requires a [custom domain](https://tally.so/help/custom-domains) and [code injection](/widgets/code-injection). Paste the snippet below into the **Code injection** box of your custom domain settings, and replace the input's ID with the one from your form. 1. Add a **Short text** field at the beginning of your form — this is what we'll write the IP into. 2. Open the form in preview mode and grab the input's UUID from the rendered HTML. 3. Paste the script and swap in the UUID. ```javascript theme={null} const getIPAddress = async () => { try { const response = await fetch('https://api.ipify.org?format=json'); if (!response.ok) { throw new Error('Network response was not ok'); } const data = await response.json(); return data.ip; } catch (error) { console.error('Error fetching the IP address:', error); throw error; } }; document.addEventListener('DOMContentLoaded', async () => { // Replace this UUID with the one of the Short text field you added const ipInput = document.getElementById('c1cbc8e4-b2f3-4e63-a683-ec9eadbcb022'); if (!ipInput) { return; } // Hide the input so it's not visible in the form ipInput.style.display = 'none'; // Get the IP address of the visitor const ip = await getIPAddress(); // This is necessary to bubble the event up to the input and update the React app state const nativeInputValueSetter = Object.getOwnPropertyDescriptor( window.HTMLInputElement.prototype, 'value', ).set; nativeInputValueSetter.call(ipInput, ip); ipInput.dispatchEvent(new Event('input', { bubbles: true })); }); ``` # Embed in Next.js Source: https://developers.tally.so/widgets/examples/nextjs Mount a Tally embed in a Next.js page using the next/script component. Next.js ships with a ` ``` Both methods below require the **Form ID**, which you can find in the URL of your form: ``` https://tally.so/r/[FORM_ID] https://tally.so/embed/[FORM_ID] https://tally.so/forms/[FORM_ID]/edit ``` ## Tally.openPopup() Opens a form in a popup. ```javascript theme={null} // Example Tally.openPopup('mRoDv3', { layout: 'modal' }); ``` ### Reference ```typescript theme={null} openPopup: ( formId: string, options?: { // Unique identifier used by "Show only once" and "Don't show after submit" key?: string; layout?: 'default' | 'modal'; width?: number; alignLeft?: boolean; hideTitle?: boolean; overlay?: boolean; emoji?: { text: string; animation: | 'none' | 'wave' | 'tada' | 'heart-beat' | 'spin' | 'flash' | 'bounce' | 'rubber-band' | 'head-shake'; }; autoClose?: number; // in milliseconds showOnce?: boolean; doNotShowAfterSubmit?: boolean; // Load the form via its custom domain URL instead of tally.so customFormUrl?: string; hiddenFields?: { [key: string]: any; }; onOpen?: () => void; onClose?: () => void; onPageView?: (page: number) => void; onSubmit?: (payload: SubmissionPayload) => void; }, ) => void; ``` ## Tally.closePopup() Closes a previously opened popup. ```javascript theme={null} // Example Tally.closePopup('mRoDv3'); ``` ### Reference ```typescript theme={null} closePopup: (formId: string) => void; ``` ## Save page URL and query parameters The widget automatically forwards the host page's path and query parameters to the popup. To capture them in your submissions, add matching [hidden fields](https://tally.so/help/hidden-fields) to your form. For example, given the page URL: ``` https://company.com/register?ref=downloads&email=alice@example.com ``` Hidden fields named `originPage`, `ref`, and `email` will receive the following values: | Hidden field | Value | | ------------ | --------------------------------------------- | | originPage | /register | | ref | downloads | | email | [alice@example.com](mailto:alice@example.com) | ## Events Popups emit the same JavaScript events as embeds — plus a `Tally.PopupClosed` event when the popup is dismissed. See [Events](/widgets/events) for the full payload reference. You can also subscribe to popup-specific callbacks (`onOpen`, `onClose`, `onPageView`, `onSubmit`) directly via the `options` argument of `Tally.openPopup()`.