Run Discord surveys from code
The Subo API lets you create conversational surveys and polls, generate their scripts with AI, open them to a Discord server or a web audience, and pull the answers back out. Every recipe below is a working request you can paste into a terminal.
Quickstart
Three steps: get a key, find your community id, make a call. Everything after that is the same pattern.
1. Create an API key
Open the Subo web app,
go to Community Account Tab → API Keys and create a key.
It is scoped to that one community and inherits your role, so you can
optionally cap it at Creator access. The key starts with
sbo_live_ and is shown once, so store it before closing the dialog.
Send it on every request:
X-API-Key: sbo_live_xxxxxxxxxxxxxxxxxxxxxxxx 2. Find your community id
Almost every endpoint is nested under a community. List the ones your key can reach:
curl "https://api.subo.ai/v1/communities" \
-H "X-API-Key: $SUBO_API_KEY" For a Discord community the id is the server (snowflake) id, returned as a string.
3. Send a write request
Writes take an Idempotency-Key header holding a UUID v4. Retry
a failed request with the same key and Subo replays the original result
instead of creating a duplicate project.
-H "Idempotency-Key: $(uuidgen)" Recipe: generate a survey from a plain-English goal
This is the shortest path from nothing to a working survey, and the one
worth reaching for if you are writing an agent. Describe what you want to
learn in the intent field and Subo writes the script:
curl -X POST "https://api.subo.ai/v1/communities/$COMMUNITY_ID/projects" \
-H "X-API-Key: $SUBO_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"name": "Post-event feedback",
"type": "convo",
"intent": "Measure how members felt about the weekend tournament and collect suggestions for the next one",
"max_blocks": 6,
"privacy_mode": "anonymous"
}'
The 201 response includes the generated script with its
blocks plus a credits_used figure. A few notes that save a round trip:
typeisconvofor a multi-question survey orpollfor a single question.max_blocksdefaults to 5 and is capped at 20.-
intent, an explicitscriptandsource_project_idare mutually exclusive. Send exactly one. -
AI generation spends bot credits. With an empty balance the call returns
402 payment_requiredrather than a partial survey. - The script is generated in the community's configured language, not the language of your intent string.
New projects start inactive. Nobody can answer yet, which gives you room to review or edit before opening it.
Recipe: write the script yourself
When you want exact control, send the blocks instead of an intent. Same endpoint, different field:
curl -X POST "https://api.subo.ai/v1/communities/$COMMUNITY_ID/projects" \
-H "X-API-Key: $SUBO_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"name": "Server health check",
"type": "convo",
"privacy_mode": "anonymous",
"script": {
"blocks": [
{
"type": "single_punch",
"title": "How often do you hang out here?",
"options": [
{ "value": "Every day" },
{ "value": "A few times a week" },
{ "value": "Rarely" }
]
},
{
"type": "open_text",
"title": "What would make this server better?"
}
]
}
}'
To replace the script of an existing project, PUT it whole. The
project has to be inactive:
PUT https://api.subo.ai/v1/communities/{communityId}/projects/{projectId}/script
Individual blocks have their own endpoints for surgical edits:
POST .../script/blocks to append,
PUT .../script/blocks/{blockId} to change one,
DELETE to drop it.
Skip logic is worth calling out. You can hand-write the raw
precondition mini-language, but the API also accepts a structured
show_when / hide_when object and compiles it into a
correct precondition for you. Prefer the structured form: it removes a
whole class of quoting and inversion mistakes.
Recipe: ask a rating, a scale, an NPS or a ranking
Four of the block types are ordered question types, and they behave
differently enough from the choice blocks to be worth their own section.
The thing to know first: they store the number the respondent
picked, not a reference to an option. That is what makes an
average, a distribution and a >= 4 condition work with
nothing configured.
The point range is min / max
Not part of the scale object. The scale object
carries whole-scale presentation only: icon
(numbers, stars or emoji) and the
endpoint anchors label_left, label_center and
label_right.
options means something different here
On a choice block, options are the answers. On a scale,
option.value is the point number as a string
and the option carries that point's emoji or
label. A plain star or number scale sends no options
at all. A label on every point is how you build a
Likert item.
// Rating with an emoji set: options ARE the per-point presentation
{ "type": "rating", "prompt": "How was the playtest build?",
"min": 1, "max": 5,
"scale": { "icon": "emoji" },
"options": [ { "value": "1", "emoji": "😖" }, { "value": "2", "emoji": "🙁" },
{ "value": "3", "emoji": "😐" }, { "value": "4", "emoji": "🙂" },
{ "value": "5", "emoji": "😍" } ] }
// Opinion scale with endpoint anchors (the semantic-differential shape)
{ "type": "opinion_scale", "prompt": "How was the pacing?",
"min": 1, "max": 7,
"scale": { "label_left": "Way too slow", "label_center": "Just right",
"label_right": "Way too fast" } }
// Likert item: a word on every point. The stored answer is still the number,
// so averages and >= conditions keep working.
{ "type": "opinion_scale", "prompt": "The tutorial explained the controls well.",
"min": 1, "max": 5, "answer_style": "select_menu",
"options": [ { "value": "1", "label": "Strongly disagree", "emoji": "😡" },
{ "value": "2", "label": "Disagree", "emoji": "🙁" },
{ "value": "3", "label": "Neutral", "emoji": "😐" },
{ "value": "4", "label": "Agree", "emoji": "🙂" },
{ "value": "5", "label": "Strongly agree", "emoji": "😍" } ] }
// NPS: send the type and nothing else.
{ "type": "nps", "prompt": "How likely are you to recommend us to a friend?" }
// Ranking: items are options, rank_top_n (NOT max) asks for a partial ranking
{ "type": "ranking", "prompt": "Rank these features by what you want first.",
"answer_style": "full_text", "rank_top_n": 3, "randomize_options": true,
"options": [ { "value": "Mod support", "emoji": "🛠️" },
{ "value": "Co-op mode", "emoji": "👥" },
{ "value": "New maps", "emoji": "🗺️" },
{ "value": "Ranked play", "emoji": "🏆" } ] } Anchors and per-point labels are mutually exclusive
Sending both returns a 400. Labelling every point already
names the ends, so pick one. nps refuses a custom range,
custom anchors and a select menu on the same grounds: an NPS you can edit
is a number you cannot compare with anyone else's. Its anchors are never
stored, only rendered, so every respondent reads the standard wording in
their own language.
Ranking uses rank_top_n, not max
On a multi_punch, max means the maximum number of
selections, so ranking gets its own field for "rank your top 3 of 10".
min and max are ignored when you update a ranking
so they cannot quietly rewrite it. A ranking takes 2 to 20 items, and a
respondent submits it complete or not at all.
randomize_options applies to every block with options and
returns a 400 on the three scale types, which are ordered by
definition. Turn it on for rankings: whichever item sits first gets tapped
first, and that bias lands in the average rank your report shows.
What comes back
In /responses, a scale answer has option_id: null
and value set to the point number. A ranking writes
one row per ranked item, with value set to the
rank position.
In /analysis, the three scales return their distribution with
value as the point number, because a caller is a
program. A ranking block returns a ranking array instead, with
average_rank, first_choice_count and
ranked_count per item, best first.
A lower average_rank is better, which inverts
the reflex every other metric trains, and it is computed only over the
respondents who ranked that item, so read it next to
ranked_count.
Recipe: open the project to an audience
Opening is what makes a project live. It is also where you choose whether this runs inside Discord or as a web link.
curl -X POST "https://api.subo.ai/v1/communities/$COMMUNITY_ID/projects/$PROJECT_ID/open" \
-H "X-API-Key: $SUBO_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"delivery": {
"audience": {
"participation": "private",
"response_channel": "discord"
},
"invitation": {
"post_channel_id": "123456789012345678",
"message": "Two minutes of your time, and you get the role."
},
"closing": {
"mode": "scheduled",
"time": "2026-09-01T18:00:00Z"
}
}
}' The audience combination decides who can answer and where:
| participation | response_channel | What you get |
|---|---|---|
private | discord | Members answer in your Discord server, as a conversation with the bot. |
private | web | A link, but only your verified members can complete it. |
open_web | web | An open link anyone can answer, no Discord account needed. |
Add required_role_ids under audience to restrict a
Discord audience to specific roles. To close early, call
POST .../projects/{projectId}/close.
Recipe: pull the responses
curl "https://api.subo.ai/v1/communities/$COMMUNITY_ID/projects/$PROJECT_ID/responses?page=1&per_page=100" \
-H "X-API-Key: $SUBO_API_KEY" The shape you get back:
{
"data": [
{
"id": "...",
"project_id": "...",
"submitted_at": "2026-08-02T14:03:11Z",
"session_number": 1,
"user_id": "...",
"platform_id": "...",
"provider": "discord",
"answers": [
{ "block_id": 1, "option_id": 2, "value": "A few times a week" },
{ "block_id": 2, "value": "More events in EU hours." }
]
}
],
"pagination": { "page": 1, "per_page": 100, "total": 214, "has_more": true }
} per_pagedefaults to 20 and maxes out at 100. Page untilhas_moreis false.-
user_idis the Subo user id and the only globally unique key.platform_idis the account id inside its platform namespace, so it is only meaningful paired withprovider. Join onuser_id. -
One respondent can appear more than once when a project allows repeat
completions.
session_numberseparates those runs. - Anonymous projects come back redacted. When the project's
privacy_modeisanonymous,user_id,platform_idandsession_numberare alwaysnull, and theresponse.submittedwebhook nulls the same three. That matches what Subo's own surfaces do: the Responses tab hides identity and the XLSX export blanks the id, name, nickname and session columns. Filtering by respondent is refused rather than ignored, so?user_id=,?platform_id=and?session_number=return a400on an anonymous project. A caller who could filter and read back a non-empty page would have learned who answered. -
Redaction does not break grouping.
idstill identifies one submission and stays valid forGET .../responses/{responseId}, and the webhook always carriesresponse_idfor exactly that reason.provideris kept on anonymous projects too: the source someone came in through is not treated as identifying, the same rule the XLSX exports follow.
Polling this endpoint on a timer works, but webhooks are the better pipeline.
Recipe: summarize open-ended answers with AI
Free-text answers are the ones nobody has time to read. Trigger a
summarization pass over the open_text blocks:
curl -X POST "https://api.subo.ai/v1/communities/$COMMUNITY_ID/projects/$PROJECT_ID/analysis" \
-H "X-API-Key: $SUBO_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "force": false }'
The job runs asynchronously, so the POST returns as soon as it is queued.
Read the result with a GET on the same path, which also returns
the aggregated stats and distributions for the closed questions:
curl "https://api.subo.ai/v1/communities/$COMMUNITY_ID/projects/$PROJECT_ID/analysis" \
-H "X-API-Key: $SUBO_API_KEY"
Set force: true to re-run a summary that already exists, for
instance after a wave of new responses. Analysis spends bot credits and
returns 402 payment_required on an empty balance. Rather than
polling for completion, subscribe to analysis.completed.
Recipe: subscribe to webhooks and verify them
Register an endpoint and the events you care about:
curl -X POST "https://api.subo.ai/v1/communities/$COMMUNITY_ID/webhooks" \
-H "X-API-Key: $SUBO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Responses to our warehouse",
"url": "https://example.com/hooks/subo",
"events": ["response.submitted", "analysis.completed", "project.closed"]
}'
The response contains signing_secret, shown once and never again.
Store it before you close the terminal. Every delivery then arrives with
these headers:
| Header | Value |
|---|---|
X-Subo-Event | The event type, for example response.submitted |
X-Subo-Delivery | A unique event id, useful for deduplication |
X-Subo-Signature | sha256=<hmac hex> over the raw body |
Verifying in Node:
import crypto from "node:crypto";
// req.body must be the RAW bytes, not a parsed and re-serialized object.
function verifySuboWebhook(rawBody, header, secret) {
const expected =
"sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(header || "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
} Things worth knowing before you rely on this in production:
- Hash the raw request body. Re-serializing the JSON changes the bytes and every signature check will fail.
-
Compare in constant time. A plain
===leaks timing information. - Subo waits 10 seconds for your endpoint, then retries after 30s, 5m, 30m, 2h and 8h before abandoning the delivery. Return a 2xx fast and do the real work off the request path.
-
Deliveries are at-least-once. Deduplicate on
X-Subo-Delivery. -
response.submittedrequires a paid tier. The project lifecycle events are available to everyone. -
POST .../webhooks/{webhookId}/testfires a test delivery, andGET .../webhooks/{webhookId}/deliveriesshows attempt history with the status codes you returned. Use both before blaming the network.
Available events:
project.createdproject.updatedproject.openedproject.closedproject.status_changedproject.deletedresponse.submittedanalysis.completed
Recipe: clone a ready-made template
Subo ships curated templates: quizzes, prediction contests, feedback surveys. Cloning one is faster than writing a script and carries over the scoring, grading and reward blocks that make it work.
# Browse the catalog
curl "https://api.subo.ai/v1/templates" -H "X-API-Key: $SUBO_API_KEY"
# Clone one into your community
curl -X POST "https://api.subo.ai/v1/communities/$COMMUNITY_ID/templates/$TEMPLATE_ID/clone" \
-H "X-API-Key: $SUBO_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{ "name": "Season 4 prediction contest" }' The clone is a deep copy: script, scoring buckets, grading, action blocks and calculated fields all come along. Anything bound to the source server, such as channels, roles and schedules, is stripped so you start clean. The new project arrives inactive, ready for an open call.
A machine-readable list of every public template, with slugs and clone links, lives at /templates.json.
Reference tables
Block types
| Type | What it does |
|---|---|
single_punch | Pick one option. Also covers yes/no. |
multi_punch | Pick several options. |
open_text | Free text. The input for AI analysis. |
open_numeric | A number. |
rating | Rate on 2-10 points. Stars, numbers or an emoji set. |
opinion_scale | Agreement or satisfaction between two named ends. Label every point for a Likert item. |
nps | The standard 0-10 recommendation question. Range and anchors are locked. |
ranking | Put options in order, best first. rank_top_n asks for a partial ranking. |
content_block | Says something without asking anything. |
action_block | Grants XP, a role or an achievement mid-conversation. |
calculated_block | Computes a value from earlier answers. |
Scale family: ranges and defaults
| Type | Range | Default when omitted |
|---|---|---|
rating | 2 to 10 points, always starts at 1 | 1 to 5, stars |
opinion_scale | starts at 0 or 1, up to 11 points | 1 to 10, numbers |
nps | locked 0 to 10 | 0 to 10, not overridable |
ranking | 2 to 20 items | rank all items |
answer_style by block family
| Family | Accepted values | Rejected with 400 |
|---|---|---|
Choice (single_punch, multi_punch) | full_text, emoji_only, select_menu | — |
Scales (rating, opinion_scale, nps) | buttons (default), select_menu | full_text, emoji_only; nps also refuses select_menu |
ranking | full_text (default), emoji_only | select_menu, because a dropdown cannot express an order |
The predictable confusion: how a scale draws its points is
scale.icon, not an answer style. Stars, numbers and
emoji are icon values; answer_style only chooses
between a row of buttons and a dropdown. A dropdown is the better widget
from about six labelled points up, where a button row wraps.
Privacy modes
privacy_mode | Who can see who said what |
|---|---|
transparent | Answers are attributed openly. |
semi-private | Admins can see individual answers; other members cannot. |
anonymous | Nobody, including admins, can tie an answer to a member. |
Omit privacy_mode on create and the survey inherits the
privacy mode the community has configured as its default, the same one
the app applies. A community that has never changed that setting is on
anonymous. Set the field explicitly when
anonymity matters rather than relying on someone else's setting.
Rate limits
| Tier | Requests per minute |
|---|---|
| Free | 60 |
| Premium | 300 |
| VIP | 600 |
| Custom Bot | 1000 |
Limits are counted per key in a one-minute window. Every response carries
X-RateLimit-Limit, X-RateLimit-Remaining and
X-RateLimit-Reset; a 429 adds Retry-After.
See pricing for what else each tier includes.
Errors
| Status | Code | Usual cause |
|---|---|---|
| 400 | invalid_request | Bad body, unknown block type, or two mutually exclusive script sources. |
| 402 | payment_required | Out of bot credits for an AI call, or a feature above your tier. |
| 403 | forbidden | A Creator-scoped key reaching for a project it does not own. |
| 404 | not_found | Wrong community or project id for this key. |
| 429 | rate_limit_exceeded | Over the tier limit. Wait Retry-After seconds. |
FAQ
- How do I authenticate with the Subo API?
- Send your key in the X-API-Key header on every request. Keys start with sbo_live_ and are created in the Subo web app under Community Account Tab, then API Keys. Each key is scoped to a single community and inherits your role, and it is shown only once.
- What is the Subo API base URL?
- https://api.subo.ai/v1. Non-breaking changes ship in place under /v1; breaking changes would ship under a new version prefix.
- Can an AI agent create a Discord survey with the Subo API?
- Yes. Pass an intent field, a plain-text description of what you want to learn, when creating a project and Subo generates the full multi-question script for you. The 201 response contains the generated blocks, so an agent can create a working survey in a single call.
- What are the Subo API rate limits?
- Limits are per API key and depend on the community tier: 60 requests per minute on Free, 300 on Premium, 600 on VIP and 1000 on Custom Bot. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers, and a 429 includes Retry-After.
- How do I create a rating or Likert scale question with the Subo API?
- Send a block with type rating or opinion_scale. The point range is the block’s min and max; the scale object sets the icon (numbers, stars or emoji) and the endpoint anchors. Per-point content goes in options, where option.value is the point number as a string and carries that point’s emoji or label. A label on every point is a Likert item. A plain star or number scale sends no options at all.
- Does the Subo API support NPS?
- Yes. Send { "type": "nps", "prompt": "..." } and nothing else. The range is locked to 0 to 10 and the standard anchors are rendered rather than stored, so every respondent reads them in their own language and no caller can edit them. Analysis returns the distribution and the average. Subo does not compute a promoter, passive and detractor rollup for you.
- Can I create a matrix or grid question with the Subo API?
- No, and it is a deliberate choice. A grid is a layout, not a question: every row of one is a single agreement or likelihood question. In Subo you send one opinion_scale block per statement with the same points on each, which takes about as long to write and reports as its own scale. Facing a grid all at once, respondents tend to run a straight line down one column; asked one at a time, they answer the question in front of them. A grid is also a wide table, which does not fit a phone or a chat client.
- How do I get survey responses out of Subo?
- Either poll GET /communities/{communityId}/projects/{projectId}/responses, which is paginated, or subscribe a webhook to the response.submitted event and receive each submission as it happens. Webhooks are the recommended path for pipelines.
- How do I verify a Subo webhook signature?
- Compute an HMAC-SHA256 of the raw request body using the signing secret returned when you created the webhook, then compare it to the X-Subo-Signature header, which has the form sha256=<hex>. Always compare with a constant-time function and always hash the raw bytes, not a re-serialized object.
Where to go next
- Full endpoint reference for every field and response shape.
- OpenAPI 3.1 spec to generate a client or feed an agent.
- llms.txt if you are pointing an AI agent at Subo.
- Tutorials for the no-code path through the same features.
- Support server when something behaves in a way this page did not predict.
START A CONVERSATION WITH YOUR COMMUNITY.
No credit card. No setup hassle. Just jump in and play around with few limitations. Do more with paid plans when you're ready.