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.
🐳 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.
- 🌐 Translators — Help translate Rallly into your language
- 🔐 Security — Found a vulnerability? See our security policy
- 🤝 Community — Please read our Code of Conduct
📜 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.
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:
pnpm install mintlifyNavigate to this directory (where you can find mint.json):
cd apps/docsStart the development server:
mintlify devComponents
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"
- 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.
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.
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.
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"
To delete a meeting poll, from the admin page:
Click Manage
Select Delete from the dropdown menu
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.
<Frame caption="Admin controls">
</Frame><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.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.
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:
Authorization: Bearer sk_xxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxcurl https://api.rallly.co/v1/polls \
-H "Authorization: Bearer $RALLLY_API_KEY"Creating a key
- Open Settings → API keys in the space you want to access. Only the space owner sees this page.
- Choose Create API key and give it a name that identifies the integration.
- 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 is404withPARTICIPANT_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;endis exclusive;ifNeedBeis 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:
{
"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
4xxresponses other than429as bugs in the request. Retrying without changing it returns the same error. - Retry
429and503after theRetry-Afterheader. - Retry
500with 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.
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
https://api.rallly.co/v1v1 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/jsonwith a body. - List endpoints return the items in
data.GET /pollsis paginated: it returns anextCursorbesidedata; pass it as thecursorquery parameter for the next page, and it isnullon the last page.GET /polls/:pollId/participantsreturns every participant in one response, andGET /polls/:pollId/participants/:participantIdreturns one. - Timestamps are ISO 8601 instants in UTC. A
datepoll's options carry a floatingYYYY-MM-DDdate with no timezone; atimepoll's options carry astartTimeinstant and adurationin minutes. - A participant's
availabilityis a list of spans,{ start, end, allDay, modifiers }. A span matches an option by its time:startis the option'sstartTime, or midnight UTC of itsdatewithallDay: true.endis 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.
https://app.rallly.co/api/private/polls → https://api.rallly.co/v1/pollsError 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.
{
"success": false,
"error": [
{
"expected": "string",
"code": "invalid_type",
"path": ["title"],
"message": "Invalid input: expected string, received undefined"
}
],
"data": { "dates": ["2027-03-01"] }
}{
"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:
timezoneis renamedtimeZone.useris replaced byorganizer, which addsidandemail.kindis added:dateortime. It tells you which option shape to expect.updatedAtis added. Compare it between reads to notice changes.- The five poll settings are returned as booleans.
participantCountis on every poll, not only list items.- Options in a
datepoll carrydateinstead ofstartTimeandduration: 0. Options in atimepoll are unchanged.
{
"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"
}
}{
"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:
{
"title": "Team offsite",
"dates": ["2027-03-01", "2027-03-02", "2027-03-03"]
}{
"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.
{
"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
}
]
}
}{
"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
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:
kindis added at the top level.- Options in a
datepoll carrydateinstead ofstartTimeandduration: 0, matching the poll resource. votesis dense. The private API omitted any vote type with zero votes, so an option nobody voted on hadvotes: []. v1 lists every vote type the poll offers, in display order, withcount: 0where nobody chose it. A client that summedvotesor looked a type up bytypekeeps working; a client that treated an empty array as "no votes" must check the counts instead.
{
"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
}
}{
"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.
{
"data": {
"pollId": "Xk3pQ9vLm2Ab",
"participants": [
{
"id": "cm5j2r8wb0003q9l4a1x6p0zt",
"name": "Jessie Smith",
"email": "[email protected]",
"createdAt": "2026-09-11T08:30:00.000Z"
}
]
}
}{
"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
- Change the base URL to
https://api.rallly.co/v1. - Accept
201fromPOST /polls. - Rewrite create requests: add
kind, replacedatesandslotswithoptionsandgenerators, renametimezonetotimeZoneand the generator'sstartTimeandendTimetofromandto, and removespaceIdand any other undocumented fields. - Read
timeZoneinstead oftimezone, andorganizerinstead ofuser, in every poll response. - Branch on
kindwhen reading options:datefor date polls,startTimeanddurationfor time polls. - In results, stop treating an empty
votesarray as "no votes"; read the counts. - Read participants from
datadirectly. - Parse every error as
{ "error": { "code", "message" } }and drop any handling of the private API's validator output,DUPLICATE_DATES,NO_OPTIONS_GENERATEDandTRANSITION_NOT_AVAILABLE. - 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/1.1 429 Too Many Requests
Retry-After: 23
RateLimit-Limit: 60
RateLimit-Remaining: 0
RateLimit-Reset: 23{
"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.
Adding an endpoint
- As the space owner, open Settings → Webhooks and choose Add webhook.
- Enter the URL. It must be
https, on a public host, with no credentials in it. - Pick the events to receive. Every event is selected by default; you can change the selection later.
- 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!
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.
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:
- Pick the language you'd like to translate
- Select a file (each file contains text for different parts of the site)
- 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.
Name:If a sentence ends with a period (.) or a colon (:), you will want to include that in your translation.
❌ Nombre
✅ Nombre:Variables
Variables are placeholders for content that can change. They look like this:
Hi {name}, welcome back!{name} is a variable that will be replaced with the user's name. Please make sure variables remain unchanged when translating.
❌ Salut {nom}, bienvenue!
✅ Salut {name}, bienvenue!Tags
Tags can be used to create a hyperlink or change the appearance of some text (ex. bold, italics etc…). They look like this:
Go to <a>page</a>The tags should remain wrapped around the same content and should not be translated. The contents between the tags should be translated (unless they are a variable).
❌ Gehen Sie zur Seite
❌ <a>Gehen Sie zur Seite</a>
❌ Gehen Sie zur <a>page</a>
✅ Gehen Sie zur <a>Seite</a>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.
{count, plural, one {# option} other {# options}}Note that # is a placeholder for the value of count.
If your language requires more than one plural form, you can add additional rules.
{count, plural, one {# опция} few {# опции} other {# опций}}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
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"
- **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**.<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>If you need to make changes, you can use the dropdown menu next to your name.
<Frame>
</Frame>Frequently Asked Questions
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.
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
- You connect your Google account once, from your settings.
- 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.
- 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
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:
- Creates a Google Meet meeting space on your Google account.
- Attaches the meeting link to the event.
- 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
- Open Settings, then Conferencing.
- 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:
- Open the third party apps and services page of your Google account.
- Select Rallly.
- 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.
How it works
- You connect your Microsoft work or school account once, from your settings.
- 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.
- 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
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:
https://app.rallly.co/api/integrations/admin-consent/microsoft-teams?redirect=/settings/conferencingAfter 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:
- Creates a Teams meeting for that time on your Microsoft account.
- Attaches the join link, meeting ID and passcode to the event.
- 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
- Open Settings, then Conferencing.
- 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
- Open My Apps and sign in with the account you connected.
- Find Rallly, open its menu and choose Manage your application.
- 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.
How it works
- You connect your Webex account once, from your settings.
- 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.
- 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
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:
- Creates a Webex meeting for that time on your Webex account.
- Attaches the join link, meeting number and password to the event.
- 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
- Open Settings, then Conferencing.
- 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.
How it works
- You connect your Zoom account once, from your settings.
- 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.
- 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
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:
- Creates a scheduled Zoom meeting on your account for that date and time, using the poll title as the meeting topic.
- Attaches the join link to the event.
- 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
- Open Settings, then Conferencing.
- 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:
- Sign in to the Zoom App Marketplace.
- Click Manage, then Added Apps.
- 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
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
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.
Under APIs & Services > Library, enable the Google Meet REST API.
Under Google Auth Platform > Data Access, add the scope
https://www.googleapis.com/auth/meetings.space.created. Rallly also asks foruserinfo.emailanduserinfo.profile.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.createdis 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.
Under Google Auth Platform > Clients, open your Web application client (or create one) and add this to Authorized redirect URIs:
https://<YOUR_DOMAIN>/api/integrations/callback/google-meetKeep 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
- Under Basic Information, set the OAuth Redirect URL and add the same address to the OAuth Allow List:
https://<YOUR_DOMAIN>/api/integrations/callback/zoomUnder Scopes, add
meeting:write:meetinganduser:read:user.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.
- Under Features > Access, turn on Event Subscription and add the endpoint:
https://<YOUR_DOMAIN>/api/integrations/zoom/deauthorizeSubscribe to the App Deauthorized event.
Copy the Secret Token into
ZOOM_WEBHOOK_SECRET_TOKENand 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.
- Under Authentication, add a Web redirect URI:
https://<YOUR_DOMAIN>/api/integrations/callback/microsoft-teams- Under API permissions, add the delegated Microsoft Graph permissions
OnlineMeetings.ReadWrite,User.Readandoffline_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
- Set the Redirect URI:
https://<YOUR_DOMAIN>/api/integrations/callback/webexSelect the scopes
meeting:schedules_writeandspark:people_read.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
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.
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.
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.
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.
Set to false to disable new user registration. Takes precedence over the database setting in the admin UI.
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.
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.
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 stringS3_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:
- Ensure your Rallly instance is running.
- Open your web browser and navigate to
/control-panel. - 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:
- Ensure the user account associated with the
INITIAL_ADMIN_EMAILhas been created (e.g., by logging in once). - Navigate to the Control Panel:
/control-panel. - If you are the user specified in
INITIAL_ADMIN_EMAILand have not yet claimed admin rights, you should see a section or button prompting you to "Make me an admin". - 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_EMAILenvironment 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:
HTTPS_PROXY=http://proxy.internal.example.com:8080
HTTP_PROXY=http://proxy.internal.example.com:8080
NO_PROXY=garage,db- 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_PROXYentries 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 bothno_proxyandNO_PROXYare 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:
CA_CERT_FILE=/opt/rallly/certs/corporate-root-ca.pemThe 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.
To verify the certificate is mounted and readable inside the container:
docker compose exec web openssl x509 -in /etc/ssl/certs/rallly-custom-ca.pem -noout -subjectSuccess 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:
openssl x509 -inform DER -in ca.crt -out ca.pemAs a fallback, you can export a certificate from the chain the proxy presents. Run this from the affected server:
openssl s_client -showcerts -connect licensing.rallly.co:443 </dev/null 2>/dev/null | openssl x509 -outform PEM > ca.pemNote 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:
openssl x509 -in ca.pem -noout -subjectIf 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):
./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.
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
- Installation — set up Rallly on your server.
- Configuration — environment variables reference.
- Management — update, back up, and operate your instance.
- Control Panel — manage users and instance settings.
- Licensing — when a license is needed and how to apply one.
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 |
Activating Your License
Once you have your license key, you can activate it in your self-hosted Rallly instance:
- Log in to your Rallly instance as an administrator.
- Navigate to the Control Panel.
- Select License.
- 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.
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
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.
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.
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
./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:
./rallly.sh restartUpdating
./rallly.sh updateThis 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).
Backup and restore
Backup
./rallly.sh backupDatabase backups are written to ./backups/ as timestamped .sql.gz files. Copy these off the server regularly — they are your main recovery artifact.
Restore
Stop the app first so it releases its database connections, drop and recreate an empty rallly database, stream in the dump, then restart:
# 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 startLogs
Stream all services:
./rallly.sh logsOr a single service (web, db, garage, traefik):
./rallly.sh logs web
./rallly.sh logs traefikTroubleshooting
SSL certificate is not being issued
Let's Encrypt can take up to a minute on first start. Check:
- The domain's DNS
Arecord resolves to the server's public IP. - Ports 80 and 443 are open in the server's firewall.
./rallly.sh logs traefikfor 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:
./rallly.sh logs web
./rallly.sh logs dbCommon causes:
- Missing or invalid
.envvalues (e.g. emptySECRET_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.
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
Arecord 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:
docker compose exec -T rallly_db pg_dump -U postgres rallly | gzip > rallly-backup.sql.gzCopy rallly-backup.sql.gz off the server (e.g. scp to your laptop). Do not skip this.
Step 3 — Stop the old stack
From the old stack directory:
docker compose downIf 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.
# Examples, adjust to your setup
sudo systemctl stop nginx && sudo systemctl disable nginx
sudo systemctl stop caddy && sudo systemctl disable caddyVerify the ports are free:
sudo ss -tlnp | grep -E ':(80|443)\b'Should return nothing.
Step 4 — Install the new stack
curl -fsSL https://get.rallly.co | bashWhen 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:
- Replace the freshly-generated
SECRET_PASSWORDwith the value from your old.env. - 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
cd /opt/rallly
./rallly.sh startWait for the database to finish initialising (about 10 seconds). Check with:
./rallly.sh logs dbOnce you see database system is ready to accept connections, restore your backup:
# 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 ralllyStart the app so it picks up the restored schema:
./rallly.sh startStep 6 — Verify
- 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). - Sign in with an existing account. If
SECRET_PASSWORDwas preserved correctly, your previous session may even still be valid. - Open an existing poll and confirm votes are intact.
- Send a test sign-in email to confirm SMTP works.
- 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:
# 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>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.ymlchanges. The new stack is managed; edits will conflict with./rallly.sh update. Configure via.envonly. - 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.
Create an OAuth client ID. This gives you a
GOOGLE_CLIENT_IDandGOOGLE_CLIENT_SECRET.Set Authorized redirect URIs to include your full domain and the callback path:
https://<YOUR_DOMAIN>/api/auth/callback/googleThe 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
After creating the application, set the redirect URI to:
https://<YOUR_DOMAIN>/api/auth/callback/microsoft-entra-idOn 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:
{ "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.
https://<YOUR_DOMAIN>/api/integrations/callback/microsoft-teamsUnder 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:
https://<YOUR_DOMAIN>/api/auth/callback/oidcConfiguration
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"
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.
- Sign in as an administrator and go to
/control-panel/branding. - Set the app name, primary colors, logos, and attribution settings.
- 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
Uploading a logo
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.
Preparing your logo
There is no logo size setting. On the sign-in and registration pages your wordmark is drawn inside a fixed slot of 192 by 128 pixels, scaled down to fit inside it while keeping its aspect ratio, so it is never cropped or stretched. Elsewhere in the app it is scaled to a fixed height.
Because the slot is fixed, the shape of your asset determines how large it renders:
- Crop the asset tightly to the artwork. Whitespace baked into the file is scaled along with the logo, so a padded asset renders visually smaller than a tight one.
- Prefer SVG for the wordmark logos so they stay sharp at any size. If you upload a raster wordmark, supply it at roughly twice the slot size so it holds up on high density displays.
- Wide, horizontal wordmarks fill the slot better than tall or square ones.
- For the dark mode logo, supply a variant that reads well on a dark background rather than reusing the light one.
The Branding page previews each logo inside the slot it renders in, on the background it will appear against, so you can check the result before rolling it out.
How branding applies to spaces
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.
# 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:
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
Managing Your Subscription
Upgrading to Pro
To upgrade your space to Pro:
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:
- Navigate to Billing Settings
- Click "Manage Seats"
- Increase the seat count to accommodate new members
- Confirm the changes - billing will be prorated immediately
Removing Seats
To reduce costs when team members leave:
- Remove members from your space first
- Go to Billing Settings and click "Manage Seats"
- Decrease the seat count to match your current needs
- Confirm the changes - adjustments will be credited to your next bill
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
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
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
- Go to Settings → General in your space.
- Under the Branding section, toggle on Enable Custom Branding.
- Use the color picker to choose your primary color.
- 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.
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.
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:
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.
Accessing Seat Management
To manage your seats, you need to:
- Be a space owner - Only space owners can manage billing and seats (admins can access space settings but not billing)
- Have a Pro space - Seat management is available after upgrading to Pro
- 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:
<Frame>
</Frame><Frame>
</Frame>
<Note>
You can increase seats to any number you need. There's no upper limit on seat allocation.
</Note>Removing Seats
To reduce costs when team members leave or you no longer need as many seats:
<Warning>
You cannot reduce seats below your current member count. Remove members first, then adjust seats.
</Warning>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
Troubleshooting
- 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:
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
<Frame>
</Frame><Note>
The person will receive an email invitation with instructions to join your space.
</Note>Managing Existing Members
Changing Member Roles
To change a member's role:
- Find the member in your member list
- Click the dropdown menu next to their name
- Select "Change Role" and choose the new role
- Confirm the change - it takes effect immediately
Removing Members
To remove a member from your space:
- Locate the member you want to remove
- Click the dropdown menu and select "Remove member"
- 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.
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:
- Locate the pending invite
- Click "Cancel" to revoke the invitation
- 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
Troubleshooting Common Issues
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.
<Frame>
</Frame></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.<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"
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><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.
<Frame>
</Frame>This step is skipped when the poll has no participants yet.
<Frame>
</Frame>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.
{
"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:
{ "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
```json
{
"version": "2026-09-20",
"id": "cm5h8x2k40000q9l4f7e2d3an",
"type": "poll.created",
"createdAt": "2025-01-12T08:30:00.000Z",
"data": {
"poll": {
"id": "Xk3pQ9vLm2Ab"
}
}
}
``````json
{
"version": "2026-09-20",
"id": "cm5h8x2k40000q9l4f7e2d3an",
"type": "poll.updated",
"createdAt": "2025-01-12T08:30:00.000Z",
"data": {
"poll": {
"id": "Xk3pQ9vLm2Ab"
}
}
}
``````json
{
"version": "2026-09-20",
"id": "cm5h8x2k40000q9l4f7e2d3an",
"type": "poll.closed",
"createdAt": "2025-01-12T08:30:00.000Z",
"data": {
"poll": {
"id": "Xk3pQ9vLm2Ab"
},
"reason": "manual"
}
}
``````json
{
"version": "2026-09-20",
"id": "cm5h8x2k40000q9l4f7e2d3an",
"type": "poll.reopened",
"createdAt": "2025-01-12T08:30:00.000Z",
"data": {
"poll": {
"id": "Xk3pQ9vLm2Ab"
}
}
}
``````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
}
}
}
``````json
{
"version": "2026-09-20",
"id": "cm5h8x2k40000q9l4f7e2d3an",
"type": "poll.deleted",
"createdAt": "2025-01-12T08:30:00.000Z",
"data": {
"poll": {
"id": "Xk3pQ9vLm2Ab"
}
}
}
``````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"
}
}
}
``````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"
}
}
}
``````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.
{
"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:
X-Rallly-Signature: t=1736670600,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdt 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
- Read the raw body as bytes. Do not parse and re-serialise it first; any change to whitespace or key order changes the digest.
- Split the header on
,and each part on=to gettandv1. - Check that
v1is 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. - Compute HMAC-SHA256 of
<t>.<raw body>with the secret and compare it tov1with a constant-time comparison. - Reject the request if
tis more than a few minutes from now. This stops a captured request from being replayed later.
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
2xxas 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.
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
Arecord pointing to the server's IP - An SMTP server for sending emails (e.g. Resend, Postmark, Mailgun, Brevo)
Quick install
Run the installer on your server:
curl -fsSL https://get.rallly.co | bashThe installer will:
- Check that Docker and the required ports are available.
- Download the stack to
/opt/rallly. - Walk you through configuration (domain, support email, SMTP).
- Generate secure secrets automatically.
- 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).
PROXY_MODE=external
WEB_PORT=127.0.0.1:3000Your 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.
DATABASE_URL=postgres://user:[email protected]:5432/ralllyExternal 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.
S3_ENDPOINT=https://s3.us-east-1.amazonaws.com
S3_BUCKET_NAME=my-rallly-bucket
S3_REGION=us-east-1
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...Apply changes with ./rallly.sh restart.
Next steps
- Configuration — full environment variable reference.
- Management — updating, backups, and day-to-day operations.
- Single Sign-On — enable Google, Microsoft, or OIDC login.
Apps Docs Api Reference Webhooks Guides N8n (`apps/docs/api-reference/webhooks/guides/n8n.mdx`)
title: "Receive webhooks in n8n" sidebarTitle: "n8n" icon: "diagram-project"
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.
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 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`.```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.- **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 ---
- Files Extracted: 52
- Estimated Token Budget: ~71550 tokens
- Canonical Reference: https://codewiki.google/github.com/lukevella/rallly