# Technical Documentation: lukevella/rallly

> ℹ️ **Provenance:** Multi-Tier Ingestion · [CodeWiki Reference](https://codewiki.google/github.com/lukevella/rallly)

### Overview (`README.md`)

<div align="center">

<img src="./assets/images/app-icon.svg" width="96" height="96" alt="" />

# Rallly

**Schedule group meetings, without the back-and-forth**

[**Website**](https://rallly.co) · [**Documentation**](https://support.rallly.co) · [**Discord**](https://discord.gg/uzg4ZcHbuM)

[](https://github.com/lukevella/rallly/actions)
[](https://crowdin.com/project/rallly)
[](https://www.gnu.org/licenses/agpl-3.0)
[](https://discord.gg/uzg4ZcHbuM)
[](https://www.paypal.com/donate/?hosted_button_id=7QXP2CUBLY88E)

</div>

<img src="./assets/images/screenshot.png" alt="A Rallly poll showing participants' availability across four time slots" />

Rallly is an open-source scheduling tool that helps you find the best date and time to meet. Create a poll with a few options, share the link, and let your participants vote on when they're available. No more back-and-forth emails.

## ✨ Features

- 📅 **Date & time polls** — Propose multiple options and find what works for everyone
- 🗳️ **No account needed to vote** — Participants just open the link and respond
- 📊 **Availability grid** — See everyone's responses at a glance
- 💬 **Comments** — Discuss the details right on the poll
- 🔔 **Notifications** — Get notified when participants respond
- ✅ **Finalize a date** — Pick the winning option and notify everyone
- 🌍 **10+ languages** — Community-translated via [Crowdin](https://crowdin.com/project/rallly)
- 🔒 **Open source & self-hostable** — Own your data

## ☁️ Cloud version

The quickest way to get started is the hosted version. Create your first poll in seconds, no installation required.

**[Try Rallly free →](https://rallly.co)**

## 🐳 Self-hosting

Prefer to run your own instance? Rallly ships as a Docker image and can be self-hosted in minutes.

See the [self-hosting docs](https://support.rallly.co/self-hosting) for installation and [configuration options](https://support.rallly.co/self-hosting/configuration-options).

## 🛠️ Built with

[Next.js](https://github.com/vercel/next.js/) · [Prisma](https://github.com/prisma/prisma) · [tRPC](https://github.com/trpc/trpc) · [TailwindCSS](https://github.com/tailwindlabs/tailwindcss)

## 🤝 Contributing

Contributions are welcome! Have a look at the [contributing guide](CONTRIBUTING.md) to get started with local development and find out how you can help.

- 🌐 **Translators** — Help [translate Rallly](https://support.rallly.co/contribute/translations) into your language
- 🔐 **Security** — Found a vulnerability? See our [security policy](SECURITY.md)
- 🤝 **Community** — Please read our [Code of Conduct](CODE_OF_CONDUCT.md)

## 📜 License

Rallly is open-source under the GNU Affero General Public License Version 3 (AGPLv3) or any later version. See [LICENSE](LICENSE) for more detail.

## 💚 Sponsors

Thank you to our sponsors for making this project possible.

[Become a sponsor &rarr;](https://github.com/sponsors/lukevella)

<br />

<a href="https://vercel.com/open-source-program">
  <img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge-2026.svg" height="40" />
</a>

---

### Overview (`apps/docs/README.md`)

# Support Docs

Live site: [https://support.rallly.co](https://support.rallly.co)

Our docs are written in [MDX](https://mdxjs.com/) which is mostly [Markdown](https://www.markdownguide.org/cheat-sheet) with the ability to use components inside.

This site is kindly built with and hosted by [Mintlify](https://mintlify.com).

## How to Run Locally

To preview your changes locally, you can use the [mintlify cli](https://mintlify.com/docs/development) to run a development server.

Install the cli globally:

```bash
pnpm install mintlify
```

Navigate to this directory (where you can find `mint.json`):

```bash
cd apps/docs
```

Start the development server:

```bash
mintlify dev
```

## Components

Mintlify provides a number of useful built-in components such as [Accordion](https://mintlify.com/docs/components/accordion), [Tabs](https://mintlify.com/docs/components/tabs) and [Callouts](https://mintlify.com/docs/components/callouts). Check out their [documentation](https://mintlify.com/docs/components) to see the full list of components with examples.

---

### Apps Docs Biome.Json (`apps/docs/biome.json`)

{
  "$schema": "https://biomejs.dev/schemas/2.3.14/schema.json",
  "extends": "//",
  "files": {
    "includes": ["**", "!!api-reference/openapi.json"]
  }
}

---

### Apps Docs Docs.Json (`apps/docs/docs.json`)

{
  "$schema": "https://mintlify.com/docs.json",
  "theme": "mint",
  "name": "Rallly",
  "colors": {
    "primary": "#4f46e5",
    "light": "#847eed",
    "dark": "#4f46e5"
  },
  "favicon": "/favicon.png",
  "navigation": {
    "tabs": [
      {
        "tab": "Documentation",
        "groups": [
          {
            "group": "Get Started",
            "pages": ["introduction", "faq"]
          },
          {
            "group": "How it works",
            "pages": ["workflow/create", "workflow/invite", "workflow/schedule"]
          },
          {
            "group": "Spaces",
            "pages": [
              "spaces/introduction",
              "spaces/managing-seats",
              "spaces/members",
              "spaces/collaboration",
              "spaces/billing",
              "spaces/custom-branding"
            ]
          },
          {
            "group": "Integrations",
            "pages": [
              "integrations/google-meet",
              "integrations/zoom",
              "integrations/microsoft-teams",
              "integrations/webex"
            ]
          },
          {
            "group": "Guides",
            "pages": ["guide/participant-guide", "guide/content-moderation"]
          },
          {
            "group": "Contact",
            "pages": ["contact/support"]
          }
        ]
      },
      {
        "tab": "Self-Hosting",
        "groups": [
          {
            "group": "Get Started",
            "pages": [
              "self-hosting/introduction",
              "self-hosting/installation/docker",
              "self-hosting/configuration",
              "self-hosting/single-sign-on",
              "self-hosting/conferencing"
            ]
          },
          {
            "group": "Advanced",
            "pages": [
              "self-hosting/management",
              "self-hosting/licensing",
              "self-hosting/corporate-networks",
              "self-hosting/white-labeling"
            ]
          },
          {
            "group": "Guides",
            "pages": ["self-hosting/control-panel", "self-hosting/migration"]
          },
          {
            "group": "Misc",
            "pages": ["self-hosting/managed-hosting"]
          }
        ]
      },
      {
        "tab": "API Reference",
        "groups": [
          {
            "group": "Getting started",
            "pages": [
              "api-reference/introduction",
              "api-reference/authentication",
              "api-reference/rate-limits",
              "api-reference/errors",
              "api-reference/changelog",
              "api-reference/migrating-from-the-private-api"
            ]
          },
          {
            "group": "Polls",
            "openapi": "/api-reference/openapi.json",
            "pages": [
              "POST /v1/polls",
              "GET /v1/polls",
              "GET /v1/polls/{pollId}",
              "DELETE /v1/polls/{pollId}",
              "GET /v1/polls/{pollId}/results",
              "GET /v1/polls/{pollId}/participants",
              "GET /v1/polls/{pollId}/participants/{participantId}"
            ]
          },
          {
            "group": "Webhooks",
            "pages": [
              "api-reference/webhooks",
              "api-reference/webhooks/events",
              "api-reference/webhooks/security",
              {
                "group": "Guides",
                "pages": ["api-reference/webhooks/guides/n8n"],
                "icon": "book-open"
              }
            ],
            "tag": "Beta"
          }
        ]
      },
      {
        "tab": "Contribute",
        "groups": [
          {
            "group": "Contribute",
            "pages": ["contribute/introduction"]
          },
          {
            "group": "How to Contribute",
            "pages": [
              "contribute/donations",
              "contribute/translations",
              "contribute/documentation"
            ]
          }
        ]
      }
    ]
  },
  "logo": {
    "light": "/logo/light.svg",
    "dark": "/logo/dark.svg",
    "href": "https://rallly.co"
  },
  "background": {
    "color": {
      "light": "#FFFFFF",
      "dark": "#18181B"
    }
  },
  "navbar": {
    "links": [
      {
        "label": "Contact",
        "href": "https://support.rallly.co/contact/support"
      }
    ],
    "primary": {
      "type": "button",
      "label": "Go to app",
      "href": "https://app.rallly.co"
    }
  },
  "footer": {
    "socials": {
      "twitter": "https://twitter.com/ralllyco",
      "linkedin": "https://www.linkedin.com/company/rallly/",
      "github": "https://github.com/lukevella/rallly",
      "discord": "https://discord.com/invite/uzg4ZcHbuM",
      "website": "https://rallly.co/"
    }
  },
  "redirects": [
    {
      "source": "/administrators/creating-a-meeting-poll",
      "destination": "/workflow/create"
    },
    {
      "source": "/administrators/sharing-a-meeting-poll",
      "destination": "/workflow/invite"
    },
    {
      "source": "/participants/submitting-a-response",
      "destination": "/guide/participant-guide"
    },
    {
      "source": "/participants/editing-a-response",
      "destination": "/guide/participant-guide"
    },
    {
      "source": "/self-hosting/pricing",
      "destination": "/self-hosting/licensing"
    },
    {
      "source": "/workflow/finalize",
      "destination": "/workflow/schedule"
    }
  ]
}

---

### Apps Docs Faq (`apps/docs/faq.mdx`)

---
icon: "message-question"
title: "FAQ"
description: "Frequently asked questions about Rallly."
---

<Accordion title="Is Rallly free?">
  Most of Rallly's features are free and many users will never need to pay for
  anything. However, there are some features that are only available to paying
  customers. These features are designed to help you get the most out of Rallly
  and also help pay for the cost of running and maintaining the service.
</Accordion>

<Accordion title="Do I need to create an account to use Rallly?">
  No, you don't! You can create and share polls without creating an account.
  Without an account you can have one open poll at a time, and some features
  (like email notifications) are only available to users with an account.
</Accordion>

<Accordion title="Are there any limits to the number of polls, participants and options I can make?">
  There is no limit to the number of polls you can create, the number of
  participants who can vote or the number of options in a poll. What depends on
  your plan is how many polls can be open, collecting votes, at the same time:

  - **Guests** (no account): 1 open poll
  - **Hobby** (free): 3 open polls
  - **Pro**: unlimited open polls

  Finalize or close a poll to make room for another. There are also technical
  limits in place to prevent misuse but these are very generous and should not
  affect normal usage.
</Accordion>

<Accordion title="What counts as an open poll?">
  A poll is open while it's collecting votes. Polls you have finalized, closed or
  deleted don't count towards your limit, and polls close automatically once all
  their dates have passed. On Hobby the limit applies to the polls you created in
  each space.
</Accordion>

<Accordion title="How long will Rallly keep my poll?">
  Polls are automatically deleted when all date options are in the past AND there
  has been no activity for over 30 days. Activity includes new votes, comments, or
  changes to the poll. This does not apply to Pro users.
</Accordion>

<Accordion title="Can I limit the number of participants that can vote for an option?">
  No, this feature is currently not supported but may be added in the future.
</Accordion>

<Accordion title="Can I set a deadline for my poll?">
  No, this feature is currently not supported but may be added in the future.
</Accordion>

<Accordion title="What if I'm not receiving emails from Rallly?">
  First, check your spam folder.

Not there?

If you're using Outlook, try adding rallly.co to your [safe senders and domains](https://support.microsoft.com/en-gb/office/safe-senders-in-outlook-com-470d4ee6-e3b6-402b-8cd9-a6f00eda7339).

If that doesn't work, it's possible that you've been added to our suppression list.
This can happen if you or your email provider mark an email from Rallly as spam.
If you think this has happened, please [contact us](/contact) and we'll remove you from the list.

</Accordion>

---

### Apps Docs Introduction (`apps/docs/introduction.mdx`)

---
icon: "house"
title: "Introduction"
description: "Welcome to the official documentation for Rallly."
---

<Frame>
  <img src="/images/home.jpeg" alt="Rallly Splash Image" />
</Frame>

## What is Rallly?

Rallly is a tool for creating scheduling polls. It's designed to help people find a common time to schedule a meeting or event.

> Rallly is pronounced like "rally" but with an extra _L_.
>
> I wanted to call the product "Rally" because that's what it's for - rallying people together to make things happen.
> But, of course, the domain name was already taken. So, I did what any sane person would do - I added an extra _L_.
>
> -- [Luke Vella](https://twitter.com/imlukevella), Creator of Rallly

## What is a Scheduling Poll?

Scheduling polls help you find a time that works for everyone. The host
of the meeting will typically select a few date and time options for participants to vote
for and then select the most suitable option.

<CardGroup cols={2}>
  <Card title="Workflow" icon="user" href="/workflow">
    Learn how scheduling polls work
  </Card>
  <Card title="Participant Guide" icon="users" href="/guide/participant-guide">
    How to respond to a scheduling poll
  </Card>
  <Card title="Spaces" icon="layer-group" href="/spaces/introduction">
    Team collaboration with centralized billing
  </Card>
</CardGroup>

## Individual vs Team Usage

Rallly works great for both personal scheduling and team collaboration:

- **Individual Use**: Create personal polls and share them with participants
- **Team Use**: Create spaces for your organization with centralized billing and member management

## How much does it cost?

Most of Rallly's features are free and most users will never need to pay for anything.
However, there are some features that are only available to Pro users, and teams can benefit from centralized billing through spaces.

<Card title="Pricing" icon="gem" href="https://rallly.co/pricing">
  Learn more about Rallly's pricing
</Card>

---

### Apps Docs Package.Json (`apps/docs/package.json`)

{
  "name": "@rallly/docs",
  "version": "0.0.0",
  "private": true,
  "scripts": {
    "dev": "mint dev",
    "validate": "mint validate"
  },
  "devDependencies": {
    "@rallly/tsconfig": "workspace:*"
  },
  "dependencies": {
    "mint": "^4.2.884"
  }
}

---

### Apps Docs Administrators Deleting A Meeting Poll (`apps/docs/administrators/deleting-a-meeting-poll.mdx`)

---
title: "Delete a Poll"
---

<Warning>
  Deleting a poll will delete all data related to that poll such as options,
  participants and votes. This action cannot be undone.
</Warning>

To delete a meeting poll, from the admin page:

1. Click **Manage**
1. Select **Delete** from the dropdown menu

   <Frame>
     <img src="/images/administrators/delete-poll-menu.png" alt="The Manage menu on the poll admin page with Delete at the bottom" />
   </Frame>

1. Click **Delete** to confirm

   <Frame>
     <img src="/images/administrators/delete-poll-confirm.png" alt="The Delete poll confirmation dialog with Cancel and Delete buttons" />
   </Frame>

---

### Apps Docs Administrators Editing A Meeting Poll (`apps/docs/administrators/editing-a-meeting-poll.mdx`)

---
title: "Edit a Poll"
description: "How to make changes to an existing meeting poll."
---

Everything about a poll is edited on one page: its details, its dates and
times, and its settings.

<Steps>
  <Step title="Open the edit page">
    From the admin page, click **Manage** and select **Edit** from the
    dropdown menu.

    <Frame caption="Admin controls">
      <img src="/images/administrators/edit-poll-menu.png" alt="Manage dropdown menu with the Edit item"/>
    </Frame>

  </Step>
  <Step title="Make your changes">
    The edit page is laid out like the create page and filled in with the
    poll's current values.

    <Frame caption="Edit page">
      <img src="/images/administrators/edit-poll-page.png" alt="Edit poll page"/>
    </Frame>

    **Event** - Change the title, location or description.

    **Calendar** - Add or remove options using the calendar. Switch
    between month and week view, change the duration, or make the poll all
    day.

    **Settings** - Require an email address from participants, hide
    participant names, hide votes until a participant has voted, allow
    comments, and choose the vote options.

  </Step>
  <Step title="Save">
    Click **Save changes**. Only the parts you changed are updated, so a
    poll's responses stay intact when you fix a typo in the title.

    If you remove an option that participants have voted for, Rallly asks you
    to confirm first. Their votes for that option are deleted with it.

  </Step>
</Steps>

## Vote options

**Vote options** decides which answers participants can give. By default they
can answer **Yes**, **If need be** or **No**. Switch to **Yes** and **No** if
you want a straight answer with no maybes.

<Note>
  Once a participant has answered **If need be**, you can no longer switch that
  poll to **Yes** and **No**, so their response stays valid. Switching a poll
  back to offering **If need be** is always allowed.
</Note>

## What can't be changed

- **Time zone** - Once votes have been cast, the poll's time zone is locked.
  Changing it would move every option under participants who already answered.
- **Scheduled polls** - A poll that has been scheduled can no longer be edited,
  because the calendar invites your participants received would not pick up
  the change. The **Edit** item is hidden from the **Manage** menu for these
  polls.

---

### Apps Docs Api Reference Authentication (`apps/docs/api-reference/authentication.mdx`)

---
title: "Authentication"
icon: "key"
description: "Authenticate every request with a space API key."
---

Every Polls endpoint requires a bearer token; only the OpenAPI document at `/v1/openapi` is public. Pass the API key in the `Authorization` header:

```http
Authorization: Bearer sk_xxxxxx_xxxxxxxxxxxxxxxxxxxxxxxx
```

```bash
curl https://api.rallly.co/v1/polls \
  -H "Authorization: Bearer $RALLLY_API_KEY"
```

## Creating a key

1. Open **Settings → API keys** in the space you want to access. Only the space owner sees this page.
2. Choose **Create API key** and give it a name that identifies the integration.
3. Copy the key. It is shown once and cannot be retrieved later; if you lose it, revoke it and create a new one.

Keys start with `sk_`. Store them as you would any secret: in an environment variable or a secrets manager, never in client-side code or a repository.

## Scope

A key belongs to one space and grants access to that space's polls only. It does not carry a user identity: polls created through the API are attributed to the organizer you name in the request, who must be a member of the space.

## Revoking a key

Revoke a key from the same settings page. Requests with a revoked, expired or unknown key fail with `401` and the `UNAUTHORIZED` error code.

## Plan requirement

The space behind the key must be on a Pro plan. A key for a space without an active subscription is accepted but every request fails with `403` and the `SPACE_NOT_PRO` error code until the space upgrades.

---

### Apps Docs Api Reference Changelog (`apps/docs/api-reference/changelog.mdx`)

---
title: "Changelog"
icon: "clock-rotate-left"
description: "Changes to the API, newest first."
---

<Update label="2026-09-22">
- `GET /polls/{pollId}/participants/{participantId}` returns one participant. A missing participant on an existing poll is `404` with `PARTICIPANT_NOT_FOUND`.
- Participants now carry `availability`: one `{ start, end, allDay, modifiers }` span per option they can make, in start order, on both the list and the single-participant endpoint. A span matches an option of the poll by its time; `end` is exclusive; `ifNeedBe` is a modifier.
</Update>

<Update label="2026-09-20">
Webhooks, in beta. A space can register up to five HTTPS endpoints and receive signed, retried deliveries for nine poll and participant events. The event list and payloads may still change, and more events will be added. See [Webhooks](/api-reference/webhooks).

The poll and participant resources are unchanged.
</Update>

<Update label="v1">
Initial stable release under `/v1`.

- Create, list, get and delete polls
- Read a poll's results and participants
- Bearer authentication with space API keys
- Per space rate limits with `RateLimit-*` headers

</Update>

---

### Apps Docs Api Reference Errors (`apps/docs/api-reference/errors.mdx`)

---
title: "Errors"
icon: "triangle-exclamation"
description: "The error envelope and every code the API returns."
---

Every failure is `application/json` with the same shape:

```json
{
  "error": {
    "code": "POLL_NOT_FOUND",
    "message": "Poll not found or does not belong to this space."
  }
}
```

`code` is stable and safe to branch on. `message` is for humans and may change without notice. `VALIDATION_ERROR` messages name each offending field, separated by semicolons.

## Codes

| Status | Code | When |
| --- | --- | --- |
| 400 | `VALIDATION_ERROR` | The body or query string did not match the schema, contained an unknown field, or the body was not valid JSON |
| 400 | `INVALID_AUTHORIZATION_HEADER` | The `Authorization` header is not `Bearer <key>` |
| 400 | `ORGANIZER_NOT_MEMBER` | The organizer email is not a member of the space |
| 400 | `TOO_MANY_OPTIONS` | More than the maximum number of poll options |
| 400 | `CONFERENCING_UNAVAILABLE` | The requested video call provider is not offered on this instance |
| 400 | `CONFERENCING_NOT_CONNECTED` | The organizer has not connected an account for the requested video call provider |
| 400 | `INAPPROPRIATE_CONTENT` | The title, description, location or video call was flagged by content moderation |
| 401 | `UNAUTHORIZED` | The API key is missing, invalid, expired or revoked, or its owner is banned |
| 403 | `SPACE_NOT_PRO` | The space behind the key has no Pro subscription |
| 404 | `NOT_FOUND` | No route matches the method and path |
| 404 | `POLL_NOT_FOUND` | The poll does not exist or belongs to another space |
| 404 | `PARTICIPANT_NOT_FOUND` | The participant does not exist or belongs to another poll |
| 429 | `RATE_LIMIT_EXCEEDED` | A rate limit window is exhausted; see [Rate limits](/api-reference/rate-limits) |
| 503 | `SERVICE_UNAVAILABLE` | Maintenance, or the rate limit store cannot be reached |
| 500 | `INTERNAL_ERROR` | Unexpected failure; the request id is logged |

## Handling errors

- Treat `4xx` responses other than `429` as bugs in the request. Retrying without changing it returns the same error.
- Retry `429` and `503` after the `Retry-After` header.
- Retry `500` with backoff. If it persists, contact support with the time of the request.

---

### Apps Docs Api Reference Introduction (`apps/docs/api-reference/introduction.mdx`)

---
title: "Introduction"
icon: "house"
description: "Create and manage scheduling polls from your own systems."
---

The Rallly API lets you create polls, read their options, results and participants, and delete them from your own tools. Every request is scoped to one space: the API key identifies the space, and every poll you create or read belongs to it. [Webhooks](/api-reference/webhooks) tell your systems when a poll or a response changes, so you never have to poll for results.

<Note>The API requires a Pro plan.</Note>

## Getting a key

Only the space owner can create API keys, from **Settings → API keys**. See [Authentication](/api-reference/authentication) for the header format and how keys are handled.

## Base URL

```text
https://api.rallly.co/v1
```

`v1` is stable. Additive changes such as new endpoints or new optional fields may land at any time; breaking changes only arrive under a new version prefix. The [changelog](/api-reference/changelog) records both.

## Conventions

- Requests and responses are JSON. Send `Content-Type: application/json` with a body.
- List endpoints return the items in `data`. `GET /polls` is paginated: it returns a `nextCursor` beside `data`; pass it as the `cursor` query parameter for the next page, and it is `null` on the last page. `GET /polls/:pollId/participants` returns every participant in one response, and `GET /polls/:pollId/participants/:participantId` returns one.
- Timestamps are ISO 8601 instants in UTC. A `date` poll's options carry a floating `YYYY-MM-DD` date with no timezone; a `time` poll's options carry a `startTime` instant and a `duration` in minutes.
- A participant's `availability` is a list of spans, `{ start, end, allDay, modifiers }`. A span matches an option by its time: `start` is the option's `startTime`, or midnight UTC of its `date` with `allDay: true`. `end` is exclusive.
- Request bodies are validated strictly. A field that is not in the schema is rejected rather than ignored, so a misspelt setting never silently falls back to its default.

## Machine readable spec

The OpenAPI document that generates this reference is served at `https://api.rallly.co/v1/openapi`.

---

### Apps Docs Api Reference Migrating From The Private Api (`apps/docs/api-reference/migrating-from-the-private-api.mdx`)

---
title: "Migrating from the private API"
icon: "arrow-right-arrow-left"
description: "Move an integration from /api/private to /v1, endpoint by endpoint."
---

`/v1` replaces the unversioned private API at `https://app.rallly.co/api/private`. The private API is frozen: it still answers, but it receives no new fields or endpoints, and everything new lands on `/v1` only.

Your API keys, poll IDs, invite links and rate limits carry over unchanged. What changes is the base URL, the request and response shapes, and how errors look.

## Summary of changes

| Area | Private API | v1 |
| --- | --- | --- |
| Base URL | `https://app.rallly.co/api/private` | `https://api.rallly.co/v1` |
| Authentication | `Authorization: Bearer <key>` | Unchanged |
| Rate limits | 60 per minute, 5,000 per day, per space | Unchanged |
| Create poll status | `200 OK` | `201 Created` |
| Poll shape | `dates` or `slots` in the request, `options` in the response | `kind` plus `options` on both sides; each request option is the response option without its `id` |
| Time zone field | `timezone` (response), `slots.timezone` (request) | `timeZone` at the top level of both |
| Date options | `startTime` at UTC midnight with `duration: 0` | `date` as `YYYY-MM-DD` |
| Organizer | `user: { name, image }` | `organizer: { id, name, email, image }` |
| Poll settings in responses | Not returned | `requireEmail`, `hideParticipants`, `hideScores`, `disableComments`, `allowTentativeVotes` |
| `participantCount` | List items only | Every poll response |
| `updatedAt` | Not returned | Every poll response |
| Unknown request fields | Ignored | Rejected with `VALIDATION_ERROR` |
| Validation errors | Validator output with the request body echoed back | `{ "error": { "code", "message" } }` |
| Unknown routes and crashes | Plain text | The same JSON envelope |
| Update poll (`PATCH`) | Closes a poll | Removed, no replacement |
| Interactive docs page | `/api/private/docs` | This site; the spec stays at `/v1/openapi` |

## Global changes

### Base URL

Replace the host and prefix. The poll paths under it are the same, with two exceptions: `PATCH /polls/{pollId}` is removed, and `/docs` has no v1 equivalent. Both are covered below.

```text
https://app.rallly.co/api/private/polls   →   https://api.rallly.co/v1/polls
```

### Error envelope

Every v1 failure is JSON with the same shape, including cases the private API answered in plain text or with the validator's raw output. Branch on `error.code`; `error.message` is for humans.

<CodeGroup>

```json Private API (schema validation)
{
  "success": false,
  "error": [
    {
      "expected": "string",
      "code": "invalid_type",
      "path": ["title"],
      "message": "Invalid input: expected string, received undefined"
    }
  ],
  "data": { "dates": ["2027-03-01"] }
}
```

```json v1
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "title: Invalid input: expected string, received undefined"
  }
}
```

</CodeGroup>

| Private API | v1 | Note |
| --- | --- | --- |
| `400` validator output, no code | `400 VALIDATION_ERROR` | One field per sentence in `message`, separated by semicolons |
| `400 INVALID_INPUT` | `400 VALIDATION_ERROR` | `kind` is required, so the "neither dates nor slots" case is a schema error |
| `400 DUPLICATE_DATES` | Not an error | Duplicate dates are removed |
| `400 NO_OPTIONS_GENERATED` | `400 VALIDATION_ERROR` | The message names `generators` |
| `422 TRANSITION_NOT_AVAILABLE` | Removed | The update endpoint is gone |
| `404 Not Found` plain text | `404 NOT_FOUND` | Unknown route or method |
| `400 Malformed JSON in request body` plain text | `400 VALIDATION_ERROR` | |
| `500 Internal Server Error` plain text | `500 INTERNAL_ERROR` | |

`UNAUTHORIZED`, `INVALID_AUTHORIZATION_HEADER`, `SPACE_NOT_PRO`, `ORGANIZER_NOT_MEMBER`, `TOO_MANY_OPTIONS`, `INAPPROPRIATE_CONTENT`, `POLL_NOT_FOUND`, `RATE_LIMIT_EXCEEDED` and `SERVICE_UNAVAILABLE` keep their status codes and meanings. See [Errors](/api-reference/errors) for the full list.

### Strict request bodies

The private API silently dropped fields it did not know, including `spaceId`, which it accepted but never used. v1 rejects any field outside the documented schema with `VALIDATION_ERROR`. Remove `spaceId` and any other extra fields before switching.

### The poll resource

Every endpoint that returns a poll (`POST /polls`, `GET /polls`, `GET /polls/{pollId}`) returns the same v1 shape. Compared with the private API:

- `timezone` is renamed `timeZone`.
- `user` is replaced by `organizer`, which adds `id` and `email`.
- `kind` is added: `date` or `time`. It tells you which option shape to expect.
- `updatedAt` is added. Compare it between reads to notice changes.
- The five poll settings are returned as booleans.
- `participantCount` is on every poll, not only list items.
- Options in a `date` poll carry `date` instead of `startTime` and `duration: 0`. Options in a `time` poll are unchanged.

<CodeGroup>

```json Private API
{
  "data": {
    "id": "Xk3pQ9vLm2Ab",
    "title": "Team offsite",
    "description": null,
    "location": null,
    "timezone": null,
    "status": "open",
    "createdAt": "2026-09-10T12:00:00.000Z",
    "user": { "name": "Jessie Smith", "image": null },
    "options": [
      {
        "id": "cm5h8x2k40000q9l4f7e2d3an",
        "startTime": "2027-03-01T00:00:00.000Z",
        "duration": 0
      }
    ],
    "adminUrl": "https://app.rallly.co/poll/Xk3pQ9vLm2Ab",
    "inviteUrl": "https://rallly.co/invite/Xk3pQ9vLm2Ab"
  }
}
```

```json v1
{
  "data": {
    "id": "Xk3pQ9vLm2Ab",
    "title": "Team offsite",
    "description": null,
    "location": null,
    "timeZone": null,
    "status": "open",
    "kind": "date",
    "createdAt": "2026-09-10T12:00:00.000Z",
    "updatedAt": "2026-09-10T12:00:00.000Z",
    "organizer": {
      "id": "cm3f7d1qa0000t2k9c6b8h4jr",
      "name": "Jessie Smith",
      "email": "jessie@example.com",
      "image": null
    },
    "requireEmail": false,
    "hideParticipants": false,
    "hideScores": false,
    "disableComments": true,
    "allowTentativeVotes": true,
    "participantCount": 0,
    "options": [
      { "id": "cm5h8x2k40000q9l4f7e2d3an", "date": "2027-03-01" }
    ],
    "adminUrl": "https://app.rallly.co/poll/Xk3pQ9vLm2Ab",
    "inviteUrl": "https://rallly.co/invite/Xk3pQ9vLm2Ab"
  }
}
```

</CodeGroup>

## Endpoints

### Create a poll

`POST /api/private/polls` → `POST /v1/polls`

**Status code.** v1 responds `201 Created` instead of `200 OK`. A client that checks for exactly `200` will treat every successful create as a failure.

**Request.** The private API chose the poll type from whether you sent `dates` or `slots`. v1 takes an explicit `kind` and puts every option under `options`. Each request option is the response option without its `id`: `{ date }` for a date poll, `{ startTime, duration? }` for a time poll. The body is strict, so the other response fields (`id`, `status`, `createdAt`, `participantCount` and so on) must not be sent.

Date polls:

<CodeGroup>

```json Private API
{
  "title": "Team offsite",
  "dates": ["2027-03-01", "2027-03-02", "2027-03-03"]
}
```

```json v1
{
  "title": "Team offsite",
  "kind": "date",
  "options": [
    { "date": "2027-03-01" },
    { "date": "2027-03-02" },
    { "date": "2027-03-03" }
  ]
}
```

</CodeGroup>

Time polls: the `slots` wrapper is gone. `duration` and `timeZone` move to the top level, explicit times become `options` objects, and slot generators move to their own `generators` array.

<CodeGroup>

```json Private API
{
  "title": "Project kickoff",
  "location": "Zoom",
  "slots": {
    "duration": 60,
    "timezone": "Europe/London",
    "times": [
      "2027-03-01T09:00:00",
      {
        "startDate": "2027-03-08",
        "endDate": "2027-03-12",
        "days": ["mon", "wed"],
        "startTime": "14:00",
        "endTime": "17:00",
        "interval": 90
      }
    ]
  }
}
```

```json v1
{
  "title": "Project kickoff",
  "kind": "time",
  "location": "Zoom",
  "timeZone": "Europe/London",
  "duration": 60,
  "options": [{ "startTime": "2027-03-01T09:00:00" }],
  "generators": [
    {
      "startDate": "2027-03-08",
      "endDate": "2027-03-12",
      "days": ["mon", "wed"],
      "from": "14:00",
      "to": "17:00",
      "interval": 90
    }
  ]
}
```

</CodeGroup>

Field by field:

| Private API | v1 | Note |
| --- | --- | --- |
| `dates: [...]` | `kind: "date"`, `options: [{ date }]` | Duplicates are removed instead of rejected |
| `slots.duration` | `duration` | Optional in v1 when every option carries its own `duration`; required when `generators` is set |
| `slots.timezone` | `timeZone` | |
| `slots.times` (string entry) | `options: [{ startTime, duration? }]` | Each slot may override the poll `duration`, which the private API could not do |
| `slots.times` (generator entry) | `generators: [...]` | |
| generator `startTime` | generator `from` | |
| generator `endTime` | generator `to` | |
| generator `days` | generator `days` | Optional in v1; defaults to every day |
| `spaceId` | Removed | Was ignored; now rejected |
| `title` | `title` | Capped at 255 characters |

Everything else (`description`, `location`, `requireEmail`, `hideParticipants`, `hideScores`, `disableComments`, `allowTentativeVotes`, `organizer`) is unchanged. The interpretation of a `startTime` without an offset is also unchanged: wall clock time in `timeZone` when set, floating otherwise.

**Generator validation.** The private API expanded a generator and only then failed with `NO_OPTIONS_GENERATED` if nothing came out. v1 rejects an impossible generator up front with `VALIDATION_ERROR` naming the field: `to` not later than `from`, a daily window shorter than `duration`, or a date range containing none of the listed days. Times in a generator are `HH:mm`; the private API also accepted seconds.

**Response.** The full poll, in the [v1 shape](#the-poll-resource).

### List polls

`GET /api/private/polls` → `GET /v1/polls`

**Request.** Unchanged. `status`, `cursor` and `limit` work the same way, and `nextCursor` is still returned beside `data`.

**Response.** Each item is the full [v1 poll](#the-poll-resource). The private API's list items already carried `participantCount`; the rest of the new fields are additions, and the renames (`timezone`, `user`, date options) apply here too.

### Get a poll

`GET /api/private/polls/{pollId}` → `GET /v1/polls/{pollId}`

**Request.** Unchanged.

**Response.** The [v1 poll](#the-poll-resource). Note that `participantCount` is now included, so a client that called both `GET /polls/{pollId}` and `GET /polls/{pollId}/results` just to count participants can drop the second call.

### Update a poll

`PATCH /api/private/polls/{pollId}` → **removed**

<Warning>
There is no v1 endpoint that changes a poll's status. Its only supported use was closing a poll with `{ "status": "closed" }`.
</Warning>

Polls close on their own once every option is in the past. To close a poll earlier, or to reopen one, use the app. If your integration polled results and stopped once the poll was closed, keep reading `status` from `GET /polls/{pollId}/results`; that part is unchanged.

### Get poll results

`GET /api/private/polls/{pollId}/results` → `GET /v1/polls/{pollId}/results`

**Request.** Unchanged.

**Response.** Three changes:

- `kind` is added at the top level.
- Options in a `date` poll carry `date` instead of `startTime` and `duration: 0`, matching the poll resource.
- `votes` is dense. The private API omitted any vote type with zero votes, so an option nobody voted on had `votes: []`. v1 lists every vote type the poll offers, in display order, with `count: 0` where nobody chose it. A client that summed `votes` or looked a type up by `type` keeps working; a client that treated an empty array as "no votes" must check the counts instead.

<CodeGroup>

```json Private API
{
  "data": {
    "pollId": "Xk3pQ9vLm2Ab",
    "status": "open",
    "participantCount": 2,
    "options": [
      {
        "id": "cm5h8x2k40000q9l4f7e2d3an",
        "startTime": "2027-03-01T00:00:00.000Z",
        "duration": 0,
        "votes": [{ "type": "yes", "count": 2 }],
        "score": 2002,
        "isTopChoice": true
      },
      {
        "id": "cm5h8x2k40001q9l4b8f3e4bo",
        "startTime": "2027-03-02T00:00:00.000Z",
        "duration": 0,
        "votes": [],
        "score": 0,
        "isTopChoice": false
      }
    ],
    "highScore": 2002
  }
}
```

```json v1
{
  "data": {
    "pollId": "Xk3pQ9vLm2Ab",
    "kind": "date",
    "status": "open",
    "participantCount": 2,
    "options": [
      {
        "id": "cm5h8x2k40000q9l4f7e2d3an",
        "date": "2027-03-01",
        "votes": [
          { "type": "yes", "count": 2 },
          { "type": "ifNeedBe", "count": 0 },
          { "type": "no", "count": 0 }
        ],
        "score": 2002,
        "isTopChoice": true
      },
      {
        "id": "cm5h8x2k40001q9l4b8f3e4bo",
        "date": "2027-03-02",
        "votes": [
          { "type": "yes", "count": 0 },
          { "type": "ifNeedBe", "count": 0 },
          { "type": "no", "count": 0 }
        ],
        "score": 0,
        "isTopChoice": false
      }
    ],
    "highScore": 2002
  }
}
```

</CodeGroup>

`score`, `isTopChoice` and `highScore` are computed the same way today, but v1 documents `score` as opaque: sort by it and use `isTopChoice` or `highScore` to find the leaders, but do not decode it into vote counts or compare it across polls. Vote types are an open set in v1, so tolerate a `type` you do not recognise.

### Get poll participants

`GET /api/private/polls/{pollId}/participants` → `GET /v1/polls/{pollId}/participants`

**Request.** Unchanged.

**Response.** `data` is the array itself. The private API wrapped it in an object with `pollId`; v1 drops the wrapper, since you already know the poll ID from the URL. The participant fields (`id`, `name`, `email`, `createdAt`) and the ordering (oldest response first) are unchanged.

<CodeGroup>

```json Private API
{
  "data": {
    "pollId": "Xk3pQ9vLm2Ab",
    "participants": [
      {
        "id": "cm5j2r8wb0003q9l4a1x6p0zt",
        "name": "Jessie Smith",
        "email": "jessie@example.com",
        "createdAt": "2026-09-11T08:30:00.000Z"
      }
    ]
  }
}
```

```json v1
{
  "data": [
    {
      "id": "cm5j2r8wb0003q9l4a1x6p0zt",
      "name": "Jessie Smith",
      "email": "jessie@example.com",
      "createdAt": "2026-09-11T08:30:00.000Z"
    }
  ]
}
```

</CodeGroup>

### Delete a poll

`DELETE /api/private/polls/{pollId}` → `DELETE /v1/polls/{pollId}`

Unchanged. Responds `200` with `{ "data": { "id", "deleted": true } }`, or `404 POLL_NOT_FOUND`.

### OpenAPI document and docs page

`GET /api/private/openapi` → `GET /v1/openapi`

The spec moves with the API. The Scalar page at `/api/private/docs` has no v1 equivalent; this site is the reference. Both spec endpoints are public and need no key.

## Checklist

1. Change the base URL to `https://api.rallly.co/v1`.
2. Accept `201` from `POST /polls`.
3. Rewrite create requests: add `kind`, replace `dates` and `slots` with `options` and `generators`, rename `timezone` to `timeZone` and the generator's `startTime` and `endTime` to `from` and `to`, and remove `spaceId` and any other undocumented fields.
4. Read `timeZone` instead of `timezone`, and `organizer` instead of `user`, in every poll response.
5. Branch on `kind` when reading options: `date` for date polls, `startTime` and `duration` for time polls.
6. In results, stop treating an empty `votes` array as "no votes"; read the counts.
7. Read participants from `data` directly.
8. Parse every error as `{ "error": { "code", "message" } }` and drop any handling of the private API's validator output, `DUPLICATE_DATES`, `NO_OPTIONS_GENERATED` and `TRANSITION_NOT_AVAILABLE`.
9. Remove any call to `PATCH /polls/{pollId}`.

---

### Apps Docs Api Reference Openapi.Json (`apps/docs/api-reference/openapi.json`)

{
  "openapi": "3.1.0",
  "info": {
    "title": "Rallly API",
    "description": "## Versioning\n\n`v1` is stable. Additive changes (new endpoints, new optional fields) may land on this path at any time; breaking changes only arrive under a new version prefix.\n\n## Rate limits\n\nAll endpoints share two limits per space: **60 requests per minute** and **5000 requests per day**. Both are fixed windows that open with the first request and reset when they expire. Both limits are per space, not per API key, so creating additional keys does not increase throughput.\n\nEvery response from an authenticated request includes the standard `RateLimit-*` headers. Responses sent before the limiter runs (`401`, `403`, and the maintenance `503`) do not. `RateLimit-Policy` lists both limits; `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` describe whichever limit is closest to being exhausted. When either limit is exceeded the API responds with `429 Too Many Requests`, a `RATE_LIMIT_EXCEEDED` error body, and a `Retry-After` header indicating how many seconds to wait before retrying.\n\nIf the rate limit store cannot be reached the API fails closed and responds with `503 Service Unavailable`, a `SERVICE_UNAVAILABLE` error body, and a `Retry-After` header.\n\n## Dates and times\n\nEvery poll has a `kind`. A `date` poll offers calendar days: each option carries a `date` in `YYYY-MM-DD` format, which is a floating date with no time component and no timezone, so never convert it through a timezone. A `time` poll offers time slots: each option carries a `startTime` as an ISO 8601 instant in UTC and a `duration` in minutes; convert `startTime` into the poll's `timeZone` (or the viewer's) for display. Timestamps such as `createdAt` and `updatedAt` are always ISO 8601 instants in UTC.\n\n## Request bodies\n\nRequest bodies are validated strictly: a field that is not part of the documented schema is rejected with `VALIDATION_ERROR` rather than ignored, so a misspelt setting never silently falls back to its default.\n\n## Enums\n\nVote types are an open set. The built-in values are `yes`, `ifNeedBe` and `no`; new types may be added without a version change, so clients must tolerate values they do not recognise. `status` and `kind` are closed sets.\n\n## Lists\n\nList endpoints return the items in `data`. `GET /polls` is paginated: it returns a `nextCursor` beside `data`; pass it as the `cursor` query parameter to fetch the next page, and it is `null` on the last page. `GET /polls/:pollId/participants` returns every participant in one response.\n\n## Errors\n\nEvery failure is `application/json` with the shape `{ \"error\": { \"code\", \"message\" } }`. `code` is stable and safe to branch on; `message` is human-readable and may change. `VALIDATION_ERROR` messages name each offending field.\n\n| Status | Code | When |\n| --- | --- | --- |\n| 400 | `VALIDATION_ERROR` | The body or query string did not match the schema, contained an unknown field, or the body was not valid JSON |\n| 400 | `INVALID_AUTHORIZATION_HEADER` | The `Authorization` header is not `Bearer <key>` |\n| 400 | `ORGANIZER_NOT_MEMBER` | The organizer email is not a member of the space |\n| 400 | `TOO_MANY_OPTIONS` | More than the maximum number of poll options |\n| 400 | `CONFERENCING_UNAVAILABLE` | The requested video call provider is not offered on this instance |\n| 400 | `CONFERENCING_NOT_CONNECTED` | The organizer has not connected an account for the requested video call provider |\n| 400 | `INAPPROPRIATE_CONTENT` | The title, description, location or video call was flagged by content moderation |\n| 401 | `UNAUTHORIZED` | The API key is missing, invalid, expired or revoked, or its owner is banned |\n| 403 | `SPACE_NOT_PRO` | The space behind the key has no Pro subscription |\n| 404 | `NOT_FOUND` | No route matches the method and path |\n| 404 | `POLL_NOT_FOUND` | The poll does not exist or belongs to another space |\n| 404 | `PARTICIPANT_NOT_FOUND` | The participant does not exist or belongs to another poll |\n| 429 | `RATE_LIMIT_EXCEEDED` | A rate limit window is exhausted |\n| 503 | `SERVICE_UNAVAILABLE` | Maintenance, or the rate limit store cannot be reached |\n| 500 | `INTERNAL_ERROR` | Unexpected failure; the request id is logged |",
    "version": "1.0.0"
  },
  "servers": [
    {
      "url": "https://api.rallly.co"
    }
  ],
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer"
      }
    },
    "schemas": {
      "Poll": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "example": "Xk3pQ9vLm2Ab"
          },
          "title": {
            "type": "string",
            "example": "Team sync"
          },
          "description": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "example": "Pick a time that works for everyone"
          },
          "location": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "example": "Conference Room A"
          },
          "conferencing": {
            "anyOf": [
              {
                "oneOf": [
                  {
                    "type": "object",
                    "properties": {
                      "provider": {
                        "type": "string",
                        "const": "meet"
                      }
                    },
                    "required": [
                      "provider"
                    ]
                  },
                  {
                    "type": "object",
                    "properties": {
                      "provider": {
                        "type": "string",
                        "const": "zoom"
                      }
                    },
                    "required": [
                      "provider"
                    ]
                  },
                  {
                    "type": "object",
                    "properties": {
                      "provider": {
                        "type": "string",
                        "const": "teams"
                      }
                    },
                    "required": [
                      "provider"
                    ]
                  },
                  {
                    "type": "object",
                    "properties": {
                      "provider": {
                        "type": "string",
                        "const": "webex"
                      }
                    },
                    "required": [
                      "provider"
                    ]
                  },
                  {
                    "type": "object",
                    "properties": {
                      "provider": {
                        "type": "string",
                        "const": "custom"
                      },
                      "label": {
                        "type": "string",
                        "minLength": 1,
                        "maxLength": 100
                      },
                      "uri": {
                        "type": "string",
                        "format": "uri"
                      }
                    },
                    "required": [
                      "provider",
                      "label"
                    ]
                  }
                ]
              },
              {
                "type": "null"
              }
            ],
            "description": "The video call for the event, or `null`. A `meet`, `zoom`, `teams` or `webex` entry has no link until the poll is finalized; a `custom` entry carries the organizer's label and, when given, the link.",
            "example": {
              "provider": "zoom"
            }
          },
          "timeZone": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "example": "Europe/London"
          },
          "status": {
            "$ref": "#/components/schemas/PollStatus"
          },
          "kind": {
            "$ref": "#/components/schemas/PollKind"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time",
            "example": "2025-01-10T12:00:00.000Z"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time",
            "description": "When the poll was last modified. Changes when the poll's details, settings or status change.",
            "example": "2025-01-12T08:30:00.000Z"
          },
          "organizer": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/PollOrganizer"
              },
              {
                "type": "null"
              }
            ],
            "description": "The space member the poll belongs to. `null` when the organizer's account no longer exists."
          },
          "requireEmail": {
            "type": "boolean",
            "description": "Whether participants must provide their email address",
            "example": false
          },
          "hideParticipants": {
            "type": "boolean",
            "description": "Whether participant names are hidden from other participants",
            "example": false
          },
          "hideScores": {
            "type": "boolean",
            "description": "Whether vote counts are hidden from participants",
            "example": false
          },
          "disableComments": {
            "type": "boolean",
            "description": "Whether the comments section is disabled",
            "example": true
          },
          "allowTentativeVotes": {
            "type": "boolean",
            "description": "Whether participants may cast the tentative \"if need be\" vote",
            "example": true
          },
          "participantCount": {
            "type": "integer",
            "minimum": 0,
            "maximum": 9007199254740991,
            "description": "Number of participants who have responded to the poll",
            "example": 3
          },
          "options": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PollOption"
            }
          },
          "adminUrl": {
            "type": "string",
            "example": "https://app.rallly.co/poll/Xk3pQ9vLm2Ab"
          },
          "inviteUrl": {
            "type": "string",
            "example": "https://rallly.co/invite/Xk3pQ9vLm2Ab"
          }
        },
        "required": [
          "id",
          "title",
          "description",
          "location",
          "conferencing",
          "timeZone",
          "status",
          "kind",
          "createdAt",
          "updatedAt",
          "organizer",
          "requireEmail",
          "hideParticipants",
          "hideScores",
          "disableComments",
          "allowTentativeVotes",
          "participantCount",
          "options",
          "adminUrl",
          "inviteUrl"
        ]
      },
      "PollStatus": {
        "type": "string",
        "enum": [
          "open",
          "closed",
          "scheduled",
          "canceled"
        ]
      },
      "PollKind": {
        "type": "string",
        "enum": [
          "date",
          "time"
        ],
        "description": "Whether the poll offers calendar dates (`date`) or time slots (`time`). Determines which option shape the poll uses: every option in a `date` poll has a `date`, every option in a `time` poll has a `startTime` and `duration`.",
        "example": "time"
      },
      "PollOrganizer": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "example": "cm3f7d1qa0000t2k9c6b8h4jr"
          },
          "name": {
            "type": "string",
            "example": "John Doe"
          },
          "email": {
            "type": "string",
            "format": "email",
            "example": "organizer@example.com"
          },
          "image": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "example": "https://cdn.rallly.co/avatars/cm3f7d1qa0000t2k9c6b8h4jr.jpg"
          }
        },
        "required": [
          "id",
          "name",
          "email",
          "image"
        ]
      },
      "PollOption": {
        "anyOf": [
          {
            "$ref": "#/components/schemas/DateOption"
          },
          {
            "$ref": "#/components/schemas/TimeOption"
          }
        ],
        "description": "A poll option. The shape follows the poll's `kind`: a `DateOption` for `date` polls, a `TimeOption` for `time` polls."
      },
      "DateOption": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "example": "cm5h8x2k40000q9l4f7e2d3an"
          },
          "date": {
            "type": "string",
            "format": "date",
            "description": "Calendar date in YYYY-MM-DD format. All-day options are floating dates with no time component and no timezone.",
            "example": "2025-01-15"
          }
        },
        "required": [
          "id",
          "date"
        ],
        "description": "An all-day option. Only present in polls with `kind: date`."
      },
      "TimeOption": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "example": "cm5h8x2k40000q9l4f7e2d3an"
          },
          "startTime": {
            "type": "string",
            "format": "date-time",
            "description": "Start of the slot as an ISO 8601 instant in UTC.",
            "example": "2025-01-15T09:00:00.000Z"
          },
          "duration": {
            "type": "integer",
            "exclusiveMinimum": 0,
            "maximum": 9007199254740991,
            "description": "Duration in minutes.",
            "example": 30
          }
        },
        "required": [
          "id",
          "startTime",
          "duration"
        ],
        "description": "A time slot. Only present in polls with `kind: time`."
      },
      "PollResponse": {
        "type": "object",
        "properties": {
          "data": {
            "$ref": "#/components/schemas/Poll"
          }
        },
        "required": [
          "data"
        ]
      },
      "ErrorResponse": {
        "type": "object",
        "properties": {
          "error": {
            "type": "object",
            "properties": {
              "code": {
                "type": "string",
                "description": "Machine-readable error code. The full list is in the API description.",
                "example": "VALIDATION_ERROR"
              },
              "message": {
                "type": "string",
                "description": "Human-readable explanation. For `VALIDATION_ERROR` it names each offending field, e.g. `title: Invalid input: expected string, received undefined; dates.0: Invalid ISO date`.",
                "example": "title: Invalid input: expected string, received undefined"
              }
            },
            "required": [
              "code",
              "message"
            ]
          }
        },
        "required": [
          "error"
        ]
      },
      "CreateDatePollInput": {
        "type": "object",
        "properties": {
          "title": {
            "type": "string",
            "minLength": 1,
            "maxLength": 255,
            "example": "Team sync"
          },
          "kind": {
            "type": "string",
            "const": "date",
            "description": "Participants vote on whole days."
          },
          "description": {
            "example": "Pick a time that works for everyone",
            "type": "string",
            "maxLength": 1000
          },
          "location": {
            "description": "Where the event takes place, as free text.",
            "example": "Conference Room A",
            "type": "string",
            "maxLength": 255
          },
          "conferencing": {
            "description": "The video call for the event. `meet`, `zoom`, `teams` and `webex` need the organizer to have connected that account in their Conferencing settings; the meeting link is created when the poll is finalized. `custom` names any call, with an optional link participants join with.",
            "examples": [
              {
                "provider": "zoom"
              },
              {
                "provider": "custom",
                "label": "Microsoft Teams",
                "uri": "https://teams.microsoft.com/l/meetup-join/abc"
              }
            ],
            "oneOf": [
              {
                "type": "object",
                "properties": {
                  "provider": {
                    "type": "string",
                    "const": "meet"
                  }
                },
                "required": [
                  "provider"
                ]
              },
              {
                "type": "object",
                "properties": {
                  "provider": {
                    "type": "string",
                    "const": "zoom"
                  }
                },
                "required": [
                  "provider"
                ]
              },
              {
                "type": "object",
                "properties": {
                  "provider": {
                    "type": "string",
                    "const": "teams"
                  }
                },
                "required": [
                  "provider"
                ]
              },
              {
                "type": "object",
                "properties": {
                  "provider": {
                    "type": "string",
                    "const": "webex"
                  }
                },
                "required": [
                  "provider"
                ]
              },
              {
                "type": "object",
                "properties": {
                  "provider": {
                    "type": "string",
                    "const": "custom"
                  },
                  "label": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 100
                  },
                  "uri": {
                    "type": "string",
                    "format": "uri"
                  }
                },
                "required": [
                  "provider",
                  "label"
                ]
              }
            ]
          },
          "requireEmail": {
            "description": "Require participants to provide their email address",
            "example": true,
            "type": "boolean"
          },
          "hideParticipants": {
            "description": "Hide participant names from other participants",
            "example": false,
            "type": "boolean"
          },
          "hideScores": {
            "description": "Hide vote counts from participants",
            "example": false,
            "type": "boolean"
          },
          "disableComments": {
            "description": "Disable the comments section. Defaults to true: new polls have comments disabled unless this is set to false.",
            "example": false,
            "type": "boolean"
          },
          "allowTentativeVotes": {
            "description": "Allow participants to answer \"if need be\" as well as yes and no. Defaults to true.",
            "example": true,
            "type": "boolean"
          },
          "organizer": {
            "description": "Organizer of the poll. Defaults to the space owner if not provided. The organizer must be a member of the space.",
            "type": "object",
            "properties": {
              "email": {
                "type": "string",
                "format": "email",
                "description": "Email address of the organizer",
                "example": "organizer@example.com"
              }
            },
            "required": [
              "email"
            ],
            "additionalProperties": false
          },
          "options": {
            "minItems": 1,
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/DateOptionInput"
            },
            "description": "Calendar days to offer. Dates are floating calendar days with no timezone, so never convert them through one. Duplicates are removed."
          }
        },
        "required": [
          "title",
          "kind",
          "options"
        ],
        "additionalProperties": false,
        "title": "Date poll"
      },
      "DateOptionInput": {
        "type": "object",
        "properties": {
          "date": {
            "type": "string",
            "format": "date",
            "description": "Calendar day to offer, as a floating `YYYY-MM-DD` date.",
            "example": "2027-03-01"
          }
        },
        "required": [
          "date"
        ],
        "additionalProperties": false,
        "title": "Date option",
        "description": "One all-day option. The same shape `DateOption` returns."
      },
      "CreateTimePollInput": {
        "type": "object",
        "properties": {
          "title": {
            "type": "string",
            "minLength": 1,
            "maxLength": 255,
            "example": "Team sync"
          },
          "kind": {
            "type": "string",
            "const": "time",
            "description": "Participants vote on time slots."
          },
          "description": {
            "example": "Pick a time that works for everyone",
            "type": "string",
            "maxLength": 1000
          },
          "location": {
            "description": "Where the event takes place, as free text.",
            "example": "Conference Room A",
            "type": "string",
            "maxLength": 255
          },
          "conferencing": {
            "description": "The video call for the event. `meet`, `zoom`, `teams` and `webex` need the organizer to have connected that account in their Conferencing settings; the meeting link is created when the poll is finalized. `custom` names any call, with an optional link participants join with.",
            "examples": [
              {
                "provider": "zoom"
              },
              {
                "provider": "custom",
                "label": "Microsoft Teams",
                "uri": "https://teams.microsoft.com/l/meetup-join/abc"
              }
            ],
            "oneOf": [
              {
                "type": "object",
                "properties": {
                  "provider": {
                    "type": "string",
                    "const": "meet"
                  }
                },
                "required": [
                  "provider"
                ]
              },
              {
                "type": "object",
                "properties": {
                  "provider": {
                    "type": "string",
                    "const": "zoom"
                  }
                },
                "required": [
                  "provider"
                ]
              },
              {
                "type": "object",
                "properties": {
                  "provider": {
                    "type": "string",
                    "const": "teams"
                  }
                },
                "required": [
                  "provider"
                ]
              },
              {
                "type": "object",
                "properties": {
                  "provider": {
                    "type": "string",
                    "const": "webex"
                  }
                },
                "required": [
                  "provider"
                ]
              },
              {
                "type": "object",
                "properties": {
                  "provider": {
                    "type": "string",
                    "const": "custom"
                  },
                  "label": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 100
                  },
                  "uri": {
                    "type": "string",
                    "format": "uri"
                  }
                },
                "required": [
                  "provider",
                  "label"
                ]
              }
            ]
          },
          "requireEmail": {
            "description": "Require participants to provide their email address",
            "example": true,
            "type": "boolean"
          },
          "hideParticipants": {
            "description": "Hide participant names from other participants",
            "example": false,
            "type": "boolean"
          },
          "hideScores": {
            "description": "Hide vote counts from participants",
            "example": false,
            "type": "boolean"
          },
          "disableComments": {
            "description": "Disable the comments section. Defaults to true: new polls have comments disabled unless this is set to false.",
            "example": false,
            "type": "boolean"
          },
          "allowTentativeVotes": {
            "description": "Allow participants to answer \"if need be\" as well as yes and no. Defaults to true.",
            "example": true,
            "type": "boolean"
          },
          "organizer": {
            "description": "Organizer of the poll. Defaults to the space owner if not provided. The organizer must be a member of the space.",
            "type": "object",
            "properties": {
              "email": {
                "type": "string",
                "format": "email",
                "description": "Email address of the organizer",
                "example": "organizer@example.com"
              }
            },
            "required": [
              "email"
            ],
            "additionalProperties": false
          },
          "timeZone": {
            "description": "IANA time zone the times are written in. Datetime strings without an offset are interpreted in this zone. If omitted, offset-less datetimes are floating times (no conversion) and the poll has no time zone.",
            "example": "Europe/London",
            "type": "string"
          },
          "duration": {
            "description": "Default slot length in minutes. Required when `generators` is set or an option omits its own `duration`.",
            "example": 30,
            "type": "integer",
            "minimum": 15,
            "maximum": 1440
          },
          "options": {
            "description": "Explicit slots. Provide `options`, `generators` or both. Duplicates are removed.",
            "minItems": 1,
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/TimeOptionInput"
            }
          },
          "generators": {
            "description": "Slot generators. Each expands into recurring slots from a schedule and the result is appended to `options`.",
            "minItems": 1,
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SlotGenerator"
            }
          }
        },
        "required": [
          "title",
          "kind"
        ],
        "additionalProperties": false,
        "title": "Time poll"
      },
      "TimeOptionInput": {
        "type": "object",
        "properties": {
          "startTime": {
            "type": "string",
            "format": "date-time",
            "description": "ISO datetime start time. A string without an offset is wall clock time in `timeZone` when that is set (`2027-03-01T09:00:00` with `timeZone: Europe/London` means 09:00 in London) and a floating time with no conversion otherwise. A string with an offset or `Z` is an absolute instant.",
            "example": "2027-03-01T09:00:00"
          },
          "duration": {
            "description": "Length of this slot in minutes. Defaults to the poll's `duration`.",
            "example": 30,
            "type": "integer",
            "minimum": 15,
            "maximum": 1440
          }
        },
        "required": [
          "startTime"
        ],
        "additionalProperties": false,
        "title": "Time option",
        "description": "One time slot. The same shape `TimeOption` returns."
      },
      "SlotGenerator": {
        "type": "object",
        "properties": {
          "startDate": {
            "type": "string",
            "format": "date",
            "description": "First day of the range to generate slots on, inclusive.",
            "example": "2027-03-01"
          },
          "endDate": {
            "type": "string",
            "format": "date",
            "description": "Last day of the range, inclusive. The range must span fewer than 366 days.",
            "example": "2027-03-05"
          },
          "days": {
            "default": [
              "mon",
              "tue",
              "wed",
              "thu",
              "fri",
              "sat",
              "sun"
            ],
            "description": "Days of the week to generate slots on. Days in the range that are not listed are skipped. Defaults to every day.",
            "example": [
              "mon",
              "tue",
              "wed",
              "thu",
              "fri"
            ],
            "minItems": 1,
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "mon",
                "tue",
                "wed",
                "thu",
                "fri",
                "sat",
                "sun"
              ]
            }
          },
          "from": {
            "type": "string",
            "pattern": "^(?:[01]\\d|2[0-3]):[0-5]\\d$",
            "description": "Earliest slot start on each day, as a wall clock time in `timeZone`.",
            "example": "09:00"
          },
          "to": {
            "type": "string",
            "pattern": "^(?:[01]\\d|2[0-3]):[0-5]\\d$",
            "description": "End of the daily window, as a wall clock time in `timeZone`. A slot is only generated if it ends at or before this time.",
            "example": "17:00"
          },
          "interval": {
            "description": "Minutes between consecutive slot starts. Defaults to `duration`, which produces back to back slots.",
            "example": 60,
            "type": "integer",
            "minimum": 15,
            "maximum": 1440
          }
        },
        "required": [
          "startDate",
          "endDate",
          "from",
          "to"
        ],
        "additionalProperties": false,
        "title": "Slot generator",
        "description": "Expands into one slot of `duration` minutes every `interval` minutes between `from` and `to`, on each listed day of the week between `startDate` and `endDate`. Slots that would not end by `to` are not generated."
      },
      "CreatePollInput": {
        "oneOf": [
          {
            "$ref": "#/components/schemas/CreateDatePollInput"
          },
          {
            "$ref": "#/components/schemas/CreateTimePollInput"
          }
        ],
        "description": "`kind` chooses what participants vote on: whole days (`date`) or time slots (`time`). The request mirrors the poll the API returns: the same settings, and `options` in the same shape as the response.",
        "discriminator": {
          "propertyName": "kind",
          "mapping": {
            "date": "#/components/schemas/CreateDatePollInput",
            "time": "#/components/schemas/CreateTimePollInput"
          }
        }
      },
      "ListPollsResponse": {
        "type": "object",
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Poll"
            }
          },
          "nextCursor": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "description": "Cursor to fetch the next page. `null` when there are no more results.",
            "example": "Xk3pQ9vLm2Ab"
          }
        },
        "required": [
          "data",
          "nextCursor"
        ]
      },
      "OptionResult": {
        "anyOf": [
          {
            "$ref": "#/components/schemas/DateOptionResult"
          },
          {
            "$ref": "#/components/schemas/TimeOptionResult"
          }
        ],
        "description": "Results for one option. The shape follows the poll's `kind`: a `DateOptionResult` for `date` polls, a `TimeOptionResult` for `time` polls."
      },
      "DateOptionResult": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "example": "cm5h8x2k40000q9l4f7e2d3an"
          },
          "date": {
            "type": "string",
            "format": "date",
            "description": "Calendar date in YYYY-MM-DD format. All-day options are floating dates with no time component and no timezone.",
            "example": "2025-01-15"
          },
          "votes": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/VoteCount"
            },
            "description": "One entry per vote type the poll offers, in display order, always present with `count: 0` when nobody chose it."
          },
          "score": {
            "type": "integer",
            "minimum": 0,
            "maximum": 9007199254740991,
            "description": "Opaque ranking value: higher is better. Only comparable between options in the same response. The formula is not part of the contract and may change; do not decode it into vote counts, compare it across polls or threshold on it. Use `votes` for counts.",
            "example": 5004
          },
          "isTopChoice": {
            "type": "boolean",
            "description": "Whether this option has the highest `score` in the poll. Several options share the flag when they tie. Always `false` when nobody has voted.",
            "example": true
          }
        },
        "required": [
          "id",
          "date",
          "votes",
          "score",
          "isTopChoice"
        ],
        "description": "Results for an all-day option. Only present in polls with `kind: date`."
      },
      "VoteCount": {
        "type": "object",
        "properties": {
          "type": {
            "$ref": "#/components/schemas/VoteType"
          },
          "count": {
            "type": "integer",
            "minimum": 0,
            "maximum": 9007199254740991,
            "description": "Number of participants who gave this answer.",
            "example": 3
          }
        },
        "required": [
          "type",
          "count"
        ]
      },
      "VoteType": {
        "type": "string",
        "description": "A participant's answer for an option. The built-in types are `yes` (available), `ifNeedBe` (available if needed) and `no` (unavailable). New types may be added without a version change, so treat unknown values as a vote of an unfamiliar type rather than an error.",
        "example": "yes"
      },
      "TimeOptionResult": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "example": "cm5h8x2k40000q9l4f7e2d3an"
          },
          "startTime": {
            "type": "string",
            "format": "date-time",
            "description": "Start of the slot as an ISO 8601 instant in UTC.",
            "example": "2025-01-15T09:00:00.000Z"
          },
          "duration": {
            "type": "integer",
            "exclusiveMinimum": 0,
            "maximum": 9007199254740991,
            "description": "Duration in minutes.",
            "example": 30
          },
          "votes": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/VoteCount"
            },
            "description": "One entry per vote type the poll offers, in display order, always present with `count: 0` when nobody chose it."
          },
          "score": {
            "type": "integer",
            "minimum": 0,
            "maximum": 9007199254740991,
            "description": "Opaque ranking value: higher is better. Only comparable between options in the same response. The formula is not part of the contract and may change; do not decode it into vote counts, compare it across polls or threshold on it. Use `votes` for counts.",
            "example": 5004
          },
          "isTopChoice": {
            "type": "boolean",
            "description": "Whether this option has the highest `score` in the poll. Several options share the flag when they tie. Always `false` when nobody has voted.",
            "example": true
          }
        },
        "required": [
          "id",
          "startTime",
          "duration",
          "votes",
          "score",
          "isTopChoice"
        ],
        "description": "Results for a time slot. Only present in polls with `kind: time`."
      },
      "GetPollResultsResponse": {
        "type": "object",
        "properties": {
          "data": {
            "type": "object",
            "properties": {
              "pollId": {
                "type": "string",
                "example": "Xk3pQ9vLm2Ab"
              },
              "kind": {
                "$ref": "#/components/schemas/PollKind"
              },
              "status": {
                "description": "Current poll status. Polls close automatically when all options are in the past.",
                "example": "open",
                "$ref": "#/components/schemas/PollStatus"
              },
              "participantCount": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991,
                "description": "Total number of participants",
                "example": 8
              },
              "options": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/OptionResult"
                }
              },
              "highScore": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991,
                "description": "The highest `score` among the options. Opaque like `score`: use it to identify the leading options, not as a measurement.",
                "example": 5004
              }
            },
            "required": [
              "pollId",
              "kind",
              "status",
              "participantCount",
              "options",
              "highScore"
            ]
          }
        },
        "required": [
          "data"
        ]
      },
      "Participant": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "example": "cm5j2r8wb0003q9l4a1x6p0zt"
          },
          "name": {
            "type": "string",
            "example": "Jane Smith"
          },
          "email": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "example": "jane@example.com"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time",
            "example": "2025-01-10T12:00:00.000Z"
          },
          "availability": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/AvailabilitySpan"
            },
            "description": "When the participant is available: one span per option they can make, in start order, not merged. Options they cannot make, or did not answer, are absent. Each span matches an option of the poll by its time."
          }
        },
        "required": [
          "id",
          "name",
          "email",
          "createdAt",
          "availability"
        ]
      },
      "AvailabilitySpan": {
        "type": "object",
        "properties": {
          "start": {
            "type": "string",
            "format": "date-time",
            "description": "Start of the span, inclusive, as a UTC instant.",
            "example": "2025-01-15T09:00:00.000Z"
          },
          "end": {
            "type": "string",
            "format": "date-time",
            "description": "End of the span, exclusive, as a UTC instant: a 30 minute slot starting at 09:00 ends at 09:30, and the instant 09:30 is not in it.",
            "example": "2025-01-15T09:30:00.000Z"
          },
          "allDay": {
            "type": "boolean",
            "description": "True for a whole calendar day. The day is a floating date with no timezone, given as `start` at 00:00:00Z on that date and `end` at 00:00:00Z on the next; read the date from `start` and ignore the time of day.",
            "example": false
          },
          "modifiers": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Qualifiers on the availability. `ifNeedBe` means the participant can make it but would rather not. New modifiers may be added without a version change; one you do not recognise still leaves the span available.",
            "example": [
              "ifNeedBe"
            ]
          }
        },
        "required": [
          "start",
          "end",
          "allDay",
          "modifiers"
        ]
      },
      "GetPollParticipantsResponse": {
        "type": "object",
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Participant"
            }
          }
        },
        "required": [
          "data"
        ]
      },
      "GetPollParticipantResponse": {
        "type": "object",
        "properties": {
          "data": {
            "$ref": "#/components/schemas/Participant"
          }
        },
        "required": [
          "data"
        ]
      },
      "DeletePollResponse": {
        "type": "object",
        "properties": {
          "data": {
            "type": "object",
            "properties": {
              "id": {
                "type": "string",
                "example": "Xk3pQ9vLm2Ab"
              },
              "deleted": {
                "type": "boolean",
                "const": true,
                "example": true
              }
            },
            "required": [
              "id",
              "deleted"
            ]
          }
        },
        "required": [
          "data"
        ]
      }
    }
  },
  "paths": {
    "/v1/polls": {
      "post": {
        "operationId": "postV1Polls",
        "tags": [
          "Polls"
        ],
        "summary": "Create a poll",
        "description": "Creates a poll and responds with `201 Created` and the full poll, exactly as `GET /polls/:pollId` returns it. Share `inviteUrl` with participants. The request mirrors the response: `kind` chooses what participants vote on, and `options` takes the same shape the poll returns, so a poll can be recreated from the body of a `GET`. A poll has at most 100 options.\n\n## Date poll\n\nPass `kind: \"date\"` and `options`, one `{ \"date\": \"YYYY-MM-DD\" }` per day. Each becomes one all-day option. Dates are floating calendar days with no timezone, so never convert them through one. Duplicate dates are removed.\n\n```json\n{\n  \"title\": \"Team offsite\",\n  \"kind\": \"date\",\n  \"options\": [{ \"date\": \"2027-03-01\" }, { \"date\": \"2027-03-02\" }, { \"date\": \"2027-03-03\" }]\n}\n```\n\n## Time poll\n\nPass `kind: \"time\"`, optionally a `timeZone` (an IANA zone the times are written in) and a default `duration` in minutes, and the slots as `options`, `generators` or both.\n\n### Explicit slots\n\nEach entry in `options` is one slot: a `startTime` and an optional `duration` that overrides the poll default. A `startTime` with no offset, like `2027-03-01T09:00:00`, is wall clock time in `timeZone` when that is set and a floating time with no conversion otherwise; one with an offset or `Z` is an absolute instant.\n\n```json\n{\n  \"title\": \"Kickoff\",\n  \"kind\": \"time\",\n  \"timeZone\": \"Europe/London\",\n  \"duration\": 60,\n  \"options\": [\n    { \"startTime\": \"2027-03-01T09:00:00\" },\n    { \"startTime\": \"2027-03-02T14:00:00\", \"duration\": 90 }\n  ]\n}\n```\n\n### Slot generators\n\nEach object in `generators` expands into recurring slots of `duration` minutes from a schedule, so availability across days or weeks does not have to be listed slot by slot. `duration` is required when `generators` is set.\n\n```json\n{\n  \"title\": \"Interview availability\",\n  \"kind\": \"time\",\n  \"timeZone\": \"America/New_York\",\n  \"duration\": 30,\n  \"generators\": [\n    {\n      \"startDate\": \"2027-03-01\",\n      \"endDate\": \"2027-03-05\",\n      \"days\": [\"mon\", \"tue\", \"wed\", \"thu\", \"fri\"],\n      \"from\": \"09:00\",\n      \"to\": \"12:00\",\n      \"interval\": 60\n    }\n  ]\n}\n```\n\nThis produces 30 minute slots at 09:00, 10:00 and 11:00 New York time on each weekday from 1 to 5 March, fifteen options in all. Drop `interval` and the window fills with back to back slots at 09:00, 09:30, 10:00, 10:30, 11:00 and 11:30.\n\n| Field | Meaning |\n| --- | --- |\n| `startDate`, `endDate` | The date range, inclusive. Fewer than 366 days. |\n| `days` | Days of the week to include: `mon` to `sun`. Optional; defaults to every day. |\n| `from` | Earliest slot start on each day, `HH:mm` in `timeZone`. |\n| `to` | End of the daily window, `HH:mm` in `timeZone`. A slot is only generated if it ends by this time. Must be later than `from`. |\n| `interval` | Minutes between slot starts. Optional; defaults to `duration`, which gives back to back slots. |\n\nA generator that cannot produce a slot is rejected with `VALIDATION_ERROR` naming the field: `to` not later than `from`, a window shorter than `duration`, or a range that contains none of the listed days. Generators are expanded when the poll is created, the result is appended to `options`, and duplicate slots are removed. A request that would exceed 100 options fails with `TOO_MANY_OPTIONS`.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "201": {
            "description": "Poll created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PollResponse"
                }
              }
            }
          },
          "400": {
            "description": "Invalid input",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "description": "The API key is missing, invalid, expired or revoked, or its owner is banned. Includes a `WWW-Authenticate` header.",
            "headers": {
              "WWW-Authenticate": {
                "description": "The bearer challenge, per RFC 6750.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "403": {
            "description": "The space associated with the API key does not have a Pro subscription",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Includes a `Retry-After` header indicating how many seconds to wait before retrying.",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying the request.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "503": {
            "description": "The API is temporarily unavailable, for maintenance or because the rate limit store cannot be reached. Includes a `Retry-After` header. Maintenance responses are sent before the rate limiter runs and carry no `RateLimit-*` headers.",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying the request.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        },
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreatePollInput"
              },
              "examples": {
                "Date poll": {
                  "summary": "Date poll (all-day options)",
                  "description": "Let participants pick between calendar days. Each option is one all-day date.",
                  "value": {
                    "title": "Team offsite",
                    "kind": "date",
                    "description": "Which days work for a two day offsite?",
                    "options": [
                      {
                        "date": "2027-03-01"
                      },
                      {
                        "date": "2027-03-02"
                      },
                      {
                        "date": "2027-03-03"
                      }
                    ]
                  }
                },
                "Time poll with explicit slots": {
                  "summary": "Time poll (explicit slots)",
                  "description": "Offer specific time slots. Datetimes without an offset are wall clock times in `timeZone`; this example offers 09:00 and 14:00 in London. Append `Z` or an offset for an absolute instant instead. A slot may override the poll's `duration`.",
                  "value": {
                    "title": "Project kickoff",
                    "kind": "time",
                    "location": "Zoom",
                    "timeZone": "Europe/London",
                    "duration": 60,
                    "options": [
                      {
                        "startTime": "2027-03-01T09:00:00"
                      },
                      {
                        "startTime": "2027-03-01T14:00:00",
                        "duration": 90
                      }
                    ]
                  }
                },
                "Time poll with a slot generator": {
                  "summary": "Time poll (slot generator)",
                  "description": "Expand recurring slots across a date range. This example generates 30 minute slots at 09:00, 10:00 and 11:00 (New York time) every weekday from 1 to 5 March.",
                  "value": {
                    "title": "Interview availability",
                    "kind": "time",
                    "timeZone": "America/New_York",
                    "duration": 30,
                    "generators": [
                      {
                        "startDate": "2027-03-01",
                        "endDate": "2027-03-05",
                        "days": [
                          "mon",
                          "tue",
                          "wed",
                          "thu",
                          "fri"
                        ],
                        "from": "09:00",
                        "to": "12:00",
                        "interval": 60
                      }
                    ]
                  }
                },
                "Time poll mixing slots and a generator": {
                  "summary": "Time poll (explicit slots + generator)",
                  "description": "`options` and `generators` combine. When `interval` is omitted it defaults to `duration`, so this generator produces back to back 90 minute slots at 14:00 and 15:30 on Monday and Wednesday.",
                  "value": {
                    "title": "Product workshop",
                    "kind": "time",
                    "timeZone": "Europe/Berlin",
                    "duration": 90,
                    "options": [
                      {
                        "startTime": "2027-03-06T10:00:00"
                      }
                    ],
                    "generators": [
                      {
                        "startDate": "2027-03-08",
                        "endDate": "2027-03-10",
                        "days": [
                          "mon",
                          "wed"
                        ],
                        "from": "14:00",
                        "to": "17:00"
                      }
                    ]
                  }
                }
              }
            }
          }
        }
      },
      "get": {
        "operationId": "getV1Polls",
        "tags": [
          "Polls"
        ],
        "summary": "List polls",
        "description": "Lists the polls in the space associated with the API key, sorted by creation date (newest first).\n\nUse the `status` query parameter to only return polls in a given state — for example `status=open` to sweep polls that are still collecting responses. Results are paginated with a cursor: pass the `nextCursor` value from the previous response to fetch the next page.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Successful response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ListPollsResponse"
                }
              }
            }
          },
          "400": {
            "description": "Invalid query parameters",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "description": "The API key is missing, invalid, expired or revoked, or its owner is banned. Includes a `WWW-Authenticate` header.",
            "headers": {
              "WWW-Authenticate": {
                "description": "The bearer challenge, per RFC 6750.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "403": {
            "description": "The space associated with the API key does not have a Pro subscription",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Includes a `Retry-After` header indicating how many seconds to wait before retrying.",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying the request.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "503": {
            "description": "The API is temporarily unavailable, for maintenance or because the rate limit store cannot be reached. Includes a `Retry-After` header. Maintenance responses are sent before the rate limiter runs and carry no `RateLimit-*` headers.",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying the request.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        },
        "parameters": [
          {
            "in": "query",
            "name": "status",
            "schema": {
              "example": "open",
              "$ref": "#/components/schemas/PollStatus"
            },
            "description": "Filter polls by status. Omit to include all statuses."
          },
          {
            "in": "query",
            "name": "cursor",
            "schema": {
              "example": "Xk3pQ9vLm2Ab",
              "type": "string"
            },
            "description": "Cursor for pagination. Pass the `nextCursor` value from the previous response to fetch the next page."
          },
          {
            "in": "query",
            "name": "limit",
            "schema": {
              "default": 20,
              "example": 20,
              "type": "integer",
              "minimum": 1,
              "maximum": 100
            },
            "description": "Number of polls to return per page (1-100)."
          }
        ]
      }
    },
    "/v1/polls/{pollId}": {
      "get": {
        "operationId": "getV1PollsByPollId",
        "tags": [
          "Polls"
        ],
        "summary": "Get a poll",
        "description": "Retrieves a poll by ID with its options, settings, organizer and `participantCount`. The poll must belong to the space associated with the API key. Compare `updatedAt` between reads to notice changes.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Successful response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PollResponse"
                }
              }
            }
          },
          "401": {
            "description": "The API key is missing, invalid, expired or revoked, or its owner is banned. Includes a `WWW-Authenticate` header.",
            "headers": {
              "WWW-Authenticate": {
                "description": "The bearer challenge, per RFC 6750.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "403": {
            "description": "The space associated with the API key does not have a Pro subscription",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "404": {
            "description": "Poll not found",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Includes a `Retry-After` header indicating how many seconds to wait before retrying.",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying the request.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "503": {
            "description": "The API is temporarily unavailable, for maintenance or because the rate limit store cannot be reached. Includes a `Retry-After` header. Maintenance responses are sent before the rate limiter runs and carry no `RateLimit-*` headers.",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying the request.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        },
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "pollId",
            "required": true
          }
        ]
      },
      "delete": {
        "operationId": "deleteV1PollsByPollId",
        "tags": [
          "Polls"
        ],
        "summary": "Delete a poll",
        "description": "Deletes a poll by ID. The poll must belong to the space associated with the API key.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Poll deleted successfully",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DeletePollResponse"
                }
              }
            }
          },
          "401": {
            "description": "The API key is missing, invalid, expired or revoked, or its owner is banned. Includes a `WWW-Authenticate` header.",
            "headers": {
              "WWW-Authenticate": {
                "description": "The bearer challenge, per RFC 6750.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "403": {
            "description": "The space associated with the API key does not have a Pro subscription",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "404": {
            "description": "Poll not found",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Includes a `Retry-After` header indicating how many seconds to wait before retrying.",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying the request.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "503": {
            "description": "The API is temporarily unavailable, for maintenance or because the rate limit store cannot be reached. Includes a `Retry-After` header. Maintenance responses are sent before the rate limiter runs and carry no `RateLimit-*` headers.",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying the request.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        },
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "pollId",
            "required": true
          }
        ]
      }
    },
    "/v1/polls/{pollId}/results": {
      "get": {
        "operationId": "getV1PollsByPollIdResults",
        "tags": [
          "Polls"
        ],
        "summary": "Get poll results",
        "description": "Retrieves aggregated voting results for a poll: vote counts per option without individual participant data. Use `GET /polls/:pollId/participants` to list who responded.\n\n`votes` lists every vote type the poll offers with its count, zero included. `score` is an opaque ranking value: sort by it to order options from best to worst, and use `isTopChoice` or `highScore` to find the leading options. Its formula is not part of the contract, so do not decode it, compare it across polls or threshold on it.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Successful response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GetPollResultsResponse"
                }
              }
            }
          },
          "401": {
            "description": "The API key is missing, invalid, expired or revoked, or its owner is banned. Includes a `WWW-Authenticate` header.",
            "headers": {
              "WWW-Authenticate": {
                "description": "The bearer challenge, per RFC 6750.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "403": {
            "description": "The space associated with the API key does not have a Pro subscription",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "404": {
            "description": "Poll not found",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Includes a `Retry-After` header indicating how many seconds to wait before retrying.",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying the request.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "503": {
            "description": "The API is temporarily unavailable, for maintenance or because the rate limit store cannot be reached. Includes a `Retry-After` header. Maintenance responses are sent before the rate limiter runs and carry no `RateLimit-*` headers.",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying the request.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        },
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "pollId",
            "required": true
          }
        ]
      }
    },
    "/v1/polls/{pollId}/participants": {
      "get": {
        "operationId": "getV1PollsByPollIdParticipants",
        "tags": [
          "Polls"
        ],
        "summary": "List poll participants",
        "description": "Lists every participant of a poll in one response, oldest response first, each with their availability. The poll must belong to the space associated with the API key.\n\nFor aggregate counts per option use the results endpoint.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Successful response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GetPollParticipantsResponse"
                }
              }
            }
          },
          "401": {
            "description": "The API key is missing, invalid, expired or revoked, or its owner is banned. Includes a `WWW-Authenticate` header.",
            "headers": {
              "WWW-Authenticate": {
                "description": "The bearer challenge, per RFC 6750.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "403": {
            "description": "The space associated with the API key does not have a Pro subscription",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "404": {
            "description": "Poll not found",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Includes a `Retry-After` header indicating how many seconds to wait before retrying.",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying the request.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "503": {
            "description": "The API is temporarily unavailable, for maintenance or because the rate limit store cannot be reached. Includes a `Retry-After` header. Maintenance responses are sent before the rate limiter runs and carry no `RateLimit-*` headers.",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying the request.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        },
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "pollId",
            "required": true
          }
        ]
      }
    },
    "/v1/polls/{pollId}/participants/{participantId}": {
      "get": {
        "operationId": "getV1PollsByPollIdParticipantsByParticipantId",
        "tags": [
          "Polls"
        ],
        "summary": "Get a poll participant",
        "description": "Returns one participant of a poll with their availability. The poll must belong to the space associated with the API key. This is the endpoint to call from a `poll.participant.*` webhook.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Successful response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GetPollParticipantResponse"
                }
              }
            }
          },
          "401": {
            "description": "The API key is missing, invalid, expired or revoked, or its owner is banned. Includes a `WWW-Authenticate` header.",
            "headers": {
              "WWW-Authenticate": {
                "description": "The bearer challenge, per RFC 6750.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "403": {
            "description": "The space associated with the API key does not have a Pro subscription",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "404": {
            "description": "Poll not found (`POLL_NOT_FOUND`) or participant not found (`PARTICIPANT_NOT_FOUND`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Includes a `Retry-After` header indicating how many seconds to wait before retrying.",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying the request.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "503": {
            "description": "The API is temporarily unavailable, for maintenance or because the rate limit store cannot be reached. Includes a `Retry-After` header. Maintenance responses are sent before the rate limiter runs and carry no `RateLimit-*` headers.",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying the request.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        },
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "pollId",
            "required": true
          },
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "participantId",
            "required": true
          }
        ]
      }
    }
  }
}

