---
name: simple-poll
description: Post polls and surveys in Slack with Simple Poll and read their results, through Simple Poll's HTTP API, as the Slack user you work for. Use it when you need a decision, a vote, availability, a team pulse, a retro or any other input from several people in a Slack workspace. Also use it to list the polls your user posted and read their results, open or closed, to close or reopen them, to remind the people who haven't answered, and to fix or delete a poll after it's posted. Covers getting an API key with no copy-paste (Connect), finding a channel by its name, the /poll text grammar, dry runs, listing polls and finding new answers, reading results and responses, closing polls and sending reminders, editing and deleting polls, plans and errors.
---

# Simple Poll

Simple Poll is the polls and surveys app for Slack. Its HTTP API posts polls as your user, reads their results, closes them, reminds the people who haven't answered, and edits or deletes them.

## Quick start

1. **The API.** Base URL `https://simplepoll.rocks/api/v1`. Send `Authorization: Bearer <key>` and JSON bodies. An error's `error` says what to do (see "Errors").
2. **Get a key** (every plan). If you can read your user's Slack DMs, connect in 3 calls, with no auth:
   1. `POST /connect/start` with `{"team_id": "T...", "user_id": "U..."}`: their workspace ID and Slack member ID, from your Slack tools (`auth.test` on a token that acts as your user returns both). Keep the `request_token` it returns.
   2. Read the code from the DM the Simple Poll app sends your user after your start: "Your code: `K7QM-4D2X`". Check every few seconds, for up to a minute. An older or struck-through code won't redeem.
   3. `POST /connect/redeem` with `{"request_token": "...", "code": "K7QM-4D2X", "client_name": "Claude"}`. It returns `api_key`.

   Otherwise, ask your user to run `/simplepoll api` in Slack and give you the key it shows.
3. **Create a poll** (every plan). Look the channel up by name, `GET /channels/?query=launch`, rather than asking your user for its ID. Send the create with `"dry_run": true` first, check the `poll` that comes back, then send the same body without `dry_run`. Keep the `poll_id` it returns, and give your user its `permalink`, the poll's link in Slack.

   ```sh
   curl -s -X POST https://simplepoll.rocks/api/v1/polls/ \
     -H "Authorization: Bearer $SIMPLE_POLL_API_KEY" -H "Content-Type: application/json" \
     -d '{"channel_id": "C0123ABCD", "text": "\"Ship the release today?\" \"Yes\" \"Hold until Monday\" anonymous", "dry_run": true}'
   ```

4. **Read the results** (Premium): `curl -s https://simplepoll.rocks/api/v1/polls/$POLL_ID/results/ -H "Authorization: Bearer $SIMPLE_POLL_API_KEY"`
5. **Follow up** (Premium): `POST /polls/<poll_id>/reminders/` DMs the people who haven't answered, and `POST /polls/<poll_id>/close/` closes the poll and announces its results.

The rules that matter:

