rallly Documentation

Rallly is an open-source scheduling and collaboration tool designed to make organizing events and meetings easier.

5,313 stars TypeScript TypeScript 52 files · ~71,629 tokens #i18next#next-auth#nextjs#postgresql#prisma#react-email#t3-stack#tailwindcss
RAW Doc

Docker & Self-Hosted Deployment Verified OCI

Deploy rallly on any server, homelab, or cloud container platform.

1-Click Terminal Command
bash (docker run)
docker run -d --name rallly -p 5450:5450 --restart always postgres:18-alpine
Configured Services & Ports
⚙️ rallly_db
image: postgres:18-alpine
🔌 Ports: 5450:5432
⚙️ redis
image: redis:7.4-alpine
🔌 Ports: 6379:6379
⚙️ serverless-redis-http
image: hiett/serverless-redis-http:latest
🔌 Ports: 8079:80
⚙️ garage
image: dxflrs/garage:v2.3.0
🔌 Ports: 3900:3900, 3903:3903
⚙️ mailpit
image: axllent/mailpit
🔌 Ports: 8025:8025, 1025:1025
⚙️ garage_init
image: amazon/aws-cli
docker-compose.yml
yaml (docker-compose.yml)
services:
  rallly_db:
    image: postgres:18-alpine
    restart: always
    ports:
      - "5450:5432"
    volumes:
      - db-data:/var/lib/postgresql
    environment:
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_DB=rallly
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5
  redis:
    image: redis:7.4-alpine
    restart: always
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data
    healthcheck:
      test: ["CMD-SHELL", "redis-cli ping"]
      interval: 5s
      timeout: 5s
      retries: 5
  serverless-redis-http:
    ports:
      - "8079:80"
    image: hiett/serverless-redis-http:latest
    environment:
      SRH_MODE: env
      SRH_TOKEN: dev_fake_token_123456789abcdefghijklmnop
      SRH_CONNECTION_STRING: "redis://redis:6379"
  garage:
    image: dxflrs/garage:v2.3.0
    restart: always
    ports:
      - "3900:3900"
      - "3903:3903"
    volumes:
      - ./docker/garage.toml:/etc/garage.toml:ro
      - garage-meta:/var/lib/garage/meta
      - garage-data:/var/lib/garage/data
    environment:
      - GARAGE_DEFAULT_ACCESS_KEY=rallly-access-key
      - GARAGE_DEFAULT_SECRET_KEY=rallly-secret-key
      - GARAGE_DEFAULT_BUCKET=rallly
    command: /garage server --single-node --default-bucket
    healthcheck:
      test: ["CMD", "/garage", "stats", "-a"]
      interval: 1h
      timeout: 5s
      retries: 3
      start_period: 10s
      start_interval: 5s
  mailpit:
    image: axllent/mailpit
    ports:
      - "8025:8025"
      - "1025:1025"
    healthcheck:
      test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8025/readyz"]
      interval: 5s
      timeout: 5s
      retries: 5


  garage_init:
    image: amazon/aws-cli
    depends_on:
      garage:
        condition: service_healthy
    environment:
      - AWS_ACCESS_KEY_ID=rallly-access-key
      - AWS_SECRET_ACCESS_KEY=rallly-secret-key
      - AWS_DEFAULT_REGION=garage
    volumes:
      - ./docker/cors.json:/cors.json:ro
    entrypoint: ["sh", "-c", "aws --endpoint-url http://garage:3900 s3api put-bucket-cors --bucket rallly --cors-configuration file:///cors.json"]
    restart: "no"

volumes:
  db-data:
    driver: local
  redis-data:
    driver: local
  garage-meta:
    driver: local
  garage-data:
    driver: local

Technical Documentation: lukevella/rallly

ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference

Overview (`README.md`)

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
  • 🔒 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 →

🐳 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 for installation and configuration options.

🛠️ Built with

Next.js · Prisma · tRPC · TailwindCSS

🤝 Contributing

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

📜 License

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

💚 Sponsors

Thank you to our sponsors for making this project possible.

Become a sponsor →


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

Support Docs

Live site: https://support.rallly.co

Our docs are written in MDX which is mostly Markdown with the ability to use components inside.

This site is kindly built with and hosted by Mintlify.

How to Run Locally