---

### Apps Docs Api Reference Rate Limits (`apps/docs/api-reference/rate-limits.mdx`)

---
title: "Rate limits"
icon: "gauge-high"
description: "Per space request limits and how to back off."
---

All endpoints share two limits per space:

| Window | Limit |
| --- | --- |
| Per minute | 60 requests |
| Per day | 5,000 requests |

Both are fixed windows that open with the first request and reset when they expire. Limits apply to the space, not the key, so creating more keys does not increase throughput.

## Headers

Every response to an authenticated request carries the standard `RateLimit-*` headers:

| Header | Meaning |
| --- | --- |
| `RateLimit-Policy` | Both limits and their window lengths |
| `RateLimit-Limit` | The limit closest to being exhausted |
| `RateLimit-Remaining` | Requests left in that window |
| `RateLimit-Reset` | Seconds until that window resets |

Responses sent before the limiter runs, such as `401`, `403` and the maintenance `503`, do not include them.

## Exceeding a limit

When either limit is exhausted the API responds with `429 Too Many Requests`, a `RATE_LIMIT_EXCEEDED` error body and a `Retry-After` header with the number of seconds to wait.

```http
HTTP/1.1 429 Too Many Requests
Retry-After: 23
RateLimit-Limit: 60
RateLimit-Remaining: 0
RateLimit-Reset: 23
```