- Post only where your user is: a channel or group DM they're in (`channel_id` is its ID, like `C...`), or their own DM with Simple Poll (their user ID). `GET /channels/` lists exactly those. See "Where you can post".
- Reading polls and acting on them (closing, reminding, editing) need Premium; deleting doesn't. On Free, their 403 ends with how to start the free 14-day trial (or upgrade): pass that sentence to your user unchanged and stop. Only they can do it.
- Send reminders only when your user asks for them, and at most once per poll: each round DMs everyone who hasn't answered.
- Never retry a create blindly: a create that timed out or got a 500 may have posted. Check the channel (or list your user's polls in it) before sending it again. A duplicate can be deleted, but people may have answered it already.
- Edits and deletes change a poll people can see, and can throw away their answers: get your user's yes before discarding votes or deleting a poll.

## Endpoints

| Method and path | Auth | Plan | What it does |
|---|---|---|---|
| `POST /api/v1/connect/start` | None | Every plan | DMs your user a one-time code. |
| `POST /api/v1/connect/redeem` | None | Every plan | Swaps the code for your user's API key. |
| `GET /api/v1/channels/` | Key | Every plan | Where your user can post, with each channel's ID. Find one by name. |
| `POST /api/v1/polls/` | Key | Every plan | Posts a poll, or checks it with `dry_run`. |
| `GET /api/v1/polls/` | Key | Premium | Your user's polls, newest first, with filters. |
| `GET /api/v1/polls/<poll_id>/` | Key | Premium | The poll's settings and state. |
| `GET /api/v1/polls/<poll_id>/results/` | Key | Premium | Counts per question. |
| `GET /api/v1/polls/<poll_id>/responses/` | Key | Premium | One row per person who answered, with their answers. |
| `POST /api/v1/polls/<poll_id>/close/` | Key | Premium | Closes the poll and announces its results, or closes it quietly. |
| `POST /api/v1/polls/<poll_id>/reopen/` | Key | Premium | Opens a closed poll again. |
| `GET /api/v1/polls/<poll_id>/non-respondents/` | Key | Premium | Who the poll was posted to and hasn't answered. |
| `POST /api/v1/polls/<poll_id>/reminders/` | Key | Premium | DMs them a reminder, one round per poll every 8 hours. |
| `PATCH /api/v1/polls/<poll_id>/` | Key | Premium | Changes a posted poll's question, description or options. |
| `DELETE /api/v1/polls/<poll_id>/` | Key | Every plan | Deletes a posted poll, its Slack message and its answers. |

There are no other endpoints. A key belongs to one Slack user and acts as them: polls you create show them as the creator, and every other poll endpoint reaches only polls they created, in the key's workspace. The list finds your user's polls and their `id`s, which the other endpoints take as `poll_id`. There are no webhooks: for final results, read a poll after it closes (`is_closed`); results and responses also work while it's open, for an interim tally, and the list's `responded_since` finds new answers. Don't poll the API in a tight loop. Successful calls are 200, except connect start and reminders (202). Keep the trailing slash on `/polls/` paths.

### `POST /api/v1/connect/start`

| Field | Value |
|---|---|
| `team_id` | The workspace ID, `T...`. On Enterprise Grid, the ID of the workspace where Simple Poll is installed, not the organization's `E...`. |
| `user_id` | Your user's Slack member ID, `U...` or `W...`. |

Besides `auth.test`, most Slack tools show them: the member ID is on the user's profile, and the workspace ID is in Slack web URLs (`app.slack.com/client/T0123ABCD/...`).

```sh
curl -s -X POST https://simplepoll.rocks/api/v1/connect/start \
  -H "Content-Type: application/json" \
  -d '{"team_id": "T0123ABCD", "user_id": "U0123ABCD"}'
```

```json
{"ok": true, "request_token": "q8Vb3xZ0kP1mN7rT4yW2eA9sD6fG5hJ8kL0zX3cV1bM"}
```

Every well-formed body gets this same answer, whether or not a DM will be sent, so it tells you nothing about the workspace. Keep `request_token` secret and in memory: only it can redeem this request's code.

Within a few seconds Simple Poll DMs your user: "You or your AI agent asked to connect to your Simple Poll account. Connecting lets it create polls and, on Premium, read poll results as you. Your code: `K7QM-4D2X`". Look in their DM conversation with the Simple Poll app (or search their messages for "Your code" from Simple Poll). An older code belongs to another request, and a struck-through one is used up. Only trust a code in a DM from the Simple Poll app itself.

No DM within a minute? The IDs may be wrong, Simple Poll may not be installed in that workspace, your user may not be an active member, Slack may have refused the DM, or they've asked for 5 codes in the last 24 hours (connecting successfully starts that count again). Ask them for the key instead (`/simplepoll api`).

### `POST /api/v1/connect/redeem`

Within 10 minutes of the start.

| Field | Value |
|---|---|
| `request_token` | From the start. |
| `code` | From the DM. Case, the dash and spaces don't matter. |
| `client_name` | Optional. What the DM calls you: 1 to 40 ASCII letters, digits, spaces and `. , ' ( ) _ -`, starting with a letter or digit. |

```sh
curl -s -X POST https://simplepoll.rocks/api/v1/connect/redeem \
  -H "Content-Type: application/json" \
  -d '{"request_token": "q8Vb3xZ0kP1mN7rT4yW2eA9sD6fG5hJ8kL0zX3cV1bM", "code": "K7QM-4D2X", "client_name": "Claude"}'
```

```json
{"ok": true, "api_key": "sp_9f2c4e1a7b3d4c5e8f6a0b1c2d3e4f5a"}
```

Within a few seconds the code DM changes to say "An app or AI agent called `Claude` is now connected to your Simple Poll account", with a Revoke button (or Simple Poll sends that as a new DM). Lost the response? Send the same redeem again before the 10 minutes are up: it returns the same key. Five wrong codes end a request.

The key is your user's only key: the same one `/simplepoll api` shows and Connect returns. `/simplepoll api rotate` replaces it, and the dashboard shows it under Settings. Store it as a secret, and never post it in Slack, in a file you share or in logs.

### `GET /api/v1/channels/`

Where your user can post a poll, on every plan: the channels and group DMs they're in that Simple Poll may post to (the same check a create runs), never an archived one.

| Parameter | Value |
|---|---|
| `query` | Part of a channel's name, in any case, with or without the `#`: `launch` finds `#launch` and `#q3-launch`. For a group DM, part of a member's username. |
| `limit` | 1 to 1000 (default 100). When `has_more` is `true`, pass `next_cursor` back as `?cursor=`, unchanged and with the same `query`, until it's `false`. |

```sh
curl -s "https://simplepoll.rocks/api/v1/channels/?query=launch" \
  -H "Authorization: Bearer $SIMPLE_POLL_API_KEY"
```

```json
{
  "data": [
    {"id": "C0123ABCD", "name": "launch", "label": "#launch", "type": "public_channel"},
    {"id": "C0456EFGH", "name": "q3-launch", "label": "#q3-launch", "type": "private_channel"}
  ],
  "own_dm": {"id": "U0123ABCD", "label": "Your DM with Simple Poll"},
  "next_cursor": null,
  "has_more": false
}
```

Rows are in `id` order. `type` is `public_channel`, `private_channel` or `mpim`, a group DM: its `name` is `null` and its `label` names its members. `own_dm` is your user's own DM with Simple Poll, in every response. Use a row's `id` as the create's `channel_id`. If several match, show your user their `label`s and ask which. A private channel without Simple Poll in it isn't listed: your user can type `/invite @Simple Poll` in it. Each call reads your user's channels from Slack, so look up once per task, not before every create.

### `POST /api/v1/polls/`

Posts a poll as your user, on every plan. The body has exactly these fields; any other is a 400, because poll settings go in `text`.

| Field | Value |
|---|---|
| `channel_id` | Where to post. See "Where you can post". |
| `text` | The poll, in the grammar people type after `/poll` (see "The /poll text grammar"). A leading `/poll` is optional. |
| `dry_run` | Optional, `true` or `false`. `true` checks and describes the poll without posting it (see "Dry run first"). |

Build the body with a JSON library rather than by hand: `text` is full of double quotes, and an apostrophe breaks a single-quoted shell string.

| Request | Response (200) |
|---|---|
| `"dry_run": true` | `ok`, `"dry_run": true`, `poll` (what would post), `warnings` (always there, maybe empty), and `notice` on Free. |
| A create | `ok`, `poll_id`, `text`, `channel_id`, `ts`, `permalink`, plus `warnings` when there are any, and `notice` on Free. |
| A create with `schedule ...` in the text | `ok`, `"scheduled": true`, `scheduled_for`, `channel_id`, `text`, plus `warnings` and `notice` as for a create. No `poll_id`. |

`notice` says that reading polls needs Premium, and how to start the trial.

#### Dry run first

A poll reaches everyone in the channel the moment it posts. A dry run runs every check a create runs (the text, the plan, and that your user may post in that channel), and posts and saves nothing. Read `poll` to confirm it's the poll you meant (the question, `options`, `anonymous`, `limit`, the close and the reminders), not just `warnings`, and fix anything in `warnings`. If your user didn't spell out this exact poll, show them the question, options and settings and get a yes.

```json
{
  "ok": true,
  "dry_run": true,
  "poll": {
    "channel_id": "C0123ABCD",
    "question": "Ship the release today?",
    "description": null,
    "type_of_poll": "standard",
    "options": [
      {"text": "Yes", "emoji": null, "position": 1},
      {"text": "Hold until Monday", "emoji": null, "position": 2}
    ],
    "anonymous": true,
    "custom_anon_emoji": null,
    "limit": null,
    "allows_options_from_others": false,
    "show_results": "immediately",
    "order_results": "created",
    "scheduled_for": null,
    "scheduled_close": null,
    "reminders": [],
    "mentions": {"broadcast": null, "user_ids": []}
  },
  "warnings": []
}
```

`poll` uses the field names of `GET /api/v1/polls/<poll_id>/`, plus:

| Field | Value |
|---|---|
| `options` | Each `{"text", "emoji", "position"}`. Empty for a survey: its `questions` hold them. |
| `scheduled_for` | When `schedule ...` will post it, or `null` for now. |
| `scheduled_close` | `{"after_seconds": 86400, "at": "..."}`, or `null`. `after_seconds` is `null` for `close at`. |
| `reminders` | Each `{"when": "after_posting" or "before_closing", "seconds", "at"}`. |
| `mentions` | `{"broadcast": "@here", "@channel" or null, "user_ids": [...]}`. |
| `questions` | Surveys only. Each has `position`, `question`, `type_of_poll`, `required`, `limit` and `options`. |

`question` starts with the `@here` or `@channel` mention, if any, as the poll will post it. A dry run doesn't check that Simple Poll itself can post in the channel, so the create can still be a 424.

#### The create

```json
{
  "ok": true,
  "poll_id": "3f1c2b9e-7d4a-4c1e-9b2f-6a8d0e5c7f21",
  "text": "\"Ship the release today?\" \"Yes\" \"Hold until Monday\" anonymous",
  "channel_id": "C0123ABCD",
  "ts": "1760000000.000100",
  "permalink": "https://acme.slack.com/archives/C0123ABCD/p1760000000000100"
}
```

`permalink` opens the poll in Slack: give it to your user. `channel_id` is the one you sent; for a poll in your user's own DM, `permalink` goes to that DM. `ts` is the Slack message timestamp, if you want to reply in its thread. A dry run and a `schedule ...` create post nothing yet, so they have no `permalink`.

#### Posting later: `schedule ...` (Premium)

End the text with `schedule` and a time to post the poll once, later: `schedule tomorrow 9am`, `schedule 15:00`, `schedule 2026-11-02 14:00+01:00`, `schedule 2030-01-15T14:00:00Z`. A time without an offset or `Z` is in your user's Slack timezone (a 400 if they haven't set one). A zone name like `9am PST` doesn't set the timezone either (it's read in your user's), so give an offset or an ISO 8601 time when the zone matters. A time in the past is a 400, and so are words after the time that it doesn't use. Put `schedule ...` last: a modifier after it can be read as part of the time (a 400 tells you).