To preview your changes locally, you can use the mintlify cli 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, Tabs and Callouts. Check out their documentation 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"

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. 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. 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.

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. 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. No, this feature is currently not supported but may be added in the future. No, this feature is currently not supported but may be added in the future. First, check your spam folder.

Not there?

If you're using Outlook, try adding rallly.co to your safe senders and domains.

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 and we'll remove you from the list.


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


icon: "house" title: "Introduction"

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, 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.

Learn how scheduling polls work How to respond to a scheduling poll Team collaboration with centralized billing

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.

Learn more about Rallly's pricing

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"

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

To delete a meeting poll, from the admin page:

  1. Click Manage

  2. Select Delete from the dropdown menu

  3. Click Delete to confirm


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


title: "Edit a Poll"

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

From the admin page, click **Manage** and select **Edit** from the dropdown menu.
text
<Frame caption="Admin controls">
  
</Frame>
The edit page is laid out like the create page and filled in with the poll's current values.
text
<Frame caption="Edit 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.
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.
text
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.

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.

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.

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"

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"

  • 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.

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.

The poll and participant resources are unchanged.

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

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


title: "Errors" icon: "triangle-exclamation"

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
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"

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 tell your systems when a poll or a response changes, so you never have to poll for results.

The API requires a Pro plan.

Getting a key

Only the space owner can create API keys, from Settings → API keys. See 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 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"

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

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"
  }
}
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 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.
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": "[email protected]",
      "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"
  }
}

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:

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" }
  ]
}

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.

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
    }
  ]
}

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.

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 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. 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

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

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.
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
  }
}

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.

json private api
{
  "data": {
    "pollId": "Xk3pQ9vLm2Ab",
    "participants": [
      {
        "id": "cm5j2r8wb0003q9l4a1x6p0zt",
        "name": "Jessie Smith",
        "email": "[email protected]",
        "createdAt": "2026-09-11T08:30:00.000Z"
      }
    ]
  }
}
json v1
{
  "data": [
    {
      "id": "cm5j2r8wb0003q9l4a1x6p0zt",
      "name": "Jessie Smith",
      "email": "[email protected]",
      "createdAt": "2026-09-11T08:30:00.000Z"
    }
  ]
}

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\nv1 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": "[email protected]"
},
"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": "[email protected]"
}
},
"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": "[email protected]"
}
},
"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": "[email protected]"
},
"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\njson\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\njson\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\njson\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\nvotes 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"

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"

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.

Webhooks require a Pro plan.

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.

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 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 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 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

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

Check out the [FAQ](/faq) page to see if your question has already been answered. Send us an email Join Discord If the email link doesn't open your email client, email us at `[email protected]`.

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


title: Documentation icon: book

If you have a Github account, you can make changes or report issues using the links that appear at the top of each page.

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

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:

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

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

Help fund this project Help translate Rallly into your language Help us find and fix bugs Help improve our documentation Help spread the word Help us grow our community

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


title: Translations icon: globe

Rallly is made available in different languages by volunteer translators.
Translations can be submitted through Crowdin.
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. 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!

Tips for Translators

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

Punctuation

Pay close attention to how phrases are punctuated.

text
Name:

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

text
❌ Nombre
✅ Nombre:

Variables

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

text
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.

text
❌ 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:

text
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).

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

Plurals

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

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.