```json
{
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "Rate limit exceeded. Retry after the time indicated in the Retry-After header."
  }
}
```

Wait for `Retry-After` before retrying. Do not retry on a fixed interval: a client that ignores the header spends its next window on requests that fail.

## Store outages

If the rate limit store cannot be reached the API fails closed and responds with `503 Service Unavailable`, a `SERVICE_UNAVAILABLE` error body and a `Retry-After` header.

---

### Apps Docs Api Reference Webhooks (`apps/docs/api-reference/webhooks.mdx`)

---
title: "Overview"
sidebarTitle: "Overview"
icon: "webhook"
description: "Get an HTTPS request when a poll or a response changes."
---

Webhooks tell your own systems about poll and participant changes as they happen, so an integration never has to poll the API for results. Each endpoint you register receives a signed `POST` for every event it subscribes to.

<Note>Webhooks require a Pro plan.</Note>

<Warning>
  Webhooks are in beta. The event list and payloads may still change while we settle the design, and more events will be added. Changes that could break a receiver will arrive under a new payload version.
</Warning>

## Adding an endpoint

1. As the space owner, open **Settings → Webhooks** and choose **Add webhook**.
2. Enter the URL. It must be `https`, on a public host, with no credentials in it.
3. Pick the events to receive. Every event is selected by default; you can change the selection later.
4. Copy the signing secret. It is shown once. If you lose it, delete the endpoint and add it again.