```json
{
  "ok": true,
  "scheduled": true,
  "scheduled_for": "2026-10-09T09:00:00-07:00",
  "channel_id": "C0123ABCD",
  "text": "\"Lunch on Friday?\" \"Pizza\" \"Tacos\" schedule tomorrow 9am"
}
```

There's no `poll_id`: the poll gets one only when it posts, so you can't read a scheduled poll's results through the API. If you'll need them, post it now instead. Your user gets a DM confirming the schedule (if that DM fails, the poll is still scheduled, and `warnings` says so).

#### Where you can post

- **A channel your user is in**: its ID, `C...` (or `G...`). If Simple Poll isn't in a public channel yet, it joins it to post. A private channel needs Simple Poll as a member: ask your user to type `/invite @Simple Poll` in it.
- **A group DM** your user and Simple Poll are both in: its ID.
- **Your user's own DM with Simple Poll**: their user ID (`U...` or `W...`), or that DM's `D...` ID. Handy for trying a poll out privately.

Anything else is a 403: another person's DM, an archived channel, or a channel your user isn't in or that's private without Simple Poll (that one is worded the same whether or not the channel exists). A channel name like `#general` isn't a `channel_id`: look its ID up with `GET /api/v1/channels/?query=general`.

### `GET /api/v1/polls/` (Premium)

Your user's posted polls in this workspace, newest first: a survey once, never its questions, and never a scheduled poll that hasn't posted. `limit` is 1 to 100 (default 25). When `has_more` is `true`, pass `next_cursor` back as `?cursor=`, unchanged and with the same filters, until it's `false`.

| Filter | Value |
|---|---|
| `channel_id` | A channel ID, like `C0123ABCD`. |
| `status` | `open` or `closed`. |
| `type` | A `type_of_poll`: `standard`, `ranked_poll`, `scale_rating`, `write_short_text`, `write_short_text_with_voting`, `q&a` (send it as `q%26a`), `multiquestion` or `brainstorm`. |
| `created_after`, `created_before` | ISO 8601 with a timezone, like `2026-10-01T09:00:00Z`. Exclusive. Send `+` as `%2B`. |
| `responded_since` | ISO 8601 with a timezone: polls someone answered for the first time after this. |
| `source` | How the poll was made, exactly as rows show it: `api`, `/poll`, `recurring_poll`, ... |

Filters combine. Any other parameter, or an empty or repeated one, is a 400, never ignored.

```sh
curl -s "https://simplepoll.rocks/api/v1/polls/?status=open&channel_id=C0123ABCD" \
  -H "Authorization: Bearer $SIMPLE_POLL_API_KEY"
```

