---
title: "Create project"
method: POST
path: "/v1/communities/{communityId}/projects"
tags: ["Projects"]
---

# Create project

`POST /v1/communities/{communityId}/projects`

## Request

- Base URL: `https://api.subo.ai`
- URL: `https://api.subo.ai/v1/communities/{communityId}/projects`
- Auth: API key in header `X-API-Key`

Create a new project. Starts inactive. Script input options (mutually exclusive):

- **`script.blocks`** — provide an explicit block list; written immediately, no credits consumed
- **`intent`** — natural-language description; AI drafts the script (every block type, scoring, `show_when`) and writes it through the same validation as `PUT /script`; consumes bot credits; returns 402 if the balance is below the chosen quality's floor. Takes the same draft inputs as `POST /projects/generate` (`quality`, `question_count`, `language`, `facts`, and `plan` + `answers` + `session_id` from `POST /projects/generate/plan`). The community's default welcome block is not added: the draft brings its own intro when it wants one.
- **`source_project_id`** — fork script from an existing project
- (none) — project is created with an empty script

When `script` or `intent` is provided the response includes a `script.blocks` field; with `intent` it also includes `draft` (author notes, dropped blocks, model, credits).

## Path parameters

- `communityId` string, required

## Request body

- object
  - `name` string, required
  - `type` 'convo' | 'poll', required
  - `source_project_id` string, nullable: Fork script from this project ID
  - `max_completes_per_user` integer: Maximum times a single participant can complete this project
  - `privacy_mode` 'transparent' | 'semi-private' | 'anonymous': Controls response visibility and identity disclosure. If omitted, the community's default privacy mode is used (`GET /v1/communities/{communityId}` → `defaults.privacy_mode`; `anonymous` for communities that never changed it). - **`transparent`** — All community members can see exactly who answered what for every question. - **`semi-private`** — Only the project creator and community admins can view individual responses (who answered what). The rest of the community sees aggregated results only if the creator chooses to share them. - **`anonymous`** — Respondent identity is hidden from everyone, including the creator and admins. Data is pseudo-anonymous: answers are recorded but not linked to a user ID or display name, unless a respondent volunteers that information in an open-text answer. **Caveat:** XP and achievements can still be granted in anonymous projects, so participation is not entirely hidden — particularly relevant in small communities.
  - `delivery` object
    - `opening` object
      - `mode` 'manual' | 'scheduled'
      - `time` string, date-time, nullable: Required when mode is `scheduled`. The project opens itself at this time (UTC unless the value carries an offset) and posts its invitation then; do not also call open. Set it on an inactive project.
    - `closing` object
      - `mode` 'manual' | 'scheduled'
      - `time` string, date-time, nullable: Required when mode is `scheduled`. The project closes itself at this time.
    - `audience` object
      - `participation` 'private' | 'open_web': `private`: invite-only via Discord; `open_web`: public link anyone can open
      - `response_channel` 'discord' | 'web': Where respondents complete the survey
      - `required_role_ids` string[]: Discord role snowflakes required to participate
      - `verified_source_ids` integer[]: Audience source ids (from GET /communities/{communityId}/sources) a respondent may verify with, any-of. Requires participation 'private' and response_channel 'web'.
    - `answer_surfaces` string[], nullable: Ordered fallback chain of surfaces a Convo is attempted on, first to last. `null` means no override: the project follows Subo's default rules, which are resolved at press time and can differ between two posts of the same project in different channels. Send `null` to clear an override; an empty array is rejected.
    - `clear_conversation` boolean: Purge the respondent's thread transcript once they COMPLETE the Convo. Never fires on a timeout: a timed-out Convo is resumable and the respondent returns to it. Defaults to false. Only honored on a thread: Subo can bulk-delete in a thread but only its own messages in a DM, so a project with this on AND a `dm` step in `answer_surfaces` fails script validation and will not open. A web Convo carries no durable transcript, so the flag is inert there.
    - `invitation` object
      - `post_channel_id` string, nullable: Discord channel snowflake where the invitation embed is posted. For a project answered on the web (`open_web`, or `private` with `response_channel` `web`) it is the only place the invitation is posted: `null` or an empty string means post nowhere, and respondents come in through `delivery.share_url`. A project answered in Discord needs it to open. On update and open: omit to leave it, send `null` or an empty string to clear it.
      - `message` string, nullable: Custom invitation message (Markdown)
      - `thumbnail_url` string, uri, nullable
      - `image_url` string, uri, nullable
      - `border_color` string, nullable: 6-digit hex colour without '#' for the embed border when the project is open (e.g. `e1287e`). A leading '#' is accepted and removed; anything else is a 400.
      - `border_color_closed` string, nullable: 6-digit hex colour without '#' for the embed border when the project is closed (e.g. `745399`). A leading '#' is accepted and removed. Premium only.
      - `footer` string, nullable: Text shown below the invitation embed. Premium only.
      - `embed_config` object, nullable: Invitation embed layout — whether project info shows inline (`embed`) or behind a button, plus per-row visibility toggles
        - `info_display` 'embed' | 'button', nullable
        - `rows` object: Per-row visibility toggles for the invitation embed (open map, e.g. status/privacyMode/roles/xpReward)
      - `answer_button_label` string, nullable: Custom label for the answer/vote button. Premium only.
      - `answer_button_color` integer, nullable: Discord button style for the answer button (1 primary, 2 secondary, 3 success, 4 danger). Premium only.
  - `rewards` object
    - `discord_role_id` string, nullable: Discord role snowflake awarded on completion
    - `achievement_id` string, nullable: Achievement granted on completion
    - `use_xp` boolean: Award XP to participants on completion
    - `achievement_enabled` boolean, nullable: Enable achievement grant on completion
    - `achievement_image_url` string, uri, nullable: Badge image URL for the achievement
    - `achievement_name` string, nullable: Custom achievement name
    - `achievement_role_id` string, nullable: Discord role snowflake granted alongside the achievement
  - `presentation` object
    - `theme_id` integer, nullable: ID from GET /v1/themes
    - `interviewer_id` integer, nullable: ID from GET /v1/interviewers
    - `branding` string, nullable: Branding shown in the web survey interface: logo in the header, favicon, and page title
    - `chart_emoji` string, nullable: Emoji used as the bar character in poll result embeds
  - `settings` object
    - `change_poll` boolean, nullable: Allow respondents to change their poll answer after submission
    - `poll_results_mode` 'public' | 'voters_only' | 'hidden', nullable: `public`: results visible in the poll embed; `voters_only`: only voters can see results; `hidden`: results hidden until poll closes
    - `reveal_results` boolean, nullable: Post a results summary to the results channel when the project closes
    - `text_analysis` boolean, nullable: Send this project's open-text answers for AI summary. A new project starts with the community's text analysis setting (`general.text_analysis`) written on it; a clone keeps the source's value only when the destination community has text analysis on, and starts off otherwise. `null` (projects created before 2026-10) follows the community setting. A block can still override it, and an `open_text` block whose question asks for personal data (name, email, phone...) is off by default. On update: omit to leave it, send `null` to go back to the community setting.
  - `scoring_enabled` boolean, nullable: Enable quiz/scoring mode. When `true`, question blocks may carry `correct_answer_index`, `when_correct`, `when_incorrect`, and per-option `score_values`. Set this **before** writing a script that uses score variables — without it, `score_values` weights are silently ignored and `[score]`/`[correct_answers]` variables render as literal `[brackets]`. Do not also set `rewards.use_xp: true` when using an in-survey `give_xp` action block with a `source_key` — that combination awards XP twice.
  - `script` object: Explicit block list. Mutually exclusive with `intent` and `source_project_id`. The response includes `script.blocks` with the persisted blocks.
    - `blocks` Block[]
      - `id` integer, nullable
      - `name` string, nullable: Variable token name for this block. Used in pipe variables (`[q_name]`), precondition expressions (`NOT((q_name="value"))`), and calculated formulas. Auto-assigned as `q0`, `q1`, … (by position) when omitted. Must be unique within the script.
      - `section` 'main' | 'intro' | 'outro', nullable: Block section. `intro` blocks are shown before the survey begins (editable and deletable). `outro` blocks are returned only when `GET /script?expand=outro` is used and are read-only in script write endpoints.
      - `type` 'single_punch' | 'multi_punch' | 'open_text' | 'open_numeric' | 'rating' | 'opinion_scale' | 'nps' | 'ranking' | 'content_block' | 'action_block' | 'calculated_block', required
      - `answer_style` 'full_text' | 'emoji_only' | 'select_menu' | 'buttons', nullable: How answer options are displayed. `full_text`: labelled buttons; `emoji_only`: keycap emoji buttons; `select_menu`: dropdown. Applies to single_punch and multi_punch blocks, to `ranking` (which takes `full_text` or `emoji_only` only), and to the scale family (`rating` / `opinion_scale` / `nps`), which accept only `buttons` (default) or `select_menu` — a scale's point rendering is set with `scale.icon`, not here. A select menu is the better widget from six labeled points up, where a button row wraps. Sending `full_text` or `emoji_only` on a scale returns `400 invalid_request`. Null inherits the project default. For Discord-delivered projects, `full_text` and `select_menu` reject options that exceed the Discord character limit (40 for buttons, 50 for select menus) or carry more than one emoji that needs rendering (a button/select label has a single emoji slot) — the request returns `400 invalid_request` with a `violations` array. Use `emoji_only` (its answer list renders every emoji), shorten the option, or remove the extra emoji.
      - `color` string, nullable: Discord embed accent color as a hex string without the leading `#` (e.g. `e1287e`). Applies to content_block and action_block embeds when the project is Discord-delivered. **Requires Premium tier.** Ignored on read if community is on the Basic tier; blocks authored via the API will still persist the value, but it will not render until the community upgrades.
      - `image_url` string, uri, nullable: Image displayed above the question prompt. **Requires Premium tier** to persist via the editor; readable by all tiers.
      - `thumbnail_url` string, uri, nullable: Thumbnail image displayed top-right of the Discord embed. Available on all block types. **Requires Premium tier** to persist via the editor; readable by all tiers.
      - `prompt` string, required
      - `options` object[], nullable: Required for single_punch, multi_punch and ranking blocks — on a ranking these are the ITEMS to be ranked (2-20 of them). Optional for rating / opinion_scale / nps, where options are per-point presentation only: send one option per point with `value` set to that point's NUMBER (e.g. "1"…"5") carrying its `emoji` or its `label`. A `label` on every point is the Likert mechanism — the respondent taps "Strongly agree" while the stored answer stays the number 5, so averages keep working. Omit options entirely for a plain star or number scale.
        - `id` integer, nullable: Backend answer id. Omit (or send null) to insert a new option; include the existing id to update in place — preserves response.answer_id references through value / emoji / score / order edits. On reads this is always populated.
        - `value` string, required
        - `label` string, nullable
        - `emoji` string, nullable: Per-option emoji stored separately from `value`. Displayed alongside the label by the renderer. Discord buttons and select options have a single emoji slot, so this emoji plus any emoji in `value` must total one renderable emoji for `full_text`/`select_menu` styles; otherwise the write is rejected (use `emoji_only`).
        - `score_values` object, nullable: Per-bucket score weights for this option. Keys are bucket names (lowercased, spaces → underscores); values are numeric weights. Only present when `scoring.enabled` is true on the project.
        - `display_order` integer, nullable: Explicit 0-based display position within the question. Omit on create to default to array index.
        - `anchor_position` 'last' | 'null', nullable: When set to 'last', this option is pinned to the end of the list instead of being shuffled — the usual home for an "Other" or "None of the above" choice. Only has an effect when the block sets `randomize_options: true`; without it every option keeps its stored order anyway. Null = participates in normal ordering.
        - `is_deleted` boolean, nullable: Whether this option is hidden. A hidden option is out of the live list (nobody can pick it, it is left out of chart options) but keeps every response ever cast for it, so historical results still resolve. Omitting an option from a block PATCH still means "hide it if it has responses, delete it if it has none" — send `is_deleted: true` on an option you keep in the payload to say so explicitly, or `false` on a hidden one to restore it. Ignored when creating an option (no id). Reads never list hidden options. Destroying an option together with its responses is not available through the API.
      - `min` integer, nullable: Min selections (multi_punch) or min value (open_numeric)
      - `max` integer, nullable: Max selections (multi_punch) or max value (open_numeric)
      - `required` boolean, nullable: Whether a response is required to advance
      - `precondition` string, nullable: Raw skip-logic **hide expression**: when it evaluates to `true` the block is hidden; when `false` (or null) it is shown. **Prefer the structured `show_when` / `hide_when` fields below** — they compile to this and avoid its quoting/operator/casing pitfalls. Reference a prior block by its `name` (matched case-sensitively against the stored, title-cased name, e.g. a block named `NotifyPref` is referenced as `Notifypref`) or by position as `q1`, `q2`, … `qN`; named references are recommended because positions shift when blocks are added or removed. Operators: `=` (or `==`), `!=`, `<`, `>`, `>=`, `<=`, `in` (membership/substring), `AND`, `OR`, `NOT`. String literals use double quotes. To **show only when** a condition holds, wrap it: `NOT((Notifypref = "Other"))`. Invalid expressions (unknown variable, syntax error) are rejected with `400 invalid_request` at write time rather than silently failing open. See the Script tag description for the full reference.
      - `show_when` Condition: A skip-logic condition for `show_when` / `hide_when`. Either a **leaf** (`block` or `variable`, plus `op` and `value`) or a **group** (`all` for AND, `any` for OR, each a list of conditions).
        - `block` string, nullable: Name of the block to test. Resolved at runtime by its stored, title-cased name (you may pass any casing).
        - `variable` string, nullable: Raw variable token instead of a block name: `score`, `score_<bucket>`, `correct_answers`, `q3`, …
        - `op` 'eq' | 'ne' | 'lt' | 'gt' | 'gte' | 'lte' | 'in', nullable: Comparison. `in` requires a list `value`.
        - `value` unknown
        - `all` Condition[], nullable: AND of nested conditions.
        - `any` Condition[], nullable: OR of nested conditions.
      - `hide_when` Condition: A skip-logic condition for `show_when` / `hide_when`. Either a **leaf** (`block` or `variable`, plus `op` and `value`) or a **group** (`all` for AND, `any` for OR, each a list of conditions).
        - `block` string, nullable: Name of the block to test. Resolved at runtime by its stored, title-cased name (you may pass any casing).
        - `variable` string, nullable: Raw variable token instead of a block name: `score`, `score_<bucket>`, `correct_answers`, `q3`, …
        - `op` 'eq' | 'ne' | 'lt' | 'gt' | 'gte' | 'lte' | 'in', nullable: Comparison. `in` requires a list `value`.
        - `value` unknown
        - `all` Condition[], nullable: AND of nested conditions.
        - `any` Condition[], nullable: OR of nested conditions.
      - `action_kind` 'give_xp' | 'give_achievement' | 'give_role', nullable: Required for `action_block`. Null for all other block types.
      - `action_config` object, nullable: Configuration for `action_block`.
        - `xp_amount` integer, nullable: XP to grant for `give_xp`.
        - `xp_role_id` integer, nullable: Achievement ID to grant for `give_achievement`.
        - `role_id` string, nullable: Discord role snowflake to grant for `give_role`.
        - `fire_scope` 'respondent' | 'session', nullable: Idempotency scope. `respondent`: once total; `session`: once per completion session.
      - `correct_answer_ids` integer[], nullable: Canonical correct-answer field. Array of real `option.id` values that are correct. Single-element today (MVP single-select grading); array type future-proofs multi-select. Survives option reorder because ids are stable. Only meaningful when `scoring_enabled` is true on the project.
      - `correct_answer_index` integer, nullable: Deprecated — use `correct_answer_ids` instead. 0-based position into the current `options` array of the first correct answer. Still accepted on input (translated to the option's `id` at write time) and emitted on output for back-compat, but breaks when options are reordered. Only meaningful when `scoring_enabled` is true.
      - `when_correct` string, nullable: Message shown to the respondent immediately after they answer correctly. Only meaningful when `correct_answer_ids` is set. Supports template variables: [UserName], [answer] (their response), [score], [max_score], [score_correct], [max_score_correct], [correct_answers], [max_correct_answers], [score_<bucket>], [max_score_<bucket>], [score_correct_<bucket>], [max_score_correct_<bucket>], and answer-pipe tokens [BlockLabel]. `score_correct` counts only points earned on correctly-answered graded questions; `correct_answers` / `max_correct_answers` count graded questions right vs total.
      - `when_incorrect` string, nullable: Message shown to the respondent immediately after they answer incorrectly. Only meaningful when `correct_answer_ids` is set. Supports the same template variables as `when_correct`.
      - `scale` object, nullable: Whole-scale presentation for `rating` / `opinion_scale` / `nps`. The point RANGE lives in the block's `min` / `max`, not here; per-point emoji or labels live in `options`. Responses store the picked NUMBER, so a scale answer reads back as an integer and compares numerically in skip logic (e.g. an NPS detractor follow-up is `answer <= 6`). Ranges: `rating` starts at min=1 with 2-10 points (default 1-5); `opinion_scale` starts at 0 or 1 with 2-11 points; `nps` is locked to 0-10 with standard anchors. Out-of-range values return `400 invalid_request`.
        - `icon` 'numbers' | 'stars' | 'emoji', nullable: Point rendering. `stars` is the rating default; `numbers` the scale/NPS default. `emoji` requires one option per point carrying that point's emoji.
        - `label_left` string, nullable: Endpoint anchor shown at `min` (e.g. "Not at all likely"). Mutually exclusive with per-point `label`s: name the two ends, or name every point, not both. Fixed and translated automatically on `nps` — sending it there returns `400 invalid_request`.
        - `label_center` string, nullable: Optional midpoint anchor. Only meaningful on an odd point count, which is the only shape with a real middle.
        - `label_right` string, nullable: Endpoint anchor shown at `max` (e.g. "Extremely likely"). Same rules as `label_left`.
      - `randomize_options` boolean, nullable: Shuffle the option order per respondent, so the list a participant sees is not the order you authored. Valid on `single_punch`, `multi_punch` and `ranking`; sending it on `rating` / `opinion_scale` / `nps` returns `400 invalid_request` — a scale is ordered by definition and its points are never shuffled. It matters most on `ranking`, where a fixed order is not a cosmetic default but a measurement bias: whichever item sits first gets tapped first, and that bias lands in the average rank the results report. The shuffle is seeded per respondent per session, so revisiting a question does not reorder it mid-answer. Options carrying `anchor_position: "last"` are appended after the shuffle in their stored order. Defaults to false. Discord poll embeds are out of scope — one embed renders for every viewer, so there is no per-respondent order to vary.
      - `text_analysis` boolean, nullable: `open_text` only: send this block's answers for AI summary. `null` (or absent on read) follows the project's `settings.text_analysis`. Without an explicit value, a block whose question asks for personal data (name, email, phone, address...) is not summarized. Whatever this says, respondents are sent as pseudonyms, personal data in answers (emails, phone numbers, ids...) is removed before sending, and a block whose answers are mostly personal data is not summarized at all. Sending true/false on another block type returns `400 invalid_request`. On update: omit to leave it, send `null` to follow the project again.
      - `rank_top_n` integer, nullable: Ranking only: how many items the respondent is asked to put in order ("rank your top 3"). Omit or null to have every item ranked. It has its own field rather than riding `max`, which means max SELECTIONS on a multi_punch. A ranking submits complete or not at all — every item, or exactly the top N — so average ranks stay comparable across respondents. Each ranked item is stored as one response row whose `value` is the rank position (1 = first) and whose `option_id` is the item; unranked items in top-N mode have no row. Ranking has one interaction model (tap in order) on both Discord and web, so its `answer_style` is only about how the items are drawn: `full_text` (default) or `emoji_only`, which puts the item names in a list above the buttons and is the way to fit long names on Discord. `select_menu` returns `400 invalid_request` — a dropdown can't express an order. The item list is capped at 20 — a full grid of Discord buttons — and more than about 7 items is ranked unreliably, which is what `rank_top_n` is for.
      - `calculated_formula` string, nullable: Formula for `calculated_block` type. Evaluated in script order; earlier calculated blocks can be referenced by subsequent blocks via [BlockLabel]. Three expression types are supported: 1. `argmax([score_a], [score_b], ...)` — returns the display name of the bucket with the highest value. 2. `if <cond> then <val> else <fallback>` — keywords are case-insensitive. `<cond>` supports `>=`, `<=`, `!=`, `==`, `>`, `<` (numeric). A bare numeric condition is truthy if non-zero. 3. Arithmetic: any expression using `[variable]` tokens, digits, `+`, `-`, `*`, `/`, `(`, `)`. Unknown variables resolve to 0. Available variables: `[score]`, `[max_score]`, `[score_correct]`, `[max_score_correct]`, `[correct_answers]`, `[max_correct_answers]`, `[score_<bucket>]`, `[max_score_<bucket>]`, `[score_correct_<bucket>]`, `[max_score_correct_<bucket>]`, answer-pipe tokens `[BlockLabel]`, and previously evaluated `[CalcFieldName]` tokens. `score_correct` / `max_score_correct` accumulate only from correctly-answered graded questions; `correct_answers` / `max_correct_answers` are question counts (right vs total gradeable). Examples: `argmax([score_gryffindor], [score_slytherin], [score_ravenclaw])` `if [score] >= 15 then Excellent else Good` `[score] / [max_score] * 100` `if [correct_answers] == [max_correct_answers] then Perfect! else [correct_answers] out of [max_correct_answers]` `[score_correct] / [max_score_correct] * 100`
      - `position` integer, nullable: 0-based position in the script. Omit to append.
      - `continue` object, nullable: Advance settings for `content_block` and `action_block`.
        - `after` 'pause' | 'click': `pause`: auto-advance after `continue.pause` seconds; `click`: respondent must press a button to continue.
        - `pause` integer, nullable: Seconds to wait before auto-advancing. Only used when `after` is `pause`.
        - `label` string, nullable: Button label shown to the respondent. Only used when `after` is `click`. Defaults to `Got it 👍`.
  - `intent` string: Natural-language description of the survey goal. Mutually exclusive with `script`. AI drafts the script and writes it; the response includes `script.blocks` and `draft`. The draft inputs below are read only when `intent` is set.
  - `quality` 'quick' | 'deep': Draft quality. `quick` is a fast, low-cost model; `deep` is a stronger model that costs far more credits (typically ~24,000-48,000 versus ~700) and needs a balance of at least its floor to start. Defaults to `deep` on any paid plan, `quick` otherwise.
  - `question_count` integer: How many questions to draft (intro, content and action blocks not counted). Capped by the plan's limit. Omit to let the model choose what the goal needs.
  - `max_blocks` integer: Deprecated alias of `question_count`.
  - `language` string: Language respondents will read: any language's name (`French`, `Tagalog`) or a locale code (`fr`, `pt-BR`, `ja`). Defaults to a language the intent names ("a poll in Tagalog"), else the community's. Only a value that is not a language name or code is a 400.
  - `admin_language` string: Language the plan and `author_notes` are written in for you (the author): any language's name or a locale code. It never sets the project's language. When omitted it is inferred from the intent, and the response's `notices` say so.
  - `facts` string: Facts the model cannot know and must use as given: dates, line-ups, results, odds. The model's knowledge has a cutoff, so pass anything current here.
  - `plan` DraftPlan: What the AI understood and what it would like to know before drafting.
    - `kind` 'survey' | 'poll'
    - `title` string
    - `question_count` integer, nullable
    - `outline` string[]: One line per planned block.
    - `questions` object[]: Questions for the author. Answer them in `answers` by `id`; unanswered ones are left to the model. A question with `id` `language` comes when the intent is written in a language that is neither yours (`admin_language`) nor the project's: its 4 suggestions, in order, keep the project's language, write the project in the intent's language, write only `author_notes` in it, or both. Answer with the suggestion's text.
      - `id` string
      - `question` string
      - `suggestions` string[]
    - `facts_needed` string: Facts the draft needs from you (pass them in `facts`); empty when none.
    - `notes` string
    - `request_language` string: The language the intent is written in, as the model read it.
    - `named_language` string: The language respondents should read when the intent names one ("a poll in Tagalog"); empty otherwise.
  - `answers` object: Your answers to the plan's questions: plan question `id` -> answer text.
  - `session_id` string: The `session_id` from `POST /projects/generate/plan`, so the plan and the draft are recorded as one exchange. Optional.

## Response `201`

Created. Response includes `data.script.blocks` when `script` or `intent` was provided. If the AI draft failed after the project was created, `data.script` is `null`, `data.script_error` is `"ai_failure"` and `data.script_error_detail` says why and what was charged. The project still exists, empty; add a script with `PUT /script` or retry with `POST /projects/generate`.

- object
  - `data` Project
    - `id` string
    - `community_id` string
    - `name` string
    - `status` 'active' | 'inactive': Lifecycle state. `POST /open` → `active` (accepting responses). `POST /close` → `inactive` (closed). Projects start `inactive` on creation.
    - `info` object: Core identity and aggregate counts for this project
      - `type` 'convo' | 'poll'
      - `created_at` string, date-time, nullable
      - `block_count` integer
      - `is_cloneable` boolean: Whether this project may be cloned by other communities (read-only opt-out flag)
      - `author_id` string, nullable: DB user ID of the project creator
      - `author_name` string, nullable: Display name of the project creator
      - `completion_count` integer: Number of participant sessions that reached the end of the project (i.e. `saveFinalStatus` was called). For polls this equals the number of votes cast; for convos it counts only fully-completed runs. Decremented when a poll voter retracts their answer.
      - `response_count` integer: Running total of individual question-answer rows written across all participants. Each block a participant answers increments this by 1. For a 5-block convo with 10 completions this can reach 50. To count distinct participant sessions use the `total` field from `GET /responses`.
    - `settings` object: Participation rules and result visibility for this project
      - `privacy_mode` 'transparent' | 'semi-private' | 'anonymous', nullable: Controls response visibility and identity disclosure. - **`transparent`** — All community members can see exactly who answered what for every question. - **`semi-private`** — Only the project creator and community admins can view individual responses (who answered what). The rest of the community sees aggregated results only if the creator chooses to share them. - **`anonymous`** — Respondent identity is hidden from everyone, including the creator and admins. Data is pseudo-anonymous: answers are recorded but not linked to a user ID or display name, unless a respondent volunteers that information in an open-text answer. **Caveat:** XP and achievements can still be granted in anonymous projects, so participation is not entirely hidden — this is especially relevant in small communities where deduction is easier.
      - `max_completes_per_user` integer, nullable
      - `change_poll` boolean, nullable: Allow respondents to change their poll answer after submission
      - `poll_results_mode` 'public' | 'voters_only' | 'hidden', nullable: `public`: results visible in the poll embed; `voters_only`: only voters can see results; `hidden`: results hidden until poll closes
      - `reveal_results` boolean, nullable: Post a results summary to the results channel when the project closes
      - `text_analysis` boolean, nullable: Send this project's open-text answers for AI summary. A new project starts with the community's text analysis setting (`general.text_analysis`) written on it; a clone keeps the source's value only when the destination community has text analysis on, and starts off otherwise. `null` (projects created before 2026-10) follows the community setting. A block can still override it, and an `open_text` block whose question asks for personal data (name, email, phone...) is off by default. On update: omit to leave it, send `null` to go back to the community setting.
    - `delivery` object, nullable
      - `opening` object
        - `mode` 'manual' | 'scheduled'
        - `time` string, date-time, nullable
      - `closing` object
        - `mode` 'manual' | 'scheduled'
        - `time` string, date-time, nullable
      - `audience` object
        - `participation` 'private' | 'open_web'
        - `response_channel` 'discord' | 'web'
        - `required_role_ids` string[]
        - `verified_source_ids` integer[]
      - `answer_surfaces` string[], nullable: Ordered fallback chain of surfaces a Convo is attempted on, first to last. `null` means no override: the project follows Subo's default rules, which are resolved at press time and can differ between two posts of the same project in different channels. Send `null` to clear an override; an empty array is rejected.
      - `clear_conversation` boolean: Purge the respondent's thread transcript once they COMPLETE the Convo. Never fires on a timeout: a timed-out Convo is resumable and the respondent returns to it. Defaults to false. Only honored on a thread: Subo can bulk-delete in a thread but only its own messages in a DM, so a project with this on AND a `dm` step in `answer_surfaces` fails script validation and will not open. A web Convo carries no durable transcript, so the flag is inert there.
      - `share_url` string, nullable: The link respondents use to answer on the web, for `open_web` and `private` + `web` projects; null for a project answered in Discord. Share it as given: its shape may change (community vanity URLs).
      - `invitation` object, nullable
        - `post_channel_id` string, nullable: Where the invitation embed is posted; null means nowhere.
        - `message` string, nullable
        - `thumbnail_url` string, uri, nullable
        - `image_url` string, uri, nullable
        - `border_color` string, nullable: Hex colour without '#' for the embed border while the project is open (e.g. `e1287e`)
        - `border_color_closed` string, nullable: 6-digit hex colour without '#' for the embed border when the project is closed (e.g. `745399`). A leading '#' is accepted and removed. Premium only.
        - `footer` string, nullable: Text shown below the invitation embed. Premium only.
        - `embed_config` object, nullable: Invitation embed layout — whether project info shows inline (`embed`) or behind a button, plus per-row visibility toggles
          - `info_display` 'embed' | 'button', nullable
          - `rows` object: Per-row visibility toggles for the invitation embed (open map, e.g. status/privacyMode/roles/xpReward)
        - `answer_button_label` string, nullable: Custom label for the answer/vote button. Premium only.
        - `answer_button_color` integer, nullable: Discord button style for the answer button (1 primary, 2 secondary, 3 success, 4 danger). Premium only.
    - `rewards` object, nullable
      - `discord_role_id` string, nullable
      - `achievement_id` string, nullable
      - `use_xp` boolean, nullable
      - `achievement_enabled` boolean, nullable: True if this project grants an achievement on completion
      - `achievement_image_url` string, uri, nullable: Badge image URL for the achievement
      - `achievement_name` string, nullable: Custom achievement name; null uses the computed default
      - `achievement_role_id` string, nullable: Discord role snowflake granted alongside the achievement
    - `presentation` object, nullable
      - `theme_id` integer, nullable
      - `interviewer_id` integer, nullable
      - `branding` string, nullable
      - `chart_emoji` string, nullable: Emoji used as the bar character in poll result embeds
    - `scoring` object, nullable: Quiz and scoring configuration for this project
      - `enabled` boolean: When true, blocks may carry `correct_answer_index`, `when_correct`, `when_incorrect`, and per-option `score_values` for quiz scoring. Enable via `POST /projects` or `PUT /projects/{projectId}` with `scoring_enabled: true`.
      - `buckets` object[]: Score dimensions defined for this project. Each bucket's `name` (lowercased, spaces replaced with underscores) is the key used in `score_values` on block options and in template variables such as `[score_gryffindor]`. Empty when `enabled` is false.
        - `name` string: Display name of the bucket (e.g. `Gryffindor`). Variable key is this value lowercased with spaces replaced by underscores.
        - `order` integer: Display order (0-based).

## Other responses

- `400`: Invalid request body
- `401`: Invalid or missing API key
- `402`: Insufficient bot credits (only when `intent` is provided), or a Premium-only invitation field (`border_color_closed`, `footer`, `answer_button_label`, `answer_button_color`) was set on a Basic tier
- `429`: Rate limit exceeded — wait `retry_after` seconds before retrying. The same value appears in the `Retry-After` header.

---

[API](https://skmtc.dev/subo/apis/subo-api.md) · [All operations](https://skmtc.dev/subo/apis/subo-api/llms.txt) · [OpenAPI document](https://skmtc.dev/subo/apis/subo-api/revisions/12eab2ae4505?raw)