A space can have up to five endpoints. A new endpoint starts receiving events from the moment it is created; history is never replayed.

To check the endpoint before any poll changes, choose **Send test event** from its menu. Rallly sends a signed [`ping`](/api-reference/webhooks/events#test-events) and shows the response it got back.

## What an event carries

An event says what happened and to which poll or participant. It references both by id and carries only the facts that belong to the event itself: the reason a poll closed, the time it was scheduled for. It does not carry the poll's title, options or settings, or the participant's details. Fetch the API when you need them; the fetch returns the current state, where a snapshot in the event would already be stale by the time a retry lands.

See [Events](/api-reference/webhooks/events) for the envelope, the headers and every event with its payload.

## Delivery

Every delivery is a `POST` with a JSON body. Respond with any `2xx` status within 10 seconds; the response body is ignored. Redirects are not followed and count as failures.

Delivery is at least once. A delivery that fails is retried with backoff after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours; after the sixth failure it is exhausted and not retried. Failed deliveries never block later ones, so a burst of events can arrive out of order: sort on `createdAt` if order matters, and deduplicate on `id`, which retries of one event share.

The settings page shows each endpoint's health. An endpoint is **Failing** once an attempt fails, and selecting that label shows the last error and response status. An endpoint that exhausts twenty deliveries in a row is turned off and shown as **Turned off after failures**. Fix the receiver, send a test event to confirm it, then turn the endpoint back on; events that happened while it was off are not delivered.

Every request is signed. Verify the signature before acting on a body; [Security](/api-reference/webhooks/security) shows how.

## Versioning

The payload contract is dated. `version` in the body and the `X-Rallly-Webhook-Version` header carry the version a delivery was built against; today that is `2026-09-20`. Additive changes, such as new fields, new events or new modifiers, keep the version, so ignore fields you do not recognise. A change that could break a receiver arrives under a new date, and existing endpoints keep the version they were created with until you upgrade them.

---

### Apps Docs Contact Support (`apps/docs/contact/support.mdx`)

---
title: Support
description: Get in touch with us if you need help with anything
---

If you have any questions, feedback or suggestions, please get in touch. We'd love to hear from you!

<Tip>
  Check out the [FAQ](/faq) page to see if your question has already been
  answered.
</Tip>

<CardGroup cols={2}>
  <Card
    title="Email (recommended)"
    icon="envelope"
    href="mailto:support@rallly.co"
  >
    Send us an email
  </Card>
  <Card
    title="Discord"
    icon="discord"
    href="https://discord.com/invite/uzg4ZcHbuM"
  >
    Join Discord
  </Card>
</CardGroup>

<Note>
  If the email link doesn't open your email client, email us at
  `support@rallly.co`.
</Note>

---

### Apps Docs Contribute Documentation (`apps/docs/contribute/documentation.mdx`)

---
title: Documentation
icon: book
description: Help us maintain and improve our documentation
---

If you have a [Github](https://github.com) account, you can make changes or report issues using the links that appear at the top of each page.

<Frame caption="Links to edit a page or open an issue on Github">
  <img
    src="/images/contribute/edit-documentation.png"
    alt="Edit Documentation Links"
  />
</Frame>

## Submitting a Pull Request

Submitting a pull request is a great way to fix minor issues like typos or broken links.
Click on the "pencil" icon and fork our repository to submit a pull request.

## Creating an Issue

For larger tasks, you can create a new issue by clicking on the "exclamation" icon.

---

### Apps Docs Contribute Donations (`apps/docs/contribute/donations.mdx`)

---
title: Donations
icon: coins
description: Help fund this project and keep it ad-free
---

## Why should I donate?

Most users of Rallly use it for free, and that's great!
However, running Rallly costs money.
By donating, you can help keep Rallly running and ad-free.

## How can I donate?

You can donate using any of the following methods:

<CardGroup cols={3}>
  <Card
    title="Donate with PayPal"
    icon="paypal"
    href="https://www.paypal.com/donate/?hosted_button_id=7QXP2CUBLY88E"
  ></Card>
  <Card
    title="Sponsor on Github"
    icon="github"
    href="https://github.com/sponsors/lukevella"
  ></Card>
  <Card
    title="Buy me a coffee"
    icon="cup-togo"
    href="https://www.buymeacoffee.com/lukevella"
  />
</CardGroup>

## What will my donation be used for?

Your donation will be used to cover the costs of running Rallly and to fund future development.

---

### Apps Docs Contribute Introduction (`apps/docs/contribute/introduction.mdx`)

---
title: Introduction
icon: hand-holding-heart
description: Help us make Rallly better
---

If you enjoy using Rallly and would like to contribute to its development, here are a few ways you can help out.

<CardGroup cols={2}>
  <Card title="Donate" icon="coins" href="/contribute/donations">
    Help fund this project
  </Card>
  <Card title="Translate" icon="globe" href="/contribute/translations">
    Help translate Rallly into your language
  </Card>
  <Card
    title="Report a Bug"
    icon="bug"
    href="https://github.com/lukevella/rallly/issues/new?assignees=&labels=bug&template=---bug-report.md&title="
  >
    Help us find and fix bugs
  </Card>
  <Card title="Documentation" icon="book" href="/contribute/documentation">
    Help improve our documentation
  </Card>
  <Card
    title="Leave a review on Trustpilot"
    icon="star"
    href="https://www.trustpilot.com/review/rallly.co"
  >
    Help spread the word
  </Card>
  <Card
    title="Follow @ralllyco on Twitter"
    icon="twitter"
    href="https://twitter.com/ralllyco"
  >
    Help us grow our community
  </Card>
</CardGroup>

---

### Apps Docs Contribute Translations (`apps/docs/contribute/translations.mdx`)

---
title: Translations
icon: globe
description: Help translate Rallly into your language
---

Rallly is made available in different languages by volunteer translators.
Translations can be submitted through [Crowdin](https://crowdin.com/project/rallly).
Submitting translations is super easy and doesn't require any coding skills.

## Sign Up to Crowdin

The first thing you need to do is [create a Crowdin account](https://accounts.crowdin.com/register). Crowdin offers a user-friendly interface to submit translations.
You can then join the project using this link: https://crowdin.com/project/rallly

Once you've joined the project:

1. Pick the language you'd like to translate
2. Select a file (each file contains text for different parts of the site)
3. Start translating!

<Frame>
  <img src="/images/contribute/crowdin-project.png" alt="Crowdin Project" />
</Frame>

## Tips for Translators

Here are a few things to keep in mind when translating.

### Punctuation

Pay close attention to how phrases are punctuated.

```
Name:
```

If a sentence ends with a period (.) or a colon (:), you will want to include that in your translation.

```
❌ Nombre
✅ Nombre:
```

### Variables

Variables are placeholders for content that can change. They look like this:

```
Hi {name}, welcome back!
```

`{name}` is a variable that will be replaced with the user's name. Please make sure variables remain unchanged when translating.

```
❌ Salut {nom}, bienvenue!
✅ Salut {name}, bienvenue!
```

### Tags

Tags can be used to create a hyperlink or change the appearance of some text (ex. bold, italics etc…). They look like this:

```
Go to <a>page</a>
```

The tags should remain wrapped around the same content and should not be translated. The contents between the tags should be translated (unless they are a [variable](#variables)).

```
❌ Gehen Sie zur Seite
❌ <a>Gehen Sie zur Seite</a>
❌ Gehen Sie zur <a>page</a>
✅ Gehen Sie zur <a>Seite</a>
```

### Plurals

<Note>
  We use [ICU message format](https://crowdin.com/blog/2022/04/13/icu-guide) to
  format our plurals
</Note>

Plurals are like special rules that help make sure a message is written correctly when you're using different values.

For example, the following message will display **1 option** if count is `1` or **2 options** if count is anything else.

```
{count, plural, one {# option} other {# options}}
```

Note that `#` is a placeholder for the value of `count`.

If your language requires more than one plural form, you can add additional rules.

```
{count, plural, one {# опция} few {# опции} other {# опций}}
```

<Frame caption="Crowdin let's you preview your message to make sure it works">
  <img
    src="/images/contribute/icu-message-format.png"
    alt="Using ICU Message Format on Corwdin"
  />
</Frame>

### Register

For languages that have different registers (informal vs. formal), the general advise is to use an **informal** register. However, there may be exceptions for certain languages so if you are not sure please [reach out](#contact).

## FAQ

<AccordionGroup>
  <Accordion title="What if my language is already translated?">
    You should still join the Crowdin project. When new translations are added
    you will be notified to help translate them.
  </Accordion>
  <Accordion title="What if I don't see my language on Crowdin?">
    New languages need to be manually added by us. If you'd like us to add your
    language please [contact us](#contact).
  </Accordion>
  <Accordion title="How quickly will my translations get released?">
    Translations are manually approved before being released. This can take
    anywhere from a few hours to a few days.
  </Accordion>
</AccordionGroup>

## Contact

If you have an questions you can reach out using one of the following methods:

<CardGroup cols={3}>

<Card title="Email" icon="envelope" href="mailto:support@rallly.co" />
<Card title="Discord" icon="discord" href="https://discord.gg/uzg4ZcHbuM" />
<Card
  title="Support chat"
  icon="comments"
  href="https://go.crisp.chat/chat/embed/?website%5Fid=19d601db-af05-4f24-8342-8dd4449aedb8"
/>

</CardGroup>

---

### Apps Docs Guide Content Moderation (`apps/docs/guide/content-moderation.mdx`)

---
icon: shield-check
title: Content Moderation
description: Learn about what content is allowed when creating polls on Rallly
---

Rallly uses automated content moderation to keep the platform safe for all users. When you create or update a poll, the content is checked against our content policy.

Content that is not related to scheduling or that violates our policies will be flagged and the poll will not be created or updated.

## What happens if my content is flagged?

If your content is flagged, you will see an error message. You can edit your content and try again.

Repeated violations may result in your account being suspended.

## My content was flagged by mistake

If you believe your content was incorrectly flagged, please [contact support](/contact/support) and we'll review your case.

---

### Apps Docs Guide Participant Guide (`apps/docs/guide/participant-guide.mdx`)

---
icon: user
title: "Participant Guide"
description: "How to respond to a Rallly poll"
---

<Steps>
  <Step title="Select your preferred dates">
    Click the button under each date to toggle through the answers:

    - **Yes** - you're available
    - **If need be** - you can make it work if needed
    - **No** - you're not available

    Some polls only offer **Yes** and **No**.

    <Note>If you leave an option unselected, it will be counted as a **No** vote.</Note>

    <Frame>
      <img src="/images/guide/voting-in-progress.png" alt="Submit a Response"/>
    </Frame>

    When you're done, click **Continue**.

  </Step>
  <Step title="Enter your details">
    Enter your name and email address, then click **Save availability**.

    <Tip>To ensure you don't lose the ability to edit your response, enter your email address to receive a confirmation email with a magic link that identifies you .</Tip>

    <Frame>
      <img src="/images/guide/voting-submitting.png" alt="Submittting a Response"/>
    </Frame>

  </Step>
  <Step title="Done!">
    
    If you need to make changes, you can use the dropdown menu next to your name.

    <Frame>
      <img src="/images/guide/voting-submitted.png" alt="Vote Submitted"/>
    </Frame>

  </Step>
</Steps>

### Frequently Asked Questions

<AccordionGroup>
  <Accordion title="Why can't I edit my response?">
    Rallly uses cookies in your browser to recognize you as the creator of a
    response. If your cookies have been deleted or you are using a different
    device/browser, you may no longer be recognized as the user who created the response.

In this case, ask the organizer of the poll to send you an edit link for your
response. The organizer can copy it from the menu next to your name on the
poll page, and opening it restores your access from any device or browser.
Alternatively, the organizer can delete your response so that you can create a
new one, or edit it for you.

  </Accordion>
  <Accordion title="How can I avoid losing access to my own response?">
  If you enter your email address when you submit your response, you will
  receive a confirmation email with a link that allows you to edit your response from
  anywhere. If you did not enter an email address, or no longer have the email,
  the organizer can send you the same link.
  </Accordion>
</AccordionGroup>

---

### Apps Docs Integrations Google Meet (`apps/docs/integrations/google-meet.mdx`)

---
icon: video
title: Google Meet
description: Connect your Google account so Rallly creates the Google Meet link when you finalize a poll
---

With the Google Meet integration, Rallly creates a Google Meet link on your Google account at the moment you finalize a poll. The link goes into the event, the calendar invite and the confirmation emails, so participants never wait for a link to follow.

## How it works

1. You connect your Google account once, from your settings.
2. When you create a poll, you choose Google Meet as the video call. The poll shows "Google Meet" to participants while voting is open; there is no link yet.
3. When you finalize the poll, Rallly creates a Google Meet meeting space on your Google account and attaches its link to the event.

The meeting space belongs to the Google account you connected, so it follows that account's Meet settings, such as who can join without asking. Rallly does not change those settings.

## Requirements

- A Google account. A personal Google account or a Google Workspace account both work. On Google Workspace, your administrator must allow Google Meet and let you connect third party apps.
- Google Meet works on every plan; the link is created when the poll is finalized.

## Adding the app

<Steps>
  <Step title="Open Conferencing settings">
    In Rallly, open **Settings** from the sidebar and choose **Conferencing**.
  </Step>
  <Step title="Connect your account">
    Next to **Google Meet**, click **Connect**. You are sent to Google to choose an account and approve the connection.

<Frame>
  <img src="/images/integrations/google-meet/connect-account.png" alt="The Conferencing settings page listing Google Meet, Zoom, Microsoft Teams and Webex, each with a Connect button" />
</Frame>
  </Step>
  <Step title="Approve the permissions">
    Google shows what Rallly is asking for (listed under [Permissions](#permissions)). Make sure the Google Meet permission is selected, then click **Continue**. You are returned to Rallly, where the email address of your Google account appears next to Google Meet.

<Frame>
  <img src="/images/integrations/google-meet/connected-account.png" alt="The Conferencing settings page showing the connected account's email address next to Google Meet" />
</Frame>
  </Step>
</Steps>

You can connect one Google account for Google Meet. To switch to a different Google account, disconnect the current one, then connect the new one.

### If Google Calendar is already connected

Google Calendar and Google Meet are separate connections in Rallly, but they use the same Google sign in. If you connect Google Meet with the Google account you already connected for Google Calendar, Google asks you to approve the Meet permission, and the calendar access you approved earlier is kept. Rallly keeps a single set of tokens for that Google account, covering both the calendar access you approved earlier and the new Meet permission. Your calendar connection is not affected.

You can also connect Google Meet with a different Google account from the one you use for Google Calendar. Each Google account then has its own connection and its own tokens.

## Using the integration

### Choosing Google Meet for a poll

When creating a poll, click **Add video call** and choose **Google Meet**.

<Frame>
  <img src="/images/integrations/google-meet/video-call-menu.png" alt="The Add video call menu on the poll form, listing connected and available providers, then Custom" />
</Frame>

The form shows "Google Meet" as the video call for this poll.

<Frame>
  <img src="/images/integrations/google-meet/selected.png" alt="The poll form with Google Meet chosen as the video call" />
</Frame>

If your Google Meet account is not connected, the form tells you so and links to Conferencing settings.

<Frame>
  <img src="/images/integrations/google-meet/not-connected.png" alt="The poll form showing the message that Google Meet is not connected, with a link to Conferencing settings" />
</Frame>

Participants see "Google Meet" on the poll page while voting is open. No meeting link exists yet.

### Finalizing the poll

When you finalize the poll and pick the winning time, Rallly:

1. Creates a Google Meet meeting space on your Google account.
2. Attaches the meeting link to the event.
3. Includes the link in the calendar invite (`.ics`) and in the confirmation emails sent to you and to participants.

A meeting space is a reusable Meet room with its own link. It is not a Google Calendar event, so Rallly does not add anything to your Google Calendar through this integration, and the link is not tied to the scheduled time.

If the link cannot be created, for example because the Google connection has expired or was removed, the poll stays open and Rallly shows an error. Reconnect your account in Conferencing settings and finalize again.

### Which account hosts the meeting

The meeting space is created on the Google account of the poll's organizer. If a space member finalizes a poll organized by someone else, the meeting is still created on the organizer's Google account, and the organizer is the host.

## Permissions

When you connect, Rallly asks Google for these permissions:

| Permission | Why |
| --- | --- |
| Create, edit and see information about the Google Meet conferences Rallly creates (`meetings.space.created`) | To create the meeting space when you finalize a poll. |
| See your primary Google Account email address (`userinfo.email`) | To identify the connection and show which account is connected. |
| See your personal info (`userinfo.profile`) | Requested with the email address to identify the account. Rallly does not store your name or profile picture. |

The `meetings.space.created` permission only lets Rallly create meeting spaces and manage the spaces it created itself. Rallly does not read your other meetings, recordings, transcripts, participants, contacts or email, and does not join or record meetings.

## Data Rallly stores

For a connected Google account, Rallly stores:

- Your Google account id and email address, to identify the connection and show it in your settings.
- The access token and refresh token Google issues, encrypted at rest, so Rallly can create meeting spaces on your behalf without asking you to sign in each time. If the same Google account is also connected for Google Calendar, both connections share these tokens.

For each finalized poll, Rallly stores the link and meeting code of the meeting space it created, as part of the event.

Rallly does not store the content of any meeting.

## Removing the app

### From Rallly

1. Open **Settings**, then **Conferencing**.
2. Next to **Google Meet**, click the **More options** button and choose **Disconnect**.

<Frame>
  <img src="/images/integrations/google-meet/disconnect-menu.png" alt="The connected Google Meet row with its menu open, showing Disconnect" />
</Frame>

The connection is removed immediately and Rallly stops creating Google Meet links for you. What happens to the tokens depends on whether the same Google account is connected for Google Calendar:

- If it is not, Rallly deletes the tokens it stored for that Google account.
- If it is, Rallly keeps the tokens, because your calendar connection still needs them.

Disconnecting in Rallly does not revoke the access you granted in your Google account, because the same Google access can also serve your calendar connection. To revoke it, remove Rallly from your Google account as described below.

Polls that already chose Google Meet keep that choice but can no longer be finalized with a Google Meet link until an account is connected again. Links Rallly already created keep working.

### From your Google account

To revoke Rallly's access on the Google side:

1. Open the [third party apps and services](https://myaccount.google.com/connections) page of your Google account.
2. Select **Rallly**.
3. Click **Delete all connections you have with Rallly** and confirm.

This revokes every permission Rallly holds on that Google account, including Google Calendar access if you connected it. Google does not tell Rallly, so the connections still appear in your settings until you disconnect them in Rallly as well; Rallly can no longer use them. If you sign in to Rallly with Google, you can still do so; Google asks you to approve Rallly again the next time.

## Troubleshooting

**"Google Meet is not connected" when creating a poll.** Your Rallly account has no Google Meet connection, or it was removed. Connect one in Conferencing settings and choose Google Meet again.

**Finalizing fails with a video call error.** The Google connection has expired, Rallly's access was removed from your Google account, or the Google Meet permission was not selected when you connected. Disconnect and reconnect Google Meet in Conferencing settings, then finalize again. The poll is unchanged until finalizing succeeds.

**Google blocks the connection.** On a Google Workspace account, your administrator may not allow third party apps or may have turned off Google Meet for your account. Ask your administrator to allow Rallly, or connect a different Google account.

**Participants have to ask to join.** Who can join without asking follows the Meet settings of the host's Google account or Workspace organization. The host can admit people from the meeting, or change the meeting access in Meet's host controls.

**The meeting was created on the wrong account.** The meeting is always created on the poll organizer's connected Google account. Check which account is connected in Conferencing settings.

Still stuck? [Contact support](/contact/support).

---

### Apps Docs Integrations Microsoft Teams (`apps/docs/integrations/microsoft-teams.mdx`)

---
icon: video
title: Microsoft Teams
description: Connect your Microsoft work or school account so Rallly creates the Teams meeting link when you finalize a poll
---

With the Microsoft Teams integration, Rallly creates a Teams meeting on your Microsoft account at the moment you finalize a poll. The join link goes into the event, the calendar invite and the confirmation emails, so participants never wait for a link to follow.

<Callout icon="gem" color="#4f46e5">
  Microsoft Teams requires a [Pro subscription](https://rallly.co/pricing).
</Callout>

## How it works

1. You connect your Microsoft work or school account once, from your settings.
2. When you create a poll, you choose Microsoft Teams as the video call. The poll shows "Microsoft Teams" to participants while voting is open; there is no link yet.
3. When you finalize the poll, Rallly creates a Teams meeting for the chosen time on your Microsoft account and attaches the join link to the event.

The meeting is created on the Microsoft account you connected, so it follows your organization's Teams meeting policies, such as the lobby and who can present. Rallly does not change those settings.

## Requirements

- A Microsoft work or school account with a Microsoft Teams license. Personal Microsoft accounts (Outlook.com, Hotmail, Live) are not supported: Microsoft does not let apps create Teams meetings for them, so Rallly asks Microsoft for a work or school account when you connect.
- You must have used Teams at least once with that account. An account whose Microsoft 365 plan does not include Teams cannot create meetings.
- Your organization must let you approve Rallly, or an administrator must approve it for the organization. See [Admin approval](#admin-approval).

## Adding the app

<Steps>
  <Step title="Open Conferencing settings">
    In Rallly, open **Settings** from the sidebar and choose **Conferencing**.
  </Step>
  <Step title="Connect your account">
    Next to **Microsoft Teams**, click **Connect**. You are sent to Microsoft to pick your work or school account and sign in.

<Frame>
  <img src="/images/integrations/microsoft-teams/connect-account.png" alt="The Conferencing settings page listing Google Meet, Zoom, Microsoft Teams and Webex, each with a Connect button" />
</Frame>
  </Step>
  <Step title="Approve the permissions">
    Microsoft shows what Rallly is asking for (listed under [Permissions](#permissions)). Click **Accept**. You are returned to Rallly, where the email address of your Microsoft account appears next to Microsoft Teams.

<Frame>
  <img src="/images/integrations/microsoft-teams/connected-account.png" alt="The Conferencing settings page showing the connected account's email address next to Microsoft Teams" />
</Frame>
  </Step>
</Steps>

When you connect, Rallly checks that the account can create Teams meetings by creating a test meeting and deleting it straight away. The test meeting does not appear in your calendar or in Teams. If Microsoft refuses it, Rallly does not connect the account and tells you why; see [Troubleshooting](#troubleshooting).

You can connect one Microsoft account for Microsoft Teams. To switch to a different account, disconnect the current one, then connect the new one.

Connecting Microsoft Teams is separate from signing in to Rallly with Microsoft. You can sign in with any method and still connect Teams, and connecting Teams does not change how you sign in.

### Admin approval

Many organizations do not let their users approve apps themselves. If yours is one of them, Microsoft shows "Need admin approval" instead of the permissions, and Rallly shows an error when you return. Only an administrator of your organization can resolve this.

When the connection fails, Rallly offers a **Copy link** button. Send that link to your IT administrator. The administrator opens it, signs in with their administrator account and approves Rallly for the whole organization; they do not need a Rallly account. The link is:

```
https://app.rallly.co/api/integrations/admin-consent/microsoft-teams?redirect=/settings/conferencing
```

After the administrator approves, connect Microsoft Teams again. Microsoft no longer asks for approval. Administrators who prefer the Microsoft Entra admin center can grant the same approval under **Enterprise apps**, after searching for Rallly and opening **Permissions**.

## Using the integration

### Choosing Microsoft Teams for a poll

When creating a poll, click **Add video call** and choose **Microsoft Teams**. The form shows "Microsoft Teams" as the video call for this poll.

<Frame>
  <img src="/images/integrations/microsoft-teams/video-call-menu.png" alt="The Add video call menu on the poll form, listing connected and available providers, then Custom" />
</Frame>

<Frame>
  <img src="/images/integrations/microsoft-teams/selected.png" alt="The poll form with Microsoft Teams chosen as the video call" />
</Frame>

If your Microsoft Teams account is not connected, the form tells you so and links to Conferencing settings.

Participants see "Microsoft Teams" on the poll page while voting is open. No meeting link exists yet.

### Finalizing the poll

When you finalize the poll and pick the winning time, Rallly:

1. Creates a Teams meeting for that time on your Microsoft account.
2. Attaches the join link, meeting ID and passcode to the event.
3. Includes them in the calendar invite (`.ics`) and in the confirmation emails sent to you and to participants.

Rallly does not add the meeting to your Outlook calendar; the calendar invite Rallly sends covers it.

If the meeting cannot be created, for example because the Microsoft connection has expired or your account no longer has a Teams license, the poll stays open and Rallly shows an error that says which it is. See [Troubleshooting](#troubleshooting).

### Which account hosts the meeting

The meeting is created on the Microsoft account of the poll's organizer. If a space member finalizes a poll organized by someone else, the meeting is still created on the organizer's Microsoft account, and the organizer is the host.

## Permissions

When you connect, Rallly asks Microsoft for these permissions:

| Permission | Why |
| --- | --- |
| Read and create your online meetings (`OnlineMeetings.ReadWrite`) | To create the Teams meeting when you finalize a poll, and to check once, when you connect, that your account can create meetings. |
| Sign you in and read your profile (`User.Read`) | To identify the connection and show which account is connected. |
| Maintain access to data you have given it access to (`offline_access`) | So Rallly can create meetings when you finalize a poll without asking you to sign in each time. |
| View your basic profile and email address (`openid`, `profile`, `email`) | Requested with the sign in to identify the account. Rallly does not store your name. |

Rallly uses `OnlineMeetings.ReadWrite` only to create a meeting when you finalize a poll and to create and immediately delete one test meeting when you connect. Rallly does not read your other meetings, chats, recordings, transcripts, files, contacts, calendar or email, and does not join or record meetings.

## Data Rallly stores

For a connected Microsoft account, Rallly stores:

- Your Microsoft account id and email address, to identify the connection and show it in your settings.
- The access token and refresh token Microsoft issues, encrypted at rest, so Rallly can create meetings on your behalf without asking you to sign in each time.

For each finalized poll, Rallly stores the join link, meeting ID and passcode of the meeting it created, as part of the event.

Rallly does not store the content of any meeting.

## Removing the app

### From Rallly

1. Open **Settings**, then **Conferencing**.
2. Next to **Microsoft Teams**, click the **More options** button and choose **Disconnect**.

The connection is removed immediately, Rallly deletes the tokens it stored for that Microsoft account and stops creating Teams meetings for you.

Microsoft does not let apps revoke their own access, so disconnecting in Rallly does not remove Rallly from your Microsoft account. Rallly no longer holds tokens to use it, but to remove the permission itself, follow the steps below.

Polls that already chose Microsoft Teams keep that choice but can no longer be finalized with a Teams link until an account is connected again. Meetings Rallly already created keep working.

### From your Microsoft account

1. Open [My Apps](https://myapps.microsoft.com) and sign in with the account you connected.
2. Find **Rallly**, open its menu and choose **Manage your application**.
3. Choose **Revoke permissions** and confirm.

If you do not see the option, your organization manages app permissions centrally; ask your administrator to remove Rallly. Microsoft does not tell Rallly, so the connection still appears in your settings until you disconnect it in Rallly as well; Rallly can no longer use it.

## Troubleshooting

**Microsoft says "Need admin approval".** Your organization does not let users approve apps. Send your administrator the link described under [Admin approval](#admin-approval).

**Microsoft does not accept your personal account.** Microsoft Teams meetings can only be created for work or school accounts. Connect the account your organization gave you.

**Rallly says the account can't create Teams meetings.** Microsoft refused the test meeting Rallly creates when you connect. The account has no Teams license, Teams has never been opened with it, or your organization's policies do not let it create meetings. Open [Teams](https://teams.microsoft.com) with that account: if Teams does not open, ask your IT administrator for a license that includes Teams. Once Teams opens, connect the account in Rallly again.

**"Microsoft Teams is not connected" when creating a poll.** Your Rallly account has no Microsoft Teams connection, or it was removed. Connect one in Conferencing settings and choose Microsoft Teams again.

**Finalizing fails because Microsoft Teams couldn't create the meeting.** The account could create meetings when you connected it and no longer can, usually because its Teams license was removed. Check that you can open Teams with that account, then finalize again. The poll is unchanged until finalizing succeeds.

**Finalizing fails because the account is not connected.** The connection expired after 90 days without use, your password was changed, or your administrator removed Rallly. Disconnect and reconnect Microsoft Teams in Conferencing settings and finalize again.

**Participants wait in the lobby.** Who waits in the lobby follows your organization's Teams meeting policies. The host can admit people from the meeting, or change the meeting options in Teams.

**The meeting was created on the wrong account.** The meeting is always created on the poll organizer's connected Microsoft account. Check which account is connected in Conferencing settings.

Still stuck? [Contact support](/contact/support).

---

### Apps Docs Integrations Webex (`apps/docs/integrations/webex.mdx`)

---
icon: video
title: Webex
description: Connect your Webex account so Rallly creates the Webex meeting link when you finalize a poll
---

With the Webex integration, Rallly creates a Webex meeting on your account at the moment you finalize a poll. The join link goes into the event, the calendar invite and the confirmation emails, so participants never wait for a link to follow.

<Callout icon="gem" color="#4f46e5">
  Webex requires a [Pro subscription](https://rallly.co/pricing).
</Callout>

## How it works

1. You connect your Webex account once, from your settings.
2. When you create a poll, you choose Webex as the video call. The poll shows "Webex" to participants while voting is open; there is no link yet.
3. When you finalize the poll, Rallly creates a Webex meeting for the chosen time on your Webex account and attaches the join link to the event.

The meeting is created on the Webex account you connected, so it follows that account's and your organization's Webex settings, such as the lobby and the meeting password. Rallly does not change those settings.

## Requirements

- A Webex account that can host meetings.
- Your organization must allow Webex integrations. Integrations are allowed by default; an administrator can block them in Control Hub. See [Organization restrictions](#organization-restrictions).

## Adding the app

<Steps>
  <Step title="Open Conferencing settings">
    In Rallly, open **Settings** from the sidebar and choose **Conferencing**.
  </Step>
  <Step title="Connect your account">
    Next to **Webex**, click **Connect**. You are sent to Webex to sign in.

<Frame>
  <img src="/images/integrations/webex/connect-account.png" alt="The Conferencing settings page listing Google Meet, Zoom, Microsoft Teams and Webex, each with a Connect button" />
</Frame>
  </Step>
  <Step title="Approve the permissions">
    Webex shows what Rallly is asking for (listed under [Permissions](#permissions)). Click **Accept**. You are returned to Rallly, where the email address of your Webex account appears next to Webex.

<Frame>
  <img src="/images/integrations/webex/connected-account.png" alt="The Conferencing settings page showing the connected account's email address next to Webex" />
</Frame>
  </Step>
</Steps>

You can connect one Webex account. To switch to a different account, disconnect the current one, then connect the new one.

### Organization restrictions

A Webex administrator can block integrations for everyone in the organization, or allow only some of them, under **Management** > **Apps** > **Integrations** in Control Hub. If yours does, Webex refuses the connection and Rallly shows an error when you return. Only an administrator of your organization can allow Rallly.

## Using the integration

### Choosing Webex for a poll

When creating a poll, click **Add video call** and choose **Webex**. The form shows "Webex" as the video call for this poll.

<Frame>
  <img src="/images/integrations/webex/video-call-menu.png" alt="The Add video call menu on the poll form, listing connected and available providers, then Custom" />
</Frame>

<Frame>
  <img src="/images/integrations/webex/selected.png" alt="The poll form with Webex chosen as the video call" />
</Frame>

If your Webex account is not connected, the form tells you so and links to Conferencing settings.

Participants see "Webex" on the poll page while voting is open. No meeting link exists yet.

### Finalizing the poll

When you finalize the poll and pick the winning time, Rallly:

1. Creates a Webex meeting for that time on your Webex account.
2. Attaches the join link, meeting number and password to the event.
3. Includes them in the calendar invite (`.ics`) and in the confirmation emails sent to you and to participants.

Rallly asks Webex not to send its own invitation emails; the invite Rallly sends covers it.

Webex only schedules meetings between 10 minutes and 23 hours 59 minutes long that have not started yet. Rallly fits the Webex meeting to those limits: an all day event gets a meeting of 23 hours 59 minutes, an event shorter than 10 minutes gets a 10 minute meeting, and an event that has already started gets a meeting that starts at the next minute. The event itself keeps the time you picked.

If the meeting cannot be created, for example because the Webex connection has expired, the poll stays open and Rallly shows an error. See [Troubleshooting](#troubleshooting).

### Which account hosts the meeting

The meeting is created on the Webex account of the poll's organizer. If a space member finalizes a poll organized by someone else, the meeting is still created on the organizer's Webex account, and the organizer is the host.

## Permissions

When you connect, Rallly asks Webex for these permissions:

| Permission | Why |
| --- | --- |
| Create, manage, or cancel your scheduled Webex meetings (`meeting:schedules_write`) | To create the meeting when you finalize a poll. |
| Read your users' company directory (`spark:people_read`) | To read your Webex account id, email address and name, which identify the connection and show which account is connected. |

Rallly uses `meeting:schedules_write` only to create a meeting when you finalize a poll, and `spark:people_read` only to read your own profile. Rallly does not read your existing meetings, messages, recordings, transcripts or contacts, and does not join or record meetings.

## Data Rallly stores

For a connected Webex account, Rallly stores:

- Your Webex account id and email address, to identify the connection and show it in your settings.
- The access token and refresh token Webex issues, encrypted at rest, so Rallly can create meetings on your behalf without asking you to sign in each time.

For each finalized poll, Rallly stores the join link, meeting number and password of the meeting it created, as part of the event.

Rallly does not store the content of any meeting.

## Removing the app

1. Open **Settings**, then **Conferencing**.
2. Next to **Webex**, click the **More options** button and choose **Disconnect**.

The connection is removed immediately, Rallly deletes the tokens it stored for that Webex account and stops creating Webex meetings for you. Polls that already chose Webex keep that choice but can no longer be finalized with a Webex link until an account is connected again. Meetings Rallly already created stay in your Webex account; delete them from Webex if you no longer need them.

## Troubleshooting

**Webex refuses the connection.** Your organization blocks Webex integrations. Ask your Webex administrator to allow Rallly; see [Organization restrictions](#organization-restrictions).

**"Webex is not connected" when creating a poll.** Your Rallly account has no Webex connection, or it was removed. Connect one in Conferencing settings and choose Webex again.

**Finalizing fails because the account is not connected.** The connection expired after a long period without use, or your administrator removed Rallly. Disconnect and reconnect Webex in Conferencing settings and finalize again.

**Finalizing fails because the meeting link couldn't be created.** The connected account may not be able to host meetings, for example a Webex account that only has messaging. Check that you can schedule a meeting in Webex with that account, then finalize again. The poll is unchanged until finalizing succeeds.

**The meeting was created on the wrong account.** The meeting is always created on the poll organizer's connected Webex account. Check which account is connected in Conferencing settings.

Still stuck? [Contact support](/contact/support).

---

### Apps Docs Integrations Zoom (`apps/docs/integrations/zoom.mdx`)

---
icon: video
title: Zoom
description: Connect your Zoom account so Rallly creates the meeting link when you finalize a poll
---

With the Zoom integration, Rallly creates a Zoom meeting on your account at the moment you finalize a poll. The join link goes into the event, the calendar invite and the confirmation emails, so participants never wait for a link to follow.

<Callout icon="gem" color="#4f46e5">
  Zoom requires a [Pro subscription](https://rallly.co/pricing).
</Callout>

## How it works

1. You connect your Zoom account once, from your settings.
2. When you create a poll, you choose Zoom as the video call. The poll shows "Zoom" to participants while voting is open; there is no link yet.
3. When you finalize the poll, Rallly creates a Zoom meeting for the chosen time on your Zoom account and attaches the join link to the event.

The meeting is created on the Zoom account you connected, so it follows that account's settings, such as waiting rooms, passcodes and recording defaults. Rallly does not change those settings.

## Requirements

- A Zoom account. A free Zoom plan is enough.

## Adding the app

<Steps>
  <Step title="Open Conferencing settings">
    In Rallly, open **Settings** from the sidebar and choose **Conferencing**.
  </Step>
  <Step title="Connect your account">
    Next to **Zoom**, click **Connect**. You are sent to Zoom to sign in and approve the connection.

<Frame>
  <img src="/images/integrations/zoom/connect-account.png" alt="The Conferencing settings page listing Google Meet, Zoom, Microsoft Teams and Webex, each with a Connect button" />
</Frame>
  </Step>
  <Step title="Approve the permissions">
    Zoom shows what Rallly is asking for (listed under [Permissions](#permissions)). Click **Allow**. You are returned to Rallly, where the email address of your Zoom account appears next to Zoom.

<Frame>
  <img src="/images/integrations/zoom/connected-account.png" alt="The Conferencing settings page showing the connected account's email address next to Zoom" />
</Frame>
  </Step>
</Steps>

You can connect one Zoom account per Rallly account. To switch to a different Zoom account, disconnect the current one, then connect the new one.

## Using the integration

### Choosing Zoom for a poll

When creating a poll, click **Add video call** and choose **Zoom**.

<Frame>
  <img src="/images/integrations/zoom/video-call-menu.png" alt="The Add video call menu on the poll form, listing connected and available providers, then Custom" />
</Frame>

The form shows "Zoom" as the video call for this poll.

<Frame>
  <img src="/images/integrations/zoom/selected.png" alt="The poll form with Zoom chosen as the video call" />
</Frame>

If your Zoom account is not connected, the form tells you so and links to Conferencing settings.

<Frame>
  <img src="/images/integrations/zoom/not-connected.png" alt="The poll form showing the message that Zoom is not connected, with a link to Conferencing settings" />
</Frame>

Participants see "Zoom" on the poll page while voting is open. No meeting exists yet.

<Frame>
  <img src="/images/integrations/zoom/poll-page.png" alt="The poll page showing Zoom under the organizer on the event card" />
</Frame>

### Finalizing the poll

When you finalize the poll and pick the winning time, Rallly:

1. Creates a scheduled Zoom meeting on your account for that date and time, using the poll title as the meeting topic.
2. Attaches the join link to the event.
3. Includes the link in the calendar invite (`.ics`) and in the confirmation emails sent to you and to participants.

The meeting also appears in your Zoom account under **Meetings**, where you can edit or delete it like any other meeting.

If the meeting cannot be created, for example because the Zoom connection has expired, the poll stays open and Rallly shows an error. Reconnect your account in Conferencing settings and finalize again.

### Which account hosts the meeting

The meeting is created on the Zoom account of the poll's organizer. If a space member finalizes a poll organized by someone else, the meeting is still created on the organizer's Zoom account.

## Permissions

When you connect, Rallly asks Zoom for these permissions:

| Permission | Why |
| --- | --- |
| View your user information (`user:read:user`) | To read your Zoom account id and email, which identify the connection and show which account is connected. |
| Create meetings (`meeting:write:meeting`) | To create the meeting when you finalize a poll. |

Rallly does not read your existing meetings, recordings, contacts or chat, and does not join or record meetings.

## Data Rallly stores

For a connected Zoom account, Rallly stores:

- Your Zoom account id and email address, to identify the connection and show it in your settings.
- The access token and refresh token Zoom issues, encrypted at rest, so Rallly can create meetings on your behalf without asking you to sign in each time.

For each finalized poll, Rallly stores the meeting id, join link and passcode of the meeting it created, as part of the event.

Rallly does not store the content of any meeting.

## Removing the app

### From Rallly

1. Open **Settings**, then **Conferencing**.
2. Next to **Zoom**, click the **More options** button and choose **Disconnect**.

<Frame>
  <img src="/images/integrations/zoom/disconnect-menu.png" alt="The connected Zoom row with its menu open, showing Disconnect" />
</Frame>

The connection is removed immediately: Rallly deletes the account details and tokens it stored, then asks Zoom to revoke its access. To confirm the access is gone, remove the app from Zoom as described below. Polls that already chose Zoom keep that choice but can no longer be finalized with a Zoom link until an account is connected again. Meetings Rallly already created stay in your Zoom account; delete them from Zoom if you no longer need them.

### From Zoom

You can also remove the app from the Zoom side:

1. Sign in to the [Zoom App Marketplace](https://marketplace.zoom.us/).
2. Click **Manage**, then **Added Apps**.
3. Find **Rallly** and click **Remove**.

Removing the app from Zoom revokes Rallly's access. Zoom tells Rallly, which deletes the connection and the tokens it stored, so the account no longer appears in your settings. If the account still appears in your settings, disconnect it in Rallly as well.

## Troubleshooting

**"Zoom is not connected" when creating a poll.** Your Rallly account has no Zoom connection, or it was removed. Connect one in Conferencing settings and choose Zoom again.

**Finalizing fails with a video call error.** The Zoom connection has expired or was revoked on the Zoom side. Disconnect and reconnect the account, then finalize again. The poll is unchanged until finalizing succeeds.

**The meeting was created on the wrong account.** The meeting is always created on the poll organizer's connected Zoom account. Check which account is connected in Conferencing settings.

Still stuck? [Contact support](/contact/support).

---

### Apps Docs Self Hosting Conferencing (`apps/docs/self-hosting/conferencing.mdx`)

---
icon: video
title: Video Conferencing
description: Let organizers add Google Meet, Zoom, Microsoft Teams and Webex links to their polls.
---

<Note>Available from v4.16.0 and later.</Note>

With conferencing enabled, organizers connect a Google Meet, Zoom, Microsoft Teams or Webex account in **Settings** > **Conferencing**. When they finalize a poll, Rallly creates the meeting on that account and puts the join link in the event, the calendar invite and the confirmation emails. See the [integration guides](/integrations/google-meet) for what organizers see.

Each provider needs an OAuth app that you register with the provider yourself. Rallly cannot ship one for you, because the redirect URI of an OAuth app is tied to your instance's domain. You turn on each provider separately; on a self-hosted instance every provider is available on every plan.

## Enable conferencing

<Steps>
  <Step title="Register an OAuth app">
    Follow the setup for each provider you want below. Google Meet and Microsoft Teams reuse the app you may already have for [Single Sign-On](/self-hosting/single-sign-on).
  </Step>
  <Step title="Set the variables">
    Add the provider's variables to your `.env`, and list it in `CONFERENCING_PROVIDERS`, for example `CONFERENCING_PROVIDERS=google-meet,zoom`.
  </Step>
  <Step title="Restart">
    Run `./rallly.sh restart`. **Conferencing** appears in **Settings** for every user.
  </Step>
</Steps>

<ParamField path="CONFERENCING_PROVIDERS">
  A comma separated list of the providers organizers can use: `google-meet`, `zoom`, `microsoft-teams`, `webex`. A listed provider is offered once its OAuth app variables are set; Google and Microsoft credentials used for sign in never turn on Meet or Teams by themselves. An unknown value stops Rallly from starting. A listed provider without credentials is not offered, Rallly logs a warning at startup, and the Conferencing page says no provider is set up. Organizers can always paste their own video call link, whatever this lists.
</ParamField>

Every redirect URI below starts with your instance's public address, written as `https://<YOUR_DOMAIN>`. It must match exactly, including the scheme and without a trailing slash.

## Google Meet

Google Meet uses the same Google OAuth app as Google sign in and Google Calendar. Setting the Google variables for sign in does not turn on Google Meet: finish the steps below, then add `google-meet` to `CONFERENCING_PROVIDERS`.

<Accordion title="Setup">
1. In the [Google Cloud console](https://console.cloud.google.com/), open the project that holds your OAuth client, or create one.

2. Under **APIs & Services** > **Library**, enable the **Google Meet REST API**.

3. Under **Google Auth Platform** > **Data Access**, add the scope `https://www.googleapis.com/auth/meetings.space.created`. Rallly also asks for `userinfo.email` and `userinfo.profile`.

4. Under **Google Auth Platform** > **Audience**, choose who can connect:
   - **Internal** if everyone using your instance is in your Google Workspace organization. Google does not review internal apps and shows no warning.
   - **External** otherwise. `meetings.space.created` is a sensitive scope, so until Google verifies your app, users see an "unverified app" warning and the app is limited to 100 users. While the app is in **Testing**, only the test users you list can connect.

5. Under **Google Auth Platform** > **Clients**, open your Web application client (or create one) and add this to **Authorized redirect URIs**:

```
https://<YOUR_DOMAIN>/api/integrations/callback/google-meet
```

Keep the existing `/api/auth/callback/google` URI if you use Google sign in. To use the client for Google Meet only, set `GOOGLE_LOGIN_ENABLED=false` so the Google sign in button is hidden.
</Accordion>

<ParamField path="GOOGLE_CLIENT_ID">
  The client ID of your Google OAuth app. Shared with Google sign in and Google Calendar.
</ParamField>

<ParamField path="GOOGLE_CLIENT_SECRET">
  The client secret of your Google OAuth app.
</ParamField>

## Zoom

<Accordion title="Setup">
1. Sign in to the [Zoom App Marketplace](https://marketplace.zoom.us/) with an account on your organization's Zoom account and choose **Develop** > **Build App**. Create a **General App** that is **User managed**. Building the app here does not list it on the Marketplace.

2. Under **Basic Information**, set the **OAuth Redirect URL** and add the same address to the **OAuth Allow List**:

```
https://<YOUR_DOMAIN>/api/integrations/callback/zoom
```

3. Under **Scopes**, add `meeting:write:meeting` and `user:read:user`.

4. Copy the **Client ID** and **Client Secret** from **Basic Information**.

You do not need to publish the app. An unpublished app stays private to the Zoom account it was built on, and every user on that account can connect it, which covers an organization on a single Zoom account. Organizers on other Zoom accounts cannot connect it unless the app is published, which requires Zoom's review.
</Accordion>

<Accordion title="Deauthorization notices (optional)">
When someone removes the app from their Zoom account, Zoom can tell Rallly so the connection disappears from their settings. Zoom only sends this notice for apps published on the Marketplace. With an unpublished app, someone who removes it in Zoom also needs to disconnect Zoom in **Settings** > **Conferencing**; until then the connection stays listed but no longer works.

1. Under **Features** > **Access**, turn on **Event Subscription** and add the endpoint:

```
https://<YOUR_DOMAIN>/api/integrations/zoom/deauthorize
```

2. Subscribe to the **App Deauthorized** event.

3. Copy the **Secret Token** into `ZOOM_WEBHOOK_SECRET_TOKEN` and restart before Zoom validates the endpoint.
</Accordion>

<ParamField path="ZOOM_CLIENT_ID">
  The client ID of your Zoom app. Set together with `ZOOM_CLIENT_SECRET`, or Rallly refuses to start.
</ParamField>

<ParamField path="ZOOM_CLIENT_SECRET">
  The client secret of your Zoom app.
</ParamField>

<ParamField path="ZOOM_WEBHOOK_SECRET_TOKEN">
  The secret token of the app's event subscription. Only needed for deauthorization notices.
</ParamField>

## Microsoft Teams

Microsoft Teams uses the same Microsoft Entra ID registration as Microsoft sign in, with an extra redirect URI and Graph permissions. Setting the Microsoft variables for sign in does not turn on Teams: finish the steps below, then add `microsoft-teams` to `CONFERENCING_PROVIDERS`. To use the registration for Teams only, set `MICROSOFT_LOGIN_ENABLED=false` so the Microsoft sign in button is hidden.

<Accordion title="Setup">
1. Register an application in the [Microsoft Entra admin center](https://entra.microsoft.com/), or open your existing one. See [Single Sign-On](/self-hosting/single-sign-on#microsoft) for sign in.

2. Under **Authentication**, add a **Web** redirect URI:

```
https://<YOUR_DOMAIN>/api/integrations/callback/microsoft-teams
```

3. Under **API permissions**, add the delegated Microsoft Graph permissions `OnlineMeetings.ReadWrite`, `User.Read` and `offline_access`. If your organization does not let users consent to apps, choose **Grant admin consent**.

Teams meetings can only be created for work or school accounts with a Teams license. With `MICROSOFT_TENANT_ID` set to `common` or `consumers`, Teams connects through the `organizations` endpoint; with a tenant ID it connects through that tenant.

Administrators of other organizations can approve the app for their whole organization by opening `https://<YOUR_DOMAIN>/api/integrations/admin-consent/microsoft-teams`. See [Admin approval](/integrations/microsoft-teams#admin-approval).
</Accordion>

<ParamField path="MICROSOFT_CLIENT_ID">
  The client ID of your Microsoft registration. Shared with Microsoft sign in.
</ParamField>

<ParamField path="MICROSOFT_CLIENT_SECRET">
  The client secret of your Microsoft registration.
</ParamField>

<ParamField path="MICROSOFT_TENANT_ID" default="common">
  The tenant of your Microsoft registration.
</ParamField>

## Webex

<Accordion title="Setup">
1. Sign in to the [Webex Developer Portal](https://developer.webex.com/) and choose **My Webex Apps** > **Create a New App** > **Integration**.

2. Set the **Redirect URI**:

```
https://<YOUR_DOMAIN>/api/integrations/callback/webex
```

3. Select the scopes `meeting:schedules_write` and `spark:people_read`.

4. Save, then copy the **Client ID** and **Client Secret**. Webex shows the secret only once.
</Accordion>

<ParamField path="WEBEX_CLIENT_ID">
  The client ID of your Webex integration. Set together with `WEBEX_CLIENT_SECRET`, or Rallly refuses to start.
</ParamField>

<ParamField path="WEBEX_CLIENT_SECRET">
  The client secret of your Webex integration.
</ParamField>

## Troubleshooting

<Accordion title="Rallly does not start after enabling conferencing">
`CONFERENCING_PROVIDERS` has a value that is not a provider, or only one of `ZOOM_CLIENT_ID` and `ZOOM_CLIENT_SECRET` (or the Webex pair) is set. Run `./rallly.sh logs web` to see which.
</Accordion>

<Accordion title="Conferencing does not appear in Settings">
`CONFERENCING_PROVIDERS` is unset or empty. Restart after editing `.env`.
</Accordion>

<Accordion title="Conferencing says no video call providers are set up, or a provider is missing">
A provider in `CONFERENCING_PROVIDERS` has no credentials. The startup warning in `./rallly.sh logs web` names it; add its variables as described above and restart.
</Accordion>

<Accordion title="The provider says the redirect URI does not match">
The URI registered with the provider differs from the one Rallly sends (Google reports this as `redirect_uri_mismatch`). Compare it character by character with the URI above, including `https`, your exact domain and the provider name at the end.
</Accordion>

<Accordion title='Google shows an "unverified app" warning'>
Your consent screen is External and Google has not verified the `meetings.space.created` scope. Users can continue past the warning, or you can make the app Internal or submit it for verification.
</Accordion>

<Accordion title="Connecting works but finalizing fails">
The provider refused to create the meeting. For Google Meet, check that the Google Meet REST API is enabled. For Teams, the account needs a Teams license. Disconnecting and reconnecting the account in Conferencing settings refreshes its tokens.
</Accordion>

---

### Apps Docs Self Hosting Configuration (`apps/docs/self-hosting/configuration.mdx`)

---
icon: gear
title: Configuration Options
description: Environment variables for a self-hosted Rallly instance.
---

All configuration lives in the `.env` file at the root of your self-hosted stack. The [installer](/self-hosting/installation/docker) writes a starter `.env` for you; edit it directly to change any value. After edits, apply them with `./rallly.sh restart`.

<Note>
  When using the Rallly CLI, internal wiring (database URL, S3 endpoint, `NEXT_PUBLIC_BASE_URL`) is handled by the Compose stack and derived from the values below — you do not need to set them yourself. If you're running the Rallly Docker image without the CLI, see [Running the image directly](#running-the-image-directly).
</Note>

## General

<ParamField path="DOMAIN" required>
  The domain where your instance is served (e.g. `rallly.example.com`). Traefik uses this to request a Let's Encrypt certificate and to route traffic to the app.
</ParamField>

<ParamField path="ACME_EMAIL" required>
  Email address for Let's Encrypt certificate notifications (expiry warnings, etc.).
</ParamField>

<ParamField path="SECRET_PASSWORD" required>
  A random secret key used to encrypt user sessions. Must be at least 32 characters. Generate one with `openssl rand -base64 32`.
</ParamField>

<ParamField path="SUPPORT_EMAIL" required>
  Shown to users as the contact email for support queries.
</ParamField>

<ParamField path="NOREPLY_EMAIL">
  Sender address for all transactional emails. Falls back to `SUPPORT_EMAIL` if not set.
</ParamField>

<ParamField path="NOREPLY_EMAIL_NAME" default="Rallly">
  Sender name for all transactional emails.
</ParamField>

<ParamField path="INITIAL_ADMIN_EMAIL">
  The first user who signs in with this email can claim the admin role from `/control-panel`. See the [Control Panel guide](/self-hosting/control-panel).
</ParamField>

## Email (SMTP)

An SMTP server is required to send magic-link sign-in emails and notifications.

<ParamField path="SMTP_HOST" required>The host address of your SMTP server</ParamField>

<ParamField path="SMTP_PORT" default="587">
  Common values: 587 (STARTTLS), 465 (implicit TLS), 25 (plain). Ports 587 and 25 use STARTTLS and require `SMTP_SECURE=false`; port 465 uses implicit TLS and requires `SMTP_SECURE=true`.
</ParamField>

<ParamField path="SMTP_SECURE" default="false">
  Set to `true` for implicit TLS (typically port 465). Leave as `false` for STARTTLS (typically port 587), which upgrades a plain connection to TLS after the initial handshake.
</ParamField>

<ParamField path="SMTP_USER">
  SMTP username, if authentication is enabled.
</ParamField>

<ParamField path="SMTP_PWD">
  SMTP password, if authentication is enabled.
</ParamField>

<ParamField path="SMTP_REJECT_UNAUTHORIZED" default="true">
  Validate TLS certificates. Set to `false` to accept self-signed certificates. Not recommended for production.

  <Note>Available from v4.4.0 and later.</Note>
</ParamField>

<ParamField path="SMTP_TLS_SERVERNAME">
  Hostname used for TLS certificate validation. Required when `SMTP_HOST` is an IP address or a hostname that only resolves via `/etc/hosts`. See [nodemailer TLS options](https://nodemailer.com/smtp#tls-options).

  <Note>Available from v4.8.0 and later.</Note>
</ParamField>

<ParamField path="SMTP_DEBUG" deprecated>
  No longer supported since v4.16.0 and can be removed from your configuration. Send failures are now logged with the SMTP server's response (`errorResponse`, `errorResponseCode`, `errorCommand`), which covers the cases this option was used for. For connection level diagnosis, run `openssl s_client -starttls smtp -connect <host>:<port>` for STARTTLS (`SMTP_SECURE=false`), or `openssl s_client -connect <host>:<port>` for implicit TLS (`SMTP_SECURE=true`, usually port 465).
</ParamField>

## Auth

<ParamField path="EMAIL_LOGIN_ENABLED" default="true">
  Set to `false` to disable email sign-in. When disabled, users can only sign in via a configured SSO provider. New users can still register through SSO unless `REGISTRATION_ENABLED` is `false` or registration is disabled in the control panel.

  <Note>Available from v4.4.0 and later.</Note>
</ParamField>

<ParamField path="REGISTRATION_ENABLED" default="true">
  Set to `false` to disable new user registration. Takes precedence over the database setting in the admin UI.

  <Note>Available from v4.4.0 and later.</Note>
</ParamField>

<ParamField path="ALLOWED_EMAILS">
  Comma-separated list of email addresses allowed to register and sign in. Wildcards are supported. Example: `*@example.com` allows any address on that domain.
</ParamField>

### Single Sign-On

See the [Single Sign-On guide](/self-hosting/single-sign-on) for setup instructions and the full list of variables.

## Video Conferencing

Google Meet, Zoom, Microsoft Teams and Webex links on polls are turned on per provider with `CONFERENCING_PROVIDERS` and an OAuth app for each. See the [Video Conferencing guide](/self-hosting/conferencing) for setup instructions and the full list of variables.

<Note>Available from v4.16.0 and later.</Note>

## Branding

Customise the look of your instance. These settings can also be changed from the Control Panel, where a saved value takes precedence over the environment variable. See [White Labeling](/self-hosting/white-labeling) for details.

<Note>
  Available from v4.6.0 and later. Requires an Enterprise license with the white-label add-on.
</Note>

<ParamField path="APP_NAME" default="Rallly">
  Application name. Appears in page titles, navigation, and emails.
</ParamField>

<ParamField path="PRIMARY_COLOR" default="#4f46e5">
  Primary brand color for light mode. Must be a valid hex code.
</ParamField>

<ParamField path="PRIMARY_COLOR_DARK">
  Primary brand color for dark mode. Auto-calculated from `PRIMARY_COLOR` if not set.
</ParamField>

<ParamField path="LOGO_URL">
  Absolute URL to your logo for light mode. SVG recommended. Defaults to the Rallly logo.
</ParamField>

<ParamField path="LOGO_URL_DARK">
  Absolute URL to your logo for dark mode. Falls back to `LOGO_URL` if not set.
</ParamField>

<ParamField path="LOGO_ICON_URL">
  Absolute URL to your logo icon, shown at the top of emails. Defaults to the Rallly logo mark. Use a raster format, as SVG rendering is unreliable across mail clients.
</ParamField>

<ParamField path="HIDE_ATTRIBUTION" default="false">
  Set to `true` to hide "Powered by Rallly" attribution in polls and emails.
</ParamField>

## Advanced

<ParamField path="RALLLY_IMAGE" default="lukevella/rallly:4">
  Override the Rallly Docker image. Pin a major version (e.g. `lukevella/rallly:4`) to avoid pulling in breaking changes. See the [releases](https://github.com/lukevella/rallly/releases) for available versions.
</ParamField>

<ParamField path="RATE_LIMIT_ENABLED" default="true">
  Limits how often one visitor can perform actions such as submitting responses. Counts are kept in memory and reset when the container restarts. Rallly reads the visitor's address from the `X-Real-IP` header, so if you run your own reverse proxy, make sure it sets that header. The bundled proxy does. Set to `false` to turn the limits off.
</ParamField>

### External reverse proxy

The stack bundles Traefik for TLS termination. To put Rallly behind your own reverse proxy (Nginx, Caddy, Cloudflare Tunnel, etc.), set `PROXY_MODE=external` — the bundled `traefik` container is skipped and the `web` container is published on a host port your proxy can route to. See [External reverse proxy](/self-hosting/installation/docker#external-reverse-proxy) in the installation guide for the full setup.

<ParamField path="PROXY_MODE" default="bundled">
  Set to `external` to disable the bundled Traefik reverse proxy and publish the `web` container on a host port instead. Your external proxy is responsible for TLS termination.
</ParamField>

<ParamField path="WEB_PORT" default="127.0.0.1:3000">
  Host port binding for the `web` container when `PROXY_MODE=external`. Format is `<host>:<port>`. Use `0.0.0.0:3000` to expose on all interfaces, or pick a different port if 3000 is in use.
</ParamField>

### External database

The stack bundles a PostgreSQL container that works out of the box. Set `DATABASE_URL` in `.env` to point at an external Postgres instead (RDS, Supabase, Neon, etc.) — the bundled `db` container is skipped automatically when this variable is set.

<ParamField path="DATABASE_URL">
  Postgres connection string. Leave unset to use the bundled database. Example: `postgres://user:password@db.example.com:5432/rallly`.
</ParamField>

### External object storage

The stack bundles [Garage](https://garagehq.deuxfleurs.fr/) for file uploads, which works out of the box. To use an external S3-compatible service (AWS S3, Cloudflare R2, MinIO, etc.), set the variables below in `.env` — the bundled `garage` container is skipped automatically when `S3_ENDPOINT` points elsewhere.

<ParamField path="S3_ENDPOINT">
  S3 API endpoint. Leave unset to use the bundled storage. Example: `https://s3.us-east-1.amazonaws.com`.
</ParamField>

<ParamField path="S3_BUCKET_NAME">
  Bucket name for uploads.
</ParamField>

<ParamField path="S3_REGION">
  Bucket region (e.g. `us-east-1`, `auto` for Cloudflare R2).
</ParamField>

<ParamField path="S3_ACCESS_KEY_ID">
  Access key for the bucket.
</ParamField>

<ParamField path="S3_SECRET_ACCESS_KEY">
  Secret key for the bucket.
</ParamField>

### Corporate networks

If outbound traffic from your server must pass through a proxy, or your network intercepts TLS with an internal root CA, see the [Corporate Networks guide](/self-hosting/corporate-networks) for the `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY` variables and `CA_CERT_FILE`.

## Running the image directly

If you're running the Rallly Docker image without the [Rallly CLI](/self-hosting/installation/docker), a few variables that the CLI normally supplies or derives become your responsibility.

<ParamField path="NEXT_PUBLIC_BASE_URL" required>
  Fully qualified public URL of your instance, including scheme. Example: `https://rallly.example.com`. The CLI generates this from `DOMAIN`; without the CLI, you must set it explicitly.
</ParamField>

The variables documented under [External database](#external-database) and [External object storage](#external-object-storage) also become required, since there are no bundled services to fall back to:

- `DATABASE_URL` — Postgres connection string
- `S3_ENDPOINT`, `S3_BUCKET_NAME`, `S3_REGION`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` — S3-compatible object storage credentials

`DOMAIN` and `ACME_EMAIL` are only used by the bundled Traefik reverse proxy and can be left unset in this mode. TLS termination is your reverse proxy's responsibility.

---

### Apps Docs Self Hosting Control Panel (`apps/docs/self-hosting/control-panel.mdx`)

---
title: "Accessing the Control Panel"
description: "Learn how to access and use the Control Panel to manage your self-hosted Rallly instance."
---

<Frame>
  <img src="/images/self-hosting/control-panel.jpeg" alt="Control Panel" />
</Frame>

The Control Panel is your central hub for administering your self-hosted Rallly instance. From here, you can manage users, activate your license, and configure instance-specific settings.

To access the Control Panel:

1.  Ensure your Rallly instance is running.
2.  Open your web browser and navigate to `/control-panel`.
3.  You may be prompted to log in if you haven't already. Only users with administrative privileges can access the Control Panel features.

## Claiming Initial Admin Rights

The first user designated as an administrator needs to manually claim their admin role. This user is specified by the `INITIAL_ADMIN_EMAIL` environment variable during setup (see [Configuration](/self-hosting/configuration)).

To claim admin rights:

1.  Ensure the user account associated with the `INITIAL_ADMIN_EMAIL` has been created (e.g., by logging in once).
2.  Navigate to the Control Panel: `/control-panel`.
3.  If you are the user specified in `INITIAL_ADMIN_EMAIL` and have not yet claimed admin rights, you should see a section or button prompting you to "Make me an admin".
4.  Click this button to elevate your account to an administrator.

Once claimed, this user can manage other users and settings.

## Troubleshooting

If you see an **Administrator access required** message when visiting `/control-panel`, the account you are signed in with does not have the admin role. This usually means:

- The `INITIAL_ADMIN_EMAIL` environment variable is not set, or
- It does not match the email address of the account you are signed in with.

Update `INITIAL_ADMIN_EMAIL` to match your account's email address, restart your instance, and visit `/control-panel` again to claim admin rights. Alternatively, an existing administrator can grant your account the admin role from the **Users** section of the Control Panel.

## Update notifications

The Control Panel home page shows the version your instance is running and whether a newer release is available. The check runs against `api.rallly.co` once an hour and only sends the running version and an anonymous instance identifier. On a restricted network, see [Corporate networks](/self-hosting/corporate-networks#hosts-to-allow).

When a release you have not installed yet contains security fixes, every Control Panel page shows a banner linking to the release notes. If your major version has reached end of life and the fix only shipped in a newer major, the banner links to the migration guide instead. Older instances than 4.16 render the plain update notice and cannot tell you about security fixes, so keep an eye on the [releases page](https://github.com/lukevella/rallly/releases) until you have updated past that version.

---

### Apps Docs Self Hosting Corporate Networks (`apps/docs/self-hosting/corporate-networks.mdx`)

---
icon: network-wired
title: Corporate Networks
description: Run Rallly behind a corporate proxy or TLS-intercepting firewall.
---

Corporate networks often restrict outbound traffic in two ways: all egress must pass through an explicit proxy, and/or outbound TLS is intercepted and re-signed with an internal root certificate authority that Node.js does not trust. Either one breaks Rallly's outbound HTTPS requests. [License activation](/self-hosting/licensing), which connects to `licensing.rallly.co` on port 443, is usually where you notice it first, but any outbound call is affected. Some networks require both fixes below together.

If your server can reach the internet directly and your network does not intercept TLS, none of this applies and you can skip this page.

## Hosts to allow

Rallly makes outbound requests to these hosts, all over HTTPS on port 443. If your firewall filters by destination, allow them.

| Host | Purpose | When blocked |
|:-----|:--------|:-------------|
| `licensing.rallly.co` | License activation and refresh | Activation fails with an error in the Control Panel |
| `api.rallly.co` | Hourly update check: sends the running version and an anonymous instance identifier, receives the latest release and any security advisories affecting your version | Fails silently. The version tile shows no status and you are not told about security updates, so watch the [releases page](https://github.com/lukevella/rallly/releases) instead |

Pulling images with `./rallly.sh update` reaches Docker Hub (`registry-1.docker.io` and `auth.docker.io`), which is governed by Docker's own proxy settings rather than the variables below.

Anything you configure yourself, such as your SMTP server, an identity provider, or external object storage, needs to be reachable too.

## Routing traffic through a proxy

Rallly honours the standard `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` environment variables for outbound requests. Set them in `.env`:

```sh
HTTPS_PROXY=http://proxy.internal.example.com:8080
HTTP_PROXY=http://proxy.internal.example.com:8080
NO_PROXY=garage,db
```

<Note>
  Available from v4.12.3 and later. Earlier versions ignore these variables entirely, so if you're on an older version, [update](/self-hosting/management#updating) first.
</Note>

- If your proxy requires basic authentication, include the credentials in the URL: `http://user:password@proxy.internal.example.com:8080`.
- Loopback traffic is never sent through the proxy, and the container healthcheck bypasses proxy settings automatically.
- The `NO_PROXY` entries above keep traffic to the stack's bundled storage (`garage`) and database (`db`) services off the proxy.
- The lowercase variants (`http_proxy`, `https_proxy`, `no_proxy`) are also recognised. If both `no_proxy` and `NO_PROXY` are set, the lowercase value wins.

Apply the change with `./rallly.sh restart`.

## Trusting a custom root CA

If your network intercepts TLS, the proxy terminates each HTTPS connection and re-signs it with your organisation's internal root CA. Node.js only trusts its built-in certificate authorities, so these connections fail until you tell Rallly about the internal CA.

Set `CA_CERT_FILE` in `.env` to the absolute path of a PEM certificate file on the host:

```sh
CA_CERT_FILE=/opt/rallly/certs/corporate-root-ca.pem
```

The file is mounted read-only into the `web` container and added to Node's trust store. It is trusted **in addition to** the built-in certificate authorities, so certificates from public websites keep validating as before. When `CA_CERT_FILE` is unset, this feature is completely inert.

Apply the change with `./rallly.sh restart`. Node reads the file once at startup, so if you later replace the certificate on disk, restart again to pick it up.

<Note>
  If `CA_CERT_FILE` points to a path that doesn't exist, the stack refuses to start with an error naming the path. This is deliberate: it catches typos that would otherwise fail silently.
</Note>

To verify the certificate is mounted and readable inside the container:

```sh
docker compose exec web openssl x509 -in /etc/ssl/certs/rallly-custom-ca.pem -noout -subject
```

Success prints the CA's subject line. `Unable to load certificate` means the file at `CA_CERT_FILE` is missing or not valid PEM.

### Obtaining the certificate

The file must be in PEM format: base64 text starting with `-----BEGIN CERTIFICATE-----`. Multiple certificates can be concatenated into one file.

The best source is your IT department: ask for the organisation's **root CA certificate**. The root stays valid as the proxy reissues per-host certificates, so this is the option that keeps working.

If IT hands you a DER-encoded file (binary, often `.crt` or `.cer`), convert it:

```sh
openssl x509 -inform DER -in ca.crt -out ca.pem
```

As a fallback, you can export a certificate from the chain the proxy presents. Run this **from the affected server**:

```sh
openssl s_client -showcerts -connect licensing.rallly.co:443 </dev/null 2>/dev/null | openssl x509 -outform PEM > ca.pem
```

Note that `openssl` does not read the proxy environment variables. On a network with no direct egress, add `-proxy proxy.internal.example.com:8080` to the command so the connection goes through your proxy.

Two caveats: this writes only the leaf certificate, so it stops working when the proxy reissues it — treat it as a stopgap until IT provides the root. It also doubles as a diagnostic — inspect the exported certificate's subject:

```sh
openssl x509 -in ca.pem -noout -subject
```

If the subject names your organisation instead of `licensing.rallly.co`, TLS interception is confirmed.

## Diagnosing failures

Watch the logs while triggering the failing request (for example, a license activation attempt):

```sh
./rallly.sh logs web
```

| Error in logs | Cause | Fix |
|:--------------|:------|:----|
| `UNABLE_TO_VERIFY_LEAF_SIGNATURE`, `SELF_SIGNED_CERT_IN_CHAIN`, or `unable to get local issuer certificate` | TLS is intercepted and re-signed by an untrusted CA | Set [`CA_CERT_FILE`](#trusting-a-custom-root-ca) |
| `ENOTFOUND` | DNS resolution for the host is blocked | Allow DNS for the host, or route through a proxy that resolves it |
| `ETIMEDOUT` or `ECONNREFUSED` | No direct route to the host | Configure the [proxy variables](#routing-traffic-through-a-proxy) (v4.12.3+), or request a firewall exception for the [hosts above](#hosts-to-allow) on port 443 |
| `EAI_AGAIN` with no connection attempts | Proxy-only network with no external DNS, on a version before v4.12.3 that ignores proxy variables | [Update](/self-hosting/management#updating) to v4.12.3 or later, then configure the proxy variables |

---

### Apps Docs Self Hosting Introduction (`apps/docs/self-hosting/introduction.mdx`)

---
icon: "house"
title: "Introduction"
description: "Self-host Rallly on your own infrastructure."
---

Self-hosting Rallly gives you full control over your instance and your data. The self-hosted distribution is designed to be easy to install and maintain on a single server — everything you need is bundled into one Docker Compose stack.

## Rallly Cloud vs Rallly Self-Hosted

Rallly comes in two flavours built from the same codebase.

**[Rallly Cloud](https://rallly.co)** is the hosted service we run. It is designed to scale horizontally across distributed infrastructure and serve a large number of users. It is the fastest way to get started and is maintained by us.

**Rallly Self-Hosted** is optimised for simplicity of deployment. Out of the box, the application, database, object storage, and TLS termination all run on a single machine via Docker Compose — you own the data, you own the server, and updates are a single command. If you already run a managed Postgres or S3-compatible service, you can point Rallly at those instead and the bundled containers are skipped automatically.

<Note>
  Some Cloud features (billing, metered quotas) are compiled out of the self-hosted image. Licensing for multi-user self-hosted instances is covered on the [Licensing page](/self-hosting/licensing).
</Note>

## Differences from Rallly Cloud

The user guides describe Rallly Cloud. On a self-hosted instance, these work differently:

- **Open polls**: there is no [open poll limit](/faq). Every account can have any number of polls collecting votes at once.
- **API**: the [Rallly API](/api-reference/introduction) is only served by Rallly Cloud. Self-hosted instances do not have it.
- **Spaces**: [collaboration](/spaces/collaboration) is always on and the setting is not shown. The instance is already the boundary of your organization, so adding someone to a space is how you work together.

## What's included

A self-hosted install is a batteries-included stack. You do not need to provision these services separately.

- **Traefik** — reverse proxy with automatic HTTPS via Let's Encrypt
- **Rallly** — the web application
- **PostgreSQL** — primary database
- **Garage** — S3-compatible object storage for file uploads

Only Traefik binds to host ports (80 and 443). Everything else is isolated on the internal Docker network.

## Next steps

- **[Installation](/self-hosting/installation/docker)** — set up Rallly on your server.
- **[Configuration](/self-hosting/configuration)** — environment variables reference.
- **[Management](/self-hosting/management)** — update, back up, and operate your instance.
- **[Control Panel](/self-hosting/control-panel)** — manage users and instance settings.
- **[Licensing](/self-hosting/licensing)** — when a license is needed and how to apply one.

If you would rather not run a server, use [Rallly Cloud](https://rallly.co).

---

### Apps Docs Self Hosting Licensing (`apps/docs/self-hosting/licensing.mdx`)

---
icon: "key"
title: "Licensing"
description: "Understand Rallly's self-hosting license requirements, tiers, and pricing."
---

Rallly is open-source and always free for personal use.
If you want to run a multi-user setup, we ask that you purchase a license key.

## Pricing


| Tier         | User Limit    | Space Members | Price       |                                      |
|:-------------|:--------------|:--------------|:------------|:-------------------------------------------------------|
| **Personal** | 1 user        | 1 seat        | Free        | -                                                      |
| **Plus**     | Up to 5 users | 5 seats       | $49         | [Buy License](https://rallly.co/buy-license/plus) |
| **Organization** | Up to 50 users| 50 seats      | $499        | [Buy License](https://rallly.co/buy-license/organization) |
| **Enterprise** | 50+ users     | 100 seats     | From $999   | [Get Quote](https://app.formbricks.com/s/cmebvnf2ohvjkt901eefxsonr) |

<Note>
The license key you purchase is a **perpetual license specifically for Rallly v4.x versions**. This means your license will grant you access to all features and updates within the entire Rallly v4 series for the user tier you've selected.
</Note>

## Activating Your License

Once you have your license key, you can activate it in your self-hosted Rallly instance:

1.  Log in to your Rallly instance as an administrator.
2.  Navigate to the [Control Panel](/self-hosting/control-panel).
3.  Select **License**.
4.  Enter your license key in the provided field and click **Activate**.

Your instance will now be licensed for multi-user access according to your chosen tier.

<Note>
Activation requires your server to reach `licensing.rallly.co` over HTTPS. If activation fails and your server sits behind a corporate proxy or a firewall that inspects TLS, see [Corporate Networks](/self-hosting/corporate-networks).
</Note>

## Spaces and Collaboration

Starting with Rallly v4.3.0, **Spaces** allow you to create collaborative workspaces where you can invite other users to work together on polls. Each Space has a seat limit based on your licensing tier:

- **Personal**: Cannot invite other members (1 seat only)
- **Plus**: Up to 5 members per Space
- **Organization**: Up to 50 members per Space  
- **Enterprise**: Up to 100 members per Space

Members invited to a Space can create and manage polls within that workspace, making it easier for teams and organizations to collaborate on scheduling.

## FAQ

<Accordion title="What constitutes a user?">
Only individuals with a **registered user account** on your Rallly instance count towards your user limit. People who participate in polls without being registered (i.e., guest voters) **do not** count as users for licensing purposes.

When you invite someone to a Space, they become a member of that Space and count towards both your instance user limit and the Space's seat limit.
</Accordion>

<Accordion title="What happens if I exceed my user limit?">
We operate an **honor system** for user limits. If you exceed your user limit, your instance will continue to function without any interruption. You'll simply see a friendly reminder asking you to upgrade to a higher tier.
</Accordion>

<Accordion title="Can I get an invoice?">
Yes, an invoice is automatically generated for every purchase. You'll receive it via email shortly after your payment is processed.
</Accordion>

<Accordion title="Can I get a refund?">
Our license keys provide perpetual access and cannot be "returned".
Due to the nature of these licenses and the costs associated with processing transactions, we are unable to offer refunds.
Issuing a refund would mean we incur a financial loss while you retain the perpetual license.
</Accordion>

<Accordion title="How much does an Enterprise license cost?">
The Enterprise license is designed for organizations typically needing support for **more than 50 users**, or those with other specific requirements.

Enterprise licenses start from **$999** and are **customized** based on the scale of your deployment and specific needs.
We offer a **one-time perpetual license fee** that provides broad usage rights for your organization.
This approach ensures you get a fair price tailored to your situation, rather than a fixed per-user calculation that may not be suitable for very large organizations.

All Enterprise licenses include access to updates and priority support.

To get started, please fill out our [Enterprise Requirements Survey](https://app.formbricks.com/s/cmebvnf2ohvjkt901eefxsonr) so we can better understand your needs and provide an accurate quote.

You can also [contact us](/contact/support) directly to discuss your requirements.
We're happy to work with you to find a solution that fits.
</Accordion>

<Accordion title="Do you offer discounts for nonprofits or schools?">
No. Rallly licenses are priced to be affordable for small teams and organizations on tight budgets, so instead of offering discounts to some customers, we keep the price low for everyone.
Charities, schools and community groups are also welcome to use the free **Personal** tier.
</Accordion>


If you have any questions about licensing, pricing, or which tier is right for you, please don't hesitate to [contact us](/contact/support).

---

### Apps Docs Self Hosting Managed Hosting (`apps/docs/self-hosting/managed-hosting.mdx`)

---
title: Managed Hosting
description: Using a managed hosting service to self-host Rallly.
---

Managed hosting is a service where a hosting provider takes care of provisioning the underlying infrastructure and some of the day-to-day maintenance of the app such as keeping it up to date.

## Revenue Sharing Providers

By choosing a hosting provider that shares revenue with us, you are not only getting the best service for your needs, but also supporting the development and maintenance of Rallly.
These providers understand the value of our open source project and are committed to helping us grow.
We appreciate their support and hope you will consider using their services.

### [PikaPods](https://www.pikapods.com)

PikaPods shares 20% of its revenue with this project.

[](https://www.pikapods.com/apps/)

### [Cloudron](https://cloudron.io)

Cloudron sponsors this project on Github.

<a href="https://www.cloudron.io/store/co.rallly.cloudronapp.html">
  <img
    src="/images/self-hosting/cloudron-logo.svg"
    alt="Run on Cloudron"
    width="200"
  />
</a>

### [Elestio](https://elest.io/)

Elestio shares 30% of it's revenue with this project.

[](https://elest.io/open-source/rallly)

---

If you have a provider you'd like to recommend, please [open an issue](https://github.com/lukevella/rallly/issues/new) on Github.

---

### Apps Docs Self Hosting Management (`apps/docs/self-hosting/management.mdx`)

---
icon: terminal
title: Management
description: Operate a self-hosted Rallly instance with the Rallly CLI.
---

The **Rallly CLI** (`rallly.sh`) is the single entry point for day-to-day operations on a self-hosted instance. It ships with the [self-hosted stack](https://github.com/lukevella/rallly-selfhosted) and should be run from the stack directory (e.g. `/opt/rallly` for installer-based setups).

## Commands

```sh
./rallly.sh start          # Start all services
./rallly.sh stop           # Stop all services
./rallly.sh restart        # Restart all services
./rallly.sh status         # Show service status
./rallly.sh logs           # Stream logs for all services
./rallly.sh logs web       # Stream logs for a specific service
./rallly.sh update         # Pull latest images and recreate containers
./rallly.sh backup         # Back up the database to ./backups/
```

## Applying configuration changes

After editing `.env`, apply changes with:

```sh
./rallly.sh restart
```

## Updating

```sh
./rallly.sh update
```

This pulls the latest Docker images and recreates containers. Your data and configuration are preserved.

By default the stack tracks the `lukevella/rallly:4` tag, so `update` will bring you the latest compatible `4.x` release. To pin to a specific version or follow a different major, set `RALLLY_IMAGE` in `.env` (see [Configuration](/self-hosting/configuration#advanced)).

<Warning>
  Do not edit `docker-compose.yml` or other stack files. `./rallly.sh update` expects these files unchanged and will overwrite local edits. Only `.env` is safe to modify.
</Warning>

## Backup and restore

### Backup

```sh
./rallly.sh backup
```

Database backups are written to `./backups/` as timestamped `.sql.gz` files. Copy these off the server regularly — they are your main recovery artifact.

<Note>
  The backup captures the PostgreSQL database. Uploaded files (avatars, etc.) are stored in the `garage-data` Docker volume; back that up separately if you rely on uploads.
</Note>

### Restore

Stop the app first so it releases its database connections, drop and recreate an empty `rallly` database, stream in the dump, then restart:

```sh
# Stop the app (leave the db service running)
docker compose stop web

# Drop and recreate the database — connect to the "postgres" db so we can drop "rallly"
docker compose exec -T db psql -U postgres -d postgres -c "DROP DATABASE IF EXISTS rallly;"
docker compose exec -T db psql -U postgres -d postgres -c "CREATE DATABASE rallly;"

# Restore — ON_ERROR_STOP=1 fails fast on the first error instead of continuing silently
gunzip < backups/rallly_20260101_120000.sql.gz | docker compose exec -T db psql -v ON_ERROR_STOP=1 -U postgres rallly

# Bring the app back up
./rallly.sh start
```

## Logs

Stream all services:

```sh
./rallly.sh logs
```

Or a single service (`web`, `db`, `garage`, `traefik`):

```sh
./rallly.sh logs web
./rallly.sh logs traefik
```

## Troubleshooting

### SSL certificate is not being issued

Let's Encrypt can take up to a minute on first start. Check:

- The domain's DNS `A` record resolves to the server's public IP.
- Ports 80 and 443 are open in the server's firewall.
- `./rallly.sh logs traefik` for ACME errors.

### Port 80 or 443 already in use

Another service (nginx, apache, another Docker stack) is bound to the port. Stop it or reconfigure it to leave 80/443 free — Traefik needs both.

### Containers keep restarting

Check the failing service's logs:

```sh
./rallly.sh logs web
./rallly.sh logs db
```

Common causes:

- Missing or invalid `.env` values (e.g. empty `SECRET_PASSWORD`, unreachable SMTP).
- Database is still initialising on first boot — usually resolves within a few retries.

### Database connection errors on first boot

PostgreSQL takes a few seconds to initialise. The app retries automatically. If errors persist, inspect `./rallly.sh logs db`.

---

### Apps Docs Self Hosting Migration (`apps/docs/self-hosting/migration.mdx`)

---
title: Migrate to the new Docker stack
description: Move an existing self-hosted instance onto the new Docker stack.
---

Starting with **v4.10**, Rallly Self-Hosted ships as a managed Docker Compose stack with an installer, the **Rallly CLI** (`rallly.sh`), bundled reverse proxy (Traefik), and bundled object storage (Garage). If you set up Rallly before v4.10 — with a hand-edited `docker-compose.yml`, your own reverse proxy, or a `config.env` file — this guide walks you through the upgrade.

<Warning>
  This guide assumes you will tear down the old stack and run the new stack on the same server. Read every step before starting. A database backup is mandatory.
</Warning>

## Before you begin

- You have **root (or sudo) access** to the server.
- You have a **recent, tested database backup** stored off the server.
- Your DNS `A` record for the domain points at this server's IP. Traefik will request a fresh Let's Encrypt certificate on first boot.

## Step 1 — Save your secrets

Open your existing `.env` or `config.env`. Copy these values somewhere safe — you'll paste them into the new stack's `.env`:

| Value | Notes |
|---|---|
| `SECRET_PASSWORD` | **Critical.** Must carry over. Encrypts sessions; if it changes, all existing sessions are invalidated. |
| `SUPPORT_EMAIL` | |
| `NOREPLY_EMAIL` | If set. |
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_USER`, `SMTP_PWD` | All SMTP config. |
| `ALLOWED_EMAILS` | If set. |
| `INITIAL_ADMIN_EMAIL` | If set. |
| `OIDC_*`, `GOOGLE_*`, `MICROSOFT_*` | Any SSO credentials you use. |

Your domain (previously in `NEXT_PUBLIC_BASE_URL`) becomes `DOMAIN` in the new stack — **without the scheme or trailing slash**. For example `https://rallly.example.com` → `rallly.example.com`.

## Step 2 — Back up the database

From your existing stack directory, with the old stack running:

```sh
docker compose exec -T rallly_db pg_dump -U postgres rallly | gzip > rallly-backup.sql.gz
```

Copy `rallly-backup.sql.gz` off the server (e.g. `scp` to your laptop). Do not skip this.

<Note>
  `rallly_db` is the Postgres service name in the official legacy `docker-compose.yml`. If you renamed it or authored your own compose file, replace it with whatever yours is called (run `docker compose ps` to check).
</Note>

## Step 3 — Stop the old stack

From the old stack directory:

```sh
docker compose down
```

If you were running an external reverse proxy (Nginx, Caddy, Apache) in front of Rallly, **stop and disable it now** — Traefik needs ports 80 and 443.

```sh
# Examples, adjust to your setup
sudo systemctl stop nginx && sudo systemctl disable nginx
sudo systemctl stop caddy && sudo systemctl disable caddy
```

Verify the ports are free:

```sh
sudo ss -tlnp | grep -E ':(80|443)\b'
```

Should return nothing.

## Step 4 — Install the new stack

```sh
curl -fsSL https://get.rallly.co | bash
```

When prompted, **use the same `DOMAIN` as before**. For `SUPPORT_EMAIL`, SMTP settings, etc., paste in the values you saved in Step 1.

After setup completes, **do not start the stack yet**. Edit `/opt/rallly/.env` and:

1. Replace the freshly-generated `SECRET_PASSWORD` with the value from your old `.env`.
2. Add any SSO variables (`OIDC_*`, `GOOGLE_*`, `MICROSOFT_*`) that the interactive setup didn't cover.

See the full [configuration reference](/self-hosting/configuration).

## Step 5 — Start the stack and restore the database

```sh
cd /opt/rallly
./rallly.sh start
```

Wait for the database to finish initialising (about 10 seconds). Check with:

```sh
./rallly.sh logs db
```

Once you see `database system is ready to accept connections`, restore your backup:

```sh
# Stop the app so it releases its connections to the rallly database
docker compose stop web

# Drop the freshly-migrated empty database and recreate it — connect to "postgres" to drop "rallly"
docker compose exec -T db psql -U postgres -d postgres -c "DROP DATABASE IF EXISTS rallly;"
docker compose exec -T db psql -U postgres -d postgres -c "CREATE DATABASE rallly;"

# Restore — ON_ERROR_STOP=1 fails fast on the first error instead of continuing silently
gunzip < /path/to/rallly-backup.sql.gz | docker compose exec -T db psql -v ON_ERROR_STOP=1 -U postgres rallly
```

Start the app so it picks up the restored schema:

```sh
./rallly.sh start
```

## Step 6 — Verify

1. Visit `https://<your-domain>`. You should see the sign-in page served over HTTPS (Traefik's Let's Encrypt certificate will be issued within a minute of first boot).
2. Sign in with an existing account. If `SECRET_PASSWORD` was preserved correctly, your previous session may even still be valid.
3. Open an existing poll and confirm votes are intact.
4. Send a test sign-in email to confirm SMTP works.
5. If you use SSO, sign in through each configured provider.

If anything is missing or broken, **do not delete the old stack's volumes yet**. You can roll back by stopping the new stack and restarting the old one.

## Step 7 — Clean up

Once you're satisfied the new stack is working:

```sh
# Remove the old stack directory
rm -rf /path/to/old/rallly-selfhosted

# Remove orphaned Docker volumes from the old stack
docker volume ls
docker volume rm <old-volume-name>
```

<Warning>
  Double-check volume names before removing. The new stack's volumes are prefixed with its Docker Compose project name, so confirm the prefix before deleting anything. The project name comes from `COMPOSE_PROJECT_NAME`, an explicit `name:` in `docker-compose.yml`, or the directory the compose file lives in. From inside the new stack's directory, run `docker compose config --format json | jq -r .name` to print it, or `docker compose ls` to list running projects and their volumes.
</Warning>

## What doesn't carry over

- **TLS certificates.** Traefik issues fresh certificates via Let's Encrypt on first start. Any existing certs (from certbot or your old proxy) are not imported.
- **Custom `docker-compose.yml` changes.** The new stack is managed; edits will conflict with `./rallly.sh update`. Configure via `.env` only.
- **Uploaded files from an external S3 bucket.** The new stack ships with [Garage](https://garagehq.deuxfleurs.fr/) for uploads. If you were previously using external S3, either keep pointing at it (advanced, see [External object storage](/self-hosting/configuration#external-object-storage)) or accept that previously uploaded files will not appear in the new Garage instance.

## Moving to a different server

If you're migrating to a new server rather than upgrading in place, the flow is the same, with one change: complete Step 4 (install new stack) on the new server, then update your DNS `A` record to point at it before Step 5. Keep the old server running until Step 6 verification passes, which gives you a rollback path.

---

### Apps Docs Self Hosting Single Sign On (`apps/docs/self-hosting/single-sign-on.mdx`)

---
icon: cloud
title: Single Sign-On (SSO)
description: Sign in with Google, Microsoft, or any OpenID Connect provider.
---

<Info>Available in v3.4.0 and later.</Info>

Rallly supports three SSO options: Google, Microsoft (Entra ID), and any provider that speaks OpenID Connect (OIDC). You can enable more than one at a time.

<Warning>
  Accounts using the same email address are linked together. This assumes your identity provider only issues verified email addresses.
</Warning>

## Google

<Accordion title="Setup">
1. Open the Google Cloud Credentials tab: https://console.developers.google.com/apis/credentials

2. Create an OAuth client ID. This gives you a `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`.

3. Set **Authorized redirect URIs** to include your full domain and the callback path:

```
https://<YOUR_DOMAIN>/api/auth/callback/google
```
</Accordion>

<ParamField path="GOOGLE_CLIENT_ID">
  The client ID of your Google application.
</ParamField>

<ParamField path="GOOGLE_CLIENT_SECRET">
  The client secret of your Google application.
</ParamField>

<ParamField path="GOOGLE_LOGIN_ENABLED" default="true">
  Google sign in is offered when `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` are set. Set to `false` to hide it and keep the Google app for [Google Meet](/self-hosting/conferencing#google-meet) and Google Calendar only.
</ParamField>

## Microsoft

<Accordion title="Setup">
Follow the [Microsoft Entra ID quickstart](https://docs.microsoft.com/en-us/entra/identity-platform/quickstart-register-app) to register an application.

After creating the application, set the redirect URI to:

```
https://<YOUR_DOMAIN>/api/auth/callback/microsoft-entra-id
```

On a multi-tenant endpoint (`MICROSOFT_TENANT_ID` set to `common`, `organizations` or `consumers`) any tenant can assert any email address, so Rallly needs Microsoft to confirm the address before it creates an account from it. Microsoft does not include that information unless the app registration asks for it, so a multi-tenant registration must emit the verified email claims: without them every new Microsoft sign-in fails with "Your email address is not verified". Work and school accounts are vouched for by `verified_primary_email` and `verified_secondary_email`; personal Microsoft accounts (Outlook, Hotmail, Live) are vouched for by `xms_edov`, which Microsoft only emits alongside the `email` claim. Add all four as optional claims on the ID token. In the app registration open **Token configuration**, choose **Add optional claim**, pick the **ID** token type and tick `email`, `verified_primary_email`, `verified_secondary_email` and `xms_edov`. If the registration lives in an Azure AD B2C tenant, that blade is hidden; open **Manifest** instead and add these entries to the `optionalClaims.idToken` array, keeping any claims already there:

```json
{ "name": "email", "source": null, "essential": false, "additionalProperties": [] },
{ "name": "verified_primary_email", "source": null, "essential": false, "additionalProperties": [] },
{ "name": "verified_secondary_email", "source": null, "essential": false, "additionalProperties": [] },
{ "name": "xms_edov", "source": null, "essential": false, "additionalProperties": [] }
```

If `optionalClaims` is `null`, replace it with `{ "idToken": [ ...the four entries... ] }`.

Once the claims are present, a Microsoft sign-in whose address Microsoft does not vouch for fails with "Your email address is not verified". Existing users are not affected. Single-tenant registrations do not need this.
</Accordion>

<Accordion title="Using the same registration for Microsoft Teams">
With `microsoft-teams` in `CONFERENCING_PROVIDERS` (see [Video Conferencing](/self-hosting/conferencing)), the Microsoft registration also powers the [Microsoft Teams integration](/integrations/microsoft-teams); there are no separate Teams variables. Add the redirect URI:

```
https://<YOUR_DOMAIN>/api/integrations/callback/microsoft-teams
```

Under **API permissions**, add the delegated Microsoft Graph permissions `OnlineMeetings.ReadWrite`, `User.Read` and `offline_access`. If your organization does not let users consent to apps, choose **Grant admin consent** on the same page. Teams meetings can only be created for work or school accounts, so with `MICROSOFT_TENANT_ID` set to `common` or `consumers` Teams connects through the `organizations` endpoint; with a tenant ID it connects through that tenant.
</Accordion>

<ParamField path="MICROSOFT_TENANT_ID">
  The tenant ID of your Microsoft application.
</ParamField>

<ParamField path="MICROSOFT_CLIENT_ID">
  The client ID of your Microsoft application.
</ParamField>

<ParamField path="MICROSOFT_CLIENT_SECRET">
  The client secret of your Microsoft application.
</ParamField>

<ParamField path="MICROSOFT_LOGIN_ENABLED" default="true">
  Microsoft sign in is offered when `MICROSOFT_CLIENT_ID` and `MICROSOFT_CLIENT_SECRET` are set. Set to `false` to hide it and keep the registration for [Microsoft Teams](/self-hosting/conferencing#microsoft-teams) only.
</ParamField>

## OpenID Connect (OIDC)

If your identity provider supports OAuth 2.0 with OpenID Connect, you can use it to authenticate users on your Rallly instance.

### Required scopes

Your OAuth 2.0 application needs these scopes:

- `openid` — perform authentication (required).
- `profile` — access name and picture.
- `email` — access the user's email address.

### Callback URL

Your identity provider should redirect the user back to:

```
https://<YOUR_DOMAIN>/api/auth/callback/oidc
```

### Configuration

All required fields must be set for OIDC to be enabled.

<ParamField path="OIDC_NAME" default="OpenID Connect">
  Display name of your provider as shown on the login page.
</ParamField>

<ParamField path="OIDC_DISCOVERY_URL" required>
  URL of the `.well-known/openid-configuration` endpoint for your OIDC provider.
</ParamField>

<ParamField path="OIDC_CLIENT_ID" required>
  The client ID of your OIDC application.
</ParamField>

<ParamField path="OIDC_CLIENT_SECRET" required>
  The client secret of your OIDC application.
</ParamField>

<ParamField path="OIDC_NAME_CLAIM_PATH" default="name">
  Path to the claim that contains the user's name.
</ParamField>

<ParamField path="OIDC_EMAIL_CLAIM_PATH" default="email">
  Path to the claim that contains the user's email address.
</ParamField>

<ParamField path="OIDC_PICTURE_CLAIM_PATH" default="picture">
  Path to the claim that contains the user's profile picture.
</ParamField>

<Info>Use dot notation in `_CLAIM_PATH` fields to access nested objects.</Info>

---

### Apps Docs Self Hosting White Labeling (`apps/docs/self-hosting/white-labeling.mdx`)

---
icon: "paintbrush"
title: "White Labeling"
description: "Customize the branding of your self-hosted Rallly instance"
---

<Note>
These options are available from v4.6.0 and later and require an Enterprise license with a white-label add-on.
</Note>

<Frame>
  <img src="/images/self-hosting/whitelabel-splash.webp" alt="White Labeling Example" />
</Frame>

White labeling allows you to customize the appearance of your Rallly instance to match your organization's branding. You can change the application name, primary colors, logos, and hide attribution text.

## What you can customize

- **App name** - The name displayed in page titles, navigation, and emails
- **Primary colors** - Brand colors for both light and dark modes
- **Logos** - Your own logo for light mode, dark mode, and the icon used in emails
- **Attribution** - Hide the "Powered by Rallly" text in polls and emails

## Configuring branding

Branding is configured from the [Control Panel](/self-hosting/control-panel) under **Branding**. Changes take effect immediately, with no restart required.

1. Sign in as an administrator and go to `/control-panel/branding`.
2. Set the app name, primary colors, logos, and attribution settings.
3. Each field saves on its own, so you can change one setting at a time.

<Frame>
  <img src="/images/self-hosting/whitelabel-branding-page.png" alt="The Branding page in the Control Panel" />
</Frame>

The same settings can also be supplied as environment variables. A value saved in the Control Panel takes precedence over the matching environment variable, and clearing it falls back to the environment variable, then to the Rallly default. Precedence is applied per field, so you can set some values in `.env` and others in the UI.

When a field is backed by an environment variable and has no saved value yet, the Branding page shows a note saying so. Saving a value in the UI overrides it.

## Logos

The three logo slots serve different purposes:

- **Logo** - The wordmark shown on light backgrounds
- **Logo (dark mode)** - The wordmark shown on dark backgrounds. Falls back to the light logo if not set
- **Logo icon** - A square icon shown on polls and at the top of emails

### Uploading a logo

Logo uploads require object storage to be configured. The stack installed by the [Rallly CLI](/self-hosting/installation/docker) includes storage by default, so this works out of the box. If your instance has no storage configured, the Branding page says so and you can point at externally hosted images with `LOGO_URL`, `LOGO_URL_DARK` and `LOGO_ICON_URL` instead. See [External object storage](/self-hosting/configuration#external-object-storage) for the storage variables.

| Slot | Accepted formats | Maximum size |
| --- | --- | --- |
| Logo | SVG, PNG, JPEG | 2 MB |
| Logo (dark mode) | SVG, PNG, JPEG | 2 MB |
| Logo icon | PNG, JPEG | 2 MB |

The logo icon is raster only because it is embedded in emails, where SVG rendering is unreliable across mail clients.

Removing an uploaded logo clears the saved value, so the instance falls back to the environment variable if one is set, and to the Rallly logo otherwise.

### Preparing your logo

There is no logo size setting. On the sign-in and registration pages your wordmark is drawn inside a fixed slot of 192 by 128 pixels, scaled down to fit inside it while keeping its aspect ratio, so it is never cropped or stretched. Elsewhere in the app it is scaled to a fixed height.

Because the slot is fixed, the shape of your asset determines how large it renders:

- Crop the asset tightly to the artwork. Whitespace baked into the file is scaled along with the logo, so a padded asset renders visually smaller than a tight one.
- Prefer SVG for the wordmark logos so they stay sharp at any size. If you upload a raster wordmark, supply it at roughly twice the slot size so it holds up on high density displays.
- Wide, horizontal wordmarks fill the slot better than tall or square ones.
- For the dark mode logo, supply a variant that reads well on a dark background rather than reusing the light one.

The Branding page previews each logo inside the slot it renders in, on the background it will appear against, so you can check the result before rolling it out.

## How branding applies to spaces

<Note>
This behavior applies from v4.14.0. On earlier versions, spaces can override the instance branding with their own colors and logo.
</Note>

While the white label add-on is active, your instance branding applies everywhere. Spaces cannot show their own colors or logo to participants:

- Polls, scheduled event pages, and emails always use the instance's colors, and polls display the instance's logo icon and app name.
- The branding controls in each space's settings are replaced with a note explaining that branding is managed by the instance administrator. Space admins can still set the space's name and logo, but these are only used to identify the space inside the app.

<Frame>
  <img src="/images/self-hosting/whitelabel-poll.png" alt="A poll showing the instance logo icon and app name" />
</Frame>

<Frame>
  <img src="/images/self-hosting/whitelabel-space-settings.png" alt="Space settings showing that branding is managed by the instance administrator" />
</Frame>

There is nothing to configure. The behavior is tied to the white label add-on on your license. Any space branding that was set before is preserved but not shown, so if the add-on is removed, spaces return to using their own branding.

## Environment variables

Use these when you prefer to configure branding from `.env`, or when your instance has no object storage for uploads. Add the values to the `.env` file at the root of your self-hosted stack, then apply them with `./rallly.sh restart`.

```sh
# White labeling
APP_NAME="Acme Scheduler"
PRIMARY_COLOR="#0066cc"
PRIMARY_COLOR_DARK="#3399ff"
LOGO_URL="https://example.com/logo.svg"
LOGO_URL_DARK="https://example.com/logo-dark.svg"
LOGO_ICON_URL="https://example.com/icon.png"
HIDE_ATTRIBUTION="true"
```

The three logo variables must be absolute URLs to images your instance can reach. See the [Configuration Options](/self-hosting/configuration#branding) page for the full list of available settings.

## Requirements

- **Enterprise License with White Label Add-on**: White labeling features are only available with an active Enterprise license that includes the white label add-on.
- Without the add-on, the Branding page is read-only and your instance uses the default Rallly branding regardless of any saved or environment variable settings.

To inquire about Enterprise licensing, please fill out our [Enterprise Requirements Survey](https://app.formbricks.com/s/cmebvnf2ohvjkt901eefxsonr).

---

### Apps Docs Spaces Billing (`apps/docs/spaces/billing.mdx`)

---
icon: "credit-card"
title: "Billing & Seats"
description: "Understanding seat-based pricing, centralized billing, and subscription management for spaces."
---

## How Seat-Based Billing Works

Rallly spaces use a seat-based pricing model that scales with your team size:

<Card title="Seat-Based Pricing Benefits" icon="users">
- **Easy scaling** - add or remove seats as your team changes
- **Centralized billing** - one subscription for your entire team
- **Pro features for all** - every member gets Pro benefits
</Card>

## Understanding Seats

### What is a Seat?
A seat represents one team member's access to your Pro space. Each member you invite to your space requires one seat.

### Seat Allocation
- **Used Seats**: Current number of active members in your space
- **Total Seats**: Maximum number of members you're paying for
- **Available Seats**: Remaining seats you can fill without increasing your bill

<Note>
You must have at least as many total seats as used seats. You cannot reduce total seats below your current member count.
</Note>

## Managing Your Subscription

### Upgrading to Pro

To upgrade your space to Pro:

<Steps>
  <Step title="Navigate to Billing Settings">
    Go to your space settings and select the "Billing" tab.
  </Step>
  
  <Step title="Choose Your Plan">
    Select either monthly or yearly Pro billing. Yearly billing offers a discount.
  </Step>
  
  <Step title="Complete Payment">
    Enter your payment details and complete the subscription setup through Stripe.
  </Step>
</Steps>

### Managing Seats

You can adjust your seat allocation at any time from the Billing page of a Pro space:

<Frame>
  <img src="/images/spaces/billing.png" alt="The Billing page showing the active Pro plan, its seats and renewal date, with Manage seats and Manage billing buttons" />
</Frame>

#### Adding Seats
When you need to invite more members:

1. **Navigate to Billing Settings**
2. **Click "Manage Seats"**
3. **Increase the seat count** to accommodate new members
4. **Confirm the changes** - billing will be prorated immediately

#### Removing Seats
To reduce costs when team members leave:

1. **Remove members** from your space first
2. **Go to Billing Settings** and click "Manage Seats"
3. **Decrease the seat count** to match your current needs
4. **Confirm the changes** - adjustments will be credited to your next bill

<Warning>
You cannot reduce seats below your current member count. Remove members first, then adjust seat allocation.
</Warning>

## Billing Portal Access

As a space owner, you have full access to Stripe's customer portal for:

### Subscription Management
- **View billing history** and download invoices
- **Update payment methods** for automatic renewals
- **Switch between monthly and yearly** billing cycles
- **Manage seat quantities** with immediate proration

### Payment Method Updates
- **Add new cards** or payment methods
- **Set default payment method** for renewals
- **Update billing address** and tax information

## Billing Timeline and Prorations

### Immediate Changes
- **Adding seats**: Prorated charge applied immediately
- **Payment method updates**: Takes effect for next billing cycle
- **Seat increases**: Available immediately for new invitations

### Next Billing Cycle
- **Seat reductions**: Credits applied to next bill
- **Plan changes**: New pricing takes effect at renewal
- **Cancellations**: Access continues until period end

## Frequently Asked Questions

<AccordionGroup>
  <Accordion title="What happens if I exceed my seat limit?">
    You cannot invite more members than you have seats available. You'll need to purchase additional seats first, then send invitations.
  </Accordion>
  
  <Accordion title="How is proration calculated?">
    When you add seats mid-cycle, you're charged a prorated amount for the remaining days in your billing period. When you remove seats, the credit is applied to your next bill.
  </Accordion>
  
  <Accordion title="Can I downgrade from Pro to Hobby?">
    Yes, but you'll lose Pro features and need to remove all members from your space. Cancel your subscription and it will downgrade at the end of your billing period.
  </Accordion>
  
  <Accordion title="What happens to my polls if I cancel?">
    Pro spaces that cancel will revert to Hobby tier limitations, including the limit of 3 open polls per organizer. Polls you already have stay open, but you can't open more until you're under the limit. Polls may be subject to automatic deletion based on inactivity rules.
  </Accordion>
</AccordionGroup>

---

### Apps Docs Spaces Collaboration (`apps/docs/spaces/collaboration.mdx`)

---
icon: "handshake"
title: "Collaboration"
description: "Choose whether members of your space work together or independently."
---

Every space has a collaboration setting that controls what members can see of each other's work.

- **Collaboration on** — members work together. Everyone in the space can see and manage everything created in it, including polls and events created before collaboration was turned on.
- **Collaboration off** (the default) — members work independently. Everyone, including admins and the owner, only sees what they create themselves.

This makes spaces useful in two different ways: as a shared workspace for a team that schedules together, or as a way for an organization to give many people Pro access under one subscription while keeping each person's scheduling private.

<Frame>
  <img
    src="/images/spaces/collaboration-settings.png"
    alt="Collaboration settings page with the Team collaboration switch"
  />
</Frame>

## Turning collaboration on or off

<Steps>
  <Step title="Open your space settings">
    Go to **Settings → General** and find the **Collaboration** section. You
    need to be an admin of the space.
  </Step>
  <Step title="Toggle Team collaboration">
    Turn the **Team collaboration** switch on or off, then press **Save**.
  </Step>
</Steps>

<Warning>
Turning collaboration on gives members access to everything already in the space, not just things created afterwards.
</Warning>

Collaboration is a Pro feature. You can turn it off at any time, but turning it on requires an active Pro subscription.

## What it affects

Collaboration controls visibility between members inside the space: polls, events, and anything else created in it.

It does not affect sharing with people outside the space. Invite links and participant pages work the same in both modes, so anyone you share a poll with can always vote on it.

## Roles and visibility

Roles grant management capabilities, not visibility:

- **Owner** — manages billing, members, and space settings
- **Admin** — manages members and space settings
- **Member** — creates and manages their own polls and events

Whether anyone can see other members' work is decided only by the collaboration setting, and it applies to every role equally.

## Defaults

New spaces start with collaboration off. Spaces that already had multiple members (or pending invitations) when this setting was introduced have collaboration on, so nothing changed for existing teams.

---

### Apps Docs Spaces Custom Branding (`apps/docs/spaces/custom-branding.mdx`)

---
icon: "palette"
title: "Custom Branding"
description: "Apply your brand identity to polls and emails with a custom primary color."
---

## Overview

Custom Branding is a Pro feature that lets you replace Rallly's default styling with your own brand identity. When enabled, your chosen primary color is applied to your public poll pages and outgoing emails.

<Frame>
  <img src="https://d39ixtfgglw55o.cloudfront.net/images/branded-poll.webp" alt="Branding settings panel" />
</Frame>
## Requirements

- A Pro space subscription
- Space Owner or Admin role

## Enabling Custom Branding

1. Go to **Settings → General** in your space.
2. Under the **Branding** section, toggle on **Enable Custom Branding**.
3. Use the color picker to choose your primary color.
4. Click **Save** to apply the color.

Once enabled, your primary color will be used on all public-facing poll pages and emails sent to participants.

<Tip>
  Upload your logo and give your space a custom name.
</Tip>

## Disabling Custom Branding

Toggle off **Enable Custom Branding** in **Settings → General**. Rallly's default styling will be restored immediately.

---

### Apps Docs Spaces Introduction (`apps/docs/spaces/introduction.mdx`)

---
icon: "layer-group"
title: "What are Spaces?"
description: "Learn about Rallly's team collaboration features with spaces, centralized billing, and seat management."
---

## What are Spaces?

Spaces are Rallly's solution for team and organizational collaboration. Instead of everyone managing their own individual Pro subscriptions, spaces allow organizations to have centralized billing and member management under a single subscription.

<Card title="Key Benefits" icon="rocket">
  - **Centralized Billing**: One subscription covers all team members
  - **Optional Collaboration**: Choose whether members work together or keep their scheduling private
  - **Member Management**: Invite and manage team members from one place
  - **Seat-Based Pricing**: Pay only for the seats you need and scale as you grow
</Card>

## How Spaces Work

When you create a space, you become the space owner and can:

- **Invite team members** to join your space
- **Manage billing** for the entire team
- **Add or remove seats** as your team grows or changes
- **Organize polls** within your team or organization

All members of a Pro space get access to Pro features without needing their own individual subscriptions.

## Getting Started

Ready to create your first space? Here's what you need to know:

<CardGroup cols={2}>
  <Card title="Managing Seats" icon="chair" href="/spaces/managing-seats">
    Learn how to add and remove seats for your team
  </Card>
  <Card title="Member Management" icon="users" href="/spaces/members">
    Invite and manage your team members
  </Card>
  <Card title="Collaboration" icon="handshake" href="/spaces/collaboration">
    Choose whether members work together or independently
  </Card>
  <Card title="Billing & Seats" icon="credit-card" href="/spaces/billing">
    Understand seat-based pricing and billing
  </Card>
</CardGroup>

---

### Apps Docs Spaces Managing Seats (`apps/docs/spaces/managing-seats.mdx`)

---
icon: "chair"
title: "Managing Seats"
description: "Learn how to add and remove seats for your team space."
---

## What are Seats?

Seats represent the number of team members who can be part of your Pro space. Each member you invite to your space requires one seat, and you're billed based on your total seat count.

<Card title="Seat Management" icon="users">
- **Used Seats**: Current number of active members in your space
- **Total Seats**: Maximum number of members you're paying for
- **Available Seats**: Remaining seats you can use to invite new members
</Card>

## Accessing Seat Management

To manage your seats, you need to:

1. **Be a space owner** - Only space owners can manage billing and seats (admins can access space settings but not billing)
2. **Have a Pro space** - Seat management is available after upgrading to Pro
3. **Navigate to billing settings** - Go to your space settings and select "Billing"

## Adding Seats

When you need to invite more team members than your current seat allocation allows:

<Steps>
  <Step title="Go to Billing Settings">
    Navigate to your space settings and click on the "Billing" tab.
  </Step>
  
  <Step title="Click Manage Seats">
    Once your space is upgraded to Pro, you'll see a "Manage Seats" button on the billing page.

    <Frame>
      <img src="/images/spaces/billing.png" alt="The Billing page showing the active Pro plan with the Manage seats button" />
    </Frame>
  </Step>
  
  <Step title="Increase Seat Count">
    Use the seat selector to increase the number of seats to accommodate your new team members.

    <Frame>
      <img src="/images/spaces/manage-seats.png" alt="The Manage seats dialog with a seat selector and the number of seats in use" />
    </Frame>
    
    <Note>
    You can increase seats to any number you need. There's no upper limit on seat allocation.
    </Note>
  </Step>
  
  <Step title="Confirm Changes">
    Review the cost changes and confirm. The additional seats will be available immediately, and you'll be charged a prorated amount for the current billing period.
  </Step>
</Steps>

## Removing Seats

To reduce costs when team members leave or you no longer need as many seats:

<Steps>
  <Step title="Remove Members First">
    Before reducing seat count, make sure to remove any members who are no longer part of your team from the Members tab.
    
    <Warning>
    You cannot reduce seats below your current member count. Remove members first, then adjust seats.
    </Warning>
  </Step>
  
  <Step title="Access Manage Seats">
    Go to your space billing settings and click the "Manage Seats" button.
  </Step>
  
  <Step title="Decrease Seat Count">
    Use the seat selector to reduce the number of seats to match your current needs.
  </Step>
  
  <Step title="Confirm Reduction">
    Review the changes and confirm. You'll receive a credit for the unused seats on your next billing cycle.
  </Step>
</Steps>

## Understanding Billing Impact

### When You Add Seats
- **Immediate availability**: New seats are available right away for invitations
- **Prorated billing**: You're charged immediately for the remaining days in your billing period
- **Next cycle**: Full price for the new seats applies to subsequent billing cycles

### When You Remove Seats
- **Immediate effect**: Seat count is reduced immediately
- **Billing credit**: You receive a credit for unused days in the current period
- **Next cycle**: Reduced billing amount applies to subsequent cycles

## Common Scenarios

<AccordionGroup>
  <Accordion title="Adding team members mid-month">
    If you need to add 3 new team members in the middle of your billing cycle:
    1. Increase seats by 3 through "Manage Seats"
    2. Pay prorated amount for remaining days immediately
    3. Invite the new members right away
    4. Next billing cycle includes full cost for all seats
  </Accordion>
  
  <Accordion title="Team member leaves unexpectedly">
    When a team member leaves:
    1. Remove them from your space in the Members tab
    2. Go to "Manage Seats" and reduce seat count by 1
    3. Receive credit for unused seat time on next bill
    4. Seat becomes available if you need to invite someone else later
  </Accordion>
  
  <Accordion title="Seasonal team changes">
    For teams that grow and shrink seasonally:
    1. Increase seats when bringing on temporary team members
    2. Remove temporary members at the end of the season
    3. Reduce seat count to match your core team size
    4. Credits will be applied to future billing
  </Accordion>
</AccordionGroup>

## Troubleshooting

<Warning>
If you're having trouble managing seats, check these common issues:
</Warning>

- **Cannot reduce seats**: Make sure you've removed members first - you cannot have fewer seats than active members
- **Manage Seats button missing**: Ensure your space is upgraded to Pro - this feature is only available for Pro spaces
- **Not authorized**: Only space owners can manage seats - members and admins cannot access this feature
- **Payment issues**: Ensure your payment method is up to date before making seat changes

<Info>
Seat management gives you complete flexibility to scale your team up or down based on your actual needs, with immediate availability and fair prorated billing.
</Info>

---

### Apps Docs Spaces Members (`apps/docs/spaces/members.mdx`)

---
icon: "users"
title: "Member Management"
description: "Learn how to invite, manage, and organize team members in your Rallly space."
---

## Understanding Member Roles

Roles control what someone can manage in a space:

<CardGroup cols={2}>
  <Card title="Owner" icon="crown">
    **Full Control**
    - Member management
    - Space settings and configuration
    - Billing and seat management
    - Can delete space
  </Card>
  <Card title="Admin" icon="shield">
    **Team Management**
    - Member management
    - Space settings and configuration
  </Card>
  <Card title="Member" icon="user">
    **Scheduling**
    - Creates and manages their own polls and events
  </Card>
</CardGroup>

<Info>
Roles do not control who can see whose polls and events — that is decided by
the space's [collaboration setting](/spaces/collaboration), which applies to
every role equally.
</Info>

## Inviting New Members

### Prerequisites
Before inviting members, ensure:
- You have **admin or owner** permissions
- Your space has **available seats**
- You have the **email addresses** of people you want to invite

### Invitation Process

<Steps>
  <Step title="Navigate to Members">
    Go to your space settings and click on the "Members" tab.

    <Frame>
      <img src="/images/spaces/members.png" alt="The Members page listing each member with their role, the number of seats available and the Invite member button" />
    </Frame>
  </Step>
  
  <Step title="Send Invitation">
    Click "Invite Member" and enter the email address of the person you want to invite.

    <Note>
    The person will receive an email invitation with instructions to join your space.
    </Note>
  </Step>
  
  <Step title="Set Initial Role">
    Choose the appropriate role for the new member:
    - **Member**: For most team members who need collaboration access
    - **Admin**: For trusted team leads who should manage other members
  </Step>
  
  <Step title="Monitor Pending Invites">
    Track invitation status in the "Pending Invites" section until the person accepts.
  </Step>
</Steps>

## Managing Existing Members

### Changing Member Roles

To change a member's role:

1. **Find the member** in your member list
2. **Click the dropdown menu** next to their name
3. **Select "Change Role"** and choose the new role
4. **Confirm the change** - it takes effect immediately

<Warning>
Changing roles affects what the member can do immediately. Be careful when granting or removing admin permissions.
</Warning>

### Removing Members

To remove a member from your space:

1. **Locate the member** you want to remove
2. **Click the dropdown menu** and select "Remove member"
3. **Choose what happens to their content** and confirm

The member loses access to the space immediately.

#### What happens to their content

If the member created anything in the space, the dialog lists it in two groups:

- **Active**: open polls, upcoming events, event types and sheets
- **Finished**: closed polls and past or canceled events

You then choose one outcome for all of it:

- **Reassign**: everything moves to another member, active and finished alike. They become its organizer: they get its notifications, can manage it, and appear as the organizer in calendar invites. Each reassigned poll records the change in its activity.
- **Delete**: polls, event types and sheets are deleted. Upcoming events are canceled and their attendees get a cancellation email; past events are deleted without notifying anyone.

Reassign is selected by default when the member has anything active, with you as the recipient; you can pick any other current member instead. When nothing is active, delete is selected by default.

<Warning>
Video call links on upcoming events were created on the removed member's account. After reassigning, those links may stop working, so check them and share new ones with attendees if needed.
</Warning>

A member who created nothing in the space is removed with a simple confirmation.

## Managing Pending Invitations

### Invitation States
Invitations can be in several states:
- **Pending**: Invitation sent, waiting for acceptance
- **Expired**: Invitation has expired and needs to be resent
- **Accepted**: Person has joined your space

### Canceling Invitations
To cancel a pending invitation:

1. **Locate the pending invite**
2. **Click "Cancel"** to revoke the invitation
3. **Confirm the cancellation** - the invitation link will no longer work

## Seat Management and Members

### Seat Allocation
- **Used Seats**: Equal to your current member count
- **Available Seats**: How many more members you can invite
- **Total Seats**: Your current subscription level

### Adding Members vs Adding Seats
<Steps>
  <Step title="Check Available Seats">
    Before inviting someone, verify you have available seats in your billing settings.
  </Step>
  
  <Step title="Purchase Additional Seats (if needed)">
    If you're at capacity, increase your seat count through billing management first.
  </Step>
  
  <Step title="Send Invitation">
    Once you have available seats, you can invite new members immediately.
  </Step>
</Steps>


## Troubleshooting Common Issues

<AccordionGroup>
  <Accordion title="Invitation emails not being received">
    - Check spam/junk folders
    - Verify the email address is correct
    - Resend the invitation
    - Ask the recipient to whitelist rallly.co emails
  </Accordion>
  
  <Accordion title="Cannot invite new members">
    - Check if you have available seats
    - Verify you have admin or owner permissions
    - Ensure your subscription is active
    - Try refreshing the page and trying again
  </Accordion>
  
  <Accordion title="Member lost access after role change">
    - Verify the role change was intentional
    - Check if the member's permissions match their new role
    - Have the member log out and log back in
    - Contact support if issues persist
  </Accordion>
  
  <Accordion title="Cannot remove a member">
    - Ensure you have admin or owner permissions
    - Try using the member dropdown menu instead of bulk actions
    - Contact support for assistance
  </Accordion>
</AccordionGroup>

---

### Apps Docs Workflow Create (`apps/docs/workflow/create.mdx`)

---
icon: "circle-1"
title: "Create a poll"
description: "How to create a scheduling poll with Rallly"
---

Go to [app.rallly.co](https://app.rallly.co) and click **Create**, or go
straight to [app.rallly.co/new](https://app.rallly.co/new).

<Steps>
  <Step title="Enter event details">
    Give your poll a title so participants know what the event is about. You
    can also add a location and click **Add description** to include extra
    information for your participants.

    <Frame>
      <img src="/images/workflow/new-poll-page.png" alt="New poll page"/>
    </Frame>

  </Step>
  <Step title="Select dates or times">
    Use the calendar to choose the options you want participants to vote on.
    <Tabs>
      <Tab title="Month view">
        <Frame caption="Use month view to pick dates and adjust the times for each option">
          <img src="/images/workflow/month-view.png" alt="Month view"/>
        </Frame>
      </Tab>
      <Tab title="Week view">
        <Frame caption="Use week view to draw time slots on a weekly calendar">
          <img src="/images/workflow/week-view.png" alt="Week view"/>
        </Frame>
      </Tab>
    </Tabs>

    **Duration** - Each option defaults to a 1 hour time slot. Use the duration picker above the calendar to change the length (30m, 1h, 1h 30m or 2h), or choose **All day** for participants to vote on whole days without specific times.

    **Time zones** - Times are automatically shown to each participant in their own time zone. This is useful for remote meetings where participants are in different locations. If everyone should see the same times regardless of where they are, turn on **Lock time zone**.

    **Vote options** - Participants can answer **Yes**, **If need be** or **No**.
    Change this to **Yes** and **No** under **Settings** if you'd rather not
    offer a maybe.

  </Step>
  <Step title="Create your poll">
    Click **Create poll**. Your poll opens with the **Share** dialog ready, so
    you can copy the invite link or start inviting people by email right away.

    <Frame>
      <img src="/images/workflow/poll-created.png" alt="Share dialog shown after creating a poll"/>
    </Frame>

  </Step>
</Steps>

Once your poll is created you can start [inviting participants](/workflow/invite).

---

### Apps Docs Workflow Invite (`apps/docs/workflow/invite.mdx`)

---
icon: "circle-2"
title: "Invite participants"
description: "Collect responses by sharing the invite link or inviting people by email"
---

<Steps>
  <Step title="Open the Share dialog">
    The **Share** dialog opens right after you create your poll. You can get
    back to it at any time by clicking the **Share** button on your poll page.

    To share the **invite link**, click **Copy** and paste it anywhere: chat,
    email or a calendar invite. Anyone with the link can vote on your poll.

    To **invite by email**, enter an email address and click **Send invite**.
    Rallly sends that person an email with a link to your poll.

    <Frame caption="Each invitee shows whether the invite was sent, opened or responded to">
      <img src="/images/workflow/invite-link.png" alt="Share dialog with the invite link and a list of email invitees"/>
    </Frame>

    <Note>Inviting by email requires a [Pro subscription](https://rallly.co/pricing) on Rallly Cloud. Sharing the invite link is free.</Note>

  </Step>
  <Step title="Wait for participants to vote">
    Participants open the invite link, pick the options that work for them and
    save their response.

    <Note>Have a look at the [participant guide](/guide/participant-guide) for details on how participants can vote.</Note>

    <Frame>
      <img src="/images/workflow/voting.png" alt="Participant voting on a poll"/>
    </Frame>

    As responses come in, you can review the results on your poll page to see
    which dates are preferred. If you invited people by email, the Share
    dialog also shows who has opened their invite and who has responded.

    <Frame>
      <img src="/images/workflow/review.png" alt="Reviewing poll results"/>
    </Frame>

  </Step>
</Steps>

Once all participants have responded you can proceed with [scheduling your event](/workflow/schedule).

---

### Apps Docs Workflow Schedule (`apps/docs/workflow/schedule.mdx`)

---
icon: "circle-3"
title: "Finalize"
description: "Pick the final date, notify your participants and close the poll"
---

Finalizing is available on every plan. It locks in a date, emails a calendar
invite to the participants you choose and closes the poll to new responses.

<Steps>
  <Step title="Pick the date">
    Click **Finalize** in the poll header. The dates are listed from most to
    least popular, with the top three marked. Each row shows how many
    participants can make it and a breakdown of their votes. Click a date to
    continue.

    <Frame>
      <img src="/images/workflow/finalize.png" alt="Finalize dialog listing the dates by popularity"/>
    </Frame>

  </Step>
  <Step title="Choose who to notify">
    Everyone who left an email address is selected. Untick anyone you would
    rather tell yourself, or use the checkbox in the header to select or clear
    everyone at once. Participants without an email address are listed but
    cannot be notified.

    This step is skipped when the poll has no participants yet.

    <Frame>
      <img src="/images/workflow/finalize-notify.png" alt="Notify participants step with the participant list"/>
    </Frame>

  </Step>
  <Step title="Check the summary and finalize">
    The summary shows the date, the location and the video call the event will
    carry. If the poll has a video call, its link is created at this point.
    Click **Finalize**.

    This will:

    1. Send a calendar invite to you by email.
    2. Send a calendar invite to the participants you selected. Invites go out
       in batches, so on a large poll the last ones can take a few minutes.
    3. Close the poll to new responses.

    <Frame>
      <img src="/images/workflow/finalize-summary.png" alt="Summary step before finalizing"/>
    </Frame>

    The final date is displayed on your poll for everyone to see and can be
    added to a calendar with one click.

    <Frame>
      <img src="/images/workflow/finalized.png" alt="Scheduled poll"/>
    </Frame>

  </Step>
</Steps>

---

### Apps Docs Api Reference Webhooks Events (`apps/docs/api-reference/webhooks/events.mdx`)

---
title: "Events"
icon: "bolt"
description: "The envelope every event shares, the headers on every request, and the events you can subscribe to."
---

## Envelope

Every body has the same outer shape. `data` is the only part that differs between events.

```json
{
  "version": "2026-09-20",
  "id": "cm5h8x2k40000q9l4f7e2d3an",
  "type": "poll.closed",
  "createdAt": "2025-01-12T08:30:00.000Z",
  "data": {
    "poll": { "id": "Xk3pQ9vLm2Ab" },
    "reason": "manual"
  }
}
```

| Field | Meaning |
| --- | --- |
| `version` | The payload contract the body was built against. See [Versioning](/api-reference/webhooks#versioning) |
| `id` | The event's stable id. Retries carry the same `id`; deduplicate on it |
| `type` | The event, also sent as the `X-Rallly-Event` header |
| `createdAt` | When the change happened, not when the request was sent |
| `data` | The event's payload. Each event page documents it |

Payloads are deliberately thin. An event says what happened and to which poll or participant, by id; it does not describe them. Fetch the API for the current state when you need it, and treat the event as the signal to do so.

## Headers

| Header | Value |
| --- | --- |
| `Content-Type` | `application/json` |
| `X-Rallly-Event` | The event type, the same as `type` in the body |
| `X-Rallly-Delivery` | Unique per delivery attempt |
| `X-Rallly-Webhook-Version` | The payload version, the same as `version` in the body |
| `X-Rallly-Signature` | `t=<unix seconds>,v1=<hex HMAC-SHA256>`. See [Security](/api-reference/webhooks/security) |
| `User-Agent` | `Rallly-Webhooks/1.0` |

## Events

Event names are `<resource>.<transition>`, or `<resource>.<part>.<transition>` for a change to part of a resource. New events may be added without a version change; ignore types you do not recognise rather than failing on them.

Every poll event carries `data.poll`, a reference to the poll:

```json
{ "poll": { "id": "Xk3pQ9vLm2Ab" } }
```

Fetch `GET /polls/{pollId}` for the title, options, settings and current status. Events about a response add `data.participant`, a reference to the participant; fetch `GET /polls/{pollId}/participants/{participantId}` for their name, email and availability.

One event per transition. A poll that is closed, reopened and closed again produces three events, each describing the transition it records.

| Event | When |
| --- | --- |
| `poll.created` | A poll was created |
| `poll.updated` | The poll's details, options or settings changed |
| `poll.closed` | The poll stopped accepting responses |
| `poll.reopened` | A closed poll accepts responses again |
| `poll.scheduled` | The organizer picked a time |
| `poll.deleted` | The poll was deleted |
| `poll.participant.created` | Someone responded |
| `poll.participant.updated` | A participant changed their response |
| `poll.participant.deleted` | A response was removed |

### Payloads

<AccordionGroup>
  <Accordion title="poll.created">
    A poll was created, from the app or the API.

    ```json
    {
      "version": "2026-09-20",
      "id": "cm5h8x2k40000q9l4f7e2d3an",
      "type": "poll.created",
      "createdAt": "2025-01-12T08:30:00.000Z",
      "data": {
        "poll": {
          "id": "Xk3pQ9vLm2Ab"
        }
      }
    }
    ```
  </Accordion>
  <Accordion title="poll.updated">
    The poll's details, options or settings changed. The event says that something changed, not what; fetch the poll for its current state.

    ```json
    {
      "version": "2026-09-20",
      "id": "cm5h8x2k40000q9l4f7e2d3an",
      "type": "poll.updated",
      "createdAt": "2025-01-12T08:30:00.000Z",
      "data": {
        "poll": {
          "id": "Xk3pQ9vLm2Ab"
        }
      }
    }
    ```
  </Accordion>
  <Accordion title="poll.closed">
    The poll stopped accepting responses. `reason` is `manual` when the organizer closed it and `auto` when it closed because every option had passed.

    ```json
    {
      "version": "2026-09-20",
      "id": "cm5h8x2k40000q9l4f7e2d3an",
      "type": "poll.closed",
      "createdAt": "2025-01-12T08:30:00.000Z",
      "data": {
        "poll": {
          "id": "Xk3pQ9vLm2Ab"
        },
        "reason": "manual"
      }
    }
    ```
  </Accordion>
  <Accordion title="poll.reopened">
    A closed poll accepts responses again.

    ```json
    {
      "version": "2026-09-20",
      "id": "cm5h8x2k40000q9l4f7e2d3an",
      "type": "poll.reopened",
      "createdAt": "2025-01-12T08:30:00.000Z",
      "data": {
        "poll": {
          "id": "Xk3pQ9vLm2Ab"
        }
      }
    }
    ```
  </Accordion>
  <Accordion title="poll.scheduled">
    The organizer picked a time. The poll stops accepting responses, and `event` is the span that was chosen: `start` inclusive, `end` exclusive, both UTC instants. `allDay: true` means a whole calendar day, given as midnight to midnight UTC.

    ```json
    {
      "version": "2026-09-20",
      "id": "cm5h8x2k40000q9l4f7e2d3an",
      "type": "poll.scheduled",
      "createdAt": "2025-01-12T08:30:00.000Z",
      "data": {
        "poll": {
          "id": "Xk3pQ9vLm2Ab"
        },
        "event": {
          "start": "2025-01-15T09:00:00.000Z",
          "end": "2025-01-15T09:30:00.000Z",
          "allDay": false
        }
      }
    }
    ```
  </Accordion>
  <Accordion title="poll.deleted">
    The poll was deleted. `GET /polls/{pollId}` returns `404` from now on, so anything you need about it must already be stored.

    ```json
    {
      "version": "2026-09-20",
      "id": "cm5h8x2k40000q9l4f7e2d3an",
      "type": "poll.deleted",
      "createdAt": "2025-01-12T08:30:00.000Z",
      "data": {
        "poll": {
          "id": "Xk3pQ9vLm2Ab"
        }
      }
    }
    ```
  </Accordion>
  <Accordion title="poll.participant.created">
    Someone responded to the poll.

    ```json
    {
      "version": "2026-09-20",
      "id": "cm5h8x2k40000q9l4f7e2d3an",
      "type": "poll.participant.created",
      "createdAt": "2025-01-12T08:30:00.000Z",
      "data": {
        "poll": {
          "id": "Xk3pQ9vLm2Ab"
        },
        "participant": {
          "id": "cm5j2r8wb0003q9l4a1x6p0zt"
        }
      }
    }
    ```
  </Accordion>
  <Accordion title="poll.participant.updated">
    A participant changed their response or their name.

    ```json
    {
      "version": "2026-09-20",
      "id": "cm5h8x2k40000q9l4f7e2d3an",
      "type": "poll.participant.updated",
      "createdAt": "2025-01-12T08:30:00.000Z",
      "data": {
        "poll": {
          "id": "Xk3pQ9vLm2Ab"
        },
        "participant": {
          "id": "cm5j2r8wb0003q9l4a1x6p0zt"
        }
      }
    }
    ```
  </Accordion>
  <Accordion title="poll.participant.deleted">
    A response was removed, by the participant or the organizer. Fetching the participant returns `404`.

    ```json
    {
      "version": "2026-09-20",
      "id": "cm5h8x2k40000q9l4f7e2d3an",
      "type": "poll.participant.deleted",
      "createdAt": "2025-01-12T08:30:00.000Z",
      "data": {
        "poll": {
          "id": "Xk3pQ9vLm2Ab"
        },
        "participant": {
          "id": "cm5j2r8wb0003q9l4a1x6p0zt"
        }
      }
    }
    ```
  </Accordion>
</AccordionGroup>

## Test events

**Send test event** in the endpoint's menu on the settings page sends a `ping` to that endpoint straight away. It goes out whatever events the endpoint subscribes to, and it is signed and sent like any other event, so it checks your URL, your signature verification and your response in one go.

```json
{
  "version": "2026-09-20",
  "id": "ping_V1StGXR8Z5jdHi6BmyT",
  "type": "ping",
  "createdAt": "2025-01-12T08:30:00.000Z",
  "data": {}
}
```

A `ping` carries no data and is never retried. Respond with a `2xx` and otherwise ignore it. A failed test does not count toward disabling the endpoint.

---

### Apps Docs Api Reference Webhooks Security (`apps/docs/api-reference/webhooks/security.mdx`)

---
title: "Security"
icon: "shield-check"
description: "Verify that a request came from Rallly before acting on it."
---

Anyone who knows your endpoint's URL can send it a request. The signing secret is what tells a genuine delivery from a forged one, so verify every request before you trust its body.

## The signing secret

Each endpoint has its own secret, shown once when the endpoint is created. Store it as you would any secret: in an environment variable or a secrets manager, never in client-side code or a repository. If it leaks, delete the endpoint and add it again; that mints a new secret.

Deliveries do not come from a fixed set of IP addresses, so allowlisting is not a substitute for verifying the signature.

## The signature header

Every request carries:

```http
X-Rallly-Signature: t=1736670600,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

`t` is the time the request was signed, in seconds since the Unix epoch. `v1` is the hex HMAC-SHA256, keyed with the signing secret, of the string `<t>.<raw body>`: the timestamp, a full stop, and the request body exactly as received.

## Verifying it

1. Read the raw body as bytes. Do not parse and re-serialise it first; any change to whitespace or key order changes the digest.
2. Split the header on `,` and each part on `=` to get `t` and `v1`.
3. Check that `v1` is exactly 64 lowercase hex characters. Anything else is not a digest and is rejected before comparing, so a malformed header cannot make the comparison throw.
4. Compute HMAC-SHA256 of `<t>.<raw body>` with the secret and compare it to `v1` with a constant-time comparison.
5. Reject the request if `t` is more than a few minutes from now. This stops a captured request from being replayed later.

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(header: string, rawBody: string, secret: string) {
  const parts = Object.fromEntries(
    header.split(",").map((part) => part.split("=") as [string, string]),
  );
  const timestamp = Number(parts.t);
  if (Math.abs(Date.now() / 1000 - timestamp) > 300) {
    return false; // missing, or older than five minutes
  }
  if (!/^[0-9a-f]{64}$/.test(parts.v1 ?? "")) {
    return false; // not a SHA-256 hex digest, so never worth comparing
  }
  const expected = createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest();
  return timingSafeEqual(expected, Buffer.from(parts.v1, "hex"));
}
```

## After verifying

- Deduplicate on the body's `id`. Delivery is at least once, and retries of one event share it.
- Treat the body as a notification, not as authority over your data. When an action depends on the poll's current state, fetch `GET /polls/{pollId}` with your API key rather than trusting a snapshot.
- Respond `2xx` as soon as the request is stored. Work that takes longer than 10 seconds should happen after the response, or the delivery is retried.

---

### Apps Docs Self Hosting Installation Docker (`apps/docs/self-hosting/installation/docker.mdx`)

---
icon: server
title: Installation
description: Install Rallly on your own server.
---

This guide installs Rallly using the **Rallly CLI**. It bundles the app, a PostgreSQL database, S3-compatible object storage, and an HTTPS reverse proxy — everything runs on one machine with no extra setup. If you'd rather use a managed database or object store, see [External services](#external-services).

<Note>
  Already running Rallly from a hand-edited `docker-compose.yml` or `config.env`? Follow the [upgrade guide](/self-hosting/migration) to move onto the new stack without losing data.
</Note>

## Requirements

- A server with at least **2 GB RAM**
- **Docker 19.03+** with **Compose v2**
- Ports **80** and **443** open on the server (bundled proxy only — see [External reverse proxy](#external-reverse-proxy) if you're bringing your own)
- A domain name with a DNS `A` record pointing to the server's IP
- An **SMTP server** for sending emails (e.g. [Resend](https://resend.com), [Postmark](https://postmarkapp.com), [Mailgun](https://www.mailgun.com), [Brevo](https://www.brevo.com))

<Note>
  Use a transactional email provider. We don't recommend running your own SMTP server — deliverability depends on IP reputation, SPF/DKIM/DMARC, and reverse DNS, which are hard to get right. Consumer inboxes like Gmail or Proton Mail are also a poor fit: they rate-limit automated senders and will eventually block sign-in emails.
</Note>

## Quick install

Run the installer on your server:

```sh
curl -fsSL https://get.rallly.co | bash
```

The installer will:

1. Check that Docker and the required ports are available.
2. Download the stack to `/opt/rallly`.
3. Walk you through configuration (domain, support email, SMTP).
4. Generate secure secrets automatically.
5. Start the stack.

On first boot, Traefik will request a Let's Encrypt certificate for your domain. This can take up to a minute. Once it's ready, your instance is available at `https://<your-domain>`.

## Claim the admin account

After the stack is running, the first user whose email matches `INITIAL_ADMIN_EMAIL` can navigate to `/control-panel` and claim the admin role. See the [Control Panel guide](/self-hosting/control-panel) for details.

## External services

The bundled reverse proxy, Postgres database, and object storage containers are the default. You can swap any of them for external providers (your own Nginx/Caddy/Cloudflare Tunnel, managed Postgres, AWS S3, Cloudflare R2, MinIO, etc.) by setting the relevant variables in `.env`. The installer uses Docker Compose profiles to skip any bundled service that has an external endpoint configured, so no stack edits are needed.

### External reverse proxy

Set `PROXY_MODE=external` to disable the bundled Traefik. The `web` container is then published on a host port that your reverse proxy can route to. `WEB_PORT` controls the bind — defaults to `127.0.0.1:3000` (loopback only).

```env
PROXY_MODE=external
WEB_PORT=127.0.0.1:3000
```

Your reverse proxy is responsible for terminating TLS and forwarding to `WEB_PORT`. Set `WEB_PORT=0.0.0.0:3000` to expose on all interfaces, or pick a different port if 3000 is in use.

The installer prompts for this during `./rallly.sh setup` — pick option 2 ("external") at the reverse proxy step.

### External database

Set `DATABASE_URL` in `.env` to your Postgres connection string. When this variable is set, the bundled `db` container is not started.

```env
DATABASE_URL=postgres://user:password@db.example.com:5432/rallly
```

### External object storage

Set the S3 variables in `.env` to point at your bucket. When `S3_ENDPOINT` is set to anything other than the bundled Garage endpoint, the bundled `garage` container is not started.

```env
S3_ENDPOINT=https://s3.us-east-1.amazonaws.com
S3_BUCKET_NAME=my-rallly-bucket
S3_REGION=us-east-1
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
```

Apply changes with `./rallly.sh restart`.

## Next steps

- **[Configuration](/self-hosting/configuration)** — full environment variable reference.
- **[Management](/self-hosting/management)** — updating, backups, and day-to-day operations.
- **[Single Sign-On](/self-hosting/single-sign-on)** — enable Google, Microsoft, or OIDC login.

---

### Apps Docs Api Reference Webhooks Guides N8n (`apps/docs/api-reference/webhooks/guides/n8n.mdx`)

---
title: "Receive webhooks in n8n"
sidebarTitle: "n8n"
icon: "diagram-project"
description: "Trigger an n8n workflow from a Rallly event, verify it, and fetch the poll."
---

This guide builds a workflow that receives a Rallly event, checks that it really came from Rallly, and fetches the poll it is about. Five nodes: Webhook, Code, Switch, HTTP Request, and whatever you do with the result.

<Steps>
  <Step title="Add a Webhook node">
    Set **HTTP Method** to `POST` and **Respond** to `Immediately`. Rallly waits at most 10 seconds for a `2xx`, and responding first means a slow step later in the workflow never turns into a retry.

    Under **Options**, switch on **Raw Body**. This is the step people miss. The signature covers the request body byte for byte; without this option the node parses the JSON and hands you an object, and re-serialising that object produces different bytes and a digest that never matches. With Raw Body on, the exact bytes are kept as binary data on the item, in the property named `data`.

    Copy the node's **Production URL**.
  </Step>

  <Step title="Register the endpoint in Rallly">
    In Rallly, open **Settings → Webhooks**, choose **Add webhook**, paste the URL and pick the events you want. Copy the signing secret: it is shown once.

    In n8n, store the secret where the workflow can read it without it appearing in the workflow JSON. A workflow **variable** or an environment variable both work; the code below reads `$vars.RALLLY_WEBHOOK_SECRET`.
  </Step>

  <Step title="Verify the signature in a Code node">
    Add a **Code** node after the Webhook node, mode **Run Once for All Items**, and paste:

    ```js
    const { createHmac, timingSafeEqual } = require("crypto");

    const item = $input.first();
    const header = item.json.headers["x-rallly-signature"] ?? "";
    const rawBody = await this.helpers.getBinaryDataBuffer(0, "data");

    const parts = Object.fromEntries(
      header.split(",").map((part) => part.split("=")),
    );
    const timestamp = Number(parts.t);
    if (Math.abs(Date.now() / 1000 - timestamp) > 300) {
      throw new Error("Signature timestamp is missing or older than five minutes");
    }
    if (!/^[0-9a-f]{64}$/.test(parts.v1 ?? "")) {
      throw new Error("Signature is not a SHA-256 digest");
    }

    const expected = createHmac("sha256", $vars.RALLLY_WEBHOOK_SECRET)
      .update(`${parts.t}.`)
      .update(rawBody)
      .digest();
    if (!timingSafeEqual(expected, Buffer.from(parts.v1, "hex"))) {
      throw new Error("Signature does not match");
    }

    return [{ json: JSON.parse(rawBody.toString("utf8")) }];
    ```

    A request that fails any check stops the workflow with an error, and the node outputs the verified event body as the item. Everything after this node can trust `$json.type` and `$json.data`.

    If you run n8n on your own server, `require("crypto")` needs the built-in module allowed: set `NODE_FUNCTION_ALLOW_BUILTIN=crypto` in n8n's environment. n8n Cloud allows it already.
  </Step>

  <Step title="Branch on the event type">
    Add a **Switch** node on `{{ $json.type }}` with one output per event you subscribed to, for example `poll.closed`, `poll.scheduled` and `poll.participant.created`. Add a fallback output and leave it unconnected: Rallly may add event types without a version change, and an unknown type should be ignored, not treated as an error.
  </Step>

  <Step title="Fetch the poll">
    The event carries the poll's id and nothing else about it, so add an **HTTP Request** node:

    - **Method** `GET`
    - **URL** `https://api.rallly.co/v1/polls/{{ $json.data.poll.id }}`
    - **Authentication** `Generic Credential Type` → `Bearer Auth`, with a credential holding your Rallly API key from **Settings → API keys**

    For a `poll.participant.*` event, fetch the participant the same way: `https://api.rallly.co/v1/polls/{{ $json.data.poll.id }}/participants/{{ $json.data.participant.id }}` returns their name, email and availability.

    From here the poll and the participant are ordinary items: post to Slack, write a row to a sheet, or update your own system.
  </Step>
</Steps>

## Testing it

Use the Webhook node's **Test URL** while building, then register the **Production URL** in Rallly. A quick end-to-end check is to close and reopen a poll in Rallly and watch `poll.closed` and `poll.reopened` arrive.

## When something fails

- **Signature does not match** on every request: Raw Body is off, or the secret in n8n is not the one Rallly showed when the endpoint was created. Delete and re-add the endpoint if the secret is lost.
- **Timestamp older than five minutes**: the n8n host's clock is wrong, or the request was a replay. Rallly's retries are signed fresh, so a stale timestamp is never a retry.
- **Endpoint disabled in Rallly**: twenty deliveries in a row exhausted their retries. Fix the workflow, then re-enable the endpoint from the settings page; events from while it was disabled are not replayed.

---

--- METRICS ---
- Files Extracted: 52
- Estimated Token Budget: ~71550 tokens
- Canonical Reference: https://codewiki.google/github.com/lukevella/rallly