```json
{
  "data": [
    {
      "id": "3f1c2b9e-7d4a-4c1e-9b2f-6a8d0e5c7f21",
      "user_id": "U0123ABCD",
      "team_id": "T0123ABCD",
      "channel_id": "C0123ABCD",
      "ts": "1760000000.000100",
      "permalink": "https://acme.slack.com/archives/C0123ABCD/p1760000000000100",
      "type_of_poll": "standard",
      "question": "Ship the release today?",
      "description": null,
      "is_closed": false,
      "anonymous": true,
      "custom_anon_emoji": null,
      "limit": null,
      "allows_options_from_others": false,
      "order_results": "created",
      "created_at": "2026-10-06T16:02:11+00:00",
      "updated_at": "2026-10-06T16:05:00+00:00",
      "show_results": "immediately",
      "source": "api",
      "respondent_count": 14,
      "last_response_at": "2026-10-06T16:41+00:00"
    }
  ],
  "next_cursor": null,
  "has_more": false
}
```

Rows have the fields of `GET /api/v1/polls/<poll_id>/` without `text`, `questions`, `results` and `closes_at`, plus `source`, `respondent_count` (how many people answered, as `/results/` counts them) and `last_response_at` (when the latest of them first answered, or `null`). To check for new answers, pass your previous check's time as `responded_since`: it counts new respondents, not changed votes. On an anonymous poll these times are to the minute, so a check can return a poll again whose answers you already saw: keep each `id`'s last `respondent_count` and treat the row as new only when it grew.

### `GET /api/v1/polls/<poll_id>/` (Premium)

The poll's settings and state.

