REST API

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.

Base URL https://api.subo.ai/v1 · OpenAPI 3.1 spec at /v1/openapi.json

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:

  • type is convo for a multi-question survey or poll for a single question.
  • max_blocks defaults to 5 and is capped at 20.
  • intent, an explicit script and source_project_id are mutually exclusive. Send exactly one.
  • AI generation spends bot credits. With an empty balance the call returns 402 payment_required rather 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:

participationresponse_channelWhat 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_page defaults to 20 and maxes out at 100. Page until has_more is false.
  • user_id is the Subo user id and the only globally unique key. platform_id is the account id inside its platform namespace, so it is only meaningful paired with provider. Join on user_id.
  • One respondent can appear more than once when a project allows repeat completions. session_number separates those runs.
  • Anonymous projects come back redacted. When the project's privacy_mode is anonymous, user_id, platform_id and session_number are always null, and the response.submitted webhook 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 a 400 on 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. id still identifies one submission and stays valid for GET .../responses/{responseId}, and the webhook always carries response_id for exactly that reason. provider is 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:

HeaderValue
X-Subo-EventThe event type, for example response.submitted
X-Subo-DeliveryA unique event id, useful for deduplication
X-Subo-Signaturesha256=<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.submitted requires a paid tier. The project lifecycle events are available to everyone.
  • POST .../webhooks/{webhookId}/test fires a test delivery, and GET .../webhooks/{webhookId}/deliveries shows attempt history with the status codes you returned. Use both before blaming the network.

Available events:

  • project.created
  • project.updated
  • project.opened
  • project.closed
  • project.status_changed
  • project.deleted
  • response.submitted
  • analysis.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

TypeWhat it does
single_punchPick one option. Also covers yes/no.
multi_punchPick several options.
open_textFree text. The input for AI analysis.
open_numericA number.
ratingRate on 2-10 points. Stars, numbers or an emoji set.
opinion_scaleAgreement or satisfaction between two named ends. Label every point for a Likert item.
npsThe standard 0-10 recommendation question. Range and anchors are locked.
rankingPut options in order, best first. rank_top_n asks for a partial ranking.
content_blockSays something without asking anything.
action_blockGrants XP, a role or an achievement mid-conversation.
calculated_blockComputes a value from earlier answers.

Scale family: ranges and defaults

TypeRangeDefault when omitted
rating2 to 10 points, always starts at 11 to 5, stars
opinion_scalestarts at 0 or 1, up to 11 points1 to 10, numbers
npslocked 0 to 100 to 10, not overridable
ranking2 to 20 itemsrank all items

answer_style by block family

FamilyAccepted valuesRejected 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_modeWho can see who said what
transparentAnswers are attributed openly.
semi-privateAdmins can see individual answers; other members cannot.
anonymousNobody, 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

TierRequests per minute
Free60
Premium300
VIP600
Custom Bot1000

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

StatusCodeUsual cause
400invalid_requestBad body, unknown block type, or two mutually exclusive script sources.
402payment_requiredOut of bot credits for an AI call, or a feature above your tier.
403forbiddenA Creator-scoped key reaching for a project it does not own.
404not_foundWrong community or project id for this key.
429rate_limit_exceededOver 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

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.