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: 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.
  • Privacy modes do not redact this endpoint. Subo's own surfaces mask identity on an anonymous project: the Responses tab hides it and the XLSX export blanks the id, name, nickname and session columns. The API does neither. user_id, platform_id and session_number come back on every project whatever its privacy_mode, and the response.submitted webhook carries the same fields. If you promised your members anonymity, drop those fields at your ingestion boundary rather than assuming the API already did.

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.
content_blockSays something without asking anything.
action_blockGrants XP, a role or an achievement mid-conversation.
calculated_blockComputes a value from earlier answers.

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