| Field | Value |
|---|---|
| `id` | The `poll_id`. |
| `user_id`, `team_id` | Its creator and their workspace. |
| `channel_id`, `ts` | Where it posted, and its Slack message timestamp. |
| `permalink` | The poll's link in Slack (a survey question's `id` gets its survey's). `null` (and `ts` empty) for a poll posted with `/poll` that nobody has clicked yet: Simple Poll learns where its message is from the first click. |
| `type_of_poll` | `standard`, `ranked_poll`, `scale_rating`, `write_short_text`, `write_short_text_with_voting`, `q&a`, `multiquestion` (a survey) or `brainstorm`. |
| `question`, `description` | The question (a survey's title), and the description or `null`. |
| `is_closed` | `true` once the poll has closed. |
| `anonymous`, `custom_anon_emoji`, `limit`, `allows_options_from_others` | The poll's settings. `limit` is `null` for no limit. |
| `show_results` | `immediately`, or how the results are hidden (`later`, ...). |
| `order_results` | `created` or `most_votes`, or `null` for a type it doesn't apply to. |
| `created_at`, `updated_at` | ISO 8601, with an offset. |
| `closes_at` | When it will close on its own (`close after` or `close at`), or `null`: no close set, or it's closed. A poll you close and reopen before then still closes at that time. |
| `text` | The poll as `/poll` text, rebuilt from its settings. It can leave some out (like the close, reminders or mentions), so keep the text you created it with if you'll post it again. |
| `questions` | Surveys only: one entry per question, with its `id`, `question`, `type_of_poll`, `position` and `required`. |
| `results` | Deprecated (and `null` for a survey): use `/results/` and `/responses/`. |

### `GET /api/v1/polls/<poll_id>/results/` (Premium)

Counts per question. Never names anyone, so it's the same for anonymous polls.

```json
{
  "poll_id": "3f1c2b9e-7d4a-4c1e-9b2f-6a8d0e5c7f21",
  "type_of_poll": "standard",
  "respondent_count": 14,
  "questions": [
    {
      "id": "3f1c2b9e-7d4a-4c1e-9b2f-6a8d0e5c7f21",
      "question": "Ship the release today?",
      "type_of_poll": "standard",
      "position": 1,
      "required": true,
      "results": {
        "respondent_count": 14,
        "response_count": 14,
        "options": [
          {"id": "a1b2c3d4e5f6", "text": "Yes", "emoji": null, "position": 1, "vote_count": 9},
          {"id": "f6e5d4c3b2a1", "text": "Hold until Monday", "emoji": null, "position": 2, "vote_count": 5}
        ]
      }
    }
  ],
  "next_cursor": null,
  "has_more": false
}
```

A survey has one entry in `questions` per question. By type, `results` holds:

| Type | `results` |
|---|---|
| choice (`standard`, `brainstorm`) | `options`, each with `id`, `text`, `emoji`, `position` and `vote_count`. |
| `scale_rating` | The same `options` (the five points), plus `statistics: {"mean", "median"}`, which is `null` until someone answers. |
| `ranked_poll` | `ranked_results`: the options (`id`, `text`, `emoji`, `position`) in their final order. |
| short text and `q&a` | `responses`, each with `id`, `text`, `created_at`, `user_id` (always `null` here) and `vote_count` (`null` unless people can vote on them). |

Every `results` also has `respondent_count` and `response_count`. The whole poll fits on one page.

### `GET /api/v1/polls/<poll_id>/responses/` (Premium)

One row per person who answered, with their answers. `limit` is 1 to 1000 (default 1000, so most polls fit on one page). When `has_more` is `true`, pass `next_cursor` back as `?cursor=`, unchanged, until it's `false`.

```json
{
  "poll_id": "8c0d5e2a-1b3c-4d5e-9f60-718293a4b5c6",
  "type_of_poll": "multiquestion",
  "respondent_count": 1,
  "data": [
    {
      "user_id": null,
      "responded_at": "2026-10-06T16:05+00:00",
      "answered_count": 2,
      "answers": [
        {"question_id": "51aa0c2e-3d4f-4a5b-8c6d-7e8f9a0b1c2d", "type_of_poll": "scale_rating", "option_ids": null, "rating": 4, "ranking": null, "texts": null},
        {"question_id": "77bc1d3f-4e5a-4b6c-9d7e-8f9a0b1c2d3e", "type_of_poll": "write_short_text", "option_ids": null, "rating": null, "ranking": null, "texts": ["Shipping on Thursday felt calm"]}
      ]
    }
  ],
  "next_cursor": null,
  "has_more": false
}
```

`answers` has one entry per question the person answered (a skipped optional question has none), and `answered_count` counts them. Each answer fills one field for its type: `option_ids`, `rating`, `ranking` (option IDs in the person's order) or `texts`. Rows aren't in time order, and `responded_at` is `null` for some old answers. On an anonymous poll `user_id` is `null` and `responded_at` is to the minute, but each row is still one person, so you can relate their answers across questions. Don't try to work out who wrote an anonymous answer.

### `POST /api/v1/polls/<poll_id>/close/` (Premium)

Closes the poll, as its Close button in Slack does, and returns it as `GET /api/v1/polls/<poll_id>/` does. The body is optional:

| Field | Value |
|---|---|
| `announce` | `"new_message"` (the default): the closed poll and its results post as a new message, so the channel sees the outcome, and the old message points to it. `"in_place"`: a quiet close. The poll's own message shows the results, and nothing new is posted. |
| `reveal_results` | Only for a poll that hides its results until it closes (`hide results`): `true` shows them to everyone, `false` keeps them hidden. Left out, they're revealed only if your user chose "reveal on closing" when they made the poll in Slack. |

```sh
curl -s -X POST https://simplepoll.rocks/api/v1/polls/$POLL_ID/close/ \
  -H "Authorization: Bearer $SIMPLE_POLL_API_KEY" -H "Content-Type: application/json" \
  -d '{"announce": "new_message", "reveal_results": true}'
```

### `POST /api/v1/polls/<poll_id>/reopen/` (Premium)

Opens a closed poll again, in its Slack message too, and returns it as `GET /api/v1/polls/<poll_id>/` does. No body. Results revealed when it closed stay visible, except on a ranked poll: its voters never see results while it's open.

### `GET /api/v1/polls/<poll_id>/non-respondents/` (Premium)

The people the poll was posted to who haven't answered: the channel's members as Simple Poll recorded them (never bots), just after it posted or, for a scheduled or older poll, the first time you ask. `limit` is 1 to 100 (default 25). When `has_more` is `true`, pass `next_cursor` back as `?cursor=`, unchanged, until it's `false`.

```json
{
  "poll_id": "3f1c2b9e-7d4a-4c1e-9b2f-6a8d0e5c7f21",
  "non_respondent_count": 2,
  "data": [
    {"user_id": "U0456EFGH", "excluded_from_reminders": false},
    {"user_id": "U0789IJKL", "excluded_from_reminders": true}
  ],
  "next_cursor": null,
  "has_more": false
}
```

`non_respondent_count` counts all of them, not just this page. `excluded_from_reminders` means your user took that person out of reminders in Slack. While Simple Poll is still working out who the poll went to, the answer is a 202 with `"status": "computing_audience"` and a `Retry-After` of 5 seconds: ask again then. Simple Poll never says who hasn't answered an anonymous poll, so there it's a 409, but reminders still reach them.

### `POST /api/v1/polls/<poll_id>/reminders/` (Premium)

DMs a reminder to everyone the poll was posted to who hasn't answered, except anyone your user excluded in Slack. One round per poll every 8 hours, whether the last one was sent from Slack or through the API.

To remind only some of them, send `{"user_ids": [...]}` (1 to 1000) with IDs from `/non-respondents/` whose `excluded_from_reminders` is `false`. An anonymous poll takes no `user_ids`, since that would show who answered: leave it out to remind everyone who hasn't.

```json
{"ok": true, "reminded_count": 9, "next_reminder_at": "2026-10-07T00:15:00+00:00"}
```

`reminded_count` is how many reminders were queued. One isn't sent if that person answers, or the poll closes, before it goes out. `next_reminder_at` is when the next round is allowed.

### `PATCH /api/v1/polls/<poll_id>/` (Premium)

Changes a posted poll your user created, as they can from the poll's menu in Slack, and updates its Slack message at once. Send only what changes, all in the JSON body: any other field, or a query parameter, is a 400.

| Field | Value |
|---|---|
| `question` | The new question, or a survey's new title. |
| `description` | The new description, or `""` to remove it. Only on a poll that has one. |
| `add_options` | Options to add after the others, like `["Tacos", "Sushi"]`. Not on a closed poll. |
| `remove_options` | `id`s of options to remove, from `/results/`. |
| `allow_options` | `true` lets voters add their own options, `false` stops them. |
| `discard_votes` | `true` to remove options that have votes, deleting those votes. |

- Options change on `standard` polls and brainstorms only, and a poll keeps at most 45 options. `allow_options` changes on `standard` polls (a brainstorm always takes options from others). A survey takes `question` and `description`; its questions can't be edited.
- Everything else is fixed once the poll posts: anonymity, `limit`, how results show and order, the close, reminders, mentions, the type and the channel. To change one, delete the poll and create it again.
- Removing an option that has votes deletes them. Without `"discard_votes": true` that's a 409 naming the options and their votes, so you can tell your user what would be lost.
- If Slack won't update the message, nothing changes (a 424). A poll a recurring schedule posted is edited alone, not the schedule's next polls. A poll your user set to repeat is a 409 while its schedule runs, because the edit would change every poll it posts next: your user edits it in Slack.

```sh
curl -s -X PATCH https://simplepoll.rocks/api/v1/polls/$POLL_ID/ \
  -H "Authorization: Bearer $SIMPLE_POLL_API_KEY" -H "Content-Type: application/json" \
  -d '{"question": "Ship the release on Monday?", "remove_options": ["f6e5d4c3b2a1"], "add_options": ["Hold until Tuesday"]}'
```

The edit returns the edited poll, in the shape of `GET /api/v1/polls/<poll_id>/` (its `results` hold the options' new `id`s).

### `DELETE /api/v1/polls/<poll_id>/`

Deletes a posted poll your user created, on every plan: its Slack message and every vote and answer on it. It can't be undone, so get your user's yes first. Send no body and no query parameters (either is a 400).

```sh
curl -s -X DELETE https://simplepoll.rocks/api/v1/polls/$POLL_ID/ \
  -H "Authorization: Bearer $SIMPLE_POLL_API_KEY"
```

```json
{
  "ok": true,
  "deleted": true,
  "poll": {
    "id": "3f1c2b9e-7d4a-4c1e-9b2f-6a8d0e5c7f21",
    "question": "Ship the release today?",
    "type_of_poll": "standard",
    "channel_id": "C0123ABCD",
    "ts": "1760000000.000100",
    "recurring_schedule": null
  }
}
```

`poll` is what was deleted, without its results (reading those needs Premium). A survey goes whole, by its own `poll_id`. `recurring_schedule` is `"active"` or `"stopped"` for a poll a recurring schedule posted: only that poll goes, and the schedule keeps posting (`notice` says so). A poll your user set to repeat is a 409 while its schedule runs, because deleting it would end the schedule: your user stops it in Slack. Deleting again is a 404.

## Plans

The plan belongs to the workspace, not the key.

| | Free | Premium (and the free trial) |
|---|---|---|
| API keys and Connect | Yes | Yes |
| Look up channels | Yes | Yes |
| Create polls, and dry runs | Yes, up to 5 options, without Premium modifiers | Yes, up to 45 options |
| List polls, read a poll, `/results/`, `/responses/` | No: 403 | Yes |
| Close, reopen, `/non-respondents/`, `/reminders/` | No: 403 | Yes |
| Edit a posted poll | No: 403 | Yes |
| Delete a posted poll | Yes | Yes |

Premium modifiers are the ones marked Premium in the Modifiers table, plus surveys; on Free a create using them is a 403 that names them. One exception: every year, for the whole of December (UTC), `schedule ...` works on Free too.

Every Premium 403 on Free ends with what to do next: "Start a free 14-day Premium trial with `/simplepoll trial` in Slack, or upgrade at https://simplepoll.rocks/dashboard/T0123ABCD/billing/" (just the upgrade link when the workspace can't start a trial right now). The trial is free for 14 days (card required, no charge until it ends). Don't retry until your user has started it or upgraded.

## The /poll text grammar

```text
"Question" "Option 1" "Option 2" ... modifiers
```

### Quoting

- Put the question and every option in straight double quotes. Curly quotes (`“ ” „ ‟ « »`) count as straight ones.
- There's no escaping. A question or option can't contain a double quote: use single quotes (`'`) or reword.
- Modifiers go after the last quoted string, separated by spaces. Words inside quotes are never read as modifiers, so `"Anonymous or named feedback?"` is a safe question.
- Put nothing after the last quote except modifiers. Other words there are ignored, with a warning, and so is a misspelt modifier: `anonymus` makes a poll that isn't anonymous. In a poll without quoted options, stray words become an option instead, with no warning. So check `poll` itself, not just `warnings`.
- To give an option its own emoji, put the emoji code right after its closing quote: `"Pizza" :pizza: "Tacos" :taco:`.
- Slack formatting works inside quotes: `*bold*`, `:emoji:`, and links written as `<https://example.com|the label>`.
- `@here` and `@channel` become real mentions wherever they appear in a single-question poll, inside quotes too. In a survey, they work only in the header's modifiers, after the quoted title and description. Write them in lowercase, and only when you mean to notify the channel.
- Always use the quoted form. Unquoted forms like `Lunch? Pizza Tacos` exist for humans in a hurry and are easy to get wrong.

With no options, voters add their own (as with `allow-options`): `"What should we call the new bot?"`.

### Poll types

The default is a choice poll: voters pick from your options (any number of them, unless you add `limit`). One of these makes it another type:

| Modifier | Also accepted | Poll |
|---|---|---|
| `ranked` | `ranking`, `ranked poll` | Voters put the options in order. They see the results only once the poll closes. |
| `scale` | `scaled`, `scale rating poll` | A rating from 1 to 5. Give no options, or exactly 5 labels from worst to best. Results include the mean and median. |
| `short text` | `write a short text` | Free-text answers. Don't give options. |
| `short text :fire:` | `write a short text :fire:` | Free-text answers people can also vote on with that reaction, one of `:thumbsup:` `:bulb:` `:heart:` `:joy:` `:tada:` `:rocket:` `:fire:` `:small_red_triangle:`. |
| `q&a` | `ask me anything`, `ama`, `questions and answers` | People post questions and upvote them; the most upvoted rise to the top. Don't give options. |

### Modifiers

Spellings are forgiving: case doesn't matter in modifier words, and spaces, `-` or `_` between words all work (`allow-options`, `allow options`, `allow_options`). Use the first spelling in each row. Write `@here`, `@channel` and emoji codes in lowercase: `@HERE` and `:FIRE:` aren't recognized.

| Modifier | Also accepted | Effect | Plan |
|---|---|---|---|
| `anonymous` | | Nobody, your user included, sees who answered what. | Free |
| `allow-options` | `add options`, `allow options from others` | Voters can add their own options. | Free |
| `order-results-by-votes` | `order by votes`, `show results by popularity` | Options are listed by votes instead of in your order. | Free |
| `hide results` | `keep results hidden`, `keep hidden`, `show results later`, `show results on close` | Voters can't see the results while the poll is open. On Premium your user can (and anyone they share them with in Slack), and voters see them only if your user reveals them when closing the poll (in Slack, or with `"reveal_results": true`): a poll that closes on its own keeps them hidden. On Free nobody sees them until the poll is closed in Slack, which reveals them. | Free |
| `show results to creator` | `show results to author only` | Like `hide results`, said as a promise: only your user (and anyone they share them with) sees the results. | Premium |
| `limit 1` | `limit` (means 1), `limit 2`, `limit to 2 votes` | Each person can pick at most that many options (`limit 0` counts as `limit 1`). Must not exceed the number of options, unless `allow-options` is on. Without it, people can pick as many as they like. | Premium |
| `close after 1 day` | `close in 30 mins`, `close poll automatically after 2 hours` | The poll closes on its own, that long after it posts. | Premium |
| `close at 2026-10-24T18:00:00-07:00` | `close at 2026-10-24 18:00+01:00`, `close at 2026-10-25T01:00:00Z`, `close at 1792890000` | The poll closes on its own at that moment. | Premium |
| `remind 2 hours after posting` | `remind after 2 hours`, `repost in 2 hours` | Reposts the poll at the bottom of the channel. Must fall before the close. | Premium |
| `remind 1 hour before closing` | `repost 1 hour before close` | Reposts the poll before it closes. Needs `close after` or `close at`. | Premium |
| `@here`, `@channel` | `mention @here` | Notifies the channel when the poll posts. | Free |
| `<@U0123ABCD>` | several, space-separated | Notifies those people. Use Slack's `<@USER_ID>` form; a bare `@name` doesn't work. Use either people or `@here`/`@channel`: the last one written wins. | Premium |
| `schedule tomorrow 9am` | `schedule 15:00`, `schedule 2026-11-02 14:00+01:00`, `schedule 2030-01-15T14:00:00Z` | Posts the poll once, later (see "Posting later"). Put it last. | Premium |
| `optional` | `not required` | In a survey, that question can be skipped. | Premium (surveys) |

Allowed durations, with units `minutes` (`minute`, `mins`, `min`, `m`), `hours` (`hour`, `hrs`, `hr`, `h`) or `days` (`day`, `d`). Any other value isn't recognized:

- `close after`: 10, 30 or 60 minutes; 1, 2, 3, 4, 5, 6, 12 or 24 hours; 1, 2, 3 or 7 days.
- Reminders, after posting or before closing: 5, 10, 15, 30 or 60 minutes; 1, 2, 3, 12 or 24 hours; 1 day.

`close at` takes an ISO 8601 date and time with an offset or `Z` (a `T` or a space between them, seconds optional), or a unix timestamp in seconds: `2026-10-24T18:00:00-07:00`, `2026-10-25T01:00:00Z` and `1792890000` are the same moment. A time within a minute is rounded up to the next minute. Use one of `close at` and `close after`, and check the dry run's `scheduled_close.at`.

Not possible in the text: sending a poll to other people by DM, repeating a poll (your user can set that up in Slack), and changing a poll after it's posted (see `PATCH /api/v1/polls/<poll_id>/` for what can change).

### Surveys: several questions in one poll (Premium)

Separate sections with `---` (outside quotes, with a space on each side). The first section is the header: a title, an optional description and the survey-wide modifiers. Each section after it is one question with its options and its own type.

```poll
"Sprint 42 retro" "Two minutes, anonymous." anonymous close after 2 days
--- "How did the sprint go?" scale
--- "What went well?" short text
--- "What should we change?" short text optional
--- "Keep the Thursday demo?" "Yes" "No" limit 1
```

- The header takes a title and an optional description, then the survey-wide modifiers: a third quoted string is a 400.
- Survey-wide, in the header only: `anonymous`, hidden results, mentions, `close after` or `close at`, reminders and `schedule ...`.
- Per question: the type (choice, `ranked`, `scale` or `short text`; a `q&a` question is a 400), options, `limit 1` and `optional`. A choice question needs options.
- A choice question is single-choice (`limit 1`) or unlimited: `limit 2` and up are a 400 (unless that's all its options, which is unlimited anyway).
- Questions can't use `allow-options` or `order-results-by-votes` (400), a `short text` question can't have a voting reaction (400), ranked questions can't be `optional` (400), and option emoji are ignored (a warning).
- Line breaks around `---` are fine.

### What the API refuses

`/poll` quietly drops a few things that don't work, and shows the result in a preview. The API has no preview, so it refuses them with a 400 that names the problem and the fix: a reminder that would fall after the close (or before closing with no close, or before the poll even posts), an unreadable `schedule` time, words after `schedule ...` that the time doesn't use, modifiers around the options of an unquoted question (`Lunch? "Pizza" "Tacos" anonymous`: quote the question), hiding results on a poll type that always shows them, a ranked poll without options, a description over 2,000 characters or a text over 100,000, and in a survey anything from the list above in the wrong place.

What it ignores without changing the poll comes back in `warnings` instead, and the poll is created: `no-preview` (the API never previews), options on a `short text` poll, a `scale` with other than 5 labels, `optional` outside a survey, `limit` or `allow-options` on a type that doesn't take them, `order-results-by-votes` on a type that doesn't take it, option emoji in a survey, and words it didn't recognise (including other words around an unquoted question's options).

### Limits

- Options per poll or question: 45 on Premium (more is a 400). On Free, more than 5 is a Premium 403, like any Premium feature, and that 403 comes first, even past 45.
- Questions per survey: 20.
- `limit N` can't be more than the number of options (400), unless `allow-options` is on. `limit N` equal to it does nothing.

## Errors

Every error the API handles is `{"ok": false, "error": "..."}`, and `error` says what went wrong and what to do. A wrong HTTP method is a plain 405, and a server error a plain 500.

| Status | Meaning | What to do |
|---|---|---|
| 400 | A create: bad JSON, an unknown field, text that doesn't parse or that the API refuses (see "What the API refuses"), a `schedule ...` time in the past, or with no offset when your user has no Slack timezone set, or a `close at` time that has no offset or `Z` (it never assumes a timezone), is in milliseconds, comes with `close after`, is in the past, is less than a minute after the poll posts (or before it), or is more than a year after it posts. | Fix it using `error`, and dry run again. |
| 400 | A redeem: "The request_token or code is wrong, or the request expired or was already used. Start a new request." | Start a new request. |
| 400 | Any other connect error names the field to fix: a start body that isn't a JSON object or an ID that isn't 1 to 32 capital letters and digits, or an unknown redeem field (a misspelt `client_name`, say). | Fix it and send it again. |
| 400 | A read: not a valid `poll_id`, a bad `limit`, `cursor` or `query`, or on a list a bad filter value or an unknown, empty or repeated parameter (`error` names it). | Fix it using `error`. Use a `poll_id` the create or the list returned. |
| 400 | An edit or delete: a field it doesn't take or one fixed at posting (`error` names it), nothing to change, a description on a poll without one, more than 45 options, an option `id` the poll doesn't have, one question of a survey (`error` gives the survey's `poll_id`), a query parameter on an edit, or a body or a query parameter on a delete. | Fix it using `error`. |
| 400 | A close, reopen, non-respondents or reminders: a close, reopen or reminders body that isn't a JSON object or has an unknown field (reopen takes none), `user_ids` that can't be reminded (`error` names them and why), or a survey question's ID (`error` gives the survey's). | Fix it using `error`. For `user_ids`, list the non-respondents again. |
| 401 | Missing, wrong, revoked or rotated key. | Get the key again (Connect or `/simplepoll api`). |
| 403 | The plan: a read, close, reopen, non-respondents, reminders or edit on Free, or Premium poll features in a create on Free (`error` names them). `error` ends with how to start the trial or upgrade (see "Plans"). | Drop the Premium features and dry run again, or pass that last sentence on to your user, unchanged, and stop. |
| 403 | Not allowed: somewhere you can't post (see "Where you can post"), a poll your user didn't create, a poll in another workspace (use a key from that one), a deactivated Slack account, or Simple Poll no longer installed (a redeem too). | Don't retry. Pick a channel your user is in, or tell your user. |
| 404 | No such poll, or it was deleted. | Check the ID. |
| 409 | The poll isn't in a state for that: already closed (a close or reminders) or already open (a reopen), not posted, a `/poll` reply in Slack that nobody has opened since (close, reopen, edit or delete it in Slack), anonymous (non-respondents), nobody left to remind, or Simple Poll can't tell who it went to (a DM, a Slack workflow, a channel Simple Poll isn't in, or a poll that closed before it knew). With `Retry-After`, it's still working out who the poll went to. | Already closed or open: carry on. With `Retry-After`, ask again after that many seconds. Otherwise do what `error` says, or tell your user. |
| 409 | The poll's state refuses the edit or delete: removing options that have votes without `"discard_votes": true` (`error` names them and their votes), adding options to a closed poll, or editing or deleting a poll your user set to repeat while its schedule runs. Nothing changed. | Get your user's yes and send `discard_votes`, or ask them to do it in Slack. |
| 424 | A create or channel lookup: Simple Poll couldn't read all of your user's channels (they're in more than about 10,000), or its Slack permissions are out of date. A create: Slack refused the post. | Do what `error` says: invite Simple Poll, reinstall it, check the channel ID, or post with `/poll` in Slack. |
| 424 | A close or reopen: the poll did close or reopen, but Slack wouldn't update its message, so it may look unchanged there. `error` says if Simple Poll was removed from the channel or the channel is archived. | Don't repeat it. Tell your user if the message matters, with how to invite Simple Poll back if `error` gives it. |
| 424 | An edit or delete: Slack refused to update or delete the poll's message, so nothing changed. `error` says if Simple Poll was removed from the channel or the channel is archived. | Removed: ask your user to do what `error` says, then send it again. Archived: it can't change. Otherwise ask your user to do it from the poll's menu in Slack. |
| 429 | Reminders within 8 hours of the poll's last round, sent from Slack or through the API. | Don't loop: skip it, or wait `Retry-After` seconds. |
| 500 | Something broke on Simple Poll's side, and the outcome is unknown: a create may have posted, a close, reopen, edit or delete may have happened. | Check the channel before repeating a create, and read the poll before repeating anything else. |
| 503 | Slack isn't answering or asks to try again later (every call checks your user is still in the workspace). An edit or delete wasn't saved, though Slack may have applied it to the message before it stopped answering. | Retry after `Retry-After` seconds if set, otherwise a few seconds, backing off. Repeat an edit or delete exactly as you sent it: that brings the poll and its message back in line. |

## Recipes

### Writing good polls

- Ask one thing, and put the decision in the question: "Which launch date works for you?" beats "Launch date".
- Two to five short options that don't overlap. If you might have missed one, add `allow-options`.
- People can pick several options unless you add `limit 1` (Premium). Use it when you need one answer per person.
- Make anything about feelings or feedback `anonymous`, so people answer honestly.
- Give it an end with `close after ...` or, for a deadline, `close at ...` (Premium), or close it yourself: results only settle once it's closed.
- One poll per decision. Don't post the same poll twice.

### A decision poll that closes itself

1. Dry run, then create:

   ```poll
   "Which launch date works for you?" "Oct 20" "Oct 22" "Oct 27" limit 1 close after 1 day remind 2 hours before closing @here
   ```

2. Need the answer sooner, say once `/non-respondents/` shows `"non_respondent_count": 0`? Close it yourself with `POST /api/v1/polls/<poll_id>/close/`: the results go to the channel as a new message. If it already closed itself, that's a 409: carry on.
3. After the close, check `GET /api/v1/polls/<poll_id>/` shows `"is_closed": true`, then read `/results/` and report the winner with its votes. On a tie or a low turnout, say so and let your user decide.

### An anonymous retro, with the answers grouped

1. Dry run, then create:

   ```poll
   "Sprint 42 retro" "Anonymous. Two minutes." anonymous close after 2 days
   --- "What went well?" short text
   --- "What didn't?" short text
   --- "What should we try next sprint?" short text optional
   ```

2. Once it has closed, read `/responses/`. For each question, group the answers into a handful of themes, count them, and quote one or two answers word for word. Never guess at who wrote what.
3. Offer a follow-up vote on the top suggestions, with the options in quotes: `"What should we try first?" "Shorter standups" "No-meeting Wednesdays" "Demo every Friday" ranked close after 1 day`.