text
{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.

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

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.

FAQ

You should still join the Crowdin project. When new translations are added you will be notified to help translate them. New languages need to be manually added by us. If you'd like us to add your language please [contact us](#contact). Translations are manually approved before being released. This can take anywhere from a few hours to a few days.

Contact

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


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


icon: shield-check title: Content Moderation

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 and we'll review your case.


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


icon: user title: "Participant Guide"

Click the button under each date to toggle through the answers:
text
- **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>
  
</Frame>

When you're done, click **Continue**.
Enter your name and email address, then click **Save availability**.
text
<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>
  
</Frame>
text
If you need to make changes, you can use the dropdown menu next to your name.

<Frame>
  
</Frame>

Frequently Asked Questions

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.

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.

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


icon: video title: Google Meet

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

In Rallly, open **Settings** from the sidebar and choose **Conferencing**. Next to **Google Meet**, click **Connect**. You are sent to Google to choose an account and approve the connection. 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.

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.

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

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

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.

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 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.


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


icon: video title: Microsoft Teams

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.

Microsoft Teams requires a [Pro subscription](https://rallly.co/pricing).

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.

Adding the app

In Rallly, open **Settings** from the sidebar and choose **Conferencing**. Next to **Microsoft Teams**, click **Connect**. You are sent to Microsoft to pick your work or school account and sign in. 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.

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.

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:

text
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.

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.

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 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.

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 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.


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


icon: video title: Webex

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.

Webex requires a [Pro subscription](https://rallly.co/pricing).

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.

Adding the app

In Rallly, open **Settings** from the sidebar and choose **Conferencing**. Next to **Webex**, click **Connect**. You are sent to Webex to sign in. 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.

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.

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.

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.

"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.


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


icon: video title: Zoom

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.

Zoom requires a [Pro subscription](https://rallly.co/pricing).

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

In Rallly, open **Settings** from the sidebar and choose **Conferencing**. Next to **Zoom**, click **Connect**. You are sent to Zoom to sign in and approve the connection. 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.

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.

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

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

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

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.

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.
  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.


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


icon: video title: Video Conferencing

Available from v4.16.0 and later.

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 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

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). Add the provider's variables to your `.env`, and list it in `CONFERENCING_PROVIDERS`, for example `CONFERENCING_PROVIDERS=google-meet,zoom`. Run `./rallly.sh restart`. **Conferencing** appears in **Settings** for every user.

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.

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.

1. In the [Google Cloud console](https://console.cloud.google.com/), open the project that holds your OAuth client, or create one.
  1. Under APIs & Services > Library, enable the Google Meet REST API.

  2. 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.

  3. 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.
  4. Under Google Auth Platform > Clients, open your Web application client (or create one) and add this to Authorized redirect URIs:

text
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.

The client ID of your Google OAuth app. Shared with Google sign in and Google Calendar.

The client secret of your Google OAuth app.

Zoom

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.
  1. Under Basic Information, set the OAuth Redirect URL and add the same address to the OAuth Allow List:
text
https://<YOUR_DOMAIN>/api/integrations/callback/zoom
  1. Under Scopes, add meeting:write:meeting and user:read:user.

  2. 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.

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:
text
https://<YOUR_DOMAIN>/api/integrations/zoom/deauthorize
  1. Subscribe to the App Deauthorized event.

  2. Copy the Secret Token into ZOOM_WEBHOOK_SECRET_TOKEN and restart before Zoom validates the endpoint.

The client ID of your Zoom app. Set together with ZOOM_CLIENT_SECRET, or Rallly refuses to start.

The client secret of your Zoom app.

The secret token of the app's event subscription. Only needed for deauthorization notices.

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.

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.
  1. Under Authentication, add a Web redirect URI:
text
https://<YOUR_DOMAIN>/api/integrations/callback/microsoft-teams
  1. 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.

The client ID of your Microsoft registration. Shared with Microsoft sign in.

The client secret of your Microsoft registration.

The tenant of your Microsoft registration.

Webex

1. Sign in to the [Webex Developer Portal](https://developer.webex.com/) and choose **My Webex Apps** > **Create a New App** > **Integration**.
  1. Set the Redirect URI:
text
https://<YOUR_DOMAIN>/api/integrations/callback/webex
  1. Select the scopes meeting:schedules_write and spark:people_read.

  2. Save, then copy the Client ID and Client Secret. Webex shows the secret only once.

The client ID of your Webex integration. Set together with WEBEX_CLIENT_SECRET, or Rallly refuses to start.

The client secret of your Webex integration.

Troubleshooting

`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. `CONFERENCING_PROVIDERS` is unset or empty. Restart after editing `.env`. 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. 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. 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. 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.

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


icon: gear title: Configuration Options

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

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).

General

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.

Email address for Let's Encrypt certificate notifications (expiry warnings, etc.).

A random secret key used to encrypt user sessions. Must be at least 32 characters. Generate one with openssl rand -base64 32.

Shown to users as the contact email for support queries.

Sender address for all transactional emails. Falls back to SUPPORT_EMAIL if not set.

Sender name for all transactional emails.

The first user who signs in with this email can claim the admin role from /control-panel. See the Control Panel guide.

Email (SMTP)

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

The host address of your SMTP server

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.

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.

SMTP username, if authentication is enabled.

SMTP password, if authentication is enabled.

Validate TLS certificates. Set to false to accept self-signed certificates. Not recommended for production.

Available from v4.4.0 and later.

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.

Available from v4.8.0 and later.

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>: for STARTTLS (SMTP_SECURE=false), or openssl s_client -connect <host>: for implicit TLS (SMTP_SECURE=true, usually port 465).

Auth

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.

Available from v4.4.0 and later.

Set to false to disable new user registration. Takes precedence over the database setting in the admin UI.

Available from v4.4.0 and later.

Comma-separated list of email addresses allowed to register and sign in. Wildcards are supported. Example: *@example.com allows any address on that domain.

Single Sign-On

See the Single Sign-On guide 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 for setup instructions and the full list of variables.

Available from v4.16.0 and later.

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 for details.

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

Application name. Appears in page titles, navigation, and emails.

Primary brand color for light mode. Must be a valid hex code.

Primary brand color for dark mode. Auto-calculated from PRIMARY_COLOR if not set.

Absolute URL to your logo for light mode. SVG recommended. Defaults to the Rallly logo.

Absolute URL to your logo for dark mode. Falls back to LOGO_URL if not set.

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.

Set to true to hide "Powered by Rallly" attribution in polls and emails.

Advanced

Override the Rallly Docker image. Pin a major version (e.g. lukevella/rallly:4) to avoid pulling in breaking changes. See the releases for available versions.

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.

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 in the installation guide for the full setup.

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.

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

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.

Postgres connection string. Leave unset to use the bundled database. Example: postgres://user:[email protected]:5432/rallly.

External object storage

The stack bundles Garage 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.

S3 API endpoint. Leave unset to use the bundled storage. Example: https://s3.us-east-1.amazonaws.com.

Bucket name for uploads.

Bucket region (e.g. us-east-1, auto for Cloudflare R2).

Access key for the bucket.

Secret key for the bucket.

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 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, a few variables that the CLI normally supplies or derives become your responsibility.

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.

The variables documented under External database and 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"

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).

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.

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 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

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, 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 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
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.
  • If your proxy requires basic authentication, include the credentials in the URL: http://user:[email protected]: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.

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.

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
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 (v4.12.3+), or request a firewall exception for the hosts above 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 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"

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 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.

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).

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. Every account can have any number of polls collecting votes at once.
  • API: the Rallly API is only served by Rallly Cloud. Self-hosted instances do not have it.
  • 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

If you would rather not run a server, use Rallly Cloud.


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


icon: "key" title: "Licensing"

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
Organization Up to 50 users 50 seats $499 Buy License
Enterprise 50+ users 100 seats From $999 Get Quote
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.

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.
  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.

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).

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

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.

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. Yes, an invoice is automatically generated for every purchase. You'll receive it via email shortly after your payment is processed. 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. 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 so we can better understand your needs and provide an accurate quote.

You can also contact us directly to discuss your requirements.
We're happy to work with you to find a solution that fits.

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.

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


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


title: Managed Hosting

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.

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

Cloudron sponsors this project on Github.

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

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


If you have a provider you'd like to recommend, please open an issue on Github.


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


icon: terminal title: Management

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 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).

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.

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.

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.

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

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.

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.

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.

`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).

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.

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>
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.

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 for uploads. If you were previously using external S3, either keep pointing at it (advanced, see 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)

Available in v3.4.0 and later.

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.

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

Google

1. Open the Google Cloud Credentials tab: https://console.developers.google.com/apis/credentials
  1. Create an OAuth client ID. This gives you a GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET.

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

text
https://<YOUR_DOMAIN>/api/auth/callback/google

The client ID of your Google application.

The client secret of your Google application.

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 and Google Calendar only.

Microsoft

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:

text
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.

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:
text
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.

The tenant ID of your Microsoft application.

The client ID of your Microsoft application.

The client secret of your Microsoft application.

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 only.

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:

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

Configuration

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

Display name of your provider as shown on the login page.

URL of the .well-known/openid-configuration endpoint for your OIDC provider.

The client ID of your OIDC application.

The client secret of your OIDC application.

Path to the claim that contains the user's name.

Path to the claim that contains the user's email address.

Path to the claim that contains the user's profile picture.

Use dot notation in _CLAIM_PATH fields to access nested objects.


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


icon: "paintbrush" title: "White Labeling"

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

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 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.

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

Logo uploads require object storage to be configured. The stack installed by the Rallly CLI 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 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.

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

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

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.

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 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.


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


icon: "credit-card" title: "Billing & Seats"

How Seat-Based Billing Works

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

- **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

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
You must have at least as many total seats as used seats. You cannot reduce total seats below your current member count.

Managing Your Subscription

Upgrading to Pro

To upgrade your space to Pro:

Go to your space settings and select the "Billing" tab. Select either monthly or yearly Pro billing. Yearly billing offers a discount. Enter your payment details and complete the subscription setup through Stripe.

Managing Seats

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

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
You cannot reduce seats below your current member count. Remove members first, then adjust seat allocation.

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

You cannot invite more members than you have seats available. You'll need to purchase additional seats first, then send invitations. 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. 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. 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.

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


icon: "handshake" title: "Collaboration"

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.

Turning collaboration on or off

Go to **Settings → General** and find the **Collaboration** section. You need to be an admin of the space. Turn the **Team collaboration** switch on or off, then press **Save**. Turning collaboration on gives members access to everything already in the space, not just things created afterwards.

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"

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.

## 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.

Upload your logo and give your space a custom name.

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?"

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.

- **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

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:

Learn how to add and remove seats for your team Invite and manage your team members Choose whether members work together or independently Understand seat-based pricing and billing

Apps Docs Spaces Managing Seats (`apps/docs/spaces/managing-seats.mdx`)


icon: "chair" title: "Managing Seats"

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.

- **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

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:

Navigate to your space settings and click on the "Billing" tab. Once your space is upgraded to Pro, you'll see a "Manage Seats" button on the billing page.
text
<Frame>
  
</Frame>
Use the seat selector to increase the number of seats to accommodate your new team members.
text
<Frame>
  
</Frame>

<Note>
You can increase seats to any number you need. There's no upper limit on seat allocation.
</Note>
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.

Removing Seats

To reduce costs when team members leave or you no longer need as many seats:

Before reducing seat count, make sure to remove any members who are no longer part of your team from the Members tab.
text
<Warning>
You cannot reduce seats below your current member count. Remove members first, then adjust seats.
</Warning>
Go to your space billing settings and click the "Manage Seats" button. Use the seat selector to reduce the number of seats to match your current needs. Review the changes and confirm. You'll receive a credit for the unused seats on your next billing cycle.

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

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 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 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

Troubleshooting

If you're having trouble managing seats, check these common issues:
  • 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

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.


Apps Docs Spaces Members (`apps/docs/spaces/members.mdx`)


icon: "users" title: "Member Management"

Understanding Member Roles

Roles control what someone can manage in a space:

**Full Control** - Member management - Space settings and configuration - Billing and seat management - Can delete space **Team Management** - Member management - Space settings and configuration **Scheduling** - Creates and manages their own polls and events

Roles do not control who can see whose polls and events — that is decided by
the space's collaboration setting, which applies to
every role equally.

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

Go to your space settings and click on the "Members" tab.
text
<Frame>
  
</Frame>
Click "Invite Member" and enter the email address of the person you want to invite.
text
<Note>
The person will receive an email invitation with instructions to join your space.
</Note>
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 Track invitation status in the "Pending Invites" section until the person accepts.

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
Changing roles affects what the member can do immediately. Be careful when granting or removing admin permissions.

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.

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.

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

Before inviting someone, verify you have available seats in your billing settings. If you're at capacity, increase your seat count through billing management first. Once you have available seats, you can invite new members immediately.

Troubleshooting Common Issues

- Check spam/junk folders - Verify the email address is correct - Resend the invitation - Ask the recipient to whitelist rallly.co emails - 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 - 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 - Ensure you have admin or owner permissions - Try using the member dropdown menu instead of bulk actions - Contact support for assistance

Apps Docs Workflow Create (`apps/docs/workflow/create.mdx`)


icon: "circle-1" title: "Create a poll"

Go to app.rallly.co and click Create, or go
straight to app.rallly.co/new.

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.
text
<Frame>
  
</Frame>
Use the calendar to choose the options you want participants to vote on.
text
</Frame>
  </Tab>
  <Tab title="Week view">
    <Frame caption="Use week view to draw time slots on a weekly calendar">
      
    </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.
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.
text
<Frame>
  
</Frame>

Once your poll is created you can start inviting participants.


Apps Docs Workflow Invite (`apps/docs/workflow/invite.mdx`)


icon: "circle-2" title: "Invite participants"

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.
text
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">
  
</Frame>

<Note>Inviting by email requires a [Pro subscription](https://rallly.co/pricing) on Rallly Cloud. Sharing the invite link is free.</Note>
Participants open the invite link, pick the options that work for them and save their response.
text
<Note>Have a look at the [participant guide](/guide/participant-guide) for details on how participants can vote.</Note>

<Frame>
  
</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>
  
</Frame>

Once all participants have responded you can proceed with scheduling your event.


Apps Docs Workflow Schedule (`apps/docs/workflow/schedule.mdx`)


icon: "circle-3" title: "Finalize"

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.

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.
text
<Frame>
  
</Frame>
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.
text
This step is skipped when the poll has no participants yet.

<Frame>
  
</Frame>
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**.
text
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>
  
</Frame>

The final date is displayed on your poll for everyone to see and can be
added to a calendar with one click.

<Frame>
  
</Frame>

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


title: "Events" icon: "bolt"

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
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=,v1=<hex HMAC-SHA256>. See Security
User-Agent Rallly-Webhooks/1.0

Events

Event names are <resource>., or <resource>.. 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

A poll was created, from the app or the API.
text
```json
{
  "version": "2026-09-20",
  "id": "cm5h8x2k40000q9l4f7e2d3an",
  "type": "poll.created",
  "createdAt": "2025-01-12T08:30:00.000Z",
  "data": {
    "poll": {
      "id": "Xk3pQ9vLm2Ab"
    }
  }
}
```
The poll's details, options or settings changed. The event says that something changed, not what; fetch the poll for its current state.
text
```json
{
  "version": "2026-09-20",
  "id": "cm5h8x2k40000q9l4f7e2d3an",
  "type": "poll.updated",
  "createdAt": "2025-01-12T08:30:00.000Z",
  "data": {
    "poll": {
      "id": "Xk3pQ9vLm2Ab"
    }
  }
}
```
The poll stopped accepting responses. `reason` is `manual` when the organizer closed it and `auto` when it closed because every option had passed.
text
```json
{
  "version": "2026-09-20",
  "id": "cm5h8x2k40000q9l4f7e2d3an",
  "type": "poll.closed",
  "createdAt": "2025-01-12T08:30:00.000Z",
  "data": {
    "poll": {
      "id": "Xk3pQ9vLm2Ab"
    },
    "reason": "manual"
  }
}
```
A closed poll accepts responses again.
text
```json
{
  "version": "2026-09-20",
  "id": "cm5h8x2k40000q9l4f7e2d3an",
  "type": "poll.reopened",
  "createdAt": "2025-01-12T08:30:00.000Z",
  "data": {
    "poll": {
      "id": "Xk3pQ9vLm2Ab"
    }
  }
}
```
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.
text
```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
    }
  }
}
```
The poll was deleted. `GET /polls/{pollId}` returns `404` from now on, so anything you need about it must already be stored.
text
```json
{
  "version": "2026-09-20",
  "id": "cm5h8x2k40000q9l4f7e2d3an",
  "type": "poll.deleted",
  "createdAt": "2025-01-12T08:30:00.000Z",
  "data": {
    "poll": {
      "id": "Xk3pQ9vLm2Ab"
    }
  }
}
```
Someone responded to the poll.
text
```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"
    }
  }
}
```
A participant changed their response or their name.
text
```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"
    }
  }
}
```
A response was removed, by the participant or the organizer. Fetching the participant returns `404`.
text
```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"
    }
  }
}
```

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"

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

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.

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.

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 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, Postmark, Mailgun, Brevo)
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.

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 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:[email protected]: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


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"

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.

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.
text
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**.
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.
text
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`.
Add a **Code** node after the Webhook node, mode **Run Once for All Items**, and paste:
text
```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.
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. The event carries the poll's id and nothing else about it, so add an **HTTP Request** node:
text
- **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.

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 ---