BetaHub API
BetaHub API
Home Docs 1.4

This is the official BetaHub API documentation.

Requesting JSON responses

A response is rendered as JSON when the request path ends in the .json extension (e.g. https://app.betahub.io/projects.json) OR the request sends an Accept: application/json header. Either mechanism selects the JSON representation; you do not need both. The .json suffix is the most reliable and is used throughout the paths in this document. Note that Content-Type describes the request body you are sending (e.g. application/json, application/x-www-form-urlencoded, or multipart/form-data for uploads) — it does NOT select the response format. Use the path suffix or the Accept header for that.

Conventions

Timestamps in request and response bodies are ISO 8601 / RFC 3339 in UTC with a millisecond fraction and a trailing Z, e.g. 2024-10-03T12:34:56.000Z.

Authentication

The API supports multiple authentication methods:

  • Anonymous access: Use FormUser anonymous for public operations
  • Token-based access: Use FormUser tkn-{token} for authenticated operations
  • Token with Submission Token (JWT): Use FormUser tkn-{token},{jwt_token} to include a server-generated JWT that carries trusted data (email, custom fields) that cannot be tampered with by the end user. See the Submission Tokens endpoint for generating these tokens.
  • Token with JWT (Legacy): Use FormUser tkn-{token},{jwt_token} where the JWT contains a user_id claim to authenticate as an existing BetaHub user
  • Personal Access Tokens: Use Bearer YOUR_TOKEN_HERE format for enhanced security Personal Access Tokens provide secure authentication for API integrations, automated scripts, and CI/CD pipelines. You can create and manage them in your account settings at Profile → Personal Access Tokens. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

    Direct file upload flow (screenshots, log files, binary files, video clips)

    Attaching media to an issue is a three-step flow. The endpoints are named presigned_upload and confirm_upload, but there is a mandatory storage PUT between them:

    1. POST .../{media}/presigned_upload with filename, byte_size, checksum (base64-encoded MD5), and content_type. The response returns direct_upload_url, a headers object, blob_signed_id, and blob_id.
    2. PUT the raw file bytes to direct_upload_url. This call goes straight to the storage backend (S3), not to BetaHub. You MUST send every key/value from the headers object returned in step 1 as request headers — they carry the Content-Type and Content-MD5 that storage validates against the checksum and content_type you declared. The presigned URL is short-lived, so perform this PUT promptly.
    3. POST .../{media}/confirm_upload with the blob_signed_id from step 1 to attach the stored file to the issue. The confirm_upload success response (201) is the created attachment record serialized as its raw database columns (there is no name column — name is a write-only transient attribute and is NOT in the response). For a screenshot the columns are id, status, description, issue_id, media_size_bytes, user_id, developer_private, and timestamps; the other media types serialize their own columns similarly. The response does not include a download URL — fetch the issue’s media list endpoint (e.g. GET .../screenshots) to obtain url.

      Limits per media type

  • Screenshots — max 10 MB, up to 10 per issue; content types image/png, image/jpeg, image/jpg.
  • Log files — max 50 MB, up to 10 per issue; ~24 text/log/archive types (text/plain, text/csv, application/json, application/xml, application/zip, application/gzip, application/x-tar, …), with application/octet-stream as a fallback.
  • Binary files — max 50 MB, up to 10 per issue; any content type (server-side type check is bypassed).
  • Video clips — max 500 MB, up to 3 per issue; content types video/mp4, video/quicktime, video/webm, video/avi, video/mov.

    Upload error responses

    Both presigned_upload and confirm_upload return errors as a {"error": "<message>"} body. Statuses: 400 missing required parameters; 422 invalid content type, file exceeds the per-type size cap, invalid checksum, per-issue count cap reached, or the file was not uploaded before confirming; 404 invalid/expired blob_signed_id; 500 server error creating the presigned upload. Rely on the HTTP status line — error message strings are human-readable English, not localized, and not a stable contract.

    Resource IDs

    BetaHub uses two id styles. Top-level resources are addressed by an obfuscated string id with a type prefix: projects are pr-XXXXXXXX (used as project_id), organizations are org-XXXXXXXX, and users are usr-XXXXXXXX. These are the values you pass in paths and see in responses (e.g. project_id = pr-5632787018). Issues, feature requests, and tickets additionally have a per-project integer scoped_id that restarts at 1 within each project (the numbers you see in the UI). A path id for these resources accepts either that per-project scoped_id OR the obfuscated global form. For issues the global form is g-{database_id} (which is why issue paths are written .../issues/g-{issue_id}); passing the plain scoped_id resolves the same record within the project. Response bodies expose scoped_id as the per-project number; treat it as unique only within its project, not globally.

    Media URL access model

    Attachment responses (screenshots, log files, binary files, video clips) include *_url fields such as url and layer_a_url. A returned media URL points at the CDN as an unsigned path of the form https://<cdn-host>/<storage-key> — it carries no signature or expiry query parameter — and is fetched directly; that fetch requires no additional BetaHub API authentication. The developer_private flag controls only whether a given media URL is included in the JSON listing returned to a particular caller; it does not gate access to the URL once the URL is known.

    CORS

    The API sends permissive CORS headers: it allows any Origin (*), the methods GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD, and any request header, and it answers OPTIONS preflight requests. Because the allowed origin is *, browser requests cannot include credentials (cookies); pass the token in the Authorization header instead.

Authentication schemes

formUserToken

Project Auth Token authentication, scoped to a single project. Send the token in the Authorization header using the FormUser scheme — the header value is the full string, e.g. FormUser tkn-YOUR_TOKEN.

Accepted values:

  • FormUser tkn-{token} — authenticated as the project token
  • FormUser anonymous — anonymous submission (no token)
  • FormUser tkn-{token},{jwt_token} — attach a server-generated Submission Token (JWT)

Tokens are created per project and carry individually configurable boolean permission flags. Each flag unlocks specific operations:

  • can_create_bug_reportPOST /projects/{project_id}/issues.json
  • can_create_feature_requestPOST /projects/{project_id}/feature_requests.json
  • can_create_ticketPOST /projects/{project_id}/tickets.json
  • can_report_crash — crash-report submission (e.g. POST /crashes/unreal/{token})
  • can_search_knowledge_basePOST /projects/{project_id}/support_knowledge/ask
  • can_read_release_listGET /projects/{project_id}/releases.json
  • can_create_release — allows a submission that names a new release via release_label to create that release on the fly

A request returns 403 Forbidden when the token lacks the flag for the operation, is used against a different project than it belongs to, or exceeds its per-IP daily rate limit (see below).

Per-IP daily rate limits. The bug-report, feature-request, ticket, crash-report, and knowledge-base-search operations are each metered by a separate per-IP daily counter (default 8 per IP per day, configurable per token). The count is keyed on (token, IP, operation, day) and resets at the day boundary. Exceeding a limit returns 403 Forbidden with a “Not allowed to …” message. No rate-limit reset metadata is returned in responses.

bearerAuth

Personal Access Token (PAT) authentication for account-level API access. Send as Authorization: Bearer pat-YOUR_TOKEN.

A PAT authenticates the request as the user who created it and acts with that user’s permissions. Unlike a Project Auth Token (which carries a fixed set of boolean flags and is scoped to one project), a PAT is not limited to a single project: for each operation, authorization is decided by the permission scopes the user holds through their role in the target project.

Scopes are string keys drawn from a fixed vocabulary. The keys that gate API-reachable operations include (grouped by area):

  • Bugs: bugs.update (alias issues.update), bugs.archive (issues.archive), bugs.merge (issues.merge), bugs.convert (issues.convert), bugs.ask_details (issues.ask_details), bugs.bulk_update (issues.bulk_update), bugs.delete (issues.delete), bugs.push_integration (issues.push_integration), bugs.public_link (issues.public_link), bugs.clarify (issues.clarify)

  • Suggestions: suggestions.update, suggestions.moderate
  • Tickets: tickets.view, tickets.update
  • Project configuration: project.settings.edit, project.members.manage, project.releases.manage, project.integrations.view, project.integrations.manage, project.api_tokens.manage, project.webhooks.manage, project.automation.manage, project.taxonomy.manage (issue statuses, tags, groups, custom fields, log-redaction patterns), project.support.manage, project.survey.manage, project.guidelines.view, project.key_collections.view, project.analytics.view

  • Members: access_requests.review

The issues.* names are legacy aliases that resolve to the canonical bugs.* keys. A request the user’s scopes do not permit returns 403 Forbidden.

Projects

List and view project details
Returns a paginated list of projects visible to the current user. For authenticated users, returns projects they are a member of. For unauthenticated users or when all=true, returns publicly discoverable projects.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Query Parameters
Name Type Description
page optional integer Page number for pagination (default: 1). The JSON endpoint returns a fixed 25 projects per page; the page size is not client-controllable (there is no per-page parameter). Use the pagination.total_pages value in the response to iterate through all pages.
min: 1
Default: 1
all optional boolean When true, returns public/discoverable projects instead of the user’s member projects
cURL
curl \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  "https://app.betahub.io/projects.json?page=1&all=true"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects.json?page=1&all=true")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"

response = http.request(request)
puts response.body
Python
import requests

response = requests.get(
    "https://app.betahub.io/projects.json?page=1&all=true",
    headers={"Authorization": "Bearer YOUR_API_TOKEN"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects.json?page=1&all=true", {
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects.json?page=1&all=true"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .GET()
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Successful response
Response fields
  • projects array[object] optional
    • id string optional Obfuscated project ID
    • name string optional Project name
    • description string optional Project description (may contain Markdown)
    • created_at string date-time optional
    • updated_at string date-time optional
  • pagination object optional
    • current_page integer optional
    • total_pages integer optional
    • total_count integer optional
application/json
{
  "projects": [
    {
      "id": "string",
      "name": "string",
      "description": "string",
      "created_at": "2026-03-12T10:30:00Z",
      "updated_at": "2026-03-12T10:30:00Z"
    }
  ],
  "pagination": {
    "current_page": 0,
    "total_pages": 0,
    "total_count": 0
  }
}
Returns detailed information about a specific project, including configuration, enabled modules, platforms, and the latest release.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
id required string The project ID (obfuscated format, e.g. “pr-1234567”)
cURL
curl \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  "https://app.betahub.io/projects/123.json"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123.json")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"

response = http.request(request)
puts response.body
Python
import requests

response = requests.get(
    "https://app.betahub.io/projects/123.json",
    headers={"Authorization": "Bearer YOUR_API_TOKEN"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123.json", {
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123.json"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .GET()
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Successful response
Response fields
  • id string optional Obfuscated project ID
  • name string optional Project name
  • description string optional Project description (may contain Markdown)
  • access string optional Project access level. private — the project is only visible to its members and organization admins. public — the project is publicly discoverable and viewable without membership.
    private public
  • created_at string date-time optional
  • updated_at string date-time optional
  • url string uri optional Full URL to the project
  • sentiment_analysis boolean optional Whether sentiment analysis is enabled
  • support_knowledge boolean optional Whether support knowledge is enabled
  • support_knowledge_chunks_size integer optional Number of support knowledge chunks
  • has_discord_bot boolean optional Whether a Discord bot is connected
  • modules array[string] optional The list of modules enabled for this project. Only enabled modules are present in the array (a project with bugs and tickets enabled returns ["bugs", "tickets"]). The enum lists every module key that may appear.
    bugs suggestions sentiments tickets support_knowledge nda surveys
  • platforms array[string] optional Free-form list of platforms this project targets, authored by the project owner. Values are arbitrary strings (not a fixed enum) — use them for display only, do not match against a predefined set.
  • min_description_length integer optional Minimum description length for bug reports
  • min_suggestion_length integer optional Minimum description length for suggestions
  • latest_release object optional The latest release for this project. Always present as an object (never null). When the project has no releases yet, this is an empty object {} and none of the sub-fields below are present — check for the presence of id (or an empty object) before reading release details.
    • id integer optional Release ID (present only when a release exists)
    • label string optional Release label (present only when a release exists)
    • description string optional Release description (present only when a release exists)
    • created_at string date-time optional Present only when a release exists
    • updated_at string date-time optional Present only when a release exists
    • url string uri optional Full URL to the release (present only when a release exists)
application/json
{
  "id": "string",
  "name": "string",
  "description": "string",
  "access": "private",
  "created_at": "2026-03-12T10:30:00Z",
  "updated_at": "2026-03-12T10:30:00Z",
  "url": "https://example.com",
  "sentiment_analysis": true,
  "support_knowledge": true,
  "support_knowledge_chunks_size": 0,
  "has_discord_bot": true,
  "modules": [
    "bugs",
    "suggestions",
    "sentiments",
    "tickets",
    "support_knowledge"
  ],
  "platforms": [
    "Windows",
    "Steam",
    "PS5"
  ],
  "min_description_length": 0,
  "min_suggestion_length": 0,
  "latest_release": {
    "id": 0,
    "label": "string",
    "description": "string",
    "created_at": "2026-03-12T10:30:00Z",
    "updated_at": "2026-03-12T10:30:00Z",
    "url": "https://example.com"
  }
}
Unauthorized. An unauthenticated request to a private or draft project returns 401 (body {"error":"You must be signed in to perform this action."}) — the unauthenticated case is handled here, before any 403.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Forbidden ({"error":"Forbidden"}). Returned only for an authenticated user who is not a member (nor org-admin/site-admin) of a private or draft project. The unauthenticated case is a 401, not a 403.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Project not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}

Issues

Create, update, search, and manage bug reports
Retrieves a list of issues for the specified project. The response includes issue details such as ID, title, description, status, priority, and associated metadata.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
Query Parameters
Name Type Description
page optional integer Page number for pagination (default: 1)
per_page optional integer Number of issues per page. Default is 25. Any positive value is honored verbatim up to a maximum of 100; values greater than 100 are clamped to 100, and values ≤0 fall back to the default of 25.
min: 1 max: 100
status optional string Filter issues by status. Accepts any built-in status (hidden, open, in_progress, resolved, closed, duplicate, pending_moderation, wont_fix, needs_more_info) or a project custom status. Pass status=any to disable status filtering. When omitted, no status filter is applied.
priority optional string Filter issues by priority
low medium high critical blocker
assigned_to_id optional string Filter by assignee. Pass a user id, or the literal not_assigned to return only unassigned issues.
reported_by_id optional string Filter by the id of the reporting user.
release_id optional string Filter by release. Pass a release id, or the literal latest to filter by the project’s most recent release.
archived optional string Filter by archived state. true returns only archived issues, false only non-archived. When omitted, archived issues are excluded by default (equivalent to false).
true false
created_after optional string Return issues created on or after this date (inclusive).
created_before optional string Return issues created on or before this date. A date-only value is treated as inclusive of the whole day (e.g. created_before=2026-05-25 includes issues created at 15:30 that day).
updated_after optional string Return issues updated on or after this date (inclusive).
updated_before optional string Return issues updated on or before this date (inclusive of the whole day).
with_media optional string true returns only issues that have at least one attachment (screenshot, video, log, or binary file); false returns only issues with none.
true false
sent_to optional string Filter by issues pushed to an external integration. Pass an integration service name, or the literal any to match issues sent to any enabled integration.
tag_ids optional string Comma-separated list of tag ids; returns issues carrying any of the given tags.
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/issues.json?page=123&per_page=123&status=example&priority=low&assigned_to_id=123&reported_by_id=123&release_id=123&archived=true&created_after=example&created_before=example&updated_after=example&updated_before=example&with_media=true&sent_to=example&tag_ids=123"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/issues.json?page=123&per_page=123&status=example&priority=low&assigned_to_id=123&reported_by_id=123&release_id=123&archived=true&created_after=example&created_before=example&updated_after=example&updated_before=example&with_media=true&sent_to=example&tag_ids=123")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.get(
    "https://app.betahub.io/projects/123/issues.json?page=123&per_page=123&status=example&priority=low&assigned_to_id=123&reported_by_id=123&release_id=123&archived=true&created_after=example&created_before=example&updated_after=example&updated_before=example&with_media=true&sent_to=example&tag_ids=123",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues.json?page=123&per_page=123&status=example&priority=low&assigned_to_id=123&reported_by_id=123&release_id=123&archived=true&created_after=example&created_before=example&updated_after=example&updated_before=example&with_media=true&sent_to=example&tag_ids=123", {
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues.json?page=123&per_page=123&status=example&priority=low&assigned_to_id=123&reported_by_id=123&release_id=123&archived=true&created_after=example&created_before=example&updated_after=example&updated_before=example&with_media=true&sent_to=example&tag_ids=123"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .GET()
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Successful response
Response fields
  • issues array[object] optional
    • id integer optional Numeric primary key of the issue. NOTE: this is the raw integer PK, NOT the obfuscated global id. URL path segments require the obfuscated g-{id} form (e.g. /issues/g-12345), which this value is not. To build links use url (already fully-formed) or scoped_id — do not concatenate this id into a path.
    • scoped_id integer optional Per-project sequential issue number (the “#42” shown in the UI). Always present. On a duplicate merge the scoped_id of a destroyed losing issue can be reassigned to the surviving canonical issue, so it is stable for a live issue but not guaranteed permanent across merges. Persist the obfuscated g-{id} (see url) if you need a permanently stable identifier.
    • title string optional
    • description string optional
    • status string optional Current issue status. This is NOT a closed enum. The built-in statuses are hidden, open, in_progress, resolved, closed, duplicate, pending_moderation, wont_fix, and needs_more_info, but a project may also define custom statuses — treat this as a free-form string.
    • status_display string optional Human-readable label for status, resolved against the project’s (possibly custom) status names.
    • priority string optional Issue priority. Built-in values: low, medium, high, critical, blocker. For issues created through SDK / FormUser tokens the priority is LLM-predicted and cannot be set on create (any submitted value is stripped); it is only settable by a developer via the update (PUT) endpoint.
    • discord_message string optional nullable Original Discord message text when the issue originated from the Discord bot; null otherwise.
    • created_at string date-time optional
    • updated_at string date-time optional
    • score string optional Read-only report-completeness signal, emitted as a decimal string in the range “0”..”1” (e.g. “0.8542”) — NOT a 0–100 number. Computed server-side; any value supplied on submit is ignored.
    • steps_to_reproduce array[object] optional
      • step string optional
    • device object optional nullable Structured device / hardware information attached to the issue, or null when none is attached. Populated by the LLM parser when issue[extras][device_info] is submitted on create (see the create endpoint).
      • id integer optional
      • device_type string optional
      • configuration object optional Free-form parsed hardware specs (e.g. CPU, GPU, OS, memory).
    • assigned_to object optional
      • id integer optional nullable
      • name string optional nullable
    • reported_by object optional Reporter identity. Both id and name are null unless the caller’s token carries reporter-visibility permission. Even when visible, name may be masked or replaced with a persona by the project’s team-identity settings.
      • id integer optional nullable
      • name string optional nullable
    • potential_duplicate object optional nullable Populated ONLY when status == 'duplicate'; null otherwise. When present it is a full nested issue object with the same shape as this IssueResponse — describing the canonical issue this submission was merged into (your report was detected as a duplicate and folded into that existing issue). The nested object is not expanded inline here to avoid a recursive schema; expect the same fields as a top-level issue.
    • screenshots array[object] optional Screenshots attached to the issue, filtered by the caller’s visibility. This array (and the other attachment arrays below) is OMITTED entirely on responses that skip attachment rendering, such as the find_similar endpoint.
      • id integer optional The ID of the screenshot
      • type string optional Attachment type identifier
      • description string optional nullable Auto-generated (or user-edited) description of the screenshot
      • size_bytes integer optional File size in bytes (alias for media_size_bytes)
      • media_size_bytes integer optional File size in bytes
      • content_type string optional nullable MIME type of the image (null when no image is attached)
      • url string uri optional nullable CDN URL to access the screenshot image (null when no image is attached). Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
      • filename string optional nullable Filename of the uploaded image (null when no image is attached)
      • layer_a_url string uri optional nullable CDN URL of the annotation overlay layer, when present
      • layer_a_filename string optional nullable Filename of the annotation overlay layer, when present
      • developer_private boolean optional Whether this screenshot is only visible to developers and admins
        Default: false
      • created_at string date-time optional Creation timestamp
      • updated_at string date-time optional Last update timestamp
      • user object optional nullable For screenshots this object reflects the issue’s reporter (issue.reported_by), NOT the screenshot’s uploader — the _screenshot.json.jbuilder view ignores the screenshot.user column. It is null only when the issue has no reporter. (The video/log/binary jbuilders differ: those DO use the uploader, resource.user.)
        • id integer optional User ID
        • name string optional Player-facing display name
    • log_files array[object] optional Log files attached to the issue (visibility-filtered). Omitted when attachments are not rendered.
      • id integer optional The ID of the log file
      • created_at string date-time optional Creation timestamp
      • updated_at string date-time optional Last update timestamp
      • media_size_bytes integer optional File size in bytes
      • type string optional Attachment type identifier
      • size_bytes integer optional File size in bytes (alias for media_size_bytes)
      • content_type string optional MIME type of the file
      • url string uri optional nullable URL to download the log file. Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
      • filename string optional nullable Filename of the uploaded file
      • developer_private boolean optional Whether this log file is only visible to developers and admins
        Default: false
      • user object optional nullable User who uploaded the file
        • id integer optional User ID
        • name string optional User display name
    • video_clips array[object] optional Video clips attached to the issue (visibility-filtered). Omitted when attachments are not rendered.
      • id integer optional The ID of the video clip
      • type string optional Attachment type identifier
      • processing boolean optional Whether the video is currently being transcoded/processed by a background job.
      • processed boolean optional Whether the clip is ready for inline web playback. For multipart uploads this becomes true only if the source is web-compatible AND under the organization’s max video length; otherwise a background job transcodes it first. Direct (presigned) uploads are marked processed=true immediately on confirm, without transcoding or a length check (see VideoClip model before_save).
      • failed boolean optional Whether video processing has failed
      • size_bytes integer optional File size in bytes (alias for media_size_bytes)
      • media_size_bytes integer optional File size in bytes
      • content_type string optional nullable MIME type of the video (null when no video is attached)
      • url string uri optional nullable CDN URL to access the video clip (null when no video is attached). Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
      • filename string optional nullable Filename of the uploaded video (null when no video is attached)
      • developer_private boolean optional Whether this video clip is only visible to developers and admins
        Default: false
      • created_at string date-time optional Creation timestamp
      • updated_at string date-time optional Last update timestamp
      • user object optional nullable User who uploaded the video clip (null for anonymous/reporter fallback)
        • id integer optional User ID
        • name string optional Player-facing display name
    • binary_files array[object] optional Binary files attached to the issue (visibility-filtered). Omitted when attachments are not rendered.
      • id integer optional The ID of the binary file
      • created_at string date-time optional Creation timestamp
      • updated_at string date-time optional Last update timestamp
      • media_size_bytes integer optional File size in bytes
      • type string optional Attachment type identifier
      • size_bytes integer optional File size in bytes (alias for media_size_bytes)
      • content_type string optional MIME type of the file
      • url string uri optional nullable URL to download the binary file. Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
      • filename string optional nullable Filename of the uploaded file
      • developer_private boolean optional Whether this binary file is only visible to developers and admins
        Default: false
      • user object optional nullable User who uploaded the file
        • id integer optional User ID
        • name string optional User display name
    • url string optional
    • token string optional JWT token for subsequent API access. Field name is ‘token’ (not ‘api_token’). Returned on successful issue creation when authenticated via a FormUser (submission form) token — present for both draft and non-draft submissions, and NOT gated on draft=true. Not returned for Discord-bot or regular web-user submissions.
    • warnings array[string] optional Partial-success notices. Present on create/update responses only when something was silently dropped despite the 2xx status — e.g. a custom-field value exceeded the 4096-character cap, the 32-auto-created-fields-per-entity limit was hit, or a field could not be auto-created. Each entry is a human-readable string. Clients that submit custom fields should inspect this array to detect partial data loss.
  • pagination object optional
    • current_page integer optional
    • per_page integer optional
    • total_pages integer optional
    • total_count integer optional
application/json
{
  "issues": [
    {
      "id": 12345,
      "scoped_id": 42,
      "title": "App crashes on login screen",
      "description": "When attempting to login, the app crashes after entering credentials.",
      "status": "open",
      "status_display": "Open",
      "priority": "high",
      "created_at": "2024-10-03T12:34:56Z",
      "updated_at": "2024-10-03T12:34:56Z",
      "score": "0.8542",
      "steps_to_reproduce": [
        {
          "step": "1. Open the app"
        },
        {
          "step": "2. Enter login credentials"
        },
        {
          "step": "3. Press login"
        }
      ],
      "assigned_to": {
        "id": 12,
        "name": "John Doe"
      },
      "reported_by": {
        "id": 34,
        "name": "Jane Smith"
      },
      "potential_duplicate": null,
      "url": "https://app.betahub.io/projects/1/issues/g-12345"
    }
  ],
  "pagination": {
    "current_page": 1,
    "per_page": 25,
    "total_pages": 4,
    "total_count": 95
  }
}

Creates a new issue for a project. Issues can be created in a draft mode, which allows for a step-by-step creation process.

Draft Mode Flow: 1. Create an issue with draft=true to keep it hidden (returns a JWT token in the ‘token’ field) 2. Optionally upload media files (screenshots, videos, log files) using the respective endpoints with the JWT token 3. Optionally set reporter email using the set_reporter_email endpoint 4. Publish the issue using the publish endpoint to make it visible

IMPORTANT NOTES:

  • Issue IDs in URLs must be prefixed with ‘g-‘ (e.g., /issues/g-12345 not /issues/12345) - JWT token is returned as ‘token’ (not ‘api_token’) and is only generated when using FormUser authentication
  • Use Bearer {jwt_token} for subsequent API calls after issue creation
  • Token is valid for 1 hour from issue creation Wire format notes:
  • Over form-encoding (application/x-www-form-urlencoded / multipart/form-data) boolean-typed custom fields (issue[custom][...]) must be sent as '1' / '0' — their validator accepts only [true, false, '1', '0', 1, 0] and rejects the literal strings 'true' / 'false' with a 422. Top-level form booleans (draft, skip_description_check, issue[process_include_flags], issue[include_screenshot] / include_video / include_other) are parsed with lenient boolean casting and DO accept 'true' / 'false' as well as '1' / '0'.
  • multi_select custom fields need bracket-array keys: issue[custom][ident][]=A&issue[custom][ident][]=B.
  • To send a JSON body set Content-Type: application/json and nest values under issue (custom-field JSON / array encoding is described on the issue[custom][FIELD_IDENT] field). Eventual consistency:
  • When async duplicate detection is enabled, a created issue is returned with status: 'open' and potential_duplicate: null, then may asynchronously flip to status: 'duplicate' with potential_duplicate populated shortly after. Re-fetch the issue if you need the settled duplicate state. Draft validation:
  • With draft=true, custom-field required/select validation is skipped at create and enforced at publish — the same payload can return 201 at create yet 422 when published. Identifier stability:
  • The obfuscated global id g-{id} is stable for the lifetime of an issue. scoped_id can be reassigned to a surviving duplicate when issues are merged (the losing issue is destroyed). Persist g-{id} (or url) when you need a permanent reference.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
Request Body
application/x-www-form-urlencoded
  • issue[title] string optional Title of the issue. If not provided, a GenAI will be used to generate a title.
  • issue[description] string required Description of the issue.
  • issue[unformatted_steps_to_reproduce] string optional Steps to reproduce the issue. A GenAI will be used to format the steps into array.
  • issue[steps_to_reproduce_array][] array[string] optional Pre-structured reproduction steps, one array entry per step. Use this as a deterministic alternative to unformatted_steps_to_reproduce: it is stored as-is and skips the AI reformatting pass. Over form-encoding, repeat the bracketed key: issue[steps_to_reproduce_array][]=Open the app&issue[steps_to_reproduce_array][]=Press login.
  • issue[logs] string optional Inline log text. When present, the server auto-creates a LogFile attachment from this string as part of issue creation, so a headless/token integration can attach logs in the same request instead of making a second multipart upload call.
  • issue[release_id] string optional ID of the release associated with this issue. If neither release_id nor release_label is provided, the latest release will be used.
  • issue[release_label] string optional Label for a release to associate with this issue. If the label exists, that release will be used; otherwise, a new release will be created (requires authorization token with create_release permission).
  • issue[source] string optional Optional free-form source identifier, stored verbatim. It has no built-in meaning except the single reserved value discord_bot, which flags the issue as Discord-originated (triggers release-confirmation automation and Discord-flavored 403 messaging). The API default is null (no source) — it is not set to “dashboard” for API submissions.
  • issue[extras][device_info] object optional Recommended way for headless / token integrations (game SDK, Discord bot) to attach device and hardware information. Submit the raw specs string as value and choose how strictly it is parsed with validation_mode. The server runs an LLM parse and populates the response device object (device_type + configuration). This is the device-attach path for callers that cannot create a Device record (FormUser / Discord tokens).
    • value string optional Raw device/hardware specs string to parse (e.g. a dxdiag dump or a free-form spec line).
    • validation_mode string optional How strictly the specs are parsed. strict rejects on unparseable input, loose (the fallback for an unrecognized mode) parses best-effort, and optional is the most permissive.
      strict loose optional
  • issue[discord_id] string optional Discord user id to attribute the report to. Only honored when the project has allow_discord_id_for_issues enabled and the caller is an anonymous token-authenticated FormUser; silently ignored otherwise.
  • issue[discord_username] string optional Discord username paired with issue[discord_id], used when creating the attributed reporter. Subject to the same gating as issue[discord_id].
  • issue[tested_on_device_id] string optional Obfuscated id of a Device the report was tested on. The device must belong to the authenticated reporter, so this only works for session / Personal Access Token User reporters — there is no API to create a Device for FormUser or Discord tokens. Token / submission-token clients therefore cannot use this field; attach hardware information via issue[extras][device_info] (documented above) instead.
  • issue[tag_ids][] array[integer] optional

    Attach existing issue tags to the new issue by their numeric IssueTag id (the id from the issue-tags list endpoint), one array entry per tag. Over form-encoding repeat the bracketed key: issue[tag_ids][]=12&issue[tag_ids][]=34.

    Developer / PAT only. This field is honored only for session or Personal Access Token reporters. For game-SDK submission tokens (FormUser) and Discord-bot tokens it is silently stripped from the permitted params (no error, tags are simply not attached), because tag assignment is a developer-only action.

  • issue[custom][FIELD_IDENT] string optional

    Custom field values for the issue. Replace FIELD_IDENT with the field’s identifier (e.g., issue[custom][severity], issue[custom][platform]). Each project can define its own custom fields via the project settings. Field identifiers are snake_case strings (e.g., actual_result, player_cloud_id). You can find the available fields and their identifiers in the project’s Custom Fields settings page. Field types and expected values:

    • text: Any string value.
    • single_select: Must exactly match one of the allowed values (case-sensitive).
    • multi_select: An array of values, each matching one of the allowed values.
    • boolean: true, false, 1, or 0. Validation: If a custom field is marked as required in the project settings and is not provided (or blank), the API returns a 422 error indicating which field is missing. For single_select fields, the value must match one of the configured options exactly. JSON format: When using application/json, send custom fields as a nested object: {"issue": {"description": "...", "custom": {"severity": "Major", "platform": "Steam"}}}. For multi_select, send a JSON array: {"custom": {"platforms": ["Steam", "Epic"]}}. Multi-select over multipart/form-data or application/x-www-form-urlencoded: repeat the bracketed key — issue[custom][platforms][]=Steam&issue[custom][platforms][]=Epic. Auto-creation (SDK / Discord-bot submissions). On the game-SDK and Discord-bot submission paths an unknown FIELD_IDENT is not rejected — it auto-creates a new project custom field (type text) and stores the value. The new field’s visibility to testers depends on the caller: it is hidden from testers on the game-SDK path, but visible to testers on the Discord-bot path. Guardrails: auto-creation stops once the project already has 32 custom fields of that entity type (total, not only auto-created ones), and each value is capped at 4096 characters. When a value is dropped for hitting a cap (or a field cannot be created), the request still returns 201 and reports it in a top-level warnings array (see the response schema) — inspect it to detect partial data loss. Values resolve by field ident, falling back to the display name.
  • issue[debug_trace] object optional Optional structured debug information with severity-labeled steps. Maximum size 128KB. Expected format: {“steps”: [{“severity”: “info|warning|danger”, “description”: “text”}]}
  • issue[process_include_flags] boolean optional LOW-LEVEL / advanced. When true, the server prunes attached media according to the include_* flags below. WARNING: enabling this WITHOUT setting the corresponding include flags deletes the attached media. Leave unset unless you specifically intend media pruning.
  • issue[include_screenshot] boolean optional With process_include_flags=true, keep (true) or drop (false) attached screenshots.
  • issue[include_video] boolean optional With process_include_flags=true, keep (true) or drop (false) attached video clips.
  • issue[include_other] boolean optional With process_include_flags=true, keep (true) or drop (false) other attached media (logs / binary files).
  • skip_description_check boolean optional LOW-LEVEL. Skips the description gibberish/quality check. Only honored for Discord-bot authenticated submissions; ignored for other callers.
  • draft boolean optional When set to true, the issue will be created in a hidden state for step-by-step completion. Default is false.
  • publish_after string optional Only applies when draft is true. Automatically publishes the draft after this duration if it is never published explicitly, e.g. 1h, 30m, 2d. Overrides the project’s default auto-publish window. Capped at 7 days (longer values are clamped). An invalid value, or supplying this without draft=true, returns 422.
cURL
curl \
  -X POST \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  -d "issue[title]=Example title" \
  -d "issue[description]=Example description" \
  -d "issue[unformatted_steps_to_reproduce]=string" \
  -d "issue[steps_to_reproduce_array][]=string" \
  -d "issue[logs]=string" \
  -d "issue[release_id]=string" \
  "https://app.betahub.io/projects/123/issues.json"
Ruby
require "net/http"
require "json"

uri = URI("https://app.betahub.io/projects/123/issues.json")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"
request.set_form_data({
  "issue[title]" => "Example title",
  "issue[description]" => "Example description",
  "issue[unformatted_steps_to_reproduce]" => "string",
  "issue[steps_to_reproduce_array][]" => "string",
  "issue[logs]" => "string",
  "issue[release_id]" => "string"
})

response = http.request(request)
puts response.body
Python
import requests

response = requests.post(
    "https://app.betahub.io/projects/123/issues.json",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"},
    data={"issue[title]": "Example title", "issue[description]": "Example description", "issue[unformatted_steps_to_reproduce]": "string", "issue[steps_to_reproduce_array][]": "string", "issue[logs]": "string", "issue[release_id]": "string"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues.json", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues.json"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString("{\"issue[title]\":\"string\",\"issue[description]\":\"string\",\"issue[unformatted_steps_to_reproduce]\":\"string\",\"issue[steps_to_reproduce_array][]\":[\"string\"],\"issue[logs]\":\"string\",\"issue[release_id]\":\"string\",\"issue[release_label]\":\"string\",\"issue[source]\":\"string\",\"issue[extras][device_info]\":{\"value\":\"string\",\"validation_mode\":\"strict\"},\"issue[discord_id]\":\"string\",\"issue[discord_username]\":\"string\",\"issue[tested_on_device_id]\":\"string\",\"issue[tag_ids][]\":[0],\"issue[custom][FIELD_IDENT]\":\"https://example.com\",\"issue[debug_trace]\":{\"steps\":[{\"severity\":\"info\",\"description\":\"Process started successfully\"},{\"severity\":\"warning\",\"description\":\"Memory usage is high\"},{\"severity\":\"danger\",\"description\":\"Critical error occurred\"}]},\"issue[process_include_flags]\":true,\"issue[include_screenshot]\":true,\"issue[include_video]\":true,\"issue[include_other]\":true,\"skip_description_check\":true,\"draft\":true,\"publish_after\":\"1h\"}"))
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Request Body
{
  "issue[title]": "string",
  "issue[description]": "string",
  "issue[unformatted_steps_to_reproduce]": "string",
  "issue[steps_to_reproduce_array][]": [
    "string"
  ],
  "issue[logs]": "string",
  "issue[release_id]": "string",
  "issue[release_label]": "string",
  "issue[source]": "string",
  "issue[extras][device_info]": {
    "value": "string",
    "validation_mode": "strict"
  },
  "issue[discord_id]": "string",
  "issue[discord_username]": "string",
  "issue[tested_on_device_id]": "string",
  "issue[tag_ids][]": [
    0
  ],
  "issue[custom][FIELD_IDENT]": "https://example.com",
  "issue[debug_trace]": {
    "steps": [
      {
        "severity": "info",
        "description": "Process started successfully"
      },
      {
        "severity": "warning",
        "description": "Memory usage is high"
      },
      {
        "severity": "danger",
        "description": "Critical error occurred"
      }
    ]
  },
  "issue[process_include_flags]": true,
  "issue[include_screenshot]": true,
  "issue[include_video]": true,
  "issue[include_other]": true,
  "skip_description_check": true,
  "draft": true,
  "publish_after": "1h"
}
Responses
Successful response
Response fields
  • id integer optional Numeric primary key of the issue. NOTE: this is the raw integer PK, NOT the obfuscated global id. URL path segments require the obfuscated g-{id} form (e.g. /issues/g-12345), which this value is not. To build links use url (already fully-formed) or scoped_id — do not concatenate this id into a path.
  • scoped_id integer optional Per-project sequential issue number (the “#42” shown in the UI). Always present. On a duplicate merge the scoped_id of a destroyed losing issue can be reassigned to the surviving canonical issue, so it is stable for a live issue but not guaranteed permanent across merges. Persist the obfuscated g-{id} (see url) if you need a permanently stable identifier.
  • title string optional
  • description string optional
  • status string optional Current issue status. This is NOT a closed enum. The built-in statuses are hidden, open, in_progress, resolved, closed, duplicate, pending_moderation, wont_fix, and needs_more_info, but a project may also define custom statuses — treat this as a free-form string.
  • status_display string optional Human-readable label for status, resolved against the project’s (possibly custom) status names.
  • priority string optional Issue priority. Built-in values: low, medium, high, critical, blocker. For issues created through SDK / FormUser tokens the priority is LLM-predicted and cannot be set on create (any submitted value is stripped); it is only settable by a developer via the update (PUT) endpoint.
  • discord_message string optional nullable Original Discord message text when the issue originated from the Discord bot; null otherwise.
  • created_at string date-time optional
  • updated_at string date-time optional
  • score string optional Read-only report-completeness signal, emitted as a decimal string in the range “0”..”1” (e.g. “0.8542”) — NOT a 0–100 number. Computed server-side; any value supplied on submit is ignored.
  • steps_to_reproduce array[object] optional
    • step string optional
  • device object optional nullable Structured device / hardware information attached to the issue, or null when none is attached. Populated by the LLM parser when issue[extras][device_info] is submitted on create (see the create endpoint).
    • id integer optional
    • device_type string optional
    • configuration object optional Free-form parsed hardware specs (e.g. CPU, GPU, OS, memory).
  • assigned_to object optional
    • id integer optional nullable
    • name string optional nullable
  • reported_by object optional Reporter identity. Both id and name are null unless the caller’s token carries reporter-visibility permission. Even when visible, name may be masked or replaced with a persona by the project’s team-identity settings.
    • id integer optional nullable
    • name string optional nullable
  • potential_duplicate object optional nullable Populated ONLY when status == 'duplicate'; null otherwise. When present it is a full nested issue object with the same shape as this IssueResponse — describing the canonical issue this submission was merged into (your report was detected as a duplicate and folded into that existing issue). The nested object is not expanded inline here to avoid a recursive schema; expect the same fields as a top-level issue.
  • screenshots array[object] optional Screenshots attached to the issue, filtered by the caller’s visibility. This array (and the other attachment arrays below) is OMITTED entirely on responses that skip attachment rendering, such as the find_similar endpoint.
    • id integer optional The ID of the screenshot
    • type string optional Attachment type identifier
    • description string optional nullable Auto-generated (or user-edited) description of the screenshot
    • size_bytes integer optional File size in bytes (alias for media_size_bytes)
    • media_size_bytes integer optional File size in bytes
    • content_type string optional nullable MIME type of the image (null when no image is attached)
    • url string uri optional nullable CDN URL to access the screenshot image (null when no image is attached). Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
    • filename string optional nullable Filename of the uploaded image (null when no image is attached)
    • layer_a_url string uri optional nullable CDN URL of the annotation overlay layer, when present
    • layer_a_filename string optional nullable Filename of the annotation overlay layer, when present
    • developer_private boolean optional Whether this screenshot is only visible to developers and admins
      Default: false
    • created_at string date-time optional Creation timestamp
    • updated_at string date-time optional Last update timestamp
    • user object optional nullable For screenshots this object reflects the issue’s reporter (issue.reported_by), NOT the screenshot’s uploader — the _screenshot.json.jbuilder view ignores the screenshot.user column. It is null only when the issue has no reporter. (The video/log/binary jbuilders differ: those DO use the uploader, resource.user.)
      • id integer optional User ID
      • name string optional Player-facing display name
  • log_files array[object] optional Log files attached to the issue (visibility-filtered). Omitted when attachments are not rendered.
    • id integer optional The ID of the log file
    • created_at string date-time optional Creation timestamp
    • updated_at string date-time optional Last update timestamp
    • media_size_bytes integer optional File size in bytes
    • type string optional Attachment type identifier
    • size_bytes integer optional File size in bytes (alias for media_size_bytes)
    • content_type string optional MIME type of the file
    • url string uri optional nullable URL to download the log file. Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
    • filename string optional nullable Filename of the uploaded file
    • developer_private boolean optional Whether this log file is only visible to developers and admins
      Default: false
    • user object optional nullable User who uploaded the file
      • id integer optional User ID
      • name string optional User display name
  • video_clips array[object] optional Video clips attached to the issue (visibility-filtered). Omitted when attachments are not rendered.
    • id integer optional The ID of the video clip
    • type string optional Attachment type identifier
    • processing boolean optional Whether the video is currently being transcoded/processed by a background job.
    • processed boolean optional Whether the clip is ready for inline web playback. For multipart uploads this becomes true only if the source is web-compatible AND under the organization’s max video length; otherwise a background job transcodes it first. Direct (presigned) uploads are marked processed=true immediately on confirm, without transcoding or a length check (see VideoClip model before_save).
    • failed boolean optional Whether video processing has failed
    • size_bytes integer optional File size in bytes (alias for media_size_bytes)
    • media_size_bytes integer optional File size in bytes
    • content_type string optional nullable MIME type of the video (null when no video is attached)
    • url string uri optional nullable CDN URL to access the video clip (null when no video is attached). Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
    • filename string optional nullable Filename of the uploaded video (null when no video is attached)
    • developer_private boolean optional Whether this video clip is only visible to developers and admins
      Default: false
    • created_at string date-time optional Creation timestamp
    • updated_at string date-time optional Last update timestamp
    • user object optional nullable User who uploaded the video clip (null for anonymous/reporter fallback)
      • id integer optional User ID
      • name string optional Player-facing display name
  • binary_files array[object] optional Binary files attached to the issue (visibility-filtered). Omitted when attachments are not rendered.
    • id integer optional The ID of the binary file
    • created_at string date-time optional Creation timestamp
    • updated_at string date-time optional Last update timestamp
    • media_size_bytes integer optional File size in bytes
    • type string optional Attachment type identifier
    • size_bytes integer optional File size in bytes (alias for media_size_bytes)
    • content_type string optional MIME type of the file
    • url string uri optional nullable URL to download the binary file. Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
    • filename string optional nullable Filename of the uploaded file
    • developer_private boolean optional Whether this binary file is only visible to developers and admins
      Default: false
    • user object optional nullable User who uploaded the file
      • id integer optional User ID
      • name string optional User display name
  • url string optional
  • token string optional JWT token for subsequent API access. Field name is ‘token’ (not ‘api_token’). Returned on successful issue creation when authenticated via a FormUser (submission form) token — present for both draft and non-draft submissions, and NOT gated on draft=true. Not returned for Discord-bot or regular web-user submissions.
  • warnings array[string] optional Partial-success notices. Present on create/update responses only when something was silently dropped despite the 2xx status — e.g. a custom-field value exceeded the 4096-character cap, the 32-auto-created-fields-per-entity limit was hit, or a field could not be auto-created. Each entry is a human-readable string. Clients that submit custom fields should inspect this array to detect partial data loss.
Examples
Example Request
{
  "issue[title]": "App crashes on login screen",
  "issue[description]": "When attempting to login, the app crashes after entering credentials.",
  "issue[unformatted_steps_to_reproduce]": "1. Open the app
2. Enter login credentials
3. Press login",
  "draft": true
}
Example Response
{
  "id": 12345,
  "scoped_id": 42,
  "title": "App crashes on login screen",
  "description": "When attempting to login, the app crashes after entering credentials.",
  "status": "hidden",
  "status_display": "Hidden",
  "priority": "high",
  "created_at": "2024-10-03T12:34:56Z",
  "updated_at": "2024-10-03T12:34:56Z",
  "score": "0.8542",
  "steps_to_reproduce": [
    {
      "step": "1. Open the app"
    },
    {
      "step": "2. Enter login credentials"
    },
    {
      "step": "3. Press login"
    }
  ],
  "assigned_to": {
    "id": 12,
    "name": "John Doe"
  },
  "reported_by": {
    "id": 34,
    "name": "Jane Smith"
  },
  "potential_duplicate": null,
  "url": "https://app.betahub.io/projects/1/issues/g-12345",
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}

Forbidden. Real causes include:

  • The auth token is not permitted to report a bug — either it lacks the can_create_bug_report scope or it has exceeded its per-IP daily bug-report limit (error: “Not allowed to report a bug.”).

  • The token belongs to a different project than the target (error: “Auth token does not belong to this project.”).

  • The project is NDA-gated and the reporter has not accepted the NDA (error: “NDA acceptance required.”).

  • release_label names a new release but the token lacks release-creation permission (error: “Auth token does not have permission to create releases”).

  • The organization has exceeded its plan’s monthly submission quota for bug reports. Note this is the OPPOSITE meaning of the token-permission 403 above: the credentials are valid, but the project is at its plan cap (error: “This project is not currently accepting new bug reports. Please try again later.”).

Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
Examples
Not Allowed To Report
{
  "error": "Not allowed to report a bug.",
  "status": "forbidden"
}
NDA Required
{
  "error": "NDA acceptance required.",
  "status": "forbidden"
}
Release Permission
{
  "error": "Auth token does not have permission to create releases",
  "status": "forbidden"
}
Org Quota Reached
{
  "error": "This project is not currently accepting new bug reports. Please try again later.",
  "status": "forbidden"
}

Validation error. Common causes:

  • Missing required custom fields, or invalid values for single_select/multi_select fields.

  • The reporter hit a per-project, per-reporter tester submission cap. Bugs are limited per rolling 24 hours and per rolling 7 days (both configurable per project; developers, support, org admins, and site admins are exempt). Error: “You have reached your 24 hours limit for bug submissions.” (or “7 days”).

  • A supplied submission token (JWT) was already used (error: “Submission token has already been used. Please generate a new one.”).

Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
Examples
Missing Required Custom Field
{
  "error": "Custom fields Severity is required, Custom fields Platform is required",
  "status": 422
}
Invalid Select Value
{
  "error": "Custom fields Severity must be one of: Blocker, Major, Minor, Cosmetic",
  "status": 422
}
Tester Rate Limit
{
  "error": "You have reached your 24 hours limit for bug submissions. You can submit again later.",
  "status": 422
}
Submission Token Reused
{
  "error": "Submission token has already been used. Please generate a new one.",
  "status": 422
}
Searches for issues (bug reports) within a project. Uses full-text search powered by Meilisearch (with relevance ranking and typo tolerance) to find matching issues based on the query string. Note: Meilisearch returns a candidate pool which is then filtered by the caller’s visibility permissions AFTER fetching, so total_count reflects the post-filter count. The JSON response returns up to 25 issue objects per page. The 4-result cap only applies to the HTML autocomplete partial (partial=true with an HTML Accept header), not to JSON.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
Query Parameters
Name Type Description
query required string The search query string to match against issue titles and descriptions
skip_ids optional string Comma-separated list of issue IDs to exclude from results
partial optional string Only affects the HTML autocomplete rendering (capping it at 4 results) and narrows the Meilisearch candidate pool (limit 200 with partial=true vs 500 without). It does NOT cap the JSON response, which always returns up to 25 results per page.
true false
page optional integer Page number for the JSON results (default: 1). 25 results per page.
sort optional string Column to sort by (any Issue column, e.g. created_at, priority). Unknown columns silently fall back to id. When omitted with a query present, results keep Meilisearch relevance order.
direction optional string Sort direction (default: asc). Only applied when sort is provided.
asc desc
scoped_id optional string Instead of searching, find a specific issue by its scoped ID (e.g., “123” or “g-456”)
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/issues/search.json?query=example&skip_ids=123&partial=true&page=123&sort=example&direction=asc&scoped_id=123"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/issues/search.json?query=example&skip_ids=123&partial=true&page=123&sort=example&direction=asc&scoped_id=123")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.get(
    "https://app.betahub.io/projects/123/issues/search.json?query=example&skip_ids=123&partial=true&page=123&sort=example&direction=asc&scoped_id=123",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/search.json?query=example&skip_ids=123&partial=true&page=123&sort=example&direction=asc&scoped_id=123", {
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/search.json?query=example&skip_ids=123&partial=true&page=123&sort=example&direction=asc&scoped_id=123"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .GET()
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Successful response
Response fields
  • issues array[object] optional
    • id integer optional Numeric primary key of the issue. NOTE: this is the raw integer PK, NOT the obfuscated global id. URL path segments require the obfuscated g-{id} form (e.g. /issues/g-12345), which this value is not. To build links use url (already fully-formed) or scoped_id — do not concatenate this id into a path.
    • scoped_id integer optional Per-project sequential issue number (the “#42” shown in the UI). Always present. On a duplicate merge the scoped_id of a destroyed losing issue can be reassigned to the surviving canonical issue, so it is stable for a live issue but not guaranteed permanent across merges. Persist the obfuscated g-{id} (see url) if you need a permanently stable identifier.
    • title string optional
    • description string optional
    • status string optional Current issue status. This is NOT a closed enum. The built-in statuses are hidden, open, in_progress, resolved, closed, duplicate, pending_moderation, wont_fix, and needs_more_info, but a project may also define custom statuses — treat this as a free-form string.
    • status_display string optional Human-readable label for status, resolved against the project’s (possibly custom) status names.
    • priority string optional Issue priority. Built-in values: low, medium, high, critical, blocker. For issues created through SDK / FormUser tokens the priority is LLM-predicted and cannot be set on create (any submitted value is stripped); it is only settable by a developer via the update (PUT) endpoint.
    • discord_message string optional nullable Original Discord message text when the issue originated from the Discord bot; null otherwise.
    • created_at string date-time optional
    • updated_at string date-time optional
    • score string optional Read-only report-completeness signal, emitted as a decimal string in the range “0”..”1” (e.g. “0.8542”) — NOT a 0–100 number. Computed server-side; any value supplied on submit is ignored.
    • steps_to_reproduce array[object] optional
      • step string optional
    • device object optional nullable Structured device / hardware information attached to the issue, or null when none is attached. Populated by the LLM parser when issue[extras][device_info] is submitted on create (see the create endpoint).
      • id integer optional
      • device_type string optional
      • configuration object optional Free-form parsed hardware specs (e.g. CPU, GPU, OS, memory).
    • assigned_to object optional
      • id integer optional nullable
      • name string optional nullable
    • reported_by object optional Reporter identity. Both id and name are null unless the caller’s token carries reporter-visibility permission. Even when visible, name may be masked or replaced with a persona by the project’s team-identity settings.
      • id integer optional nullable
      • name string optional nullable
    • potential_duplicate object optional nullable Populated ONLY when status == 'duplicate'; null otherwise. When present it is a full nested issue object with the same shape as this IssueResponse — describing the canonical issue this submission was merged into (your report was detected as a duplicate and folded into that existing issue). The nested object is not expanded inline here to avoid a recursive schema; expect the same fields as a top-level issue.
    • screenshots array[object] optional Screenshots attached to the issue, filtered by the caller’s visibility. This array (and the other attachment arrays below) is OMITTED entirely on responses that skip attachment rendering, such as the find_similar endpoint.
      • id integer optional The ID of the screenshot
      • type string optional Attachment type identifier
      • description string optional nullable Auto-generated (or user-edited) description of the screenshot
      • size_bytes integer optional File size in bytes (alias for media_size_bytes)
      • media_size_bytes integer optional File size in bytes
      • content_type string optional nullable MIME type of the image (null when no image is attached)
      • url string uri optional nullable CDN URL to access the screenshot image (null when no image is attached). Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
      • filename string optional nullable Filename of the uploaded image (null when no image is attached)
      • layer_a_url string uri optional nullable CDN URL of the annotation overlay layer, when present
      • layer_a_filename string optional nullable Filename of the annotation overlay layer, when present
      • developer_private boolean optional Whether this screenshot is only visible to developers and admins
        Default: false
      • created_at string date-time optional Creation timestamp
      • updated_at string date-time optional Last update timestamp
      • user object optional nullable For screenshots this object reflects the issue’s reporter (issue.reported_by), NOT the screenshot’s uploader — the _screenshot.json.jbuilder view ignores the screenshot.user column. It is null only when the issue has no reporter. (The video/log/binary jbuilders differ: those DO use the uploader, resource.user.)
        • id integer optional User ID
        • name string optional Player-facing display name
    • log_files array[object] optional Log files attached to the issue (visibility-filtered). Omitted when attachments are not rendered.
      • id integer optional The ID of the log file
      • created_at string date-time optional Creation timestamp
      • updated_at string date-time optional Last update timestamp
      • media_size_bytes integer optional File size in bytes
      • type string optional Attachment type identifier
      • size_bytes integer optional File size in bytes (alias for media_size_bytes)
      • content_type string optional MIME type of the file
      • url string uri optional nullable URL to download the log file. Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
      • filename string optional nullable Filename of the uploaded file
      • developer_private boolean optional Whether this log file is only visible to developers and admins
        Default: false
      • user object optional nullable User who uploaded the file
        • id integer optional User ID
        • name string optional User display name
    • video_clips array[object] optional Video clips attached to the issue (visibility-filtered). Omitted when attachments are not rendered.
      • id integer optional The ID of the video clip
      • type string optional Attachment type identifier
      • processing boolean optional Whether the video is currently being transcoded/processed by a background job.
      • processed boolean optional Whether the clip is ready for inline web playback. For multipart uploads this becomes true only if the source is web-compatible AND under the organization’s max video length; otherwise a background job transcodes it first. Direct (presigned) uploads are marked processed=true immediately on confirm, without transcoding or a length check (see VideoClip model before_save).
      • failed boolean optional Whether video processing has failed
      • size_bytes integer optional File size in bytes (alias for media_size_bytes)
      • media_size_bytes integer optional File size in bytes
      • content_type string optional nullable MIME type of the video (null when no video is attached)
      • url string uri optional nullable CDN URL to access the video clip (null when no video is attached). Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
      • filename string optional nullable Filename of the uploaded video (null when no video is attached)
      • developer_private boolean optional Whether this video clip is only visible to developers and admins
        Default: false
      • created_at string date-time optional Creation timestamp
      • updated_at string date-time optional Last update timestamp
      • user object optional nullable User who uploaded the video clip (null for anonymous/reporter fallback)
        • id integer optional User ID
        • name string optional Player-facing display name
    • binary_files array[object] optional Binary files attached to the issue (visibility-filtered). Omitted when attachments are not rendered.
      • id integer optional The ID of the binary file
      • created_at string date-time optional Creation timestamp
      • updated_at string date-time optional Last update timestamp
      • media_size_bytes integer optional File size in bytes
      • type string optional Attachment type identifier
      • size_bytes integer optional File size in bytes (alias for media_size_bytes)
      • content_type string optional MIME type of the file
      • url string uri optional nullable URL to download the binary file. Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
      • filename string optional nullable Filename of the uploaded file
      • developer_private boolean optional Whether this binary file is only visible to developers and admins
        Default: false
      • user object optional nullable User who uploaded the file
        • id integer optional User ID
        • name string optional User display name
    • url string optional
    • token string optional JWT token for subsequent API access. Field name is ‘token’ (not ‘api_token’). Returned on successful issue creation when authenticated via a FormUser (submission form) token — present for both draft and non-draft submissions, and NOT gated on draft=true. Not returned for Discord-bot or regular web-user submissions.
    • warnings array[string] optional Partial-success notices. Present on create/update responses only when something was silently dropped despite the 2xx status — e.g. a custom-field value exceeded the 4096-character cap, the 32-auto-created-fields-per-entity limit was hit, or a field could not be auto-created. Each entry is a human-readable string. Clients that submit custom fields should inspect this array to detect partial data loss.
  • pagination object optional
    • current_page integer optional
    • per_page integer optional
    • total_pages integer optional
    • total_count integer optional
  • project_id integer optional
  • id integer optional Numeric primary key of the issue. NOTE: this is the raw integer PK, NOT the obfuscated global id. URL path segments require the obfuscated g-{id} form (e.g. /issues/g-12345), which this value is not. To build links use url (already fully-formed) or scoped_id — do not concatenate this id into a path.
  • scoped_id integer optional Per-project sequential issue number (the “#42” shown in the UI). Always present. On a duplicate merge the scoped_id of a destroyed losing issue can be reassigned to the surviving canonical issue, so it is stable for a live issue but not guaranteed permanent across merges. Persist the obfuscated g-{id} (see url) if you need a permanently stable identifier.
  • title string optional
  • description string optional
  • status string optional Current issue status. This is NOT a closed enum. The built-in statuses are hidden, open, in_progress, resolved, closed, duplicate, pending_moderation, wont_fix, and needs_more_info, but a project may also define custom statuses — treat this as a free-form string.
  • status_display string optional Human-readable label for status, resolved against the project’s (possibly custom) status names.
  • priority string optional Issue priority. Built-in values: low, medium, high, critical, blocker. For issues created through SDK / FormUser tokens the priority is LLM-predicted and cannot be set on create (any submitted value is stripped); it is only settable by a developer via the update (PUT) endpoint.
  • discord_message string optional nullable Original Discord message text when the issue originated from the Discord bot; null otherwise.
  • created_at string date-time optional
  • updated_at string date-time optional
  • score string optional Read-only report-completeness signal, emitted as a decimal string in the range “0”..”1” (e.g. “0.8542”) — NOT a 0–100 number. Computed server-side; any value supplied on submit is ignored.
  • steps_to_reproduce array[object] optional
    • step string optional
  • device object optional nullable Structured device / hardware information attached to the issue, or null when none is attached. Populated by the LLM parser when issue[extras][device_info] is submitted on create (see the create endpoint).
    • id integer optional
    • device_type string optional
    • configuration object optional Free-form parsed hardware specs (e.g. CPU, GPU, OS, memory).
  • assigned_to object optional
    • id integer optional nullable
    • name string optional nullable
  • reported_by object optional Reporter identity. Both id and name are null unless the caller’s token carries reporter-visibility permission. Even when visible, name may be masked or replaced with a persona by the project’s team-identity settings.
    • id integer optional nullable
    • name string optional nullable
  • potential_duplicate object optional nullable Populated ONLY when status == 'duplicate'; null otherwise. When present it is a full nested issue object with the same shape as this IssueResponse — describing the canonical issue this submission was merged into (your report was detected as a duplicate and folded into that existing issue). The nested object is not expanded inline here to avoid a recursive schema; expect the same fields as a top-level issue.
  • screenshots array[object] optional Screenshots attached to the issue, filtered by the caller’s visibility. This array (and the other attachment arrays below) is OMITTED entirely on responses that skip attachment rendering, such as the find_similar endpoint.
    • id integer optional The ID of the screenshot
    • type string optional Attachment type identifier
    • description string optional nullable Auto-generated (or user-edited) description of the screenshot
    • size_bytes integer optional File size in bytes (alias for media_size_bytes)
    • media_size_bytes integer optional File size in bytes
    • content_type string optional nullable MIME type of the image (null when no image is attached)
    • url string uri optional nullable CDN URL to access the screenshot image (null when no image is attached). Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
    • filename string optional nullable Filename of the uploaded image (null when no image is attached)
    • layer_a_url string uri optional nullable CDN URL of the annotation overlay layer, when present
    • layer_a_filename string optional nullable Filename of the annotation overlay layer, when present
    • developer_private boolean optional Whether this screenshot is only visible to developers and admins
      Default: false
    • created_at string date-time optional Creation timestamp
    • updated_at string date-time optional Last update timestamp
    • user object optional nullable For screenshots this object reflects the issue’s reporter (issue.reported_by), NOT the screenshot’s uploader — the _screenshot.json.jbuilder view ignores the screenshot.user column. It is null only when the issue has no reporter. (The video/log/binary jbuilders differ: those DO use the uploader, resource.user.)
      • id integer optional User ID
      • name string optional Player-facing display name
  • log_files array[object] optional Log files attached to the issue (visibility-filtered). Omitted when attachments are not rendered.
    • id integer optional The ID of the log file
    • created_at string date-time optional Creation timestamp
    • updated_at string date-time optional Last update timestamp
    • media_size_bytes integer optional File size in bytes
    • type string optional Attachment type identifier
    • size_bytes integer optional File size in bytes (alias for media_size_bytes)
    • content_type string optional MIME type of the file
    • url string uri optional nullable URL to download the log file. Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
    • filename string optional nullable Filename of the uploaded file
    • developer_private boolean optional Whether this log file is only visible to developers and admins
      Default: false
    • user object optional nullable User who uploaded the file
      • id integer optional User ID
      • name string optional User display name
  • video_clips array[object] optional Video clips attached to the issue (visibility-filtered). Omitted when attachments are not rendered.
    • id integer optional The ID of the video clip
    • type string optional Attachment type identifier
    • processing boolean optional Whether the video is currently being transcoded/processed by a background job.
    • processed boolean optional Whether the clip is ready for inline web playback. For multipart uploads this becomes true only if the source is web-compatible AND under the organization’s max video length; otherwise a background job transcodes it first. Direct (presigned) uploads are marked processed=true immediately on confirm, without transcoding or a length check (see VideoClip model before_save).
    • failed boolean optional Whether video processing has failed
    • size_bytes integer optional File size in bytes (alias for media_size_bytes)
    • media_size_bytes integer optional File size in bytes
    • content_type string optional nullable MIME type of the video (null when no video is attached)
    • url string uri optional nullable CDN URL to access the video clip (null when no video is attached). Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
    • filename string optional nullable Filename of the uploaded video (null when no video is attached)
    • developer_private boolean optional Whether this video clip is only visible to developers and admins
      Default: false
    • created_at string date-time optional Creation timestamp
    • updated_at string date-time optional Last update timestamp
    • user object optional nullable User who uploaded the video clip (null for anonymous/reporter fallback)
      • id integer optional User ID
      • name string optional Player-facing display name
  • binary_files array[object] optional Binary files attached to the issue (visibility-filtered). Omitted when attachments are not rendered.
    • id integer optional The ID of the binary file
    • created_at string date-time optional Creation timestamp
    • updated_at string date-time optional Last update timestamp
    • media_size_bytes integer optional File size in bytes
    • type string optional Attachment type identifier
    • size_bytes integer optional File size in bytes (alias for media_size_bytes)
    • content_type string optional MIME type of the file
    • url string uri optional nullable URL to download the binary file. Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
    • filename string optional nullable Filename of the uploaded file
    • developer_private boolean optional Whether this binary file is only visible to developers and admins
      Default: false
    • user object optional nullable User who uploaded the file
      • id integer optional User ID
      • name string optional User display name
  • url string optional
  • token string optional JWT token for subsequent API access. Field name is ‘token’ (not ‘api_token’). Returned on successful issue creation when authenticated via a FormUser (submission form) token — present for both draft and non-draft submissions, and NOT gated on draft=true. Not returned for Discord-bot or regular web-user submissions.
  • warnings array[string] optional Partial-success notices. Present on create/update responses only when something was silently dropped despite the 2xx status — e.g. a custom-field value exceeded the 4096-character cap, the 32-auto-created-fields-per-entity limit was hit, or a field could not be auto-created. Each entry is a human-readable string. Clients that submit custom fields should inspect this array to detect partial data loss.
Examples
Search Response
{
  "issues": [
    {
      "id": 12345,
      "scoped_id": 42,
      "title": "App crashes on login screen",
      "status": "open",
      "priority": "high",
      "created_at": "2024-10-03T12:34:56Z"
    }
  ],
  "pagination": {
    "current_page": 1,
    "per_page": 25,
    "total_pages": 3,
    "total_count": 68
  },
  "project_id": 123
}
Scoped ID Response
{
  "id": 12345,
  "scoped_id": 42,
  "title": "App crashes on login screen",
  "description": "When attempting to login, the app crashes after entering credentials.",
  "status": "open",
  "status_display": "Open",
  "priority": "high",
  "created_at": "2024-10-03T12:34:56Z",
  "updated_at": "2024-10-03T12:34:56Z",
  "score": "0.8542",
  "steps_to_reproduce": [
    {
      "step": "1. Open the app"
    },
    {
      "step": "2. Enter login credentials"
    },
    {
      "step": "3. Press login"
    }
  ],
  "assigned_to": {
    "id": 12,
    "name": "John Doe"
  },
  "reported_by": {
    "id": 34,
    "name": "Jane Smith"
  },
  "potential_duplicate": null,
  "url": "https://app.betahub.io/projects/1/issues/g-12345"
}
Forbidden. User does not have permission to search issues in this project.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Issue not found (when searching by scoped_id).
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Alternative POST method for searching issues. Accepts the same parameters as the GET method but allows for longer search queries that might exceed URL length limits.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
Request Body
application/x-www-form-urlencoded
  • query string required The search query string to match against issue titles and descriptions
  • skip_ids string optional Comma-separated list of issue IDs to exclude from results
  • partial string optional Only affects the HTML autocomplete rendering (capping it at 4 results). It does NOT cap the JSON response, which always paginates at 25 results per page.
    true false
  • scoped_id string optional Instead of searching, find a specific issue by its scoped ID
cURL
curl \
  -X POST \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  -d "query=string" \
  -d "skip_ids=string" \
  -d "partial=true" \
  -d "scoped_id=string" \
  "https://app.betahub.io/projects/123/issues/search.json"
Ruby
require "net/http"
require "json"

uri = URI("https://app.betahub.io/projects/123/issues/search.json")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"
request.set_form_data({
  "query" => "string",
  "skip_ids" => "string",
  "partial" => "true",
  "scoped_id" => "string"
})

response = http.request(request)
puts response.body
Python
import requests

response = requests.post(
    "https://app.betahub.io/projects/123/issues/search.json",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"},
    data={"query": "string", "skip_ids": "string", "partial": "true", "scoped_id": "string"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/search.json", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/search.json"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString("{\"query\":\"string\",\"skip_ids\":\"string\",\"partial\":\"true\",\"scoped_id\":\"string\"}"))
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Request Body
{
  "query": "string",
  "skip_ids": "string",
  "partial": "true",
  "scoped_id": "string"
}
Responses
Successful response (same as GET method)
Response fields
  • issues array[object] optional
    • id integer optional Numeric primary key of the issue. NOTE: this is the raw integer PK, NOT the obfuscated global id. URL path segments require the obfuscated g-{id} form (e.g. /issues/g-12345), which this value is not. To build links use url (already fully-formed) or scoped_id — do not concatenate this id into a path.
    • scoped_id integer optional Per-project sequential issue number (the “#42” shown in the UI). Always present. On a duplicate merge the scoped_id of a destroyed losing issue can be reassigned to the surviving canonical issue, so it is stable for a live issue but not guaranteed permanent across merges. Persist the obfuscated g-{id} (see url) if you need a permanently stable identifier.
    • title string optional
    • description string optional
    • status string optional Current issue status. This is NOT a closed enum. The built-in statuses are hidden, open, in_progress, resolved, closed, duplicate, pending_moderation, wont_fix, and needs_more_info, but a project may also define custom statuses — treat this as a free-form string.
    • status_display string optional Human-readable label for status, resolved against the project’s (possibly custom) status names.
    • priority string optional Issue priority. Built-in values: low, medium, high, critical, blocker. For issues created through SDK / FormUser tokens the priority is LLM-predicted and cannot be set on create (any submitted value is stripped); it is only settable by a developer via the update (PUT) endpoint.
    • discord_message string optional nullable Original Discord message text when the issue originated from the Discord bot; null otherwise.
    • created_at string date-time optional
    • updated_at string date-time optional
    • score string optional Read-only report-completeness signal, emitted as a decimal string in the range “0”..”1” (e.g. “0.8542”) — NOT a 0–100 number. Computed server-side; any value supplied on submit is ignored.
    • steps_to_reproduce array[object] optional
      • step string optional
    • device object optional nullable Structured device / hardware information attached to the issue, or null when none is attached. Populated by the LLM parser when issue[extras][device_info] is submitted on create (see the create endpoint).
      • id integer optional
      • device_type string optional
      • configuration object optional Free-form parsed hardware specs (e.g. CPU, GPU, OS, memory).
    • assigned_to object optional
      • id integer optional nullable
      • name string optional nullable
    • reported_by object optional Reporter identity. Both id and name are null unless the caller’s token carries reporter-visibility permission. Even when visible, name may be masked or replaced with a persona by the project’s team-identity settings.
      • id integer optional nullable
      • name string optional nullable
    • potential_duplicate object optional nullable Populated ONLY when status == 'duplicate'; null otherwise. When present it is a full nested issue object with the same shape as this IssueResponse — describing the canonical issue this submission was merged into (your report was detected as a duplicate and folded into that existing issue). The nested object is not expanded inline here to avoid a recursive schema; expect the same fields as a top-level issue.
    • screenshots array[object] optional Screenshots attached to the issue, filtered by the caller’s visibility. This array (and the other attachment arrays below) is OMITTED entirely on responses that skip attachment rendering, such as the find_similar endpoint.
      • id integer optional The ID of the screenshot
      • type string optional Attachment type identifier
      • description string optional nullable Auto-generated (or user-edited) description of the screenshot
      • size_bytes integer optional File size in bytes (alias for media_size_bytes)
      • media_size_bytes integer optional File size in bytes
      • content_type string optional nullable MIME type of the image (null when no image is attached)
      • url string uri optional nullable CDN URL to access the screenshot image (null when no image is attached). Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
      • filename string optional nullable Filename of the uploaded image (null when no image is attached)
      • layer_a_url string uri optional nullable CDN URL of the annotation overlay layer, when present
      • layer_a_filename string optional nullable Filename of the annotation overlay layer, when present
      • developer_private boolean optional Whether this screenshot is only visible to developers and admins
        Default: false
      • created_at string date-time optional Creation timestamp
      • updated_at string date-time optional Last update timestamp
      • user object optional nullable For screenshots this object reflects the issue’s reporter (issue.reported_by), NOT the screenshot’s uploader — the _screenshot.json.jbuilder view ignores the screenshot.user column. It is null only when the issue has no reporter. (The video/log/binary jbuilders differ: those DO use the uploader, resource.user.)
        • id integer optional User ID
        • name string optional Player-facing display name
    • log_files array[object] optional Log files attached to the issue (visibility-filtered). Omitted when attachments are not rendered.
      • id integer optional The ID of the log file
      • created_at string date-time optional Creation timestamp
      • updated_at string date-time optional Last update timestamp
      • media_size_bytes integer optional File size in bytes
      • type string optional Attachment type identifier
      • size_bytes integer optional File size in bytes (alias for media_size_bytes)
      • content_type string optional MIME type of the file
      • url string uri optional nullable URL to download the log file. Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
      • filename string optional nullable Filename of the uploaded file
      • developer_private boolean optional Whether this log file is only visible to developers and admins
        Default: false
      • user object optional nullable User who uploaded the file
        • id integer optional User ID
        • name string optional User display name
    • video_clips array[object] optional Video clips attached to the issue (visibility-filtered). Omitted when attachments are not rendered.
      • id integer optional The ID of the video clip
      • type string optional Attachment type identifier
      • processing boolean optional Whether the video is currently being transcoded/processed by a background job.
      • processed boolean optional Whether the clip is ready for inline web playback. For multipart uploads this becomes true only if the source is web-compatible AND under the organization’s max video length; otherwise a background job transcodes it first. Direct (presigned) uploads are marked processed=true immediately on confirm, without transcoding or a length check (see VideoClip model before_save).
      • failed boolean optional Whether video processing has failed
      • size_bytes integer optional File size in bytes (alias for media_size_bytes)
      • media_size_bytes integer optional File size in bytes
      • content_type string optional nullable MIME type of the video (null when no video is attached)
      • url string uri optional nullable CDN URL to access the video clip (null when no video is attached). Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
      • filename string optional nullable Filename of the uploaded video (null when no video is attached)
      • developer_private boolean optional Whether this video clip is only visible to developers and admins
        Default: false
      • created_at string date-time optional Creation timestamp
      • updated_at string date-time optional Last update timestamp
      • user object optional nullable User who uploaded the video clip (null for anonymous/reporter fallback)
        • id integer optional User ID
        • name string optional Player-facing display name
    • binary_files array[object] optional Binary files attached to the issue (visibility-filtered). Omitted when attachments are not rendered.
      • id integer optional The ID of the binary file
      • created_at string date-time optional Creation timestamp
      • updated_at string date-time optional Last update timestamp
      • media_size_bytes integer optional File size in bytes
      • type string optional Attachment type identifier
      • size_bytes integer optional File size in bytes (alias for media_size_bytes)
      • content_type string optional MIME type of the file
      • url string uri optional nullable URL to download the binary file. Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
      • filename string optional nullable Filename of the uploaded file
      • developer_private boolean optional Whether this binary file is only visible to developers and admins
        Default: false
      • user object optional nullable User who uploaded the file
        • id integer optional User ID
        • name string optional User display name
    • url string optional
    • token string optional JWT token for subsequent API access. Field name is ‘token’ (not ‘api_token’). Returned on successful issue creation when authenticated via a FormUser (submission form) token — present for both draft and non-draft submissions, and NOT gated on draft=true. Not returned for Discord-bot or regular web-user submissions.
    • warnings array[string] optional Partial-success notices. Present on create/update responses only when something was silently dropped despite the 2xx status — e.g. a custom-field value exceeded the 4096-character cap, the 32-auto-created-fields-per-entity limit was hit, or a field could not be auto-created. Each entry is a human-readable string. Clients that submit custom fields should inspect this array to detect partial data loss.
  • pagination object optional
    • current_page integer optional
    • per_page integer optional
    • total_pages integer optional
    • total_count integer optional
  • project_id integer optional
  • id integer optional Numeric primary key of the issue. NOTE: this is the raw integer PK, NOT the obfuscated global id. URL path segments require the obfuscated g-{id} form (e.g. /issues/g-12345), which this value is not. To build links use url (already fully-formed) or scoped_id — do not concatenate this id into a path.
  • scoped_id integer optional Per-project sequential issue number (the “#42” shown in the UI). Always present. On a duplicate merge the scoped_id of a destroyed losing issue can be reassigned to the surviving canonical issue, so it is stable for a live issue but not guaranteed permanent across merges. Persist the obfuscated g-{id} (see url) if you need a permanently stable identifier.
  • title string optional
  • description string optional
  • status string optional Current issue status. This is NOT a closed enum. The built-in statuses are hidden, open, in_progress, resolved, closed, duplicate, pending_moderation, wont_fix, and needs_more_info, but a project may also define custom statuses — treat this as a free-form string.
  • status_display string optional Human-readable label for status, resolved against the project’s (possibly custom) status names.
  • priority string optional Issue priority. Built-in values: low, medium, high, critical, blocker. For issues created through SDK / FormUser tokens the priority is LLM-predicted and cannot be set on create (any submitted value is stripped); it is only settable by a developer via the update (PUT) endpoint.
  • discord_message string optional nullable Original Discord message text when the issue originated from the Discord bot; null otherwise.
  • created_at string date-time optional
  • updated_at string date-time optional
  • score string optional Read-only report-completeness signal, emitted as a decimal string in the range “0”..”1” (e.g. “0.8542”) — NOT a 0–100 number. Computed server-side; any value supplied on submit is ignored.
  • steps_to_reproduce array[object] optional
    • step string optional
  • device object optional nullable Structured device / hardware information attached to the issue, or null when none is attached. Populated by the LLM parser when issue[extras][device_info] is submitted on create (see the create endpoint).
    • id integer optional
    • device_type string optional
    • configuration object optional Free-form parsed hardware specs (e.g. CPU, GPU, OS, memory).
  • assigned_to object optional
    • id integer optional nullable
    • name string optional nullable
  • reported_by object optional Reporter identity. Both id and name are null unless the caller’s token carries reporter-visibility permission. Even when visible, name may be masked or replaced with a persona by the project’s team-identity settings.
    • id integer optional nullable
    • name string optional nullable
  • potential_duplicate object optional nullable Populated ONLY when status == 'duplicate'; null otherwise. When present it is a full nested issue object with the same shape as this IssueResponse — describing the canonical issue this submission was merged into (your report was detected as a duplicate and folded into that existing issue). The nested object is not expanded inline here to avoid a recursive schema; expect the same fields as a top-level issue.
  • screenshots array[object] optional Screenshots attached to the issue, filtered by the caller’s visibility. This array (and the other attachment arrays below) is OMITTED entirely on responses that skip attachment rendering, such as the find_similar endpoint.
    • id integer optional The ID of the screenshot
    • type string optional Attachment type identifier
    • description string optional nullable Auto-generated (or user-edited) description of the screenshot
    • size_bytes integer optional File size in bytes (alias for media_size_bytes)
    • media_size_bytes integer optional File size in bytes
    • content_type string optional nullable MIME type of the image (null when no image is attached)
    • url string uri optional nullable CDN URL to access the screenshot image (null when no image is attached). Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
    • filename string optional nullable Filename of the uploaded image (null when no image is attached)
    • layer_a_url string uri optional nullable CDN URL of the annotation overlay layer, when present
    • layer_a_filename string optional nullable Filename of the annotation overlay layer, when present
    • developer_private boolean optional Whether this screenshot is only visible to developers and admins
      Default: false
    • created_at string date-time optional Creation timestamp
    • updated_at string date-time optional Last update timestamp
    • user object optional nullable For screenshots this object reflects the issue’s reporter (issue.reported_by), NOT the screenshot’s uploader — the _screenshot.json.jbuilder view ignores the screenshot.user column. It is null only when the issue has no reporter. (The video/log/binary jbuilders differ: those DO use the uploader, resource.user.)
      • id integer optional User ID
      • name string optional Player-facing display name
  • log_files array[object] optional Log files attached to the issue (visibility-filtered). Omitted when attachments are not rendered.
    • id integer optional The ID of the log file
    • created_at string date-time optional Creation timestamp
    • updated_at string date-time optional Last update timestamp
    • media_size_bytes integer optional File size in bytes
    • type string optional Attachment type identifier
    • size_bytes integer optional File size in bytes (alias for media_size_bytes)
    • content_type string optional MIME type of the file
    • url string uri optional nullable URL to download the log file. Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
    • filename string optional nullable Filename of the uploaded file
    • developer_private boolean optional Whether this log file is only visible to developers and admins
      Default: false
    • user object optional nullable User who uploaded the file
      • id integer optional User ID
      • name string optional User display name
  • video_clips array[object] optional Video clips attached to the issue (visibility-filtered). Omitted when attachments are not rendered.
    • id integer optional The ID of the video clip
    • type string optional Attachment type identifier
    • processing boolean optional Whether the video is currently being transcoded/processed by a background job.
    • processed boolean optional Whether the clip is ready for inline web playback. For multipart uploads this becomes true only if the source is web-compatible AND under the organization’s max video length; otherwise a background job transcodes it first. Direct (presigned) uploads are marked processed=true immediately on confirm, without transcoding or a length check (see VideoClip model before_save).
    • failed boolean optional Whether video processing has failed
    • size_bytes integer optional File size in bytes (alias for media_size_bytes)
    • media_size_bytes integer optional File size in bytes
    • content_type string optional nullable MIME type of the video (null when no video is attached)
    • url string uri optional nullable CDN URL to access the video clip (null when no video is attached). Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
    • filename string optional nullable Filename of the uploaded video (null when no video is attached)
    • developer_private boolean optional Whether this video clip is only visible to developers and admins
      Default: false
    • created_at string date-time optional Creation timestamp
    • updated_at string date-time optional Last update timestamp
    • user object optional nullable User who uploaded the video clip (null for anonymous/reporter fallback)
      • id integer optional User ID
      • name string optional Player-facing display name
  • binary_files array[object] optional Binary files attached to the issue (visibility-filtered). Omitted when attachments are not rendered.
    • id integer optional The ID of the binary file
    • created_at string date-time optional Creation timestamp
    • updated_at string date-time optional Last update timestamp
    • media_size_bytes integer optional File size in bytes
    • type string optional Attachment type identifier
    • size_bytes integer optional File size in bytes (alias for media_size_bytes)
    • content_type string optional MIME type of the file
    • url string uri optional nullable URL to download the binary file. Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
    • filename string optional nullable Filename of the uploaded file
    • developer_private boolean optional Whether this binary file is only visible to developers and admins
      Default: false
    • user object optional nullable User who uploaded the file
      • id integer optional User ID
      • name string optional User display name
  • url string optional
  • token string optional JWT token for subsequent API access. Field name is ‘token’ (not ‘api_token’). Returned on successful issue creation when authenticated via a FormUser (submission form) token — present for both draft and non-draft submissions, and NOT gated on draft=true. Not returned for Discord-bot or regular web-user submissions.
  • warnings array[string] optional Partial-success notices. Present on create/update responses only when something was silently dropped despite the 2xx status — e.g. a custom-field value exceeded the 4096-character cap, the 32-auto-created-fields-per-entity limit was hit, or a field could not be auto-created. Each entry is a human-readable string. Clients that submit custom fields should inspect this array to detect partial data loss.
application/json
{
  "issues": [
    {
      "id": 12345,
      "scoped_id": 42,
      "title": "string",
      "description": "string",
      "status": "open",
      "status_display": "Open",
      "priority": "high",
      "discord_message": "string",
      "created_at": "2026-03-12T10:30:00Z",
      "updated_at": "2026-03-12T10:30:00Z",
      "score": "0.8542",
      "steps_to_reproduce": [
        {}
      ],
      "device": {
        "id": 0,
        "device_type": "string",
        "configuration": {}
      },
      "assigned_to": {
        "id": 0,
        "name": "string"
      },
      "reported_by": {
        "id": 0,
        "name": "string"
      },
      "potential_duplicate": {},
      "screenshots": [
        {}
      ],
      "log_files": [
        {}
      ],
      "video_clips": [
        {}
      ],
      "binary_files": [
        {}
      ],
      "url": "string",
      "token": "string",
      "warnings": [
        "string"
      ]
    }
  ],
  "pagination": {
    "current_page": 0,
    "per_page": 0,
    "total_pages": 0,
    "total_count": 0
  },
  "project_id": 0,
  "id": 12345,
  "scoped_id": 42,
  "title": "string",
  "description": "string",
  "status": "open",
  "status_display": "Open",
  "priority": "high",
  "discord_message": "string",
  "created_at": "2026-03-12T10:30:00Z",
  "updated_at": "2026-03-12T10:30:00Z",
  "score": "0.8542",
  "steps_to_reproduce": [
    {
      "step": "string"
    }
  ],
  "device": {
    "id": 0,
    "device_type": "string",
    "configuration": {}
  },
  "assigned_to": {
    "id": 0,
    "name": "string"
  },
  "reported_by": {
    "id": 0,
    "name": "string"
  },
  "potential_duplicate": {},
  "screenshots": [
    {
      "id": 0,
      "type": "screenshot",
      "description": "string",
      "size_bytes": 0,
      "media_size_bytes": 0,
      "content_type": "string",
      "url": "https://example.com",
      "filename": "string",
      "layer_a_url": "https://example.com",
      "layer_a_filename": "string",
      "developer_private": true,
      "created_at": "2026-03-12T10:30:00Z",
      "updated_at": "2026-03-12T10:30:00Z",
      "user": {
        "id": 0,
        "name": "string"
      }
    }
  ],
  "log_files": [
    {
      "id": 0,
      "created_at": "2026-03-12T10:30:00Z",
      "updated_at": "2026-03-12T10:30:00Z",
      "media_size_bytes": 0,
      "type": "log_file",
      "size_bytes": 0,
      "content_type": "text/plain",
      "url": "https://example.com",
      "filename": "string",
      "developer_private": true,
      "user": {
        "id": 0,
        "name": "string"
      }
    }
  ],
  "video_clips": [
    {
      "id": 0,
      "type": "video_clip",
      "processing": true,
      "processed": true,
      "failed": true,
      "size_bytes": 0,
      "media_size_bytes": 0,
      "content_type": "string",
      "url": "https://example.com",
      "filename": "string",
      "developer_private": true,
      "created_at": "2026-03-12T10:30:00Z",
      "updated_at": "2026-03-12T10:30:00Z",
      "user": {
        "id": 0,
        "name": "string"
      }
    }
  ],
  "binary_files": [
    {
      "id": 0,
      "created_at": "2026-03-12T10:30:00Z",
      "updated_at": "2026-03-12T10:30:00Z",
      "media_size_bytes": 0,
      "type": "binary_file",
      "size_bytes": 0,
      "content_type": "application/octet-stream",
      "url": "https://example.com",
      "filename": "string",
      "developer_private": true,
      "user": {
        "id": 0,
        "name": "string"
      }
    }
  ],
  "url": "string",
  "token": "string",
  "warnings": [
    "string"
  ]
}
Forbidden. User does not have permission to search issues in this project.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Issue not found (when searching by scoped_id).
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Returns issues semantically similar to the given issue, ranked by vector-embedding similarity. This complements the keyword-based search endpoint: instead of matching words, it matches meaning, making it well suited for agentic tooling that needs to surface likely duplicates or related reports. Access is restricted to developer-level members of the project (the same permission as merging duplicates). Authenticate with a Personal Access Token. By default, archived issues are excluded from the results; pass include_archived=true to include them.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
issue_id required string The obfuscated ID of the issue to find similar issues for
Query Parameters
Name Type Description
description optional string Optional text to search with instead of the issue’s own description. Useful for checking similarity against a draft before persisting it.
limit optional integer Maximum number of similar issues to return (clamped between 1 and 50)
min: 1 max: 50
Default: 5
include_archived optional boolean When true, archived issues are included in the results
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/issues/g-123/find_similar.json?description=example&limit=5&include_archived=true"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/issues/g-123/find_similar.json?description=example&limit=5&include_archived=true")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.get(
    "https://app.betahub.io/projects/123/issues/g-123/find_similar.json?description=example&limit=5&include_archived=true",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123/find_similar.json?description=example&limit=5&include_archived=true", {
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123/find_similar.json?description=example&limit=5&include_archived=true"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .GET()
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Successful response
Response fields
  • issues array optional
application/json
{
  "issues": [
    {
      "id": 12345,
      "scoped_id": 42,
      "title": "App crashes on login screen",
      "status": "open",
      "priority": "high",
      "created_at": "2024-10-03T12:34:56Z",
      "similarity_score": 0.87
    }
  ]
}
Forbidden. Requires developer-level access to the project.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Issue or project not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Updates an existing issue with new information. Only certain fields can be updated, and the user must have appropriate permissions to modify the issue.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
issue_id required string
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
Request Body
application/json
  • issue[title] string optional Updated title of the issue
  • issue[description] string optional Updated description of the issue
  • issue[status] string optional Updated status of the issue. Accepts any built-in status (hidden, open, in_progress, resolved, closed, duplicate, pending_moderation, wont_fix, needs_more_info) or a project custom status — not a closed enum.
  • issue[priority] string optional Updated priority of the issue
    low medium high critical blocker
  • issue[assigned_to_id] string optional ID of the user to assign the issue to
  • issue[unformatted_steps_to_reproduce] string optional Updated steps to reproduce the issue
  • issue[release_id] string optional ID of the release to associate with the issue
  • issue[tag_ids][] array[integer] optional

    Replaces the issue’s attached tags with the given set of existing IssueTag ids (the id from the issue-tags list endpoint). Over form-encoding repeat the bracketed key: issue[tag_ids][]=12&issue[tag_ids][]=34.

    Developer / PAT only. Honored only for session or Personal Access Token callers; for game-SDK submission tokens (FormUser) and Discord-bot tokens it is silently stripped from the permitted params, so tags are left unchanged.

cURL
curl \
  -X PUT \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  -H "Content-Type: application/json" \
  -d '{
  "issue[title]": "string",
  "issue[description]": "string",
  "issue[status]": "string",
  "issue[priority]": "low",
  "issue[assigned_to_id]": "string",
  "issue[unformatted_steps_to_reproduce]": "string",
  "issue[release_id]": "string",
  "issue[tag_ids][]": [
    0
  ]
}' \
  "https://app.betahub.io/projects/123/issues/g-123.json"
Ruby
require "net/http"
require "json"

uri = URI("https://app.betahub.io/projects/123/issues/g-123.json")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Put.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"
request["Content-Type"] = "application/json"
request.body = {
  "issue[title]": "string",
  "issue[description]": "string",
  "issue[status]": "string",
  "issue[priority]": "low",
  "issue[assigned_to_id]": "string",
  "issue[unformatted_steps_to_reproduce]": "string",
  "issue[release_id]": "string",
  "issue[tag_ids][]": [
    0
  ]
}

response = http.request(request)
puts response.body
Python
import requests

response = requests.put(
    "https://app.betahub.io/projects/123/issues/g-123.json",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"},
    json={
      "issue[title]": "string",
      "issue[description]": "string",
      "issue[status]": "string",
      "issue[priority]": "low",
      "issue[assigned_to_id]": "string",
      "issue[unformatted_steps_to_reproduce]": "string",
      "issue[release_id]": "string",
      "issue[tag_ids][]": [
        0
      ]
    }
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123.json", {
  method: "PUT",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123",
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    "issue[title]": "string",
    "issue[description]": "string",
    "issue[status]": "string",
    "issue[priority]": "low",
    "issue[assigned_to_id]": "string",
    "issue[unformatted_steps_to_reproduce]": "string",
    "issue[release_id]": "string",
    "issue[tag_ids][]": [
      0
    ]
  })
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123.json"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .header("Content-Type", "application/json")
    .PUT(HttpRequest.BodyPublishers.ofString("{\"issue[title]\":\"string\",\"issue[description]\":\"string\",\"issue[status]\":\"string\",\"issue[priority]\":\"low\",\"issue[assigned_to_id]\":\"string\",\"issue[unformatted_steps_to_reproduce]\":\"string\",\"issue[release_id]\":\"string\",\"issue[tag_ids][]\":[0]}"))
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Request Body
{
  "issue[title]": "string",
  "issue[description]": "string",
  "issue[status]": "string",
  "issue[priority]": "low",
  "issue[assigned_to_id]": "string",
  "issue[unformatted_steps_to_reproduce]": "string",
  "issue[release_id]": "string",
  "issue[tag_ids][]": [
    0
  ]
}
Responses
Successful response
Response fields
  • id integer optional Numeric primary key of the issue. NOTE: this is the raw integer PK, NOT the obfuscated global id. URL path segments require the obfuscated g-{id} form (e.g. /issues/g-12345), which this value is not. To build links use url (already fully-formed) or scoped_id — do not concatenate this id into a path.
  • scoped_id integer optional Per-project sequential issue number (the “#42” shown in the UI). Always present. On a duplicate merge the scoped_id of a destroyed losing issue can be reassigned to the surviving canonical issue, so it is stable for a live issue but not guaranteed permanent across merges. Persist the obfuscated g-{id} (see url) if you need a permanently stable identifier.
  • title string optional
  • description string optional
  • status string optional Current issue status. This is NOT a closed enum. The built-in statuses are hidden, open, in_progress, resolved, closed, duplicate, pending_moderation, wont_fix, and needs_more_info, but a project may also define custom statuses — treat this as a free-form string.
  • status_display string optional Human-readable label for status, resolved against the project’s (possibly custom) status names.
  • priority string optional Issue priority. Built-in values: low, medium, high, critical, blocker. For issues created through SDK / FormUser tokens the priority is LLM-predicted and cannot be set on create (any submitted value is stripped); it is only settable by a developer via the update (PUT) endpoint.
  • discord_message string optional nullable Original Discord message text when the issue originated from the Discord bot; null otherwise.
  • created_at string date-time optional
  • updated_at string date-time optional
  • score string optional Read-only report-completeness signal, emitted as a decimal string in the range “0”..”1” (e.g. “0.8542”) — NOT a 0–100 number. Computed server-side; any value supplied on submit is ignored.
  • steps_to_reproduce array[object] optional
    • step string optional
  • device object optional nullable Structured device / hardware information attached to the issue, or null when none is attached. Populated by the LLM parser when issue[extras][device_info] is submitted on create (see the create endpoint).
    • id integer optional
    • device_type string optional
    • configuration object optional Free-form parsed hardware specs (e.g. CPU, GPU, OS, memory).
  • assigned_to object optional
    • id integer optional nullable
    • name string optional nullable
  • reported_by object optional Reporter identity. Both id and name are null unless the caller’s token carries reporter-visibility permission. Even when visible, name may be masked or replaced with a persona by the project’s team-identity settings.
    • id integer optional nullable
    • name string optional nullable
  • potential_duplicate object optional nullable Populated ONLY when status == 'duplicate'; null otherwise. When present it is a full nested issue object with the same shape as this IssueResponse — describing the canonical issue this submission was merged into (your report was detected as a duplicate and folded into that existing issue). The nested object is not expanded inline here to avoid a recursive schema; expect the same fields as a top-level issue.
  • screenshots array[object] optional Screenshots attached to the issue, filtered by the caller’s visibility. This array (and the other attachment arrays below) is OMITTED entirely on responses that skip attachment rendering, such as the find_similar endpoint.
    • id integer optional The ID of the screenshot
    • type string optional Attachment type identifier
    • description string optional nullable Auto-generated (or user-edited) description of the screenshot
    • size_bytes integer optional File size in bytes (alias for media_size_bytes)
    • media_size_bytes integer optional File size in bytes
    • content_type string optional nullable MIME type of the image (null when no image is attached)
    • url string uri optional nullable CDN URL to access the screenshot image (null when no image is attached). Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
    • filename string optional nullable Filename of the uploaded image (null when no image is attached)
    • layer_a_url string uri optional nullable CDN URL of the annotation overlay layer, when present
    • layer_a_filename string optional nullable Filename of the annotation overlay layer, when present
    • developer_private boolean optional Whether this screenshot is only visible to developers and admins
      Default: false
    • created_at string date-time optional Creation timestamp
    • updated_at string date-time optional Last update timestamp
    • user object optional nullable For screenshots this object reflects the issue’s reporter (issue.reported_by), NOT the screenshot’s uploader — the _screenshot.json.jbuilder view ignores the screenshot.user column. It is null only when the issue has no reporter. (The video/log/binary jbuilders differ: those DO use the uploader, resource.user.)
      • id integer optional User ID
      • name string optional Player-facing display name
  • log_files array[object] optional Log files attached to the issue (visibility-filtered). Omitted when attachments are not rendered.
    • id integer optional The ID of the log file
    • created_at string date-time optional Creation timestamp
    • updated_at string date-time optional Last update timestamp
    • media_size_bytes integer optional File size in bytes
    • type string optional Attachment type identifier
    • size_bytes integer optional File size in bytes (alias for media_size_bytes)
    • content_type string optional MIME type of the file
    • url string uri optional nullable URL to download the log file. Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
    • filename string optional nullable Filename of the uploaded file
    • developer_private boolean optional Whether this log file is only visible to developers and admins
      Default: false
    • user object optional nullable User who uploaded the file
      • id integer optional User ID
      • name string optional User display name
  • video_clips array[object] optional Video clips attached to the issue (visibility-filtered). Omitted when attachments are not rendered.
    • id integer optional The ID of the video clip
    • type string optional Attachment type identifier
    • processing boolean optional Whether the video is currently being transcoded/processed by a background job.
    • processed boolean optional Whether the clip is ready for inline web playback. For multipart uploads this becomes true only if the source is web-compatible AND under the organization’s max video length; otherwise a background job transcodes it first. Direct (presigned) uploads are marked processed=true immediately on confirm, without transcoding or a length check (see VideoClip model before_save).
    • failed boolean optional Whether video processing has failed
    • size_bytes integer optional File size in bytes (alias for media_size_bytes)
    • media_size_bytes integer optional File size in bytes
    • content_type string optional nullable MIME type of the video (null when no video is attached)
    • url string uri optional nullable CDN URL to access the video clip (null when no video is attached). Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
    • filename string optional nullable Filename of the uploaded video (null when no video is attached)
    • developer_private boolean optional Whether this video clip is only visible to developers and admins
      Default: false
    • created_at string date-time optional Creation timestamp
    • updated_at string date-time optional Last update timestamp
    • user object optional nullable User who uploaded the video clip (null for anonymous/reporter fallback)
      • id integer optional User ID
      • name string optional Player-facing display name
  • binary_files array[object] optional Binary files attached to the issue (visibility-filtered). Omitted when attachments are not rendered.
    • id integer optional The ID of the binary file
    • created_at string date-time optional Creation timestamp
    • updated_at string date-time optional Last update timestamp
    • media_size_bytes integer optional File size in bytes
    • type string optional Attachment type identifier
    • size_bytes integer optional File size in bytes (alias for media_size_bytes)
    • content_type string optional MIME type of the file
    • url string uri optional nullable URL to download the binary file. Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
    • filename string optional nullable Filename of the uploaded file
    • developer_private boolean optional Whether this binary file is only visible to developers and admins
      Default: false
    • user object optional nullable User who uploaded the file
      • id integer optional User ID
      • name string optional User display name
  • url string optional
  • token string optional JWT token for subsequent API access. Field name is ‘token’ (not ‘api_token’). Returned on successful issue creation when authenticated via a FormUser (submission form) token — present for both draft and non-draft submissions, and NOT gated on draft=true. Not returned for Discord-bot or regular web-user submissions.
  • warnings array[string] optional Partial-success notices. Present on create/update responses only when something was silently dropped despite the 2xx status — e.g. a custom-field value exceeded the 4096-character cap, the 32-auto-created-fields-per-entity limit was hit, or a field could not be auto-created. Each entry is a human-readable string. Clients that submit custom fields should inspect this array to detect partial data loss.
Examples
Example Request
{
  "issue[title]": "Updated: App crashes on login screen",
  "issue[description]": "Updated description with more details about the crash.",
  "issue[status]": "in_progress",
  "issue[priority]": "critical"
}
Example Response
{
  "id": 12345,
  "scoped_id": 42,
  "title": "Updated: App crashes on login screen",
  "description": "Updated description with more details about the crash.",
  "status": "in_progress",
  "status_display": "In progress",
  "priority": "critical",
  "created_at": "2024-10-03T12:34:56Z",
  "updated_at": "2024-10-03T15:22:10Z",
  "score": "0.8542",
  "steps_to_reproduce": [
    {
      "step": "1. Open the app"
    },
    {
      "step": "2. Enter login credentials"
    },
    {
      "step": "3. Press login"
    }
  ],
  "assigned_to": {
    "id": 12,
    "name": "John Doe"
  },
  "reported_by": {
    "id": 34,
    "name": "Jane Smith"
  },
  "potential_duplicate": null,
  "url": "https://app.betahub.io/projects/1/issues/g-12345"
}
Forbidden. User does not have permission to update this issue.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Issue not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Unprocessable Entity. Invalid data provided.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Permanently deletes an issue. Authorization requires either a Personal Access Token with the issues.delete scope, or a request made by the issue’s original reporter. On success the response is 204 No Content with an empty body.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
issue_id required string
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -X DELETE \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/issues/g-123.json"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/issues/g-123.json")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Delete.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.delete(
    "https://app.betahub.io/projects/123/issues/g-123.json",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123.json", {
  method: "DELETE",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123.json"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .method("DELETE", HttpRequest.BodyPublishers.noBody())
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Issue deleted successfully. Empty response body.
Issue deleted successfully. Empty response body.
Forbidden. Caller is neither the original reporter nor holds the issues.delete scope.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Issue not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Archives an issue (an organizational action that does not notify watchers or trigger webhooks). Archived issues are excluded from the issue list by default; pass archived=true to the list endpoint to see them. Requires a Personal Access Token with the issues.archive scope.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
issue_id required string
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -X POST \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/issues/g-123/archive"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/issues/g-123/archive")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.post(
    "https://app.betahub.io/projects/123/issues/g-123/archive",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123/archive", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123/archive"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .method("POST", HttpRequest.BodyPublishers.noBody())
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Issue archived successfully.
Response fields
  • success boolean optional
application/json
{
  "success": true
}
Forbidden. Requires the issues.archive scope.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Issue not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Failed to archive the issue.
Response fields
  • success boolean optional
application/json
{
  "success": false
}
Restores an archived issue back into the default (non-archived) list. Like archiving, this does not notify watchers or trigger webhooks. Requires a Personal Access Token with the issues.archive scope.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
issue_id required string
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -X POST \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/issues/g-123/unarchive"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/issues/g-123/unarchive")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.post(
    "https://app.betahub.io/projects/123/issues/g-123/unarchive",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123/unarchive", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123/unarchive"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .method("POST", HttpRequest.BodyPublishers.noBody())
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Issue unarchived successfully.
Response fields
  • success boolean optional
application/json
{
  "success": true
}
Forbidden. Requires the issues.archive scope.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Issue not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Failed to unarchive the issue.
Response fields
  • success boolean optional
application/json
{
  "success": false
}
DEPRECATED: Use set_contact_info instead. This endpoint is maintained for backward compatibility. Sets the reporter email for a draft issue. The reporter is then identified by this email and receives notifications about the issue. This endpoint now also supports discord_id parameter.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
issue_id required string
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
Request Body
application/json
  • email string email optional The email address of the reporter. Must be a valid email format.
  • discord_id string optional The Discord ID of the reporter. Must be numeric.
cURL
curl \
  -X POST \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  -H "Content-Type: application/json" \
  -d '{
  "email": "user@example.com",
  "discord_id": "string"
}' \
  "https://app.betahub.io/projects/123/issues/g-123/set_reporter_email"
Ruby
require "net/http"
require "json"

uri = URI("https://app.betahub.io/projects/123/issues/g-123/set_reporter_email")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"
request["Content-Type"] = "application/json"
request.body = {
  "email": "user@example.com",
  "discord_id": "string"
}

response = http.request(request)
puts response.body
Python
import requests

response = requests.post(
    "https://app.betahub.io/projects/123/issues/g-123/set_reporter_email",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"},
    json={
      "email": "user@example.com",
      "discord_id": "string"
    }
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123/set_reporter_email", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123",
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    "email": "user@example.com",
    "discord_id": "string"
  })
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123/set_reporter_email"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString("{\"email\":\"user@example.com\",\"discord_id\":\"string\"}"))
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Request Body
{
  "email": "user@example.com",
  "discord_id": "string"
}
Responses
Successful response (same as set_contact_info)
Response fields
  • success boolean optional
  • message string optional
  • issue_id string optional
  • reporter object optional
    • id integer optional
    • email string optional
    • discord_id string optional
    • virtual boolean optional
application/json
{
  "success": true,
  "message": "string",
  "issue_id": "string",
  "reporter": {
    "id": 0,
    "email": "string",
    "discord_id": "string",
    "virtual": true
  }
}
Unprocessable Entity
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Forbidden. User does not have permission to set contact info for this issue.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Issue not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Sets contact information (email or Discord ID) for a draft issue reporter. The reporter is then identified by this contact and receives notifications about the issue. This step is optional in the draft flow. Either email or discord_id must be provided.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
issue_id required string
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
Request Body
application/json
  • email string email optional The email address of the reporter. Must be a valid email format.
  • discord_id string optional The Discord ID of the reporter. Must be numeric.
cURL
curl \
  -X POST \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  -H "Content-Type: application/json" \
  -d '{
  "email": "user@example.com",
  "discord_id": "string"
}' \
  "https://app.betahub.io/projects/123/issues/g-123/set_contact_info"
Ruby
require "net/http"
require "json"

uri = URI("https://app.betahub.io/projects/123/issues/g-123/set_contact_info")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"
request["Content-Type"] = "application/json"
request.body = {
  "email": "user@example.com",
  "discord_id": "string"
}

response = http.request(request)
puts response.body
Python
import requests

response = requests.post(
    "https://app.betahub.io/projects/123/issues/g-123/set_contact_info",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"},
    json={
      "email": "user@example.com",
      "discord_id": "string"
    }
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123/set_contact_info", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123",
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    "email": "user@example.com",
    "discord_id": "string"
  })
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123/set_contact_info"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString("{\"email\":\"user@example.com\",\"discord_id\":\"string\"}"))
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Request Body
{
  "email": "user@example.com",
  "discord_id": "string"
}
Responses
Successful response
Response fields
  • success boolean optional
  • message string optional
  • issue_id string optional
  • reporter object optional
    • id integer optional
    • email string optional
    • discord_id string optional
    • virtual boolean optional
Examples
Example Email Request
{
  "email": "reporter@example.com"
}
Example Discord Request
{
  "discord_id": "123456789"
}
Example Response
{
  "success": true,
  "message": "Contact information assigned successfully",
  "issue_id": "1234abc",
  "reporter": {
    "id": 56,
    "email": "reporter@example.com",
    "discord_id": null,
    "virtual": true
  }
}
Unprocessable Entity. May occur if email/discord_id is invalid or neither is provided.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
Examples
Invalid Email
{
  "error": "Invalid email format"
}
Invalid Discord ID
{
  "error": "Invalid discord_id format"
}
Missing Both
{
  "error": "Either email or discord_id is required"
}
Forbidden. User does not have permission to set contact info for this issue.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Issue not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Publishes a draft issue, changing its status from hidden to open and making it visible. This is the final step in the draft flow.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
issue_id required string
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
Request Body
application/json
  • email_my_report boolean optional When set to true, an email will be sent to the reporter (if a valid email was set). Default is false.
cURL
curl \
  -X POST \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  -H "Content-Type: application/json" \
  -d '{
  "email_my_report": true
}' \
  "https://app.betahub.io/projects/123/issues/g-123/publish"
Ruby
require "net/http"
require "json"

uri = URI("https://app.betahub.io/projects/123/issues/g-123/publish")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"
request["Content-Type"] = "application/json"
request.body = {
  "email_my_report": true
}

response = http.request(request)
puts response.body
Python
import requests

response = requests.post(
    "https://app.betahub.io/projects/123/issues/g-123/publish",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"},
    json={
      "email_my_report": true
    }
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123/publish", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123",
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    "email_my_report": true
  })
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123/publish"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString("{\"email_my_report\":true}"))
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Request Body
{
  "email_my_report": true
}
Responses
Successful response
Response fields
  • success boolean optional
Examples
Example Request
{
  "email_my_report": true
}
Example Response
{
  "success": true
}
Sends a request to the issue reporter asking for additional information such as reproduction steps, screenshots, video clips, log files, or device information. This creates a notification for the reporter and adds the requester as a watcher.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
issue_id required string
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
Request Body
application/json
  • what string required The type of additional information being requested from the reporter
    steps screenshot video logs device
  • comment string optional Optional comment or message to include with the request
cURL
curl \
  -X POST \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  -H "Content-Type: application/json" \
  -d '{
  "what": "steps",
  "comment": "string"
}' \
  "https://app.betahub.io/projects/123/issues/g-123/ask_for_details"
Ruby
require "net/http"
require "json"

uri = URI("https://app.betahub.io/projects/123/issues/g-123/ask_for_details")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"
request["Content-Type"] = "application/json"
request.body = {
  "what": "steps",
  "comment": "string"
}

response = http.request(request)
puts response.body
Python
import requests

response = requests.post(
    "https://app.betahub.io/projects/123/issues/g-123/ask_for_details",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"},
    json={
      "what": "steps",
      "comment": "string"
    }
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123/ask_for_details", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123",
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    "what": "steps",
    "comment": "string"
  })
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123/ask_for_details"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString("{\"what\":\"steps\",\"comment\":\"string\"}"))
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Request Body
{
  "what": "steps",
  "comment": "string"
}
Responses
Successful response
Response fields
  • success boolean optional
  • message string optional
Examples
Example Request
{
  "what": "steps",
  "comment": "Could you please provide detailed steps to reproduce this issue?"
}
Example Response
{
  "success": true,
  "message": "Request for steps sent to reporter"
}
Bad Request. Invalid request type provided.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "Invalid request type"
}
Forbidden. User does not have permission to request details for this issue.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Issue not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}

Screenshots

List and upload screenshots for issues
Returns all screenshots attached to the specified issue. Developer-private screenshots are omitted for callers who are not developers or admins.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
issue_id required string
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/issues/g-123/screenshots"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/issues/g-123/screenshots")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.get(
    "https://app.betahub.io/projects/123/issues/g-123/screenshots",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123/screenshots", {
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123/screenshots"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .GET()
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Successful response
application/json
[
  {
    "id": 0,
    "type": "screenshot",
    "description": "string",
    "size_bytes": 0,
    "media_size_bytes": 0,
    "content_type": "string",
    "url": "https://example.com",
    "filename": "string",
    "layer_a_url": "https://example.com",
    "layer_a_filename": "string",
    "created_at": "2026-03-12T10:30:00Z",
    "updated_at": "2026-03-12T10:30:00Z",
    "user": {
      "id": 0,
      "name": "string"
    }
  }
]
Forbidden.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
No description provided.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
issue_id required string
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
Request Body
multipart/form-data
  • screenshot[image] string binary optional The screenshot image file
  • screenshot[name] string optional The name of the screenshot file. If set, the file will be saved with this name.
  • screenshot[developer_private] boolean optional If true, only developers and admins can view this screenshot
    Default: false
cURL
curl \
  -X POST \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  -F "screenshot[image]=@file.bin" \
  -F "screenshot[name]=string" \
  -F "screenshot[developer_private]=true" \
  "https://app.betahub.io/projects/123/issues/g-123/screenshots"
Ruby
require "net/http"
require "json"

uri = URI("https://app.betahub.io/projects/123/issues/g-123/screenshots")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.post(
    "https://app.betahub.io/projects/123/issues/g-123/screenshots",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123/screenshots", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123/screenshots"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString("{\"screenshot[image]\":\"string\",\"screenshot[name]\":\"string\",\"screenshot[developer_private]\":true}"))
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Request Body
{
  "screenshot[image]": "string",
  "screenshot[name]": "string",
  "screenshot[developer_private]": true
}
Responses
Successful response
Response fields
  • id string optional
  • created_at string date-time optional
  • updated_at string date-time optional
  • status string optional
  • description string optional
  • issue_id string optional
Examples
Example Request
{
  "screenshot[image]": "(binary data representing an image)"
}
Example Response
{
  "id": "5678def",
  "created_at": "2024-10-03T13:45:10Z",
  "updated_at": "2024-10-03T13:45:10Z",
  "status": "pending",
  "description": "Screenshot showing the login screen crash.",
  "issue_id": "1234abc"
}
Request a presigned URL for uploading a screenshot directly to S3. This is step 1 of the three-step direct upload flow (see “Direct file upload flow” in the API overview). After this call you must PUT the raw file bytes to the returned direct_upload_url, replaying the returned headers, before calling confirm_upload.
Authorization required
API token for accessing draft issues or performing direct uploads. Can be a JWT token returned from issue creation in draft mode, or other valid authorization tokens. Format: “Bearer TOKEN” or “FormUser tkn-TOKEN”
Path Parameters
Name Type Description
project_id required string
issue_id required string
Request Body
application/json
  • filename string required Name of the file to upload
  • byte_size integer required Size of the file in bytes
  • checksum string required Base64-encoded MD5 checksum of the file
  • content_type string required MIME type of the file
    image/png image/jpeg image/jpg
  • name string optional Optional display name for the screenshot file
cURL
curl \
  -X POST \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "filename": "screenshot.png",
  "byte_size": 1048576,
  "checksum": "1B2M2Y8AsgTpgAmY7PhCfg==",
  "content_type": "image/png",
  "name": "Player Death Screenshot"
}' \
  "https://app.betahub.io/projects/123/issues/g-123/screenshots/presigned_upload"
Ruby
require "net/http"
require "json"

uri = URI("https://app.betahub.io/projects/123/issues/g-123/screenshots/presigned_upload")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["Content-Type"] = "application/json"
request.body = {
  "filename": "screenshot.png",
  "byte_size": 1048576,
  "checksum": "1B2M2Y8AsgTpgAmY7PhCfg==",
  "content_type": "image/png",
  "name": "Player Death Screenshot"
}

response = http.request(request)
puts response.body
Python
import requests

response = requests.post(
    "https://app.betahub.io/projects/123/issues/g-123/screenshots/presigned_upload",
    headers={"Authorization": "Bearer YOUR_API_TOKEN"},
    json={
      "filename": "screenshot.png",
      "byte_size": 1048576,
      "checksum": "1B2M2Y8AsgTpgAmY7PhCfg==",
      "content_type": "image/png",
      "name": "Player Death Screenshot"
    }
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123/screenshots/presigned_upload", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    "filename": "screenshot.png",
    "byte_size": 1048576,
    "checksum": "1B2M2Y8AsgTpgAmY7PhCfg==",
    "content_type": "image/png",
    "name": "Player Death Screenshot"
  })
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123/screenshots/presigned_upload"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString("{\"filename\":\"screenshot.png\",\"byte_size\":1048576,\"checksum\":\"1B2M2Y8AsgTpgAmY7PhCfg==\",\"content_type\":\"image/png\",\"name\":\"Player Death Screenshot\"}"))
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Request Body
{
  "filename": "screenshot.png",
  "byte_size": 1048576,
  "checksum": "1B2M2Y8AsgTpgAmY7PhCfg==",
  "content_type": "image/png",
  "name": "Player Death Screenshot"
}
Responses
Presigned upload URL generated successfully
Response fields
  • blob_signed_id string required Signed ID of the blob to be used in confirmation
  • direct_upload_url string uri required S3 presigned URL for uploading the file
  • headers object required Headers to include with the upload request
    • «key» string optional
  • blob_id integer optional ID of the created blob
application/json
{
  "blob_signed_id": "string",
  "direct_upload_url": "https://example.com",
  "headers": {
    "key": "string"
  },
  "blob_id": 0
}
A required parameter is missing (filename, byte_size, checksum, or content_type). Body is a flat { "error": "<message>" }.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Forbidden — the caller is not a project member, has not accepted the project NDA, or is muted on the project (write access denied).
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Invalid content type, file exceeds the per-type size cap, or the checksum failed integrity verification. Body is a flat { "error": "<message>" }.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Internal server error while creating the presigned upload (e.g. storage service failure). Body is a flat { "error": "Internal server error" }.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Unauthorized. Authentication required or invalid token provided.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}

Confirm that the file has been uploaded to S3 and attach it to the issue. This is the final step of the three-step direct upload flow (see “Direct file upload flow” in the API overview). Call it only after PUTting the file bytes to the direct_upload_url from presigned_upload. The 201 response is the raw attachment record (no download URL) — GET the media list endpoint to obtain url.

This endpoint has no HTML representation — it responds only to JSON and Turbo Stream, so a request with Accept: text/html returns 406 Not Acceptable.

Authorization required
API token for accessing draft issues or performing direct uploads. Can be a JWT token returned from issue creation in draft mode, or other valid authorization tokens. Format: “Bearer TOKEN” or “FormUser tkn-TOKEN”
Path Parameters
Name Type Description
project_id required string
issue_id required string
Request Body
application/json
  • blob_signed_id string required Signed ID of the blob received from presigned_upload
  • name string optional Optional display name for the screenshot file
  • developer_private boolean optional If true, only developers and admins can view this screenshot
    Default: false
cURL
curl \
  -X POST \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "blob_signed_id": "string",
  "name": "Player Death Screenshot",
  "developer_private": true
}' \
  "https://app.betahub.io/projects/123/issues/g-123/screenshots/confirm_upload"
Ruby
require "net/http"
require "json"

uri = URI("https://app.betahub.io/projects/123/issues/g-123/screenshots/confirm_upload")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["Content-Type"] = "application/json"
request.body = {
  "blob_signed_id": "string",
  "name": "Player Death Screenshot",
  "developer_private": true
}

response = http.request(request)
puts response.body
Python
import requests

response = requests.post(
    "https://app.betahub.io/projects/123/issues/g-123/screenshots/confirm_upload",
    headers={"Authorization": "Bearer YOUR_API_TOKEN"},
    json={
      "blob_signed_id": "string",
      "name": "Player Death Screenshot",
      "developer_private": true
    }
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123/screenshots/confirm_upload", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    "blob_signed_id": "string",
    "name": "Player Death Screenshot",
    "developer_private": true
  })
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123/screenshots/confirm_upload"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString("{\"blob_signed_id\":\"string\",\"name\":\"Player Death Screenshot\",\"developer_private\":true}"))
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Request Body
{
  "blob_signed_id": "string",
  "name": "Player Death Screenshot",
  "developer_private": true
}
Responses
Screenshot created and attached successfully
Response fields
  • id integer optional The ID of the screenshot
  • type string optional Attachment type identifier
  • description string optional nullable Auto-generated (or user-edited) description of the screenshot
  • size_bytes integer optional File size in bytes (alias for media_size_bytes)
  • media_size_bytes integer optional File size in bytes
  • content_type string optional nullable MIME type of the image (null when no image is attached)
  • url string uri optional nullable CDN URL to access the screenshot image (null when no image is attached). Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
  • filename string optional nullable Filename of the uploaded image (null when no image is attached)
  • layer_a_url string uri optional nullable CDN URL of the annotation overlay layer, when present
  • layer_a_filename string optional nullable Filename of the annotation overlay layer, when present
  • developer_private boolean optional Whether this screenshot is only visible to developers and admins
    Default: false
  • created_at string date-time optional Creation timestamp
  • updated_at string date-time optional Last update timestamp
  • user object optional nullable For screenshots this object reflects the issue’s reporter (issue.reported_by), NOT the screenshot’s uploader — the _screenshot.json.jbuilder view ignores the screenshot.user column. It is null only when the issue has no reporter. (The video/log/binary jbuilders differ: those DO use the uploader, resource.user.)
    • id integer optional User ID
    • name string optional Player-facing display name
application/json
{
  "id": 0,
  "type": "screenshot",
  "description": "string",
  "size_bytes": 0,
  "media_size_bytes": 0,
  "content_type": "string",
  "url": "https://example.com",
  "filename": "string",
  "layer_a_url": "https://example.com",
  "layer_a_filename": "string",
  "developer_private": true,
  "created_at": "2026-03-12T10:30:00Z",
  "updated_at": "2026-03-12T10:30:00Z",
  "user": {
    "id": 0,
    "name": "string"
  }
}
Missing blob_signed_id parameter. Body is a flat { "error": "<message>" }.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Forbidden — the caller is not a project member, has not accepted the project NDA, or is muted on the project (write access denied).
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
The blob_signed_id signature is invalid/expired, or no blob was found for it. Body is a flat { "error": "<message>" }.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
The per-issue file-count limit was reached, the file was not actually uploaded to storage before confirming, or the record failed model validation. The count-limit and not-uploaded cases return a flat { "error": "<message>" }; model-validation failures instead return { "errors": { "<field>": ["<message>"] } }.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Unauthorized. Authentication required or invalid token provided.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Returns a single screenshot. Developer-private screenshots are only visible to developers and admins. The ID is resolved across the issue and its merged duplicates.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
issue_id required string
id required integer The screenshot ID.
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/issues/g-123/screenshots/123"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/issues/g-123/screenshots/123")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.get(
    "https://app.betahub.io/projects/123/issues/g-123/screenshots/123",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123/screenshots/123", {
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123/screenshots/123"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .GET()
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Successful response
Response fields
  • id integer optional The ID of the screenshot
  • type string optional Attachment type identifier
  • description string optional nullable Auto-generated (or user-edited) description of the screenshot
  • size_bytes integer optional File size in bytes (alias for media_size_bytes)
  • media_size_bytes integer optional File size in bytes
  • content_type string optional nullable MIME type of the image (null when no image is attached)
  • url string uri optional nullable CDN URL to access the screenshot image (null when no image is attached). Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
  • filename string optional nullable Filename of the uploaded image (null when no image is attached)
  • layer_a_url string uri optional nullable CDN URL of the annotation overlay layer, when present
  • layer_a_filename string optional nullable Filename of the annotation overlay layer, when present
  • developer_private boolean optional Whether this screenshot is only visible to developers and admins
    Default: false
  • created_at string date-time optional Creation timestamp
  • updated_at string date-time optional Last update timestamp
  • user object optional nullable For screenshots this object reflects the issue’s reporter (issue.reported_by), NOT the screenshot’s uploader — the _screenshot.json.jbuilder view ignores the screenshot.user column. It is null only when the issue has no reporter. (The video/log/binary jbuilders differ: those DO use the uploader, resource.user.)
    • id integer optional User ID
    • name string optional Player-facing display name
application/json
{
  "id": 0,
  "type": "screenshot",
  "description": "string",
  "size_bytes": 0,
  "media_size_bytes": 0,
  "content_type": "string",
  "url": "https://example.com",
  "filename": "string",
  "layer_a_url": "https://example.com",
  "layer_a_filename": "string",
  "developer_private": true,
  "created_at": "2026-03-12T10:30:00Z",
  "updated_at": "2026-03-12T10:30:00Z",
  "user": {
    "id": 0,
    "name": "string"
  }
}
Forbidden (e.g. developer-private screenshot requested by a non-developer).
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Screenshot not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Identical to the PATCH operation. The 200 response is the raw ActiveRecord record.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
issue_id required string
id required integer The screenshot ID.
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
Request Body
application/json
  • screenshot object optional
    • description string optional
    • name string optional
    • developer_private boolean optional
cURL
curl \
  -X PUT \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  -H "Content-Type: application/json" \
  -d '{
  "screenshot": {
    "description": "string",
    "name": "string",
    "developer_private": true
  }
}' \
  "https://app.betahub.io/projects/123/issues/g-123/screenshots/123"
Ruby
require "net/http"
require "json"

uri = URI("https://app.betahub.io/projects/123/issues/g-123/screenshots/123")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Put.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"
request["Content-Type"] = "application/json"
request.body = {
  "screenshot": {
    "description": "string",
    "name": "string",
    "developer_private": true
  }
}

response = http.request(request)
puts response.body
Python
import requests

response = requests.put(
    "https://app.betahub.io/projects/123/issues/g-123/screenshots/123",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"},
    json={
      "screenshot": {
        "description": "string",
        "name": "string",
        "developer_private": true
      }
    }
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123/screenshots/123", {
  method: "PUT",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123",
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    "screenshot": {
      "description": "string",
      "name": "string",
      "developer_private": true
    }
  })
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123/screenshots/123"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .header("Content-Type", "application/json")
    .PUT(HttpRequest.BodyPublishers.ofString("{\"screenshot\":{\"description\":\"string\",\"name\":\"string\",\"developer_private\":true}}"))
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Request Body
{
  "screenshot": {
    "description": "string",
    "name": "string",
    "developer_private": true
  }
}
Responses
Screenshot updated (raw record).
Response fields
  • id integer optional
  • issue_id integer optional
  • status integer optional Internal status column (integer).
  • description string optional nullable
  • media_size_bytes integer optional
  • developer_private boolean optional
  • user_id integer optional nullable
  • created_at string date-time optional
  • updated_at string date-time optional
application/json
{
  "id": 0,
  "issue_id": 0,
  "status": 0,
  "description": "string",
  "media_size_bytes": 0,
  "developer_private": true,
  "user_id": 0,
  "created_at": "2026-03-12T10:30:00Z",
  "updated_at": "2026-03-12T10:30:00Z"
}
Forbidden.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Screenshot not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Validation failed. Body is an ActiveModel errors object keyed by field.
Response fields
  • error string optional Human-readable error message (shapes a and b). English, not localized, and not a stable contract — display it, do not parse it.
  • status object optional Present only in shape (b): a redundant copy of the HTTP status code as an integer (e.g. 422). Unreliable and often absent — rely on the HTTP status line instead.
  • errors object optional Shape (c). Either an object mapping attribute name → array of messages, or (on a few endpoints) a flat array of full-message strings.
application/json
{
  "error": "string",
  "status": {},
  "errors": {}
}
Update editable fields of a screenshot. The 200 response is the raw ActiveRecord record (not the jbuilder shape — it has no url/type/filename).
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
issue_id required string
id required integer The screenshot ID.
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
Request Body
application/json
  • screenshot object optional
    • description string optional Screenshot description.
    • name string optional Custom filename for the screenshot.
    • developer_private boolean optional If true, only developers and admins can view this screenshot.
cURL
curl \
  -X PATCH \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  -H "Content-Type: application/json" \
  -d '{
  "screenshot": {
    "description": "string",
    "name": "string",
    "developer_private": true
  }
}' \
  "https://app.betahub.io/projects/123/issues/g-123/screenshots/123"
Ruby
require "net/http"
require "json"

uri = URI("https://app.betahub.io/projects/123/issues/g-123/screenshots/123")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Patch.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"
request["Content-Type"] = "application/json"
request.body = {
  "screenshot": {
    "description": "string",
    "name": "string",
    "developer_private": true
  }
}

response = http.request(request)
puts response.body
Python
import requests

response = requests.patch(
    "https://app.betahub.io/projects/123/issues/g-123/screenshots/123",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"},
    json={
      "screenshot": {
        "description": "string",
        "name": "string",
        "developer_private": true
      }
    }
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123/screenshots/123", {
  method: "PATCH",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123",
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    "screenshot": {
      "description": "string",
      "name": "string",
      "developer_private": true
    }
  })
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123/screenshots/123"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .header("Content-Type", "application/json")
    .PATCH(HttpRequest.BodyPublishers.ofString("{\"screenshot\":{\"description\":\"string\",\"name\":\"string\",\"developer_private\":true}}"))
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Request Body
{
  "screenshot": {
    "description": "string",
    "name": "string",
    "developer_private": true
  }
}
Responses
Screenshot updated (raw record).
Response fields
  • id integer optional
  • issue_id integer optional
  • status integer optional Internal status column (integer).
  • description string optional nullable
  • media_size_bytes integer optional
  • developer_private boolean optional
  • user_id integer optional nullable
  • created_at string date-time optional
  • updated_at string date-time optional
application/json
{
  "id": 0,
  "issue_id": 0,
  "status": 0,
  "description": "string",
  "media_size_bytes": 0,
  "developer_private": true,
  "user_id": 0,
  "created_at": "2026-03-12T10:30:00Z",
  "updated_at": "2026-03-12T10:30:00Z"
}
Forbidden.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Screenshot not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Validation failed. Body is an ActiveModel errors object keyed by field.
Response fields
  • error string optional Human-readable error message (shapes a and b). English, not localized, and not a stable contract — display it, do not parse it.
  • status object optional Present only in shape (b): a redundant copy of the HTTP status code as an integer (e.g. 422). Unreliable and often absent — rely on the HTTP status line instead.
  • errors object optional Shape (c). Either an object mapping attribute name → array of messages, or (on a few endpoints) a flat array of full-message strings.
application/json
{
  "error": "string",
  "status": {},
  "errors": {}
}
No description provided.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
issue_id required string
id required integer The screenshot ID.
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -X DELETE \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/issues/g-123/screenshots/123"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/issues/g-123/screenshots/123")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Delete.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.delete(
    "https://app.betahub.io/projects/123/issues/g-123/screenshots/123",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123/screenshots/123", {
  method: "DELETE",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123/screenshots/123"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .method("DELETE", HttpRequest.BodyPublishers.noBody())
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Screenshot deleted successfully (no content).
Screenshot deleted successfully (no content).
Forbidden.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Screenshot not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}

Log Files

List and upload log files for issues
Returns all log files attached to the specified issue. Developer-private log files are omitted for callers who are not developers or admins.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
issue_id required string
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/issues/g-123/log_files"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/issues/g-123/log_files")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.get(
    "https://app.betahub.io/projects/123/issues/g-123/log_files",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123/log_files", {
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123/log_files"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .GET()
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Successful response
application/json
[
  {
    "id": 0,
    "created_at": "2026-03-12T10:30:00Z",
    "updated_at": "2026-03-12T10:30:00Z",
    "media_size_bytes": 0,
    "type": "log_file",
    "size_bytes": 0,
    "content_type": "text/plain",
    "url": "https://example.com",
    "filename": "string",
    "developer_private": true,
    "user": {
      "id": 0,
      "name": "string"
    }
  }
]
Forbidden.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}

Upload a log file to an issue. Supports two methods:

Method 1: File upload — Send the file as multipart/form-data with log_file[file] field.

Method 2: Text content — Send log contents as a string via application/json, application/x-www-form-urlencoded, or multipart/form-data using the log_file[contents] field. The text will be stored as a .txt file. This is useful for clients that cannot perform file uploads (e.g., game engines with limited HTTP support).

Both methods support the optional log_file[name] field to set a custom filename.

Automatic binary reclassification: if the uploaded content is detected as binary (not text), it is stored as a binary file instead of a log file. In that case the 201 response is a BinaryFile record (rendered from binary_files/show), not a LogFile. This is subject to the 10-binary-files-per-issue limit (exceeding it returns 422).

Asynchronous redaction: after creation, a background job scans the stored log for configured sensitive-data patterns and redacts them in place. The file is initially stored unredacted with redaction_status: pending; redaction happens shortly after and is not reflected in this synchronous response.

Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
issue_id required string
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
Request Body
multipart/form-data
  • log_file[file] string binary optional The log file binary data. Required unless log_file[contents] is provided.
  • log_file[contents] string optional Log contents as a plain string. Use this instead of log_file[file] when file uploads are not possible. The text will be stored as a text/plain file.
  • log_file[name] string optional Optional custom filename for the log file (e.g., “error_log.txt”).
  • log_file[developer_private] boolean optional If true, only developers and admins can view this log file
    Default: false
application/json
  • log_file object optional
    • contents string required Log contents as a plain string. The text will be stored as a text/plain file.
    • name string optional Optional custom filename for the log file (e.g., “error_log.txt”). Defaults to “log_file.txt” if not provided.
    • developer_private boolean optional If true, only developers and admins can view this log file
      Default: false
application/x-www-form-urlencoded
  • log_file[contents] string optional Log contents as a plain string.
  • log_file[name] string optional Optional custom filename for the log file.
  • log_file[developer_private] boolean optional If true, only developers and admins can view this log file
    Default: false
cURL
curl \
  -X POST \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  -F "log_file[file]=@file.bin" \
  -F "log_file[contents]=@file.bin" \
  -F "log_file[name]=@file.bin" \
  -F "log_file[developer_private]=true" \
  "https://app.betahub.io/projects/123/issues/g-123/log_files"
Ruby
require "net/http"
require "json"

uri = URI("https://app.betahub.io/projects/123/issues/g-123/log_files")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.post(
    "https://app.betahub.io/projects/123/issues/g-123/log_files",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123/log_files", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123/log_files"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString("{\"log_file[file]\":\"string\",\"log_file[contents]\":\"string\",\"log_file[name]\":\"string\",\"log_file[developer_private]\":true}"))
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Request Body
{
  "log_file[file]": "string",
  "log_file[contents]": "string",
  "log_file[name]": "string",
  "log_file[developer_private]": true
}
Responses
Log file created successfully. If the content was auto-detected as binary, a BinaryFile record is returned instead of a LogFile (see the reclassification note above).
Response fields
  • id integer optional The ID of the log file
  • created_at string date-time optional Creation timestamp
  • updated_at string date-time optional Last update timestamp
  • media_size_bytes integer optional File size in bytes
  • type string optional Attachment type identifier
  • size_bytes integer optional File size in bytes (alias for media_size_bytes)
  • content_type string optional MIME type of the file
  • url string uri optional nullable URL to download the log file. Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
  • filename string optional nullable Filename of the uploaded file
  • developer_private boolean optional Whether this log file is only visible to developers and admins
    Default: false
  • user object optional nullable User who uploaded the file
    • id integer optional User ID
    • name string optional User display name
application/json
{
  "id": 0,
  "created_at": "2026-03-12T10:30:00Z",
  "updated_at": "2026-03-12T10:30:00Z",
  "media_size_bytes": 0,
  "type": "log_file",
  "size_bytes": 0,
  "content_type": "text/plain",
  "url": "https://example.com",
  "filename": "string",
  "developer_private": true,
  "user": {
    "id": 0,
    "name": "string"
  }
}
Validation failed
Response fields
  • error string optional Human-readable error message (shapes a and b). English, not localized, and not a stable contract — display it, do not parse it.
  • status object optional Present only in shape (b): a redundant copy of the HTTP status code as an integer (e.g. 422). Unreliable and often absent — rely on the HTTP status line instead.
  • errors object optional Shape (c). Either an object mapping attribute name → array of messages, or (on a few endpoints) a flat array of full-message strings.
application/json
{
  "error": "string",
  "status": {},
  "errors": {}
}
Unauthorized. Authentication required or invalid token provided.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Request a presigned URL for uploading a log file directly to S3. This is step 1 of the three-step direct upload flow (see “Direct file upload flow” in the API overview). After this call you must PUT the raw file bytes to the returned direct_upload_url, replaying the returned headers, before calling confirm_upload.
Authorization required
API token for accessing draft issues or performing direct uploads. Can be a JWT token returned from issue creation in draft mode, or other valid authorization tokens. Format: “Bearer TOKEN” or “FormUser tkn-TOKEN”
Path Parameters
Name Type Description
project_id required string
issue_id required string
Request Body
application/json
  • filename string required Name of the log file to upload
  • byte_size integer required Size of the file in bytes
  • checksum string required Base64-encoded MD5 checksum of the file
  • content_type string required MIME type of the log file. Log files accept a broad set of text, config, and archive formats; use application/octet-stream as a fallback for unrecognized types.
    text/plain text/log text/x-log text/csv text/html text/xml text/yaml text/x-yaml text/markdown text/x-markdown text/x-ini text/x-properties application/json application/xml application/yaml application/x-yaml application/octet-stream application/gzip application/x-gzip application/zip application/x-zip-compressed application/x-tar application/x-bzip2 application/x-7z-compressed
  • name string optional Optional display name for the log file
cURL
curl \
  -X POST \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "filename": "application.log",
  "byte_size": 2048000,
  "checksum": "1B2M2Y8AsgTpgAmY7PhCfg==",
  "content_type": "text/plain",
  "name": "Debug Log"
}' \
  "https://app.betahub.io/projects/123/issues/g-123/log_files/presigned_upload"
Ruby
require "net/http"
require "json"

uri = URI("https://app.betahub.io/projects/123/issues/g-123/log_files/presigned_upload")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["Content-Type"] = "application/json"
request.body = {
  "filename": "application.log",
  "byte_size": 2048000,
  "checksum": "1B2M2Y8AsgTpgAmY7PhCfg==",
  "content_type": "text/plain",
  "name": "Debug Log"
}

response = http.request(request)
puts response.body
Python
import requests

response = requests.post(
    "https://app.betahub.io/projects/123/issues/g-123/log_files/presigned_upload",
    headers={"Authorization": "Bearer YOUR_API_TOKEN"},
    json={
      "filename": "application.log",
      "byte_size": 2048000,
      "checksum": "1B2M2Y8AsgTpgAmY7PhCfg==",
      "content_type": "text/plain",
      "name": "Debug Log"
    }
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123/log_files/presigned_upload", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    "filename": "application.log",
    "byte_size": 2048000,
    "checksum": "1B2M2Y8AsgTpgAmY7PhCfg==",
    "content_type": "text/plain",
    "name": "Debug Log"
  })
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123/log_files/presigned_upload"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString("{\"filename\":\"application.log\",\"byte_size\":2048000,\"checksum\":\"1B2M2Y8AsgTpgAmY7PhCfg==\",\"content_type\":\"text/plain\",\"name\":\"Debug Log\"}"))
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Request Body
{
  "filename": "application.log",
  "byte_size": 2048000,
  "checksum": "1B2M2Y8AsgTpgAmY7PhCfg==",
  "content_type": "text/plain",
  "name": "Debug Log"
}
Responses
Presigned upload URL generated successfully
Response fields
  • blob_signed_id string required Signed ID of the blob to be used in confirmation
  • direct_upload_url string uri required S3 presigned URL for uploading the file
  • headers object required Headers to include with the upload request
    • «key» string optional
  • blob_id integer optional ID of the created blob
application/json
{
  "blob_signed_id": "string",
  "direct_upload_url": "https://example.com",
  "headers": {
    "key": "string"
  },
  "blob_id": 0
}
A required parameter is missing (filename, byte_size, checksum, or content_type). Body is a flat { "error": "<message>" }.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Forbidden — the caller is not a project member, has not accepted the project NDA, or is muted on the project (write access denied).
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Invalid content type, file exceeds the per-type size cap, or the checksum failed integrity verification. Body is a flat { "error": "<message>" }.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Internal server error while creating the presigned upload (e.g. storage service failure). Body is a flat { "error": "Internal server error" }.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Unauthorized. Authentication required or invalid token provided.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}

Confirm that the log file has been uploaded to S3 and attach it to the issue. This is the final step of the three-step direct upload flow (see “Direct file upload flow” in the API overview). Call it only after PUTting the file bytes to the direct_upload_url from presigned_upload. The 201 response is the raw attachment record (no download URL) — GET the media list endpoint to obtain url.

This endpoint has no HTML representation — it responds only to JSON and Turbo Stream, so a request with Accept: text/html returns 406 Not Acceptable.

Automatic binary reclassification: if the confirmed blob is detected as binary, it is stored as a binary file instead. In that case the 201 body is a wrapper object { "reclassified": true, "binary_file": { …BinaryFile jbuilder shape… } } rather than the raw log-file record. This is subject to the 10-binary-files-per-issue limit (exceeding it returns 422).

Asynchronous redaction: after creation, a background job scans the stored log for configured sensitive-data patterns and redacts them in place; the file is initially stored unredacted with redaction_status: pending.

Authorization required
API token for accessing draft issues or performing direct uploads. Can be a JWT token returned from issue creation in draft mode, or other valid authorization tokens. Format: “Bearer TOKEN” or “FormUser tkn-TOKEN”
Path Parameters
Name Type Description
project_id required string
issue_id required string
Request Body
application/json
  • blob_signed_id string required Signed ID of the blob received from presigned_upload
  • name string optional Optional display name for the log file
  • developer_private boolean optional If true, only developers and admins can view this log file
    Default: false
cURL
curl \
  -X POST \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "blob_signed_id": "string",
  "name": "Debug Log",
  "developer_private": true
}' \
  "https://app.betahub.io/projects/123/issues/g-123/log_files/confirm_upload"
Ruby
require "net/http"
require "json"

uri = URI("https://app.betahub.io/projects/123/issues/g-123/log_files/confirm_upload")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["Content-Type"] = "application/json"
request.body = {
  "blob_signed_id": "string",
  "name": "Debug Log",
  "developer_private": true
}

response = http.request(request)
puts response.body
Python
import requests

response = requests.post(
    "https://app.betahub.io/projects/123/issues/g-123/log_files/confirm_upload",
    headers={"Authorization": "Bearer YOUR_API_TOKEN"},
    json={
      "blob_signed_id": "string",
      "name": "Debug Log",
      "developer_private": true
    }
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123/log_files/confirm_upload", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    "blob_signed_id": "string",
    "name": "Debug Log",
    "developer_private": true
  })
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123/log_files/confirm_upload"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString("{\"blob_signed_id\":\"string\",\"name\":\"Debug Log\",\"developer_private\":true}"))
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Request Body
{
  "blob_signed_id": "string",
  "name": "Debug Log",
  "developer_private": true
}
Responses
Log file created and attached successfully. If the content was auto-detected as binary, the body is a reclassification wrapper ({ "reclassified": true, "binary_file": { … } }) instead of the raw log-file record.
Response fields
  • id integer optional The ID of the log file
  • created_at string date-time optional Creation timestamp
  • updated_at string date-time optional Last update timestamp
  • media_size_bytes integer optional File size in bytes
  • type string optional Attachment type identifier
  • size_bytes integer optional File size in bytes (alias for media_size_bytes)
  • content_type string optional MIME type of the file
  • url string uri optional nullable URL to download the log file. Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
  • filename string optional nullable Filename of the uploaded file
  • developer_private boolean optional Whether this log file is only visible to developers and admins
    Default: false
  • user object optional nullable User who uploaded the file
    • id integer optional User ID
    • name string optional User display name
  • reclassified boolean optional
  • binary_file object optional Shape returned by the GET list and GET {id} (show) endpoints, rendered from the _binary_file.json.jbuilder view. NOTE: the confirm_upload 201 response instead returns the raw ActiveRecord record (integer id/issue_id/user_id, media_size_bytes, developer_private, timestamps) and does NOT include type, size_bytes, content_type, url, filename or the nested user object. GET the media list/show endpoint afterwards to obtain url.
    • id integer optional The ID of the binary file
    • created_at string date-time optional Creation timestamp
    • updated_at string date-time optional Last update timestamp
    • media_size_bytes integer optional File size in bytes
    • type string optional Attachment type identifier
    • size_bytes integer optional File size in bytes (alias for media_size_bytes)
    • content_type string optional MIME type of the file
    • url string uri optional nullable URL to download the binary file. Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
    • filename string optional nullable Filename of the uploaded file
    • developer_private boolean optional Whether this binary file is only visible to developers and admins
      Default: false
    • user object optional nullable User who uploaded the file
      • id integer optional User ID
      • name string optional User display name
application/json
{
  "id": 0,
  "created_at": "2026-03-12T10:30:00Z",
  "updated_at": "2026-03-12T10:30:00Z",
  "media_size_bytes": 0,
  "type": "log_file",
  "size_bytes": 0,
  "content_type": "text/plain",
  "url": "https://example.com",
  "filename": "string",
  "developer_private": true,
  "user": {
    "id": 0,
    "name": "string"
  },
  "reclassified": true,
  "binary_file": {
    "id": 0,
    "created_at": "2026-03-12T10:30:00Z",
    "updated_at": "2026-03-12T10:30:00Z",
    "media_size_bytes": 0,
    "type": "binary_file",
    "size_bytes": 0,
    "content_type": "application/octet-stream",
    "url": "https://example.com",
    "filename": "string",
    "developer_private": true,
    "user": {
      "id": 0,
      "name": "string"
    }
  }
}
Missing blob_signed_id parameter. Body is a flat { "error": "<message>" }.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Forbidden — the caller is not a project member, has not accepted the project NDA, or is muted on the project (write access denied).
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
The blob_signed_id signature is invalid/expired, or no blob was found for it. Body is a flat { "error": "<message>" }.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
The per-issue file-count limit was reached, the file was not actually uploaded to storage before confirming, or the record failed model validation. The count-limit and not-uploaded cases return a flat { "error": "<message>" }; model-validation failures instead return { "errors": { "<field>": ["<message>"] } }.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Unauthorized. Authentication required or invalid token provided.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Returns a single log file. The ID is resolved across the issue and its merged duplicates. Developer-private log files are only visible to developers and admins.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
issue_id required string
id required integer The log file ID.
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/issues/g-123/log_files/123"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/issues/g-123/log_files/123")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.get(
    "https://app.betahub.io/projects/123/issues/g-123/log_files/123",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123/log_files/123", {
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123/log_files/123"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .GET()
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Successful response
Response fields
  • id integer optional The ID of the log file
  • created_at string date-time optional Creation timestamp
  • updated_at string date-time optional Last update timestamp
  • media_size_bytes integer optional File size in bytes
  • type string optional Attachment type identifier
  • size_bytes integer optional File size in bytes (alias for media_size_bytes)
  • content_type string optional MIME type of the file
  • url string uri optional nullable URL to download the log file. Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
  • filename string optional nullable Filename of the uploaded file
  • developer_private boolean optional Whether this log file is only visible to developers and admins
    Default: false
  • user object optional nullable User who uploaded the file
    • id integer optional User ID
    • name string optional User display name
application/json
{
  "id": 0,
  "created_at": "2026-03-12T10:30:00Z",
  "updated_at": "2026-03-12T10:30:00Z",
  "media_size_bytes": 0,
  "type": "log_file",
  "size_bytes": 0,
  "content_type": "text/plain",
  "url": "https://example.com",
  "filename": "string",
  "developer_private": true,
  "user": {
    "id": 0,
    "name": "string"
  }
}
Forbidden.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Log file not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Deletes a log file. Users who are muted on the project are still allowed to delete their own uploads.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
issue_id required string
id required integer The log file ID.
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -X DELETE \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/issues/g-123/log_files/123"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/issues/g-123/log_files/123")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Delete.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.delete(
    "https://app.betahub.io/projects/123/issues/g-123/log_files/123",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123/log_files/123", {
  method: "DELETE",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123/log_files/123"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .method("DELETE", HttpRequest.BodyPublishers.noBody())
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Log file deleted successfully (no content).
Log file deleted successfully (no content).
Forbidden.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Log file not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Redirects (302) to the file’s CDN (CloudFront) download URL. The redirect targets a different host, so clients must follow cross-origin redirects.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
issue_id required string
id required integer The log file ID.
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/issues/g-123/log_files/123/download"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/issues/g-123/log_files/123/download")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.get(
    "https://app.betahub.io/projects/123/issues/g-123/log_files/123/download",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123/log_files/123/download", {
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123/log_files/123/download"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .GET()
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Redirect to the CDN download URL for the file.
Headers
  • Location string CDN (CloudFront) URL of the file.
Redirect to the CDN download URL for the file.
Forbidden.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Log file not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}

Binary Files

List and upload binary files for issues
Returns all binary files attached to the specified issue. Developer-private binary files are omitted for callers who are not developers or admins.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
issue_id required string
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/issues/g-123/binary_files"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/issues/g-123/binary_files")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.get(
    "https://app.betahub.io/projects/123/issues/g-123/binary_files",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123/binary_files", {
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123/binary_files"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .GET()
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Successful response
application/json
[
  {
    "id": 0,
    "created_at": "2026-03-12T10:30:00Z",
    "updated_at": "2026-03-12T10:30:00Z",
    "media_size_bytes": 0,
    "type": "binary_file",
    "size_bytes": 0,
    "content_type": "application/octet-stream",
    "url": "https://example.com",
    "filename": "string",
    "developer_private": true,
    "user": {
      "id": 0,
      "name": "string"
    }
  }
]
Forbidden.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}

Upload a binary file to an issue using multipart/form-data with binary_file[file] field.

The optional binary_file[name] field can be used to set a custom filename.

Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
issue_id required string
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
Request Body
multipart/form-data
  • binary_file[file] string binary optional The binary file data.
  • binary_file[name] string optional Optional custom filename for the binary file.
  • binary_file[developer_private] boolean optional If true, only developers and admins can view this binary file
    Default: false
cURL
curl \
  -X POST \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  -F "binary_file[file]=@file.bin" \
  -F "binary_file[name]=@file.bin" \
  -F "binary_file[developer_private]=true" \
  "https://app.betahub.io/projects/123/issues/g-123/binary_files"
Ruby
require "net/http"
require "json"

uri = URI("https://app.betahub.io/projects/123/issues/g-123/binary_files")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.post(
    "https://app.betahub.io/projects/123/issues/g-123/binary_files",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123/binary_files", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123/binary_files"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString("{\"binary_file[file]\":\"string\",\"binary_file[name]\":\"string\",\"binary_file[developer_private]\":true}"))
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Request Body
{
  "binary_file[file]": "string",
  "binary_file[name]": "string",
  "binary_file[developer_private]": true
}
Responses
Binary file created successfully
Response fields
  • id integer optional The ID of the binary file
  • created_at string date-time optional Creation timestamp
  • updated_at string date-time optional Last update timestamp
  • media_size_bytes integer optional File size in bytes
  • type string optional Attachment type identifier
  • size_bytes integer optional File size in bytes (alias for media_size_bytes)
  • content_type string optional MIME type of the file
  • url string uri optional nullable URL to download the binary file. Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
  • filename string optional nullable Filename of the uploaded file
  • developer_private boolean optional Whether this binary file is only visible to developers and admins
    Default: false
  • user object optional nullable User who uploaded the file
    • id integer optional User ID
    • name string optional User display name
application/json
{
  "id": 0,
  "created_at": "2026-03-12T10:30:00Z",
  "updated_at": "2026-03-12T10:30:00Z",
  "media_size_bytes": 0,
  "type": "binary_file",
  "size_bytes": 0,
  "content_type": "application/octet-stream",
  "url": "https://example.com",
  "filename": "string",
  "developer_private": true,
  "user": {
    "id": 0,
    "name": "string"
  }
}
Validation failed
Response fields
  • error string optional Human-readable error message (shapes a and b). English, not localized, and not a stable contract — display it, do not parse it.
  • status object optional Present only in shape (b): a redundant copy of the HTTP status code as an integer (e.g. 422). Unreliable and often absent — rely on the HTTP status line instead.
  • errors object optional Shape (c). Either an object mapping attribute name → array of messages, or (on a few endpoints) a flat array of full-message strings.
application/json
{
  "error": "string",
  "status": {},
  "errors": {}
}
Unauthorized. Authentication required or invalid token provided.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Request a presigned URL for uploading a binary file directly to S3. This is step 1 of the three-step direct upload flow (see “Direct file upload flow” in the API overview). After this call you must PUT the raw file bytes to the returned direct_upload_url, replaying the returned headers, before calling confirm_upload.
Authorization required
API token for accessing draft issues or performing direct uploads. Can be a JWT token returned from issue creation in draft mode, or other valid authorization tokens. Format: “Bearer TOKEN” or “FormUser tkn-TOKEN”
Path Parameters
Name Type Description
project_id required string
issue_id required string
Request Body
application/json
  • filename string required Name of the binary file to upload
  • byte_size integer required Size of the file in bytes
  • checksum string required Base64-encoded MD5 checksum of the file
  • content_type string required MIME type of the binary file. Binary files accept ANY content type (server-side content-type validation is bypassed), so declare the file’s true MIME type rather than forcing application/octet-stream.
  • name string optional Optional display name for the binary file
cURL
curl \
  -X POST \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "filename": "crashdump.bin",
  "byte_size": 2048000,
  "checksum": "1B2M2Y8AsgTpgAmY7PhCfg==",
  "content_type": "application/octet-stream",
  "name": "Crash Dump"
}' \
  "https://app.betahub.io/projects/123/issues/g-123/binary_files/presigned_upload"
Ruby
require "net/http"
require "json"

uri = URI("https://app.betahub.io/projects/123/issues/g-123/binary_files/presigned_upload")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["Content-Type"] = "application/json"
request.body = {
  "filename": "crashdump.bin",
  "byte_size": 2048000,
  "checksum": "1B2M2Y8AsgTpgAmY7PhCfg==",
  "content_type": "application/octet-stream",
  "name": "Crash Dump"
}

response = http.request(request)
puts response.body
Python
import requests

response = requests.post(
    "https://app.betahub.io/projects/123/issues/g-123/binary_files/presigned_upload",
    headers={"Authorization": "Bearer YOUR_API_TOKEN"},
    json={
      "filename": "crashdump.bin",
      "byte_size": 2048000,
      "checksum": "1B2M2Y8AsgTpgAmY7PhCfg==",
      "content_type": "application/octet-stream",
      "name": "Crash Dump"
    }
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123/binary_files/presigned_upload", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    "filename": "crashdump.bin",
    "byte_size": 2048000,
    "checksum": "1B2M2Y8AsgTpgAmY7PhCfg==",
    "content_type": "application/octet-stream",
    "name": "Crash Dump"
  })
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123/binary_files/presigned_upload"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString("{\"filename\":\"crashdump.bin\",\"byte_size\":2048000,\"checksum\":\"1B2M2Y8AsgTpgAmY7PhCfg==\",\"content_type\":\"application/octet-stream\",\"name\":\"Crash Dump\"}"))
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Request Body
{
  "filename": "crashdump.bin",
  "byte_size": 2048000,
  "checksum": "1B2M2Y8AsgTpgAmY7PhCfg==",
  "content_type": "application/octet-stream",
  "name": "Crash Dump"
}
Responses
Presigned upload URL generated successfully
Response fields
  • blob_signed_id string required Signed ID of the blob to be used in confirmation
  • direct_upload_url string uri required S3 presigned URL for uploading the file
  • headers object required Headers to include with the upload request
    • «key» string optional
  • blob_id integer optional ID of the created blob
application/json
{
  "blob_signed_id": "string",
  "direct_upload_url": "https://example.com",
  "headers": {
    "key": "string"
  },
  "blob_id": 0
}
A required parameter is missing (filename, byte_size, checksum, or content_type). Body is a flat { "error": "<message>" }.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Forbidden — the caller is not a project member, has not accepted the project NDA, or is muted on the project (write access denied).
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Invalid content type, file exceeds the per-type size cap, or the checksum failed integrity verification. Body is a flat { "error": "<message>" }.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Internal server error while creating the presigned upload (e.g. storage service failure). Body is a flat { "error": "Internal server error" }.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Unauthorized. Authentication required or invalid token provided.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}

Confirm that the binary file has been uploaded to S3 and attach it to the issue. This is the final step of the three-step direct upload flow (see “Direct file upload flow” in the API overview). Call it only after PUTting the file bytes to the direct_upload_url from presigned_upload. The 201 response is the raw attachment record (no download URL) — GET the media list endpoint to obtain url.

This endpoint has no HTML representation — it responds only to JSON and Turbo Stream, so a request with Accept: text/html returns 406 Not Acceptable.

Authorization required
API token for accessing draft issues or performing direct uploads. Can be a JWT token returned from issue creation in draft mode, or other valid authorization tokens. Format: “Bearer TOKEN” or “FormUser tkn-TOKEN”
Path Parameters
Name Type Description
project_id required string
issue_id required string
Request Body
application/json
  • blob_signed_id string required Signed ID of the blob received from presigned_upload
  • name string optional Optional display name for the binary file
  • developer_private boolean optional If true, only developers and admins can view this binary file
    Default: false
cURL
curl \
  -X POST \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "blob_signed_id": "string",
  "name": "Crash Dump",
  "developer_private": true
}' \
  "https://app.betahub.io/projects/123/issues/g-123/binary_files/confirm_upload"
Ruby
require "net/http"
require "json"

uri = URI("https://app.betahub.io/projects/123/issues/g-123/binary_files/confirm_upload")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["Content-Type"] = "application/json"
request.body = {
  "blob_signed_id": "string",
  "name": "Crash Dump",
  "developer_private": true
}

response = http.request(request)
puts response.body
Python
import requests

response = requests.post(
    "https://app.betahub.io/projects/123/issues/g-123/binary_files/confirm_upload",
    headers={"Authorization": "Bearer YOUR_API_TOKEN"},
    json={
      "blob_signed_id": "string",
      "name": "Crash Dump",
      "developer_private": true
    }
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123/binary_files/confirm_upload", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    "blob_signed_id": "string",
    "name": "Crash Dump",
    "developer_private": true
  })
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123/binary_files/confirm_upload"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString("{\"blob_signed_id\":\"string\",\"name\":\"Crash Dump\",\"developer_private\":true}"))
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Request Body
{
  "blob_signed_id": "string",
  "name": "Crash Dump",
  "developer_private": true
}
Responses
Binary file created and attached successfully
Response fields
  • id integer optional The ID of the binary file
  • created_at string date-time optional Creation timestamp
  • updated_at string date-time optional Last update timestamp
  • media_size_bytes integer optional File size in bytes
  • type string optional Attachment type identifier
  • size_bytes integer optional File size in bytes (alias for media_size_bytes)
  • content_type string optional MIME type of the file
  • url string uri optional nullable URL to download the binary file. Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
  • filename string optional nullable Filename of the uploaded file
  • developer_private boolean optional Whether this binary file is only visible to developers and admins
    Default: false
  • user object optional nullable User who uploaded the file
    • id integer optional User ID
    • name string optional User display name
application/json
{
  "id": 0,
  "created_at": "2026-03-12T10:30:00Z",
  "updated_at": "2026-03-12T10:30:00Z",
  "media_size_bytes": 0,
  "type": "binary_file",
  "size_bytes": 0,
  "content_type": "application/octet-stream",
  "url": "https://example.com",
  "filename": "string",
  "developer_private": true,
  "user": {
    "id": 0,
    "name": "string"
  }
}
Missing blob_signed_id parameter. Body is a flat { "error": "<message>" }.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Forbidden — the caller is not a project member, has not accepted the project NDA, or is muted on the project (write access denied).
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
The blob_signed_id signature is invalid/expired, or no blob was found for it. Body is a flat { "error": "<message>" }.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
The per-issue file-count limit was reached, the file was not actually uploaded to storage before confirming, or the record failed model validation. The count-limit and not-uploaded cases return a flat { "error": "<message>" }; model-validation failures instead return { "errors": { "<field>": ["<message>"] } }.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Unauthorized. Authentication required or invalid token provided.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Returns a single binary file. The ID is resolved across the issue and its merged duplicates. Developer-private binary files are only visible to developers and admins.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
issue_id required string
id required integer The binary file ID.
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/issues/g-123/binary_files/123"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/issues/g-123/binary_files/123")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.get(
    "https://app.betahub.io/projects/123/issues/g-123/binary_files/123",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123/binary_files/123", {
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123/binary_files/123"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .GET()
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Successful response
Response fields
  • id integer optional The ID of the binary file
  • created_at string date-time optional Creation timestamp
  • updated_at string date-time optional Last update timestamp
  • media_size_bytes integer optional File size in bytes
  • type string optional Attachment type identifier
  • size_bytes integer optional File size in bytes (alias for media_size_bytes)
  • content_type string optional MIME type of the file
  • url string uri optional nullable URL to download the binary file. Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
  • filename string optional nullable Filename of the uploaded file
  • developer_private boolean optional Whether this binary file is only visible to developers and admins
    Default: false
  • user object optional nullable User who uploaded the file
    • id integer optional User ID
    • name string optional User display name
application/json
{
  "id": 0,
  "created_at": "2026-03-12T10:30:00Z",
  "updated_at": "2026-03-12T10:30:00Z",
  "media_size_bytes": 0,
  "type": "binary_file",
  "size_bytes": 0,
  "content_type": "application/octet-stream",
  "url": "https://example.com",
  "filename": "string",
  "developer_private": true,
  "user": {
    "id": 0,
    "name": "string"
  }
}
Forbidden.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Binary file not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
No description provided.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
issue_id required string
id required integer The binary file ID.
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -X DELETE \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/issues/g-123/binary_files/123"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/issues/g-123/binary_files/123")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Delete.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.delete(
    "https://app.betahub.io/projects/123/issues/g-123/binary_files/123",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123/binary_files/123", {
  method: "DELETE",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123/binary_files/123"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .method("DELETE", HttpRequest.BodyPublishers.noBody())
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Binary file deleted successfully (no content).
Binary file deleted successfully (no content).
Forbidden.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Binary file not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Redirects (302) to the file’s CDN (CloudFront) download URL. The redirect targets a different host, so clients must follow cross-origin redirects.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
issue_id required string
id required integer The binary file ID.
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/issues/g-123/binary_files/123/download"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/issues/g-123/binary_files/123/download")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.get(
    "https://app.betahub.io/projects/123/issues/g-123/binary_files/123/download",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123/binary_files/123/download", {
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123/binary_files/123/download"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .GET()
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Redirect to the CDN download URL for the file.
Headers
  • Location string CDN (CloudFront) URL of the file.
Redirect to the CDN download URL for the file.
Forbidden.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Binary file not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}

Video Clips

List and upload video clips for issues
Returns all video clips attached to the specified issue. Developer-private video clips are omitted for callers who are not developers or admins.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
issue_id required string
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/issues/g-123/video_clips"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/issues/g-123/video_clips")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.get(
    "https://app.betahub.io/projects/123/issues/g-123/video_clips",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123/video_clips", {
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123/video_clips"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .GET()
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Successful response
application/json
[
  {
    "id": 0,
    "type": "video_clip",
    "processing": true,
    "processed": true,
    "failed": true,
    "size_bytes": 0,
    "media_size_bytes": 0,
    "content_type": "string",
    "url": "https://example.com",
    "filename": "string",
    "created_at": "2026-03-12T10:30:00Z",
    "updated_at": "2026-03-12T10:30:00Z",
    "user": {
      "id": 0,
      "name": "string"
    }
  }
]
Forbidden.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
No description provided.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
issue_id required string
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
Request Body
multipart/form-data
  • video_clip[video] string binary optional The video clip file
  • video_clip[name] string optional The name of the video clip file. If set, the file will be saved with this name.
  • video_clip[developer_private] boolean optional If true, only developers and admins can view this video clip
    Default: false
cURL
curl \
  -X POST \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  -F "video_clip[video]=@file.bin" \
  -F "video_clip[name]=string" \
  -F "video_clip[developer_private]=true" \
  "https://app.betahub.io/projects/123/issues/g-123/video_clips"
Ruby
require "net/http"
require "json"

uri = URI("https://app.betahub.io/projects/123/issues/g-123/video_clips")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.post(
    "https://app.betahub.io/projects/123/issues/g-123/video_clips",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123/video_clips", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123/video_clips"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString("{\"video_clip[video]\":\"string\",\"video_clip[name]\":\"string\",\"video_clip[developer_private]\":true}"))
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Request Body
{
  "video_clip[video]": "string",
  "video_clip[name]": "string",
  "video_clip[developer_private]": true
}
Responses
Successful response
Response fields
  • id string optional
  • issue_id string optional
  • created_at string date-time optional
  • updated_at string date-time optional
  • processing boolean optional
  • processed boolean optional
  • failed boolean optional
Examples
Example Request
{
  "video_clip[video]": "(binary data representing a video file)"
}
Example Response
{
  "id": "1213jkl",
  "issue_id": "1234abc",
  "created_at": "2024-10-03T14:30:40Z",
  "updated_at": "2024-10-03T14:30:40Z",
  "processing": true,
  "processed": false,
  "failed": false
}
Request a presigned URL for uploading a video clip directly to S3. This is step 1 of the three-step direct upload flow (see “Direct file upload flow” in the API overview). After this call you must PUT the raw file bytes to the returned direct_upload_url, replaying the returned headers, before calling confirm_upload.
Authorization required
API token for accessing draft issues or performing direct uploads. Can be a JWT token returned from issue creation in draft mode, or other valid authorization tokens. Format: “Bearer TOKEN” or “FormUser tkn-TOKEN”
Path Parameters
Name Type Description
project_id required string
issue_id required string
Request Body
application/json
  • filename string required Name of the video file to upload
  • byte_size integer required Size of the file in bytes
  • checksum string required Base64-encoded MD5 checksum of the file
  • content_type string required MIME type of the video file
    video/mp4 video/quicktime video/webm video/avi video/mov
  • name string optional Optional display name for the video clip file
cURL
curl \
  -X POST \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "filename": "gameplay.mp4",
  "byte_size": 52428800,
  "checksum": "1B2M2Y8AsgTpgAmY7PhCfg==",
  "content_type": "video/mp4",
  "name": "Boss Fight Gameplay"
}' \
  "https://app.betahub.io/projects/123/issues/g-123/video_clips/presigned_upload"
Ruby
require "net/http"
require "json"

uri = URI("https://app.betahub.io/projects/123/issues/g-123/video_clips/presigned_upload")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["Content-Type"] = "application/json"
request.body = {
  "filename": "gameplay.mp4",
  "byte_size": 52428800,
  "checksum": "1B2M2Y8AsgTpgAmY7PhCfg==",
  "content_type": "video/mp4",
  "name": "Boss Fight Gameplay"
}

response = http.request(request)
puts response.body
Python
import requests

response = requests.post(
    "https://app.betahub.io/projects/123/issues/g-123/video_clips/presigned_upload",
    headers={"Authorization": "Bearer YOUR_API_TOKEN"},
    json={
      "filename": "gameplay.mp4",
      "byte_size": 52428800,
      "checksum": "1B2M2Y8AsgTpgAmY7PhCfg==",
      "content_type": "video/mp4",
      "name": "Boss Fight Gameplay"
    }
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123/video_clips/presigned_upload", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    "filename": "gameplay.mp4",
    "byte_size": 52428800,
    "checksum": "1B2M2Y8AsgTpgAmY7PhCfg==",
    "content_type": "video/mp4",
    "name": "Boss Fight Gameplay"
  })
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123/video_clips/presigned_upload"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString("{\"filename\":\"gameplay.mp4\",\"byte_size\":52428800,\"checksum\":\"1B2M2Y8AsgTpgAmY7PhCfg==\",\"content_type\":\"video/mp4\",\"name\":\"Boss Fight Gameplay\"}"))
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Request Body
{
  "filename": "gameplay.mp4",
  "byte_size": 52428800,
  "checksum": "1B2M2Y8AsgTpgAmY7PhCfg==",
  "content_type": "video/mp4",
  "name": "Boss Fight Gameplay"
}
Responses
Presigned upload URL generated successfully
Response fields
  • blob_signed_id string required Signed ID of the blob to be used in confirmation
  • direct_upload_url string uri required S3 presigned URL for uploading the file
  • headers object required Headers to include with the upload request
    • «key» string optional
  • blob_id integer optional ID of the created blob
application/json
{
  "blob_signed_id": "string",
  "direct_upload_url": "https://example.com",
  "headers": {
    "key": "string"
  },
  "blob_id": 0
}
A required parameter is missing (filename, byte_size, checksum, or content_type). Body is a flat { "error": "<message>" }.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Forbidden — the caller is not a project member, has not accepted the project NDA, or is muted on the project (write access denied).
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Invalid content type, file exceeds the per-type size cap, or the checksum failed integrity verification. Body is a flat { "error": "<message>" }.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Internal server error while creating the presigned upload (e.g. storage service failure). Body is a flat { "error": "Internal server error" }.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Unauthorized. Authentication required or invalid token provided.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}

Confirm that the video file has been uploaded to S3 and attach it to the issue. This is the final step of the three-step direct upload flow (see “Direct file upload flow” in the API overview). Call it only after PUTting the file bytes to the direct_upload_url from presigned_upload. The 201 response is the raw attachment record (no download URL) — GET the media list endpoint to obtain url.

This endpoint has no HTML representation — it responds only to JSON and Turbo Stream, so a request with Accept: text/html returns 406 Not Acceptable.

Authorization required
API token for accessing draft issues or performing direct uploads. Can be a JWT token returned from issue creation in draft mode, or other valid authorization tokens. Format: “Bearer TOKEN” or “FormUser tkn-TOKEN”
Path Parameters
Name Type Description
project_id required string
issue_id required string
Request Body
application/json
  • blob_signed_id string required Signed ID of the blob received from presigned_upload
  • name string optional Optional display name for the video clip file
  • developer_private boolean optional If true, only developers and admins can view this video clip
    Default: false
cURL
curl \
  -X POST \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "blob_signed_id": "string",
  "name": "Boss Fight Gameplay",
  "developer_private": true
}' \
  "https://app.betahub.io/projects/123/issues/g-123/video_clips/confirm_upload"
Ruby
require "net/http"
require "json"

uri = URI("https://app.betahub.io/projects/123/issues/g-123/video_clips/confirm_upload")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["Content-Type"] = "application/json"
request.body = {
  "blob_signed_id": "string",
  "name": "Boss Fight Gameplay",
  "developer_private": true
}

response = http.request(request)
puts response.body
Python
import requests

response = requests.post(
    "https://app.betahub.io/projects/123/issues/g-123/video_clips/confirm_upload",
    headers={"Authorization": "Bearer YOUR_API_TOKEN"},
    json={
      "blob_signed_id": "string",
      "name": "Boss Fight Gameplay",
      "developer_private": true
    }
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123/video_clips/confirm_upload", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    "blob_signed_id": "string",
    "name": "Boss Fight Gameplay",
    "developer_private": true
  })
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123/video_clips/confirm_upload"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString("{\"blob_signed_id\":\"string\",\"name\":\"Boss Fight Gameplay\",\"developer_private\":true}"))
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Request Body
{
  "blob_signed_id": "string",
  "name": "Boss Fight Gameplay",
  "developer_private": true
}
Responses
Video clip created and attached successfully
Response fields
  • id integer optional The ID of the video clip
  • type string optional Attachment type identifier
  • processing boolean optional Whether the video is currently being transcoded/processed by a background job.
  • processed boolean optional Whether the clip is ready for inline web playback. For multipart uploads this becomes true only if the source is web-compatible AND under the organization’s max video length; otherwise a background job transcodes it first. Direct (presigned) uploads are marked processed=true immediately on confirm, without transcoding or a length check (see VideoClip model before_save).
  • failed boolean optional Whether video processing has failed
  • size_bytes integer optional File size in bytes (alias for media_size_bytes)
  • media_size_bytes integer optional File size in bytes
  • content_type string optional nullable MIME type of the video (null when no video is attached)
  • url string uri optional nullable CDN URL to access the video clip (null when no video is attached). Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
  • filename string optional nullable Filename of the uploaded video (null when no video is attached)
  • developer_private boolean optional Whether this video clip is only visible to developers and admins
    Default: false
  • created_at string date-time optional Creation timestamp
  • updated_at string date-time optional Last update timestamp
  • user object optional nullable User who uploaded the video clip (null for anonymous/reporter fallback)
    • id integer optional User ID
    • name string optional Player-facing display name
application/json
{
  "id": 0,
  "type": "video_clip",
  "processing": true,
  "processed": true,
  "failed": true,
  "size_bytes": 0,
  "media_size_bytes": 0,
  "content_type": "string",
  "url": "https://example.com",
  "filename": "string",
  "developer_private": true,
  "created_at": "2026-03-12T10:30:00Z",
  "updated_at": "2026-03-12T10:30:00Z",
  "user": {
    "id": 0,
    "name": "string"
  }
}
Missing blob_signed_id parameter. Body is a flat { "error": "<message>" }.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Forbidden — the caller is not a project member, has not accepted the project NDA, or is muted on the project (write access denied).
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
The blob_signed_id signature is invalid/expired, or no blob was found for it. Body is a flat { "error": "<message>" }.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
The per-issue file-count limit was reached, the file was not actually uploaded to storage before confirming, or the record failed model validation. The count-limit and not-uploaded cases return a flat { "error": "<message>" }; model-validation failures instead return { "errors": { "<field>": ["<message>"] } }.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Unauthorized. Authentication required or invalid token provided.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Returns a single video clip. The ID is resolved across the issue and its merged duplicates. Developer-private video clips are only visible to developers and admins.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
issue_id required string
id required integer The video clip ID.
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/issues/g-123/video_clips/123"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/issues/g-123/video_clips/123")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.get(
    "https://app.betahub.io/projects/123/issues/g-123/video_clips/123",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123/video_clips/123", {
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123/video_clips/123"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .GET()
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Successful response
Response fields
  • id integer optional The ID of the video clip
  • type string optional Attachment type identifier
  • processing boolean optional Whether the video is currently being transcoded/processed by a background job.
  • processed boolean optional Whether the clip is ready for inline web playback. For multipart uploads this becomes true only if the source is web-compatible AND under the organization’s max video length; otherwise a background job transcodes it first. Direct (presigned) uploads are marked processed=true immediately on confirm, without transcoding or a length check (see VideoClip model before_save).
  • failed boolean optional Whether video processing has failed
  • size_bytes integer optional File size in bytes (alias for media_size_bytes)
  • media_size_bytes integer optional File size in bytes
  • content_type string optional nullable MIME type of the video (null when no video is attached)
  • url string uri optional nullable CDN URL to access the video clip (null when no video is attached). Unsigned CDN path, fetched directly with no additional API authentication; developer_private controls only whether this URL appears in the listing, not access to the URL once known.
  • filename string optional nullable Filename of the uploaded video (null when no video is attached)
  • developer_private boolean optional Whether this video clip is only visible to developers and admins
    Default: false
  • created_at string date-time optional Creation timestamp
  • updated_at string date-time optional Last update timestamp
  • user object optional nullable User who uploaded the video clip (null for anonymous/reporter fallback)
    • id integer optional User ID
    • name string optional Player-facing display name
application/json
{
  "id": 0,
  "type": "video_clip",
  "processing": true,
  "processed": true,
  "failed": true,
  "size_bytes": 0,
  "media_size_bytes": 0,
  "content_type": "string",
  "url": "https://example.com",
  "filename": "string",
  "developer_private": true,
  "created_at": "2026-03-12T10:30:00Z",
  "updated_at": "2026-03-12T10:30:00Z",
  "user": {
    "id": 0,
    "name": "string"
  }
}
Forbidden.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Video clip not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
No description provided.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
issue_id required string
id required integer The video clip ID.
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -X DELETE \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/issues/g-123/video_clips/123"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/issues/g-123/video_clips/123")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Delete.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.delete(
    "https://app.betahub.io/projects/123/issues/g-123/video_clips/123",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123/video_clips/123", {
  method: "DELETE",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123/video_clips/123"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .method("DELETE", HttpRequest.BodyPublishers.noBody())
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Video clip deleted successfully (no content).
Video clip deleted successfully (no content).
Forbidden.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Video clip not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}

Client Events

Append client/SDK-side runtime events (logs) to an issue

Records a single client/SDK-side runtime event (a structured log line) against an issue. Intended for game clients and SDK integrations to attach diagnostic events to a report they own.

Authentication is a submission-form (SDK) FormUser token — the same Authorization: FormUser tkn-... credential used for submissions. The token must belong to the project named in the path; a token for a different project is rejected with 403, and a request that is not authenticated as a submission-form user is rejected with 401.

At most 100 client events may be stored per issue; once that cap is reached, further submissions fail validation with 422.

Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
issue_id required string
Request Body
application/json
  • client_event object required
    • level string required Severity level of the event. Required.
      info success warning error
    • message string required The event message / log text. Required.
    • event_type string optional nullable Optional caller-defined event type/category (free-form string).
    • metadata object optional nullable Optional free-form JSON object of additional structured data to store alongside the event.
cURL
curl \
  -X POST \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "client_event": {
    "level": "info",
    "message": "string",
    "event_type": "string",
    "metadata": {}
  }
}' \
  "https://app.betahub.io/projects/123/issues/g-123/client_events"
Ruby
require "net/http"
require "json"

uri = URI("https://app.betahub.io/projects/123/issues/g-123/client_events")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["Content-Type"] = "application/json"
request.body = {
  "client_event": {
    "level": "info",
    "message": "string",
    "event_type": "string",
    "metadata": {}
  }
}

response = http.request(request)
puts response.body
Python
import requests

response = requests.post(
    "https://app.betahub.io/projects/123/issues/g-123/client_events",
    headers={"Authorization": "Bearer YOUR_API_TOKEN"},
    json={
      "client_event": {
        "level": "info",
        "message": "string",
        "event_type": "string",
        "metadata": {}
      }
    }
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issues/g-123/client_events", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    "client_event": {
      "level": "info",
      "message": "string",
      "event_type": "string",
      "metadata": {}
    }
  })
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issues/g-123/client_events"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString("{\"client_event\":{\"level\":\"info\",\"message\":\"string\",\"event_type\":\"string\",\"metadata\":{}}}"))
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Request Body
{
  "client_event": {
    "level": "info",
    "message": "string",
    "event_type": "string",
    "metadata": {}
  }
}
Responses
Event recorded.
Response fields
  • id integer optional Database id of the created client event.
  • status string optional Constant string created.
    created
application/json
{
  "id": 4821,
  "status": "created"
}
Unauthorized. The request is not authenticated as a submission-form (SDK) FormUser. Body is { "error": "Unauthorized" }.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Forbidden. The FormUser token does not belong to the project in the path. Body is { "error": "Unauthorized" }.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Validation failed (e.g. missing/invalid level, blank message, or the per-issue cap of 100 events reached). Body is { "errors": ["<full message>", ...] } — a flat array of full-message strings.
Response fields
  • error string optional Human-readable error message (shapes a and b). English, not localized, and not a stable contract — display it, do not parse it.
  • status object optional Present only in shape (b): a redundant copy of the HTTP status code as an integer (e.g. 422). Unreliable and often absent — rely on the HTTP status line instead.
  • errors object optional Shape (c). Either an object mapping attribute name → array of messages, or (on a few endpoints) a flat array of full-message strings.
application/json
{
  "error": "string",
  "status": {},
  "errors": {}
}

Feature Requests

Create, list, and manage feature requests (suggestions)
Returns a paginated list of feature requests for a project, sorted by the specified criteria. By default returns publicly visible, active requests sorted by most votes (“top”).
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
Query Parameters
Name Type Description
sort optional string Sort order / view for feature requests. Any unrecognized value falls back to “top”. Public views: “top” sorts by votes descending, “new” by creation date, “team_picks” shows under_review/planned/started items, “completed” shows completed items, “all” shows all publicly visible items (combine with status to filter). Team-only views (served by moderation scopes gated on developer/admin access or the suggestions.moderate scope — NOT suggestions.update; callers lacking that get an empty set): “moderation” (pending moderation), “rejected”, “muted”, and “duplicates”.
top new team_picks completed all moderation rejected muted duplicates
Default: top
status optional string Filter by feature request status. Applies on top of the selected sort view (most relevant with sort=all). Accepts any FeatureRequest status (e.g. open, under_review, planned, started, completed, declined). Developer-only statuses (rejected, muted, duplicate, hidden, pending_moderation, split) are only honored for callers with the suggestions.update scope.
created_after optional string Return feature requests created on or after this date (inclusive).
created_before optional string Return feature requests created on or before this date. A date-only value is treated as inclusive of the whole day.
updated_after optional string Return feature requests updated on or after this date (inclusive).
updated_before optional string Return feature requests updated on or before this date (inclusive of the whole day).
category_metric optional string How category aggregates are measured for this listing. votes (default) weights categories by vote total; count weights by number of requests. Any other value falls back to votes.
votes count
Default: votes
per_page optional integer Number of feature requests per page. Default is 25. Only the values 10, 25, 50, and 100 are honored; any other value falls back to 25. The chosen value is remembered in the session.
10 25 50 100
page optional integer Page number for pagination (default: 1)
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/feature_requests.json?sort=top&status=example&created_after=example&created_before=example&updated_after=example&updated_before=example&category_metric=votes&per_page=10&page=123"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/feature_requests.json?sort=top&status=example&created_after=example&created_before=example&updated_after=example&updated_before=example&category_metric=votes&per_page=10&page=123")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.get(
    "https://app.betahub.io/projects/123/feature_requests.json?sort=top&status=example&created_after=example&created_before=example&updated_after=example&updated_before=example&category_metric=votes&per_page=10&page=123",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/feature_requests.json?sort=top&status=example&created_after=example&created_before=example&updated_after=example&updated_before=example&category_metric=votes&per_page=10&page=123", {
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/feature_requests.json?sort=top&status=example&created_after=example&created_before=example&updated_after=example&updated_before=example&category_metric=votes&per_page=10&page=123"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .GET()
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Successful response
Response fields
  • feature_requests array[object] optional
    • id integer optional Internal primary key of the feature request, emitted as a JSON number (integer), not a string. To reference a feature request in the {id} path parameter, use the scoped_id field below (the obfuscated/project-scoped id).
    • scoped_id string optional The project-scoped, obfuscated identifier used against the {id} path parameter (e.g. “123” or “fr-456”). Always emitted.
    • title string optional
    • description string optional
    • status string optional Current status of the feature request. The ‘hidden’ status indicates a draft feature request that has not been published yet and is not visible to other users.
      open under_review planned started completed declined pending_moderation rejected muted duplicate split hidden
    • created_at string date-time optional
    • updated_at string date-time optional
    • user object optional
      • id integer optional Internal user id, emitted as a JSON number.
      • name string optional Display name. May be an identity-masked placeholder when the project hides team identities from players.
    • votes integer optional Number of votes for this feature request
    • voted boolean optional Whether the calling user has voted for this feature request. NOTE: on the single-object GET detail endpoint (GET .../feature_requests/{id}.json) this is ALWAYS false regardless of the caller — it only reflects the caller’s real vote state on the list and search endpoints.
    • is_duplicate boolean optional Whether this feature request has been marked as a duplicate of another. Always emitted.
    • original_feature_request object optional The feature request this one duplicates. Present ONLY when is_duplicate is true.
      • id integer optional Internal id of the original feature request, emitted as a JSON number.
      • title string optional
      • url string optional
    • duplicates_count integer optional Number of other feature requests marked as duplicates of this one. Always emitted.
    • url string optional
    • attachments array[object] optional Attached files (screenshots, attachments).
      • id integer optional Attachment id, emitted as a JSON number.
      • type string optional Constant string ‘attachment’ identifying the record type.
        attachment
      • filename string optional Original uploaded filename (alias of original_filename).
      • original_filename string optional
      • url string optional nullable CDN URL to the file, or null if the file is not attached.
      • content_type string optional
      • file_type string optional Server-detected category of the attachment, derived from its content type.
        image video document audio other
      • size_bytes integer optional
      • display_order integer optional
      • created_at string date-time optional
      • updated_at string date-time optional
      • user object optional
        • id integer optional
        • name string optional
    • token string optional Editing token for the newly created feature request. Present only on the create (POST) response, and only when the server issued one (e.g. anonymous/SDK submissions) — it lets the submitter manage the request without a session.
    • warnings array[string] optional Partial-success notices. Only emitted for submission-form / game-SDK and Discord-bot submissions (regular authenticated session users never have custom fields applied, so they never receive warnings). Present on those create/update responses only when a custom-field value was silently dropped despite the 2xx status — e.g. a value exceeded the 4096-character cap, the 32-custom-fields-per-entity-type total was reached, or a field could not be auto-created. Clients that submit custom fields should inspect this array to detect partial data loss.
  • pagination object optional
    • current_page integer optional
    • total_pages integer optional
    • total_count integer optional
    • per_page integer optional
  • sort string optional
  • project_id integer optional
application/json
{
  "feature_requests": [
    {
      "id": 0,
      "scoped_id": "string",
      "title": "string",
      "description": "string",
      "status": "open",
      "created_at": "2026-03-12T10:30:00Z",
      "updated_at": "2026-03-12T10:30:00Z",
      "user": {
        "id": 0,
        "name": "string"
      },
      "votes": 0,
      "voted": true,
      "is_duplicate": true,
      "original_feature_request": {
        "id": 0,
        "title": "string",
        "url": "string"
      },
      "duplicates_count": 0,
      "url": "string",
      "attachments": [
        {}
      ],
      "token": "string",
      "warnings": [
        "string"
      ]
    }
  ],
  "pagination": {
    "current_page": 0,
    "total_pages": 0,
    "total_count": 0,
    "per_page": 0
  },
  "sort": "string",
  "project_id": 0
}
Forbidden. User does not have permission to view feature requests.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}

Creates a new feature request (suggestion).

Moderation: The created request may come back with a non-open status. Auto-moderation runs on submission, so a new request can land in pending_moderation (awaiting review) or muted (if the submitter is muted on this project). Callers with the suggestions.moderate scope bypass the queue and get open. Inspect the status field of the response rather than assuming open.

Attachments: Screenshots/attachments can be uploaded on create using multipart/form-data with a repeatable feature_request[files][] field (one entry per file).

Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
Request Body
application/x-www-form-urlencoded
  • feature_request[description] string required Description of the feature request.
  • feature_request[title] string optional Title of the feature request. Settable by callers with the suggestions.update scope (editors/admins), by the record owner, and by submission-form (GameFormUser / SDK) token clients. Only regular non-owner session users without the scope cannot set it.
  • feature_request[status] string optional Moderator-only. Accepted only from callers holding the suggestions.moderate scope; sets the status directly and skips auto-moderation. Ignored (stripped from permitted params) for everyone else.
  • feature_request[discord_forum_link] string optional Discord-bot metadata — link to the originating Discord forum thread.
  • skip_moderation boolean optional Discord-bot only. When true, bypasses all moderation (including the muted check) and creates the request directly as open. Only honored on Discord-bot submissions.
  • feature_request[custom][FIELD_IDENT] string optional Custom field values for the suggestion. Replace FIELD_IDENT with the field’s identifier (e.g., feature_request[custom][category], feature_request[custom][priority_level]). See the issue creation endpoint documentation for details on field types, validation, JSON/array encoding, auto-creation of unknown fields (type text; hidden from testers on the game-SDK path but visible on the Discord-bot path; auto-creation stops once the project has 32 custom fields of that entity type total, and each value is capped at 4096 characters), and the top-level warnings partial-success array. IMPORTANT: custom fields (and the warnings array) are applied ONLY on submission-form / game-SDK and Discord-bot submissions. For a regular authenticated session user the custom values are excluded from mass-assignment and silently dropped with no warnings emitted. Use the project’s Custom Fields settings to find available identifiers.
  • draft boolean optional When set to true, creates the feature request in hidden (draft) status. Draft feature requests are not visible until published.
  • user[discord_id] string optional Discord ID of the user creating the feature request. Only used when authenticated as a Discord bot.
  • user[discord_username] string optional Discord username of the user creating the feature request. Only used when authenticated as a Discord bot.
  • user[discord_discriminator] string optional Discord discriminator of the user creating the feature request. Only used when authenticated as a Discord bot.
multipart/form-data
  • feature_request[description] string required Description of the feature request.
  • feature_request[title] string optional Title of the feature request. Settable by suggestions.update scope holders, the record owner, and submission-form (GameFormUser / SDK) token clients.
  • feature_request[files][] array[string] optional File attachments (screenshots, images, documents) for the suggestion. Provide the field once per file to attach multiple files.
  • feature_request[custom][FIELD_IDENT] string optional Custom field values for the suggestion (same behavior as the urlencoded variant, including that they are applied only on submission-form / game-SDK and Discord-bot submissions). Replace FIELD_IDENT with the field’s identifier.
cURL
curl \
  -X POST \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  -d "feature_request[description]=Example description" \
  -d "feature_request[title]=Example title" \
  -d "feature_request[status]=string" \
  -d "feature_request[discord_forum_link]=https://example.com" \
  -d "skip_moderation=true" \
  -d "feature_request[custom][FIELD_IDENT]=string" \
  "https://app.betahub.io/projects/123/feature_requests.json"
Ruby
require "net/http"
require "json"

uri = URI("https://app.betahub.io/projects/123/feature_requests.json")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"
request.set_form_data({
  "feature_request[description]" => "Example description",
  "feature_request[title]" => "Example title",
  "feature_request[status]" => "string",
  "feature_request[discord_forum_link]" => "https://example.com",
  "skip_moderation" => "true",
  "feature_request[custom][FIELD_IDENT]" => "string"
})

response = http.request(request)
puts response.body
Python
import requests

response = requests.post(
    "https://app.betahub.io/projects/123/feature_requests.json",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"},
    data={"feature_request[description]": "Example description", "feature_request[title]": "Example title", "feature_request[status]": "string", "feature_request[discord_forum_link]": "https://example.com", "skip_moderation": "true", "feature_request[custom][FIELD_IDENT]": "string"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/feature_requests.json", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/feature_requests.json"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString("{\"feature_request[description]\":\"string\",\"feature_request[title]\":\"string\",\"feature_request[status]\":\"string\",\"feature_request[discord_forum_link]\":\"https://example.com\",\"skip_moderation\":true,\"feature_request[custom][FIELD_IDENT]\":\"string\",\"draft\":true,\"user[discord_id]\":\"string\",\"user[discord_username]\":\"string\",\"user[discord_discriminator]\":\"string\"}"))
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Request Body
{
  "feature_request[description]": "string",
  "feature_request[title]": "string",
  "feature_request[status]": "string",
  "feature_request[discord_forum_link]": "https://example.com",
  "skip_moderation": true,
  "feature_request[custom][FIELD_IDENT]": "string",
  "draft": true,
  "user[discord_id]": "string",
  "user[discord_username]": "string",
  "user[discord_discriminator]": "string"
}
Responses
Successful response
Response fields
  • id integer optional Internal primary key of the feature request, emitted as a JSON number (integer), not a string. To reference a feature request in the {id} path parameter, use the scoped_id field below (the obfuscated/project-scoped id).
  • scoped_id string optional The project-scoped, obfuscated identifier used against the {id} path parameter (e.g. “123” or “fr-456”). Always emitted.
  • title string optional
  • description string optional
  • status string optional Current status of the feature request. The ‘hidden’ status indicates a draft feature request that has not been published yet and is not visible to other users.
    open under_review planned started completed declined pending_moderation rejected muted duplicate split hidden
  • created_at string date-time optional
  • updated_at string date-time optional
  • user object optional
    • id integer optional Internal user id, emitted as a JSON number.
    • name string optional Display name. May be an identity-masked placeholder when the project hides team identities from players.
  • votes integer optional Number of votes for this feature request
  • voted boolean optional Whether the calling user has voted for this feature request. NOTE: on the single-object GET detail endpoint (GET .../feature_requests/{id}.json) this is ALWAYS false regardless of the caller — it only reflects the caller’s real vote state on the list and search endpoints.
  • is_duplicate boolean optional Whether this feature request has been marked as a duplicate of another. Always emitted.
  • original_feature_request object optional The feature request this one duplicates. Present ONLY when is_duplicate is true.
    • id integer optional Internal id of the original feature request, emitted as a JSON number.
    • title string optional
    • url string optional
  • duplicates_count integer optional Number of other feature requests marked as duplicates of this one. Always emitted.
  • url string optional
  • attachments array[object] optional Attached files (screenshots, attachments).
    • id integer optional Attachment id, emitted as a JSON number.
    • type string optional Constant string ‘attachment’ identifying the record type.
      attachment
    • filename string optional Original uploaded filename (alias of original_filename).
    • original_filename string optional
    • url string optional nullable CDN URL to the file, or null if the file is not attached.
    • content_type string optional
    • file_type string optional Server-detected category of the attachment, derived from its content type.
      image video document audio other
    • size_bytes integer optional
    • display_order integer optional
    • created_at string date-time optional
    • updated_at string date-time optional
    • user object optional
      • id integer optional
      • name string optional
  • token string optional Editing token for the newly created feature request. Present only on the create (POST) response, and only when the server issued one (e.g. anonymous/SDK submissions) — it lets the submitter manage the request without a session.
  • warnings array[string] optional Partial-success notices. Only emitted for submission-form / game-SDK and Discord-bot submissions (regular authenticated session users never have custom fields applied, so they never receive warnings). Present on those create/update responses only when a custom-field value was silently dropped despite the 2xx status — e.g. a value exceeded the 4096-character cap, the 32-custom-fields-per-entity-type total was reached, or a field could not be auto-created. Clients that submit custom fields should inspect this array to detect partial data loss.
Examples
Example Request
{
  "feature_request[description]": "Add a dark mode to the app",
  "user[discord_id]": "123456789",
  "user[discord_username]": "username",
  "user[discord_discriminator]": "1234"
}
Example Response
{
  "id": 1234,
  "scoped_id": "fr-1234",
  "title": "Add a dark mode to the app",
  "description": "Add a dark mode to the app",
  "status": "open",
  "created_at": "2024-10-03T12:34:56Z",
  "updated_at": "2024-10-03T12:34:56Z",
  "user": {
    "id": 34,
    "name": "username#1234"
  },
  "votes": 1,
  "voted": false,
  "is_duplicate": false,
  "duplicates_count": 0,
  "url": "https://app.betahub.io/projects/1/feature_requests/fr-1234",
  "attachments": []
}

Forbidden. The auth token cannot submit suggestions to this project. Causes include the token belonging to a different project, lacking the can_create_feature_request permission / exceeding its per-IP daily rate limit (default 8 per IP/day), or a required submission token (JWT) missing or invalid.

Distinct, OPPOSITE-meaning 403: the credentials are valid but the organization has exceeded its plan’s monthly submission quota for suggestions → error “This project is not currently accepting new suggestions. Please try again later.”

Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
Examples
Wrong Project
{
  "error": "Auth token does not belong to this project."
}
No Permission Or Rate Limited
{
  "error": "Auth token does not have permission to submit suggestions, or rate limit exceeded."
}
Org Quota Reached
{
  "error": "This project is not currently accepting new suggestions. Please try again later."
}

Unprocessable Entity. Returned for validation errors (e.g. missing required custom fields), when the reporter hits a per-project tester submission cap, or when a supplied submission token was already used.

Tester caps are per-project, per-reporter, applied over a rolling 24 hours and a rolling 7 days (both configurable per project; developers, support, org admins, and site admins are exempt): “You have reached your 24 hours limit for suggestion submissions.” (or “7 days”).

Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
Examples
Tester Rate Limit
{
  "error": "You have reached your 24 hours limit for suggestion submissions. You can submit again later."
}
Submission Token Reused
{
  "error": "Submission token has already been used. Please generate a new one."
}
Searches for feature requests (also known as suggestions) within a project. Uses full-text search powered by Meilisearch to find matching feature requests based on the query string. Returns matching titles for autocomplete functionality or full feature request objects for detailed results. Note: Only searches among publicly visible, active feature requests (excludes pending moderation, rejected, muted, duplicate, and split requests).
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
Query Parameters
Name Type Description
query required string The search query string to match against feature request titles and descriptions
skip_ids optional string Comma-separated list of feature request IDs to exclude from results
partial optional string When set to ‘true’, returns limited results optimized for autocomplete (max 4 results)
true false
scoped_id optional string Instead of searching, find a specific feature request by its scoped ID (e.g., “123” or “fr-456”)
sort optional string Sort order for the JSON search results. Defaults to “top” (votes descending). Any value other than new/team_picks/completed also falls back to creation-date ordering.
top new team_picks completed
Default: top
page optional integer Page number for the paginated JSON results (default: 1). The page size is fixed at 25; unlike the list endpoint, search does not accept a per_page parameter. Results are re-filtered to publicly visible, non-deleted requests after the full-text query, so the returned/total counts reflect that post-search visibility recount.
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/feature_requests/search.json?query=example&skip_ids=123&partial=true&scoped_id=123&sort=top&page=123"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/feature_requests/search.json?query=example&skip_ids=123&partial=true&scoped_id=123&sort=top&page=123")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.get(
    "https://app.betahub.io/projects/123/feature_requests/search.json?query=example&skip_ids=123&partial=true&scoped_id=123&sort=top&page=123",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/feature_requests/search.json?query=example&skip_ids=123&partial=true&scoped_id=123&sort=top&page=123", {
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/feature_requests/search.json?query=example&skip_ids=123&partial=true&scoped_id=123&sort=top&page=123"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .GET()
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Successful response
Response fields
  • feature_requests array[object] optional
    • id integer optional Internal primary key of the feature request, emitted as a JSON number (integer), not a string. To reference a feature request in the {id} path parameter, use the scoped_id field below (the obfuscated/project-scoped id).
    • scoped_id string optional The project-scoped, obfuscated identifier used against the {id} path parameter (e.g. “123” or “fr-456”). Always emitted.
    • title string optional
    • description string optional
    • status string optional Current status of the feature request. The ‘hidden’ status indicates a draft feature request that has not been published yet and is not visible to other users.
      open under_review planned started completed declined pending_moderation rejected muted duplicate split hidden
    • created_at string date-time optional
    • updated_at string date-time optional
    • user object optional
      • id integer optional Internal user id, emitted as a JSON number.
      • name string optional Display name. May be an identity-masked placeholder when the project hides team identities from players.
    • votes integer optional Number of votes for this feature request
    • voted boolean optional Whether the calling user has voted for this feature request. NOTE: on the single-object GET detail endpoint (GET .../feature_requests/{id}.json) this is ALWAYS false regardless of the caller — it only reflects the caller’s real vote state on the list and search endpoints.
    • is_duplicate boolean optional Whether this feature request has been marked as a duplicate of another. Always emitted.
    • original_feature_request object optional The feature request this one duplicates. Present ONLY when is_duplicate is true.
      • id integer optional Internal id of the original feature request, emitted as a JSON number.
      • title string optional
      • url string optional
    • duplicates_count integer optional Number of other feature requests marked as duplicates of this one. Always emitted.
    • url string optional
    • attachments array[object] optional Attached files (screenshots, attachments).
      • id integer optional Attachment id, emitted as a JSON number.
      • type string optional Constant string ‘attachment’ identifying the record type.
        attachment
      • filename string optional Original uploaded filename (alias of original_filename).
      • original_filename string optional
      • url string optional nullable CDN URL to the file, or null if the file is not attached.
      • content_type string optional
      • file_type string optional Server-detected category of the attachment, derived from its content type.
        image video document audio other
      • size_bytes integer optional
      • display_order integer optional
      • created_at string date-time optional
      • updated_at string date-time optional
      • user object optional
        • id integer optional
        • name string optional
    • token string optional Editing token for the newly created feature request. Present only on the create (POST) response, and only when the server issued one (e.g. anonymous/SDK submissions) — it lets the submitter manage the request without a session.
    • warnings array[string] optional Partial-success notices. Only emitted for submission-form / game-SDK and Discord-bot submissions (regular authenticated session users never have custom fields applied, so they never receive warnings). Present on those create/update responses only when a custom-field value was silently dropped despite the 2xx status — e.g. a value exceeded the 4096-character cap, the 32-custom-fields-per-entity-type total was reached, or a field could not be auto-created. Clients that submit custom fields should inspect this array to detect partial data loss.
  • pagination object optional
    • current_page integer optional
    • per_page integer optional
    • total_pages integer optional
    • total_count integer optional
  • sort string optional
  • project_id integer optional
application/json
{
  "feature_requests": [
    {
      "id": 1234,
      "scoped_id": "fr-1234",
      "title": "Add dark mode support",
      "status": "open",
      "created_at": "2024-10-03T12:34:56Z"
    }
  ],
  "pagination": {
    "current_page": 1,
    "per_page": 25,
    "total_pages": 2,
    "total_count": 42
  },
  "sort": "top",
  "project_id": 123
}
Forbidden. User does not have permission to search feature requests in this project.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Feature request not found (when searching by scoped_id).
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Returns detailed information about a specific feature request, including vote count, duplicate information, and attachments.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
id required string The feature request ID or scoped ID
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/feature_requests/123.json"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/feature_requests/123.json")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.get(
    "https://app.betahub.io/projects/123/feature_requests/123.json",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/feature_requests/123.json", {
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/feature_requests/123.json"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .GET()
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Successful response
Response fields
  • id integer optional Internal primary key of the feature request, emitted as a JSON number (integer), not a string. To reference a feature request in the {id} path parameter, use the scoped_id field below (the obfuscated/project-scoped id).
  • scoped_id string optional The project-scoped, obfuscated identifier used against the {id} path parameter (e.g. “123” or “fr-456”). Always emitted.
  • title string optional
  • description string optional
  • status string optional Current status of the feature request. The ‘hidden’ status indicates a draft feature request that has not been published yet and is not visible to other users.
    open under_review planned started completed declined pending_moderation rejected muted duplicate split hidden
  • created_at string date-time optional
  • updated_at string date-time optional
  • user object optional
    • id integer optional Internal user id, emitted as a JSON number.
    • name string optional Display name. May be an identity-masked placeholder when the project hides team identities from players.
  • votes integer optional Number of votes for this feature request
  • voted boolean optional Whether the calling user has voted for this feature request. NOTE: on the single-object GET detail endpoint (GET .../feature_requests/{id}.json) this is ALWAYS false regardless of the caller — it only reflects the caller’s real vote state on the list and search endpoints.
  • is_duplicate boolean optional Whether this feature request has been marked as a duplicate of another. Always emitted.
  • original_feature_request object optional The feature request this one duplicates. Present ONLY when is_duplicate is true.
    • id integer optional Internal id of the original feature request, emitted as a JSON number.
    • title string optional
    • url string optional
  • duplicates_count integer optional Number of other feature requests marked as duplicates of this one. Always emitted.
  • url string optional
  • attachments array[object] optional Attached files (screenshots, attachments).
    • id integer optional Attachment id, emitted as a JSON number.
    • type string optional Constant string ‘attachment’ identifying the record type.
      attachment
    • filename string optional Original uploaded filename (alias of original_filename).
    • original_filename string optional
    • url string optional nullable CDN URL to the file, or null if the file is not attached.
    • content_type string optional
    • file_type string optional Server-detected category of the attachment, derived from its content type.
      image video document audio other
    • size_bytes integer optional
    • display_order integer optional
    • created_at string date-time optional
    • updated_at string date-time optional
    • user object optional
      • id integer optional
      • name string optional
  • token string optional Editing token for the newly created feature request. Present only on the create (POST) response, and only when the server issued one (e.g. anonymous/SDK submissions) — it lets the submitter manage the request without a session.
  • warnings array[string] optional Partial-success notices. Only emitted for submission-form / game-SDK and Discord-bot submissions (regular authenticated session users never have custom fields applied, so they never receive warnings). Present on those create/update responses only when a custom-field value was silently dropped despite the 2xx status — e.g. a value exceeded the 4096-character cap, the 32-custom-fields-per-entity-type total was reached, or a field could not be auto-created. Clients that submit custom fields should inspect this array to detect partial data loss.
application/json
{
  "id": 0,
  "scoped_id": "string",
  "title": "string",
  "description": "string",
  "status": "open",
  "created_at": "2026-03-12T10:30:00Z",
  "updated_at": "2026-03-12T10:30:00Z",
  "user": {
    "id": 0,
    "name": "string"
  },
  "votes": 0,
  "voted": true,
  "is_duplicate": true,
  "original_feature_request": {
    "id": 0,
    "title": "string",
    "url": "string"
  },
  "duplicates_count": 0,
  "url": "string",
  "attachments": [
    {
      "id": 0,
      "type": "attachment",
      "filename": "string",
      "original_filename": "string",
      "url": "https://example.com",
      "content_type": "string",
      "file_type": "image",
      "size_bytes": 0,
      "display_order": 0,
      "created_at": "2026-03-12T10:30:00Z",
      "updated_at": "2026-03-12T10:30:00Z",
      "user": {
        "id": 0,
        "name": "string"
      }
    }
  ],
  "token": "string",
  "warnings": [
    "string"
  ]
}
Forbidden. User does not have permission to view this feature request.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Feature request not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Sets or updates the contact information (email or Discord ID) for a feature request. This is primarily used in the draft flow to capture user contact details. Either email or discord_id must be provided. The reporter is then identified by this contact and receives notifications about the feature request.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
id required string The feature request ID or scoped ID
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
Request Body
application/json
  • email string email optional Email address of the user. Either email or discord_id is required.
  • discord_id string optional Discord user ID (numeric string). Either email or discord_id is required.
cURL
curl \
  -X POST \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  -H "Content-Type: application/json" \
  -d '{
  "email": "user@example.com",
  "discord_id": "user@example.com"
}' \
  "https://app.betahub.io/projects/123/feature_requests/123/set_contact_info"
Ruby
require "net/http"
require "json"

uri = URI("https://app.betahub.io/projects/123/feature_requests/123/set_contact_info")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"
request["Content-Type"] = "application/json"
request.body = {
  "email": "user@example.com",
  "discord_id": "user@example.com"
}

response = http.request(request)
puts response.body
Python
import requests

response = requests.post(
    "https://app.betahub.io/projects/123/feature_requests/123/set_contact_info",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"},
    json={
      "email": "user@example.com",
      "discord_id": "user@example.com"
    }
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/feature_requests/123/set_contact_info", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123",
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    "email": "user@example.com",
    "discord_id": "user@example.com"
  })
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/feature_requests/123/set_contact_info"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString("{\"email\":\"user@example.com\",\"discord_id\":\"user@example.com\"}"))
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Request Body
{
  "email": "user@example.com",
  "discord_id": "user@example.com"
}
Responses
Contact information set successfully
Response fields
  • success boolean optional
  • message string optional
  • feature_request_id integer optional The feature request’s primary key, emitted as a JSON number (integer).
  • user object optional
    • id integer optional
    • email string optional
    • discord_id string optional
    • virtual boolean optional
application/json
{
  "success": true,
  "message": "Contact information assigned successfully",
  "feature_request_id": 1234,
  "user": {
    "id": 56,
    "email": "user@example.com",
    "discord_id": null,
    "virtual": true
  }
}
Unprocessable Entity (invalid email format, invalid discord_id, or neither provided)
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Forbidden. User does not have permission to modify this feature request.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Feature request not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Publishes a draft (hidden) feature request by changing its status from hidden to open. This makes the feature request visible to other users. Typically used at the end of the draft flow when the user completes their submission.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
id required string The feature request ID or scoped ID
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -X POST \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/feature_requests/123/publish"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/feature_requests/123/publish")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.post(
    "https://app.betahub.io/projects/123/feature_requests/123/publish",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/feature_requests/123/publish", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/feature_requests/123/publish"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .method("POST", HttpRequest.BodyPublishers.noBody())
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Feature request published successfully
Response fields
  • success boolean optional
application/json
{
  "success": true
}
Forbidden. User does not have permission to publish this feature request.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Feature request not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}

Support Tickets

Create and manage support tickets
Searches for support tickets within a project using a simple query string. Returns matching tickets with basic information (id and title) for autocomplete functionality. Uses ILIKE pattern matching on title and description fields. Results are limited to 10 tickets and filtered based on user permissions.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
Query Parameters
Name Type Description
query optional string The search query string to match against ticket titles and descriptions. Not enforced server-side: omitting it or sending a blank value returns HTTP 200 with an empty array [] rather than an error.
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/tickets/search.json?query=example"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/tickets/search.json?query=example")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.get(
    "https://app.betahub.io/projects/123/tickets/search.json?query=example",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/tickets/search.json?query=example", {
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/tickets/search.json?query=example"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .GET()
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Successful response
application/json
[
  {
    "id": 123,
    "title": "Cannot access user settings"
  },
  {
    "id": 456,
    "title": "Password reset not working"
  }
]
Forbidden. User does not have permission to view tickets in this project.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Unprocessable Entity. Support tickets are not enabled for this project.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "Support tickets are not enabled for this project. Ask the server administrator to enable them."
}

Creates a new support ticket for a project. Support tickets are used for customer support, help requests, and technical assistance.

Authentication Support:

  • Reporter contact: Provide ticket[reporter_email] in form data so the reporter can be notified about the ticket and its replies.
  • Discord Bot: Can create tickets on behalf of Discord users by providing discord_id, discord_username, and discord_discriminator in form data
  • Authenticated Users: Tickets are created with current_user as reporter
  • Anonymous Users: Must provide ticket[reporter_email] in form data. Anonymous ticket creation without contact info is not allowed.

    Attachments: Tickets can include file attachments (images, documents, logs) using multipart/form-data. Multiple files can be attached by providing the attachments parameter multiple times. Use multipart/form-data only when attaching files — a JSON body works for text-only tickets.

    Request encoding: Two request encodings are accepted. Use application/json with a nested {"ticket": {"description": "...", "custom": {...}}} body for text-only tickets, or multipart/form-data (with bracketed keys like ticket[description]) when attaching files.

    Title and priority: ticket[description] has a minimum length of 30 characters. If ticket[title] is omitted, an LLM generates the title from the description AND also sets the priority — overwriting any ticket[priority] you supplied. To keep an explicit priority, you MUST send a ticket[title] (this skips the LLM entirely).

    Submission tokens: If the project’s auth token is configured to require a submission token, the FormUser header must carry a signed JWT alongside the token: FormUser tkn-{token},{jwt}. Missing/invalid/already-consumed tokens are rejected with 403 (see responses below).

Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
Request Body
multipart/form-data
  • ticket[description] string required Description of the support ticket. Required.
  • ticket[title] string optional Title of the ticket. If not provided, will be auto-generated from description.
  • ticket[priority] string optional Priority level. Defaults to ‘low’.
    low medium high critical
  • ticket[attachments][] array[string] optional File attachments (images, documents, logs, etc.). Can be provided multiple times for multiple files.
  • ticket[custom][FIELD_IDENT] string optional Custom field values for the ticket. Replace FIELD_IDENT with the field’s identifier (e.g., ticket[custom][platform], ticket[custom][priority_level]). See the issue creation endpoint documentation for details on field types, validation, JSON/array encoding, auto-creation of unknown fields (type text; hidden from testers on the game-SDK path but visible on the Discord-bot path; auto-creation stops once the project has 32 custom fields of that entity type total, and each value is capped at 4096 characters), and the top-level warnings partial-success array — the same custom-field behavior applies to tickets. Use the project’s Custom Fields settings to find available identifiers.
  • ticket[reporter_email] string email optional Email address of the ticket reporter. Optional for authenticated users and the Discord bot. Required for anonymous FormUser submissions. When provided, the reporter is identified by this email and notified about the ticket and its replies.
  • user[discord_id] string optional Discord ID of the user creating the ticket. Only used when authenticated as a Discord bot.
  • user[discord_username] string optional Discord username. Only used when authenticated as a Discord bot.
  • user[discord_discriminator] string optional Discord discriminator. Only used when authenticated as a Discord bot.
application/json
  • ticket object required
    • description string required Description of the support ticket (minimum 30 characters). Required.
    • title string optional Title of the ticket. If omitted, an LLM generates the title AND overwrites any supplied priority — send a title to keep an explicit priority.
    • priority string optional Priority level. Defaults to ‘low’. Overwritten by the LLM when title is omitted.
      low medium high critical
    • reporter_email string email optional Email of the reporter. Required for anonymous FormUser submissions when the auth header carries no email/discord_id.
    • custom object optional Custom field values keyed by field identifier (e.g. {"platform": "windows"}). Same behavior as the multipart ticket[custom][FIELD_IDENT] parameter.
cURL
curl \
  -X POST \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  -F "ticket[description]=Example description" \
  -F "ticket[title]=Example title" \
  -F "ticket[priority]=low" \
  -F "ticket[attachments][]=string" \
  "https://app.betahub.io/projects/123/tickets.json"
Ruby
require "net/http"
require "json"

uri = URI("https://app.betahub.io/projects/123/tickets.json")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.post(
    "https://app.betahub.io/projects/123/tickets.json",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/tickets.json", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/tickets.json"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString("{\"ticket[description]\":\"string\",\"ticket[title]\":\"string\",\"ticket[priority]\":\"low\",\"ticket[attachments][]\":[\"string\"],\"ticket[custom][FIELD_IDENT]\":\"string\",\"ticket[reporter_email]\":\"user@example.com\",\"user[discord_id]\":\"string\",\"user[discord_username]\":\"string\",\"user[discord_discriminator]\":\"string\"}"))
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Request Body
{
  "ticket[description]": "string",
  "ticket[title]": "string",
  "ticket[priority]": "low",
  "ticket[attachments][]": [
    "string"
  ],
  "ticket[custom][FIELD_IDENT]": "string",
  "ticket[reporter_email]": "user@example.com",
  "user[discord_id]": "string",
  "user[discord_username]": "string",
  "user[discord_discriminator]": "string"
}
Responses
Ticket successfully created
Response fields
  • id integer optional The ID of the ticket, emitted as a JSON number (integer), not a string. This is the internal primary key. IMPORTANT: a BARE numeric {id} path parameter is looked up by the ticket’s project-scoped id, NOT by this primary key — so passing this returned id directly as {id} will 404 (or match a different ticket) unless the scoped_id happens to equal the primary key. To reference a ticket by this primary-key id, prefix it with g- (e.g. g-123); a bare number always references the project-scoped id.
  • title string optional
  • description string optional
  • status string optional Current status of the ticket
    new open pending solved closed
  • priority string optional Priority level of the ticket
    low medium high critical
  • warnings array[string] optional Partial-success notices. Present on create/update responses only when a custom-field value was silently dropped despite the 2xx status — e.g. a value exceeded the 4096-character cap, the 32-auto-created-fields-per-entity limit was hit, or a field could not be auto-created. Clients that submit custom fields should inspect this array to detect partial data loss.
  • created_at string date-time optional
  • updated_at string date-time optional
  • reporter object optional User who reported the ticket
    • id integer optional Internal user id, emitted as a JSON number.
    • name string optional Display name. May be an identity-masked placeholder when the project hides team identities from players.
  • assigned_to object optional The assignee. This key is ALWAYS present as an object — it is never JSON null. When the ticket is unassigned, both id and name are null (i.e. {"id": null, "name": null}). Do not test the object itself against null; check assigned_to.id.
    • id integer optional nullable Internal user id (JSON number), or null when unassigned.
    • name string optional nullable Assignee display name, or null when unassigned. May be an identity-masked placeholder when the project hides team identities from players.
  • url string optional URL to view the ticket in BetaHub
  • attachments array[object] optional Attached files (screenshots, documents, etc.)
    • id integer optional Attachment id, emitted as a JSON number.
    • type string optional Constant string ‘attachment’ identifying the record type.
      attachment
    • filename string optional Original uploaded filename (alias of original_filename).
    • original_filename string optional Original uploaded filename.
    • url string optional nullable CDN URL to the file, or null if the file is not attached.
    • content_type string optional
    • file_type string optional Server-detected category of the attachment, derived from its content type.
      image video document audio other
    • size_bytes integer optional File size in bytes (0 when the file is not attached).
    • display_order integer optional
    • created_at string date-time optional
    • updated_at string date-time optional
    • user object optional The uploader.
      • id integer optional Uploader user id, emitted as a JSON number.
      • name string optional Uploader display name. May be an identity-masked placeholder when the project hides team identities from players.
Examples
Example Request
{
  "ticket[description]": "I need help resetting my password",
  "ticket[priority]": "high"
}
Example Response
{
  "id": 123,
  "title": "Password reset assistance needed",
  "description": "I need help resetting my password",
  "status": "new",
  "priority": "high",
  "created_at": "2024-10-06T10:00:00Z",
  "updated_at": "2024-10-06T10:00:00Z",
  "reporter": {
    "id": 456,
    "name": "John Doe"
  },
  "assigned_to": {
    "id": null,
    "name": null
  },
  "url": "https://app.betahub.io/projects/1/tickets/123",
  "attachments": []
}

Unprocessable Entity. Returned for validation errors, when a required contact email is missing, when support tickets are not enabled for the project, when the reporter hits a per-project tester submission cap, or when a supplied submission token was already used.

Tester caps are per-project, per-reporter, applied over a rolling 24 hours and a rolling 7 days (both configurable per project; developers, support, org admins, and site admins are exempt): “You have reached your 24 hours limit for ticket submissions.” (or “7 days”).

Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
Examples
Missing Description
{
  "error": "Description can't be blank"
}
Description Too Short
{
  "error": "Description is too short (minimum is 30 characters)"
}
Missing Email Anonymous
{
  "error": "Email address is required for support tickets"
}
Invalid Email Format
{
  "error": "Invalid email format"
}
Tickets Not Enabled
{
  "error": "Support tickets are not enabled for this project. Ask the server administrator to enable them."
}
Tester Rate Limit
{
  "error": "You have reached your 24 hours limit for ticket submissions. You can submit again later."
}
Submission Token Reused
{
  "error": "Submission token has already been used. Please generate a new one."
}

Forbidden. Real causes include: the token lacks the can_create_ticket permission or has exceeded its can_create_ticket_limit_per_day (default 8 per IP/day) → error “Not allowed to create a ticket.”; the token belongs to a different project; or a required submission token (JWT) is missing or invalid.

Distinct, OPPOSITE-meaning 403: the credentials are valid but the organization has exceeded its plan’s monthly submission quota for tickets → error “This project is not currently accepting new support tickets. Please try again later.”

Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
Examples
Not Allowed
{
  "error": "Not allowed to create a ticket."
}
Submission Token Required
{
  "error": "Submission token is required for this auth token. Generate one via the API."
}
Org Quota Reached
{
  "error": "This project is not currently accepting new support tickets. Please try again later."
}
Retrieves detailed information about a specific support ticket, including all attachments, status, priority, and assignment information.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
id required string The ticket ID. Can be a numeric ID or scoped ID format.
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/tickets/123.json"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/tickets/123.json")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.get(
    "https://app.betahub.io/projects/123/tickets/123.json",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/tickets/123.json", {
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/tickets/123.json"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .GET()
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Successful response
Response fields
  • id integer optional The ID of the ticket, emitted as a JSON number (integer), not a string. This is the internal primary key. IMPORTANT: a BARE numeric {id} path parameter is looked up by the ticket’s project-scoped id, NOT by this primary key — so passing this returned id directly as {id} will 404 (or match a different ticket) unless the scoped_id happens to equal the primary key. To reference a ticket by this primary-key id, prefix it with g- (e.g. g-123); a bare number always references the project-scoped id.
  • title string optional
  • description string optional
  • status string optional Current status of the ticket
    new open pending solved closed
  • priority string optional Priority level of the ticket
    low medium high critical
  • warnings array[string] optional Partial-success notices. Present on create/update responses only when a custom-field value was silently dropped despite the 2xx status — e.g. a value exceeded the 4096-character cap, the 32-auto-created-fields-per-entity limit was hit, or a field could not be auto-created. Clients that submit custom fields should inspect this array to detect partial data loss.
  • created_at string date-time optional
  • updated_at string date-time optional
  • reporter object optional User who reported the ticket
    • id integer optional Internal user id, emitted as a JSON number.
    • name string optional Display name. May be an identity-masked placeholder when the project hides team identities from players.
  • assigned_to object optional The assignee. This key is ALWAYS present as an object — it is never JSON null. When the ticket is unassigned, both id and name are null (i.e. {"id": null, "name": null}). Do not test the object itself against null; check assigned_to.id.
    • id integer optional nullable Internal user id (JSON number), or null when unassigned.
    • name string optional nullable Assignee display name, or null when unassigned. May be an identity-masked placeholder when the project hides team identities from players.
  • url string optional URL to view the ticket in BetaHub
  • attachments array[object] optional Attached files (screenshots, documents, etc.)
    • id integer optional Attachment id, emitted as a JSON number.
    • type string optional Constant string ‘attachment’ identifying the record type.
      attachment
    • filename string optional Original uploaded filename (alias of original_filename).
    • original_filename string optional Original uploaded filename.
    • url string optional nullable CDN URL to the file, or null if the file is not attached.
    • content_type string optional
    • file_type string optional Server-detected category of the attachment, derived from its content type.
      image video document audio other
    • size_bytes integer optional File size in bytes (0 when the file is not attached).
    • display_order integer optional
    • created_at string date-time optional
    • updated_at string date-time optional
    • user object optional The uploader.
      • id integer optional Uploader user id, emitted as a JSON number.
      • name string optional Uploader display name. May be an identity-masked placeholder when the project hides team identities from players.
application/json
{
  "id": 123,
  "title": "Password reset assistance needed",
  "description": "I need help resetting my password",
  "status": "open",
  "priority": "high",
  "created_at": "2024-10-06T10:00:00Z",
  "updated_at": "2024-10-06T10:30:00Z",
  "reporter": {
    "id": 456,
    "name": "John Doe"
  },
  "assigned_to": {
    "id": 789,
    "name": "Support Agent"
  },
  "url": "https://app.betahub.io/projects/1/tickets/123",
  "attachments": [
    {
      "id": 101,
      "type": "attachment",
      "filename": "screenshot.png",
      "original_filename": "screenshot.png",
      "url": "https://cdn.betahub.io/attachments/101/screenshot.png",
      "content_type": "image/png",
      "file_type": "image",
      "size_bytes": 245678,
      "display_order": 0,
      "user": {
        "id": 456,
        "name": "John Doe"
      }
    }
  ]
}
Forbidden. User does not have permission to view this ticket.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Ticket not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Unprocessable Entity. Support tickets are not enabled for this project.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "Support tickets are not enabled for this project. Ask the server administrator to enable them."
}

Updates an existing support ticket with new information. This endpoint supports comprehensive ticket management including:

Supported Operations:

  • Update ticket fields (title, description, status, priority, assignment)
  • Add new attachments
  • Upload new files to the ticket
  • Remove existing attachments
  • Delete specific attachments by ID
  • Perform both add and remove operations in a single request

    Attachment Management: This endpoint was recently enhanced to support full attachment management via JSON API. You can now:

  • Add multiple new attachments using multipart/form-data or file upload
  • Remove specific attachments by providing their IDs
  • Combine both operations in a single update request

    Status Transitions: When a ticket is assigned for the first time (or reassignment occurs) and its status is “new”, it automatically transitions to “open” status.

Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
id required string The ticket ID. Can be a numeric ID or scoped ID format.
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
Request Body
multipart/form-data
  • ticket[title] string optional Updated title
  • ticket[description] string optional Updated description
  • ticket[status] string optional Updated status. Transition rules are enforced (violations return 422): ‘new’ can never be set manually; a ticket in ‘closed’ can only move to ‘open’; setting ‘pending’ (awaiting feedback) requires that the ticket already has at least one comment and that the last comment was authored by a team member (not the reporter). Note: only developers/support/admins (tickets.update scope) may change status; reporters cannot.
    new open pending solved closed
  • ticket[priority] string optional Updated priority
    low medium high critical
  • ticket[assigned_to_id] string optional ID of user to assign the ticket to
  • ticket[attachments][] array[string] optional New file attachments to add. Can be provided multiple times for multiple files.
  • ticket[remove_attachment_ids][] array[string] optional IDs of existing attachments to remove. Can be provided multiple times for multiple IDs.
cURL
curl \
  -X PATCH \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  -F "ticket[title]=Example title" \
  -F "ticket[description]=Example description" \
  -F "ticket[status]=new" \
  -F "ticket[priority]=low" \
  "https://app.betahub.io/projects/123/tickets/123.json"
Ruby
require "net/http"
require "json"

uri = URI("https://app.betahub.io/projects/123/tickets/123.json")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Patch.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.patch(
    "https://app.betahub.io/projects/123/tickets/123.json",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/tickets/123.json", {
  method: "PATCH",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/tickets/123.json"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .header("Content-Type", "application/json")
    .PATCH(HttpRequest.BodyPublishers.ofString("{\"ticket[title]\":\"string\",\"ticket[description]\":\"string\",\"ticket[status]\":\"new\",\"ticket[priority]\":\"low\",\"ticket[assigned_to_id]\":\"string\",\"ticket[attachments][]\":[\"string\"],\"ticket[remove_attachment_ids][]\":[\"string\"]}"))
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Request Body
{
  "ticket[title]": "string",
  "ticket[description]": "string",
  "ticket[status]": "new",
  "ticket[priority]": "low",
  "ticket[assigned_to_id]": "string",
  "ticket[attachments][]": [
    "string"
  ],
  "ticket[remove_attachment_ids][]": [
    "string"
  ]
}
Responses
Ticket successfully updated
Response fields
  • id integer optional The ID of the ticket, emitted as a JSON number (integer), not a string. This is the internal primary key. IMPORTANT: a BARE numeric {id} path parameter is looked up by the ticket’s project-scoped id, NOT by this primary key — so passing this returned id directly as {id} will 404 (or match a different ticket) unless the scoped_id happens to equal the primary key. To reference a ticket by this primary-key id, prefix it with g- (e.g. g-123); a bare number always references the project-scoped id.
  • title string optional
  • description string optional
  • status string optional Current status of the ticket
    new open pending solved closed
  • priority string optional Priority level of the ticket
    low medium high critical
  • warnings array[string] optional Partial-success notices. Present on create/update responses only when a custom-field value was silently dropped despite the 2xx status — e.g. a value exceeded the 4096-character cap, the 32-auto-created-fields-per-entity limit was hit, or a field could not be auto-created. Clients that submit custom fields should inspect this array to detect partial data loss.
  • created_at string date-time optional
  • updated_at string date-time optional
  • reporter object optional User who reported the ticket
    • id integer optional Internal user id, emitted as a JSON number.
    • name string optional Display name. May be an identity-masked placeholder when the project hides team identities from players.
  • assigned_to object optional The assignee. This key is ALWAYS present as an object — it is never JSON null. When the ticket is unassigned, both id and name are null (i.e. {"id": null, "name": null}). Do not test the object itself against null; check assigned_to.id.
    • id integer optional nullable Internal user id (JSON number), or null when unassigned.
    • name string optional nullable Assignee display name, or null when unassigned. May be an identity-masked placeholder when the project hides team identities from players.
  • url string optional URL to view the ticket in BetaHub
  • attachments array[object] optional Attached files (screenshots, documents, etc.)
    • id integer optional Attachment id, emitted as a JSON number.
    • type string optional Constant string ‘attachment’ identifying the record type.
      attachment
    • filename string optional Original uploaded filename (alias of original_filename).
    • original_filename string optional Original uploaded filename.
    • url string optional nullable CDN URL to the file, or null if the file is not attached.
    • content_type string optional
    • file_type string optional Server-detected category of the attachment, derived from its content type.
      image video document audio other
    • size_bytes integer optional File size in bytes (0 when the file is not attached).
    • display_order integer optional
    • created_at string date-time optional
    • updated_at string date-time optional
    • user object optional The uploader.
      • id integer optional Uploader user id, emitted as a JSON number.
      • name string optional Uploader display name. May be an identity-masked placeholder when the project hides team identities from players.
Examples
Example Request
{
  "ticket[status]": "solved",
  "ticket[description]": "Issue resolved - password reset email sent"
}
Example Response
{
  "id": 123,
  "title": "Password reset assistance needed",
  "description": "Issue resolved - password reset email sent",
  "status": "solved",
  "priority": "high",
  "created_at": "2024-10-06T10:00:00Z",
  "updated_at": "2024-10-06T11:00:00Z",
  "reporter": {
    "id": 456,
    "name": "John Doe"
  },
  "assigned_to": {
    "id": 789,
    "name": "Support Agent"
  },
  "url": "https://app.betahub.io/projects/1/tickets/123",
  "attachments": []
}
Unprocessable Entity. Validation errors in the provided data, an invalid status transition, or support tickets not enabled for the project.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
Examples
Blank Description
{
  "error": "Description can't be blank"
}
Description Too Short
{
  "error": "Description is too short (minimum is 30 characters)"
}
Status New Not Settable
{
  "error": "Status cannot be manually set to 'new'"
}
Status Closed Only To Open
{
  "error": "Status can only be set to 'open' from 'closed'"
}
Status Pending Needs Team Comment
{
  "error": "Status cannot mark as awaiting feedback - the last comment must be from a team member, not the reporter"
}
Tickets Not Enabled
{
  "error": "Support tickets are not enabled for this project. Ask the server administrator to enable them."
}
Forbidden. User does not have permission to update this ticket.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Ticket not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}

Releases

Create, publish, archive, and manage releases
Retrieves a list of releases for the specified project, ordered by creation date (oldest first by default). Returns release details including label, description, download links, and metadata. By default, only published releases are returned. Use the show_drafts parameter (developers only) to include draft releases, or show_archived to include archived releases.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
Query Parameters
Name Type Description
sort optional string Sort order by creation date. asc for oldest first (default), desc for newest first.
asc desc
Default: asc
show_drafts optional string Include draft releases in the response. Only project developers can view drafts. Set to true to show draft releases. This parameter is ignored for non-developers.
true false
Default: false
show_archived optional string Include archived releases in the response. Set to true to show archived releases.
true false
Default: false
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/releases.json?sort=asc&show_drafts=false&show_archived=false"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/releases.json?sort=asc&show_drafts=false&show_archived=false")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.get(
    "https://app.betahub.io/projects/123/releases.json?sort=asc&show_drafts=false&show_archived=false",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/releases.json?sort=asc&show_drafts=false&show_archived=false", {
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/releases.json?sort=asc&show_drafts=false&show_archived=false"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .GET()
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Successful response
application/json
[
  {
    "id": 123,
    "project_id": 1,
    "label": "v1.0.0",
    "summary": "Initial release",
    "description": "First stable version of our game",
    "status": "published",
    "active": true,
    "release_type": "regular",
    "download_link": "https://example.com/download/game-v1.0.0-windows.zip",
    "platforms": [
      "windows",
      "macos"
    ],
    "dynamically_created": false,
    "created_at": "2024-10-03T12:34:56Z",
    "updated_at": "2024-10-03T12:34:56Z"
  }
]
Forbidden. The caller lacks read access to this project’s releases. When authenticating with a project (FormUser) token, the token must have the can_read_release_list permission enabled; otherwise the list request is rejected.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Project not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}

Creates a new release for a project. Releases contain download links, attachments, and metadata about a version of the project. By default, releases are created as published and trigger notifications to project members. Use release[status]=draft to create a draft release that can be edited before publishing.

Requires an authenticated session with the project.releases.manage scope (a signed-in project developer / organization admin). Anonymous or read-only callers receive 403 Forbidden.

Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
Request Body
multipart/form-data
  • release[label] string required Version label for the release (e.g., “v1.0.0”)
  • release[summary] string optional Brief summary of the release
  • release[description] string optional Detailed description of the release changes
  • release[release_type] string optional The type of release. regular for minor updates, major for significant new features, hotfix for critical bug fixes. Affects the notification style. Defaults to regular.
    regular major hotfix
    Default: regular
  • release[platforms][] array[string] optional List of platform identifiers this release targets (e.g., windows, macos, linux). Sent as repeated release[platforms][] form fields. Blank values are stripped server-side.
  • release[send_images_separately] boolean optional Whether to send attachment images separately in notifications
  • release[attachments] array[string] optional File attachments for the release
  • release[download_links_attributes] array[object] optional
    • platform string optional Platform name (e.g., “windows”, “macos”, “linux”)
    • url string optional Download URL for this platform
    • link_enabled boolean optional Whether this download link is enabled
    • choose_previous_link boolean optional When true, reuse the download link from the previous release for this platform instead of requiring a new URL.
  • release[status] string optional The publication status for the release. Set to draft to create a draft release that is only visible to developers and does not trigger notifications. Set to published (default) to publish immediately and notify project members.
    draft published
    Default: published
cURL
curl \
  -X POST \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  -F "release[label]=string" \
  -F "release[summary]=string" \
  -F "release[description]=Example description" \
  -F "release[release_type]=regular" \
  "https://app.betahub.io/projects/123/releases.json"
Ruby
require "net/http"
require "json"

uri = URI("https://app.betahub.io/projects/123/releases.json")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.post(
    "https://app.betahub.io/projects/123/releases.json",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/releases.json", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/releases.json"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString("{\"release[label]\":\"string\",\"release[summary]\":\"string\",\"release[description]\":\"string\",\"release[release_type]\":\"regular\",\"release[platforms][]\":[\"string\"],\"release[send_images_separately]\":true,\"release[attachments]\":[\"string\"],\"release[download_links_attributes]\":[{\"platform\":\"string\",\"url\":\"https://example.com\",\"link_enabled\":true,\"choose_previous_link\":true}],\"release[status]\":\"draft\"}"))
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Request Body
{
  "release[label]": "string",
  "release[summary]": "string",
  "release[description]": "string",
  "release[release_type]": "regular",
  "release[platforms][]": [
    "string"
  ],
  "release[send_images_separately]": true,
  "release[attachments]": [
    "string"
  ],
  "release[download_links_attributes]": [
    {
      "platform": "string",
      "url": "https://example.com",
      "link_enabled": true,
      "choose_previous_link": true
    }
  ],
  "release[status]": "draft"
}
Responses
Successful response
Response fields
  • id integer optional The numeric ID of the release (a plain integer, e.g. 123 — not a prefixed string).
  • project_id integer optional ID of the project the release belongs to.
  • label string optional Version label of the release
  • summary string optional Brief summary of the release
  • description string optional Detailed description of the release
  • status string optional The publication status of the release. ‘draft’ releases are only visible to developers and can be edited freely before publishing. ‘published’ releases are visible to all project members and trigger notifications. ‘archived’ releases are hidden from testers and release selection forms.
    draft published archived
  • active boolean optional Deprecated Deprecated - use ‘status’ field instead. Returns true if status is ‘draft’ or ‘published’, false if ‘archived’. Maintained for backward compatibility.
  • release_type string optional The type of release. ‘regular’ for minor updates, ‘major’ for significant new features, ‘hotfix’ for critical bug fixes. This affects the notification style sent to players.
    regular major hotfix
  • download_link string optional nullable Single download URL for the release, stored verbatim (the download_link column). Null when no link is set. Note this is a singular string column — there is no per-platform download_links array.
  • platforms array[string] optional nullable The release’s platforms column, serialized as stored (a list of platform names, e.g. ["windows", "macos"]). May be null when unset.
  • dynamically_created boolean optional True when the release was auto-created (e.g. from a release_label on issue submission) rather than created explicitly through the releases UI/API.
  • created_at string date-time optional
  • updated_at string date-time optional
Examples
Example Request
{
  "release[label]": "v1.1.0",
  "release[summary]": "Bug fixes and improvements",
  "release[description]": "This release includes several bug fixes and performance improvements.",
  "release[release_type]": "hotfix",
  "release[download_links_attributes]": [
    {
      "platform": "windows",
      "url": "https://example.com/download/game-v1.1.0-windows.zip",
      "link_enabled": true
    }
  ]
}
Example Response
{
  "id": 456,
  "project_id": 1,
  "label": "v1.1.0",
  "summary": "Bug fixes and improvements",
  "description": "This release includes several bug fixes and performance improvements.",
  "status": "published",
  "active": true,
  "release_type": "hotfix",
  "download_link": "https://example.com/download/game-v1.1.0-windows.zip",
  "platforms": [
    "windows"
  ],
  "dynamically_created": false,
  "created_at": "2024-10-03T15:20:30Z",
  "updated_at": "2024-10-03T15:20:30Z"
}
Forbidden. User must be a project developer or admin.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Unprocessable Entity. Validation errors in the provided data. The body is the raw ActiveModel error map at the top level — an object of { "<attribute>": ["<message>"] } (e.g. { "label": ["can't be blank"] }), with NO error/errors/status wrapper.
Response fields
  • error string optional Human-readable error message (shapes a and b). English, not localized, and not a stable contract — display it, do not parse it.
  • status object optional Present only in shape (b): a redundant copy of the HTTP status code as an integer (e.g. 422). Unreliable and often absent — rely on the HTTP status line instead.
  • errors object optional Shape (c). Either an object mapping attribute name → array of messages, or (on a few endpoints) a flat array of full-message strings.
application/json
{
  "label": [
    "can't be blank"
  ]
}
Retrieves detailed information about a specific release, including all download links, attachments, and metadata. Draft releases are only visible to project developers.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
release_id required string
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/releases/123.json"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/releases/123.json")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.get(
    "https://app.betahub.io/projects/123/releases/123.json",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/releases/123.json", {
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/releases/123.json"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .GET()
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Successful response
Response fields
  • id integer optional The numeric ID of the release (a plain integer, e.g. 123 — not a prefixed string).
  • project_id integer optional ID of the project the release belongs to.
  • label string optional Version label of the release
  • summary string optional Brief summary of the release
  • description string optional Detailed description of the release
  • status string optional The publication status of the release. ‘draft’ releases are only visible to developers and can be edited freely before publishing. ‘published’ releases are visible to all project members and trigger notifications. ‘archived’ releases are hidden from testers and release selection forms.
    draft published archived
  • active boolean optional Deprecated Deprecated - use ‘status’ field instead. Returns true if status is ‘draft’ or ‘published’, false if ‘archived’. Maintained for backward compatibility.
  • release_type string optional The type of release. ‘regular’ for minor updates, ‘major’ for significant new features, ‘hotfix’ for critical bug fixes. This affects the notification style sent to players.
    regular major hotfix
  • download_link string optional nullable Single download URL for the release, stored verbatim (the download_link column). Null when no link is set. Note this is a singular string column — there is no per-platform download_links array.
  • platforms array[string] optional nullable The release’s platforms column, serialized as stored (a list of platform names, e.g. ["windows", "macos"]). May be null when unset.
  • dynamically_created boolean optional True when the release was auto-created (e.g. from a release_label on issue submission) rather than created explicitly through the releases UI/API.
  • created_at string date-time optional
  • updated_at string date-time optional
application/json
{
  "id": 123,
  "project_id": 1,
  "label": "v1.0.0",
  "summary": "Initial release",
  "description": "First stable version of our game",
  "status": "published",
  "active": true,
  "release_type": "regular",
  "download_link": "https://example.com/download/game-v1.0.0-windows.zip",
  "platforms": [
    "windows",
    "macos"
  ],
  "dynamically_created": false,
  "created_at": "2024-10-03T12:34:56Z",
  "updated_at": "2024-10-03T12:34:56Z"
}
Forbidden. User must have project access.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Release not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}

Updates an existing release with new information. Download links and attachments are handled together with the main release data via nested attributes.

This is a web (HTML) endpoint, not a JSON API endpoint. On success it responds with a 302 redirect to the project’s releases index (the outcome is carried in a flash message, not in the response body) — it does not return the updated release as JSON. On a validation error it re-renders the edit form as HTML (422); a client sending Accept: application/json will get a 500 (missing template) because no JSON view exists. Requires an authenticated session with the project.releases.manage scope (a signed-in project developer / organization admin).

Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
release_id required string
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
Request Body
multipart/form-data
  • release[label] string optional Updated version label
  • release[summary] string optional Updated summary
  • release[description] string optional Updated description
  • release[release_type] string optional Updated release type. regular, major, or hotfix. Note - unlike create, release[status] cannot be changed here (use the publish/archive/restore actions to change status).
    regular major hotfix
  • release[platforms][] array[string] optional Updated list of platform identifiers. Sent as repeated release[platforms][] form fields. Blank values are stripped server-side.
  • release[send_images_separately] boolean optional Whether to send attachment images separately
  • release[download_links_attributes] array[object] optional
    • id integer optional ID of an existing download link (required to update or delete it)
    • platform string optional Platform name
    • url string optional Download URL
    • link_enabled boolean optional Whether this download link is enabled
    • choose_previous_link boolean optional When true, reuse the download link from the previous release for this platform instead of requiring a URL.
    • _destroy boolean optional When true (paired with the link id), deletes that existing download link from the release.
  • release[remove_attachments] array[string] optional IDs of attachments to remove
  • release[attachments] array[string] optional New attachments to add
cURL
curl \
  -X PUT \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  -F "release[label]=string" \
  -F "release[summary]=string" \
  -F "release[description]=Example description" \
  -F "release[release_type]=regular" \
  "https://app.betahub.io/projects/123/releases/123.json"
Ruby
require "net/http"
require "json"

uri = URI("https://app.betahub.io/projects/123/releases/123.json")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Put.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.put(
    "https://app.betahub.io/projects/123/releases/123.json",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/releases/123.json", {
  method: "PUT",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/releases/123.json"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .header("Content-Type", "application/json")
    .PUT(HttpRequest.BodyPublishers.ofString("{\"release[label]\":\"string\",\"release[summary]\":\"string\",\"release[description]\":\"string\",\"release[release_type]\":\"regular\",\"release[platforms][]\":[\"string\"],\"release[send_images_separately]\":true,\"release[download_links_attributes]\":[{\"id\":0,\"platform\":\"string\",\"url\":\"https://example.com\",\"link_enabled\":true,\"choose_previous_link\":true,\"_destroy\":true}],\"release[remove_attachments]\":[\"string\"],\"release[attachments]\":[\"string\"]}"))
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Request Body
{
  "release[label]": "string",
  "release[summary]": "string",
  "release[description]": "string",
  "release[release_type]": "regular",
  "release[platforms][]": [
    "string"
  ],
  "release[send_images_separately]": true,
  "release[download_links_attributes]": [
    {
      "id": 0,
      "platform": "string",
      "url": "https://example.com",
      "link_enabled": true,
      "choose_previous_link": true,
      "_destroy": true
    }
  ],
  "release[remove_attachments]": [
    "string"
  ],
  "release[attachments]": [
    "string"
  ]
}
Responses
Release updated. Redirects (HTML) to the project’s releases index; the success message is delivered as a flash notice. No response body is returned.
Release updated. Redirects (HTML) to the project's releases index; the success message is delivered as a flash notice. No response body is returned.
Unprocessable Entity. Validation failed and the edit form is re-rendered as HTML. There is no JSON representation of this response — clients requesting JSON receive a 500 (missing template) instead.
Unprocessable Entity. Validation failed and the edit form is re-rendered as HTML. There is no JSON representation of this response — clients requesting JSON receive a `500` (missing template) instead.
Forbidden. User must be a signed-in project developer or organization admin.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Release not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}

Deletes a release from the project. Cannot delete the only published release of a project (archive it instead), nor a release that has issues assigned. When deletion succeeds, all associated download links and attachments are also removed.

This is a web (HTML) endpoint, not a JSON API endpoint. Both outcomes respond with a 302 redirect to the project’s releases index — the result (success or refusal) is conveyed only through a flash message, never in a JSON body. A constraint refusal is not an HTTP error status; it is a 302 redirect carrying an alert flash. Requires an authenticated session with the project.releases.manage scope (a signed-in project developer / organization admin).

Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
release_id required string
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -X DELETE \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/releases/123.json"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/releases/123.json")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Delete.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.delete(
    "https://app.betahub.io/projects/123/releases/123.json",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/releases/123.json", {
  method: "DELETE",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/releases/123.json"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .method("DELETE", HttpRequest.BodyPublishers.noBody())
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Redirect to the project’s releases index. Returned for BOTH outcomes - successful deletion (success flash: “Release was successfully deleted.”) and a refused deletion due to a constraint (alert flash, e.g. “Cannot delete the only published release for this project. Archive it instead.” or “Cannot delete this release because it has 3 issue(s) assigned. Archive it instead.”). The specific outcome is only distinguishable via the flash message, not the status code.
Redirect to the project's releases index. Returned for BOTH outcomes - successful deletion (success flash: "Release was successfully deleted.") and a refused deletion due to a constraint (alert flash, e.g. "Cannot delete the only published release for this project. Archive it instead." or "Cannot delete this release because it has 3 issue(s) assigned. Archive it instead."). The specific outcome is only distinguishable via the flash message, not the status code.
Forbidden. User must be a signed-in project developer or organization admin.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Release not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}

This is a web (HTML/redirect) endpoint, not a JSON API endpoint — the path has no .json suffix and it never returns a JSON body. Access requires a signed-in project member (draft releases require the project.releases.manage scope).

Behavior depends on the platform query parameter:

  • With platform (e.g. ?platform=windows): if a download link exists for that platform, a download record is created (for analytics) and the response is a 302 redirect to the external download URL (allow_other_host). If no link exists for that platform, it responds with a 302 redirect back to the release page carrying an alert flash (“Download link not available for …”).

  • Without platform: renders the release’s download HTML page listing the available platforms. It does not return a JSON payload of platforms/download counts.

Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
release_id required string
Query Parameters
Name Type Description
platform optional string Platform to download (e.g., “windows”, “macos”, “linux”). When provided, records the download and redirects to the external download URL.
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/releases/123/download?platform=example"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/releases/123/download?platform=example")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.get(
    "https://app.betahub.io/projects/123/releases/123/download?platform=example",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/releases/123/download?platform=example", {
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/releases/123/download?platform=example"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .GET()
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Redirect. When platform is supplied and a link exists, redirects to the external download URL (a download record is created first). When the platform has no download link, redirects back to the release page with an alert flash.
Redirect. When `platform` is supplied and a link exists, redirects to the external download URL (a download record is created first). When the platform has no download link, redirects back to the release page with an alert flash.
The release download page rendered as HTML (returned when no platform is specified).
text/html
"string"
Forbidden. User must be a signed-in project member (draft releases require developer access).
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Release not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Publishes a draft release, making it visible to all project members and sending notifications. This is an idempotent operation - calling it on an already published release returns success without changes.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
release_id required string
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -X POST \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/releases/123/publish.json"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/releases/123/publish.json")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.post(
    "https://app.betahub.io/projects/123/releases/123/publish.json",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/releases/123/publish.json", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/releases/123/publish.json"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .method("POST", HttpRequest.BodyPublishers.noBody())
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Release published successfully
Response fields
  • id integer optional The numeric ID of the release (a plain integer, e.g. 123 — not a prefixed string).
  • project_id integer optional ID of the project the release belongs to.
  • label string optional Version label of the release
  • summary string optional Brief summary of the release
  • description string optional Detailed description of the release
  • status string optional The publication status of the release. ‘draft’ releases are only visible to developers and can be edited freely before publishing. ‘published’ releases are visible to all project members and trigger notifications. ‘archived’ releases are hidden from testers and release selection forms.
    draft published archived
  • active boolean optional Deprecated Deprecated - use ‘status’ field instead. Returns true if status is ‘draft’ or ‘published’, false if ‘archived’. Maintained for backward compatibility.
  • release_type string optional The type of release. ‘regular’ for minor updates, ‘major’ for significant new features, ‘hotfix’ for critical bug fixes. This affects the notification style sent to players.
    regular major hotfix
  • download_link string optional nullable Single download URL for the release, stored verbatim (the download_link column). Null when no link is set. Note this is a singular string column — there is no per-platform download_links array.
  • platforms array[string] optional nullable The release’s platforms column, serialized as stored (a list of platform names, e.g. ["windows", "macos"]). May be null when unset.
  • dynamically_created boolean optional True when the release was auto-created (e.g. from a release_label on issue submission) rather than created explicitly through the releases UI/API.
  • created_at string date-time optional
  • updated_at string date-time optional
application/json
{
  "id": 123,
  "label": "v1.0.0",
  "summary": "Initial release",
  "description": "First stable version",
  "status": "published",
  "active": true,
  "release_type": "regular",
  "created_at": "2024-10-03T12:34:56Z",
  "updated_at": "2024-10-03T15:20:30Z",
  "download_link": null,
  "platforms": [],
  "dynamically_created": false
}
Forbidden. User must be a project developer or admin.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Release not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Archives a release, hiding it from testers and release selection forms. Cannot archive the only published release - at least one published release must remain. Use /restore to bring an archived release back.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
release_id required string
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -X POST \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/releases/123/archive.json"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/releases/123/archive.json")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.post(
    "https://app.betahub.io/projects/123/releases/123/archive.json",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/releases/123/archive.json", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/releases/123/archive.json"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .method("POST", HttpRequest.BodyPublishers.noBody())
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Release archived successfully
Response fields
  • id integer optional The numeric ID of the release (a plain integer, e.g. 123 — not a prefixed string).
  • project_id integer optional ID of the project the release belongs to.
  • label string optional Version label of the release
  • summary string optional Brief summary of the release
  • description string optional Detailed description of the release
  • status string optional The publication status of the release. ‘draft’ releases are only visible to developers and can be edited freely before publishing. ‘published’ releases are visible to all project members and trigger notifications. ‘archived’ releases are hidden from testers and release selection forms.
    draft published archived
  • active boolean optional Deprecated Deprecated - use ‘status’ field instead. Returns true if status is ‘draft’ or ‘published’, false if ‘archived’. Maintained for backward compatibility.
  • release_type string optional The type of release. ‘regular’ for minor updates, ‘major’ for significant new features, ‘hotfix’ for critical bug fixes. This affects the notification style sent to players.
    regular major hotfix
  • download_link string optional nullable Single download URL for the release, stored verbatim (the download_link column). Null when no link is set. Note this is a singular string column — there is no per-platform download_links array.
  • platforms array[string] optional nullable The release’s platforms column, serialized as stored (a list of platform names, e.g. ["windows", "macos"]). May be null when unset.
  • dynamically_created boolean optional True when the release was auto-created (e.g. from a release_label on issue submission) rather than created explicitly through the releases UI/API.
  • created_at string date-time optional
  • updated_at string date-time optional
application/json
{
  "id": 123,
  "label": "v0.9.0",
  "summary": "Beta release",
  "description": "Old beta version",
  "status": "archived",
  "active": false,
  "release_type": "regular",
  "created_at": "2024-09-01T12:34:56Z",
  "updated_at": "2024-10-03T15:20:30Z",
  "download_link": null,
  "platforms": [],
  "dynamically_created": false
}
Forbidden. User must be a project developer or admin.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Release not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Unprocessable Entity. Cannot archive the only published release. The body is the flat-array form { "errors": ["<full message>"] } (plural errors, an array of full-message strings) — NOT a singular error string.
Response fields
  • error string optional Human-readable error message (shapes a and b). English, not localized, and not a stable contract — display it, do not parse it.
  • status object optional Present only in shape (b): a redundant copy of the HTTP status code as an integer (e.g. 422). Unreliable and often absent — rely on the HTTP status line instead.
  • errors object optional Shape (c). Either an object mapping attribute name → array of messages, or (on a few endpoints) a flat array of full-message strings.
application/json
{
  "errors": [
    "Cannot archive the only published release for this project."
  ]
}
Restores an archived release back to published status, making it visible to testers and available in release selection forms again.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
release_id required string
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -X POST \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/releases/123/restore.json"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/releases/123/restore.json")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.post(
    "https://app.betahub.io/projects/123/releases/123/restore.json",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/releases/123/restore.json", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/releases/123/restore.json"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .method("POST", HttpRequest.BodyPublishers.noBody())
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Release restored successfully
Response fields
  • id integer optional The numeric ID of the release (a plain integer, e.g. 123 — not a prefixed string).
  • project_id integer optional ID of the project the release belongs to.
  • label string optional Version label of the release
  • summary string optional Brief summary of the release
  • description string optional Detailed description of the release
  • status string optional The publication status of the release. ‘draft’ releases are only visible to developers and can be edited freely before publishing. ‘published’ releases are visible to all project members and trigger notifications. ‘archived’ releases are hidden from testers and release selection forms.
    draft published archived
  • active boolean optional Deprecated Deprecated - use ‘status’ field instead. Returns true if status is ‘draft’ or ‘published’, false if ‘archived’. Maintained for backward compatibility.
  • release_type string optional The type of release. ‘regular’ for minor updates, ‘major’ for significant new features, ‘hotfix’ for critical bug fixes. This affects the notification style sent to players.
    regular major hotfix
  • download_link string optional nullable Single download URL for the release, stored verbatim (the download_link column). Null when no link is set. Note this is a singular string column — there is no per-platform download_links array.
  • platforms array[string] optional nullable The release’s platforms column, serialized as stored (a list of platform names, e.g. ["windows", "macos"]). May be null when unset.
  • dynamically_created boolean optional True when the release was auto-created (e.g. from a release_label on issue submission) rather than created explicitly through the releases UI/API.
  • created_at string date-time optional
  • updated_at string date-time optional
application/json
{
  "id": 123,
  "label": "v0.9.0",
  "summary": "Beta release",
  "description": "Old beta version",
  "status": "published",
  "active": true,
  "release_type": "regular",
  "created_at": "2024-09-01T12:34:56Z",
  "updated_at": "2024-10-03T15:20:30Z",
  "download_link": null,
  "platforms": [],
  "dynamically_created": false
}
Forbidden. User must be a project developer or admin.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Release not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Unprocessable Entity. Release is not archived. The body is the flat-array form { "errors": ["<full message>"] } (plural errors, an array of full-message strings) — NOT a singular error string.
Response fields
  • error string optional Human-readable error message (shapes a and b). English, not localized, and not a stable contract — display it, do not parse it.
  • status object optional Present only in shape (b): a redundant copy of the HTTP status code as an integer (e.g. 422). Unreliable and often absent — rely on the HTTP status line instead.
  • errors object optional Shape (c). Either an object mapping attribute name → array of messages, or (on a few endpoints) a flat array of full-message strings.
application/json
{
  "error": "string",
  "status": {},
  "errors": {}
}

Game Facts

Export and import game facts for AI-powered features
Retrieves comprehensive game facts data for a project in JSON format. Game facts include information about the game’s mechanics, technical specifications, target audience, platforms, glossary terms, and other project-specific details that help with AI-powered issue categorization and context understanding.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/game_facts.json"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/game_facts.json")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.get(
    "https://app.betahub.io/projects/123/game_facts.json",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/game_facts.json", {
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/game_facts.json"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .GET()
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Successful response
Response fields
  • description string optional Description of the game
  • genre string optional Game genre
  • target_audience string optional Target audience for the game
  • ui_ux_considerations string optional User interface and experience considerations
  • platforms array[string] optional Supported platforms
  • core_mechanics object optional
    • key_features array[string] optional Key game features
    • game_modes array[string] optional Available game modes
    • controls object optional Control scheme, split by input device. Each device entry is an open key -> value string map: the key is a free-form action name (e.g. “move”, “attack”) and the value is the binding description.
      • pc object optional PC control mappings as an open action -> binding string map (e.g. { “move”: “WASD”, “attack”: “Left Click” }).
        • «key» string optional
      • console object optional Console control mappings as an open action -> binding string map (e.g. { “move”: “Left Stick”, “attack”: “X Button” }).
        • «key» string optional
  • technical_specifications object optional
    • supported_platforms array[string] optional Technically supported platforms
    • minimum_system_requirements object optional Minimum system requirements as an open key -> value string map. Keys are free-form requirement names (e.g. “ram”, “cpu”, “gpu”) and values are their descriptions (e.g. { “ram”: “8GB”, “cpu”: “Intel i5” }).
      • «key» string optional
  • game_progression object optional
    • level_design string optional Level design information
    • progression_system string optional Character/game progression system
  • bug_reporting_guidelines object optional
    • critical_components array[string] optional Critical game components for bug reporting
    • non_critical_components array[string] optional Non-critical game components
    • known_bugs array[string] optional List of known bugs
  • glossary object optional
    • items array[object] optional Game items glossary
      • term string optional
      • definition string optional
    • characters array[object] optional Game characters glossary
      • term string optional
      • definition string optional
    • events array[object] optional Game events glossary
      • term string optional
      • definition string optional
    • locations array[object] optional Game locations glossary
      • term string optional
      • definition string optional
    • other_terms array[object] optional Other game terms glossary
      • term string optional
      • definition string optional
  • save_load_system object optional
    • save_method string optional How the game saves data
    • known_issues array[string] optional Known save/load issues
  • multiplayer_online_features object optional
    • supported_modes array[string] optional Supported multiplayer modes
    • networking object optional
      • server_type string optional Type of server architecture
      • known_issues array[string] optional Known networking issues
  • localization object optional
    • supported_languages array[string] optional Supported languages
    • known_issues array[string] optional Known localization issues
  • project object optional
    • id string optional Project ID
    • name string optional Project name
  • meta object optional
    • created_at string date-time optional Creation timestamp
    • updated_at string date-time optional Last update timestamp
application/json
{
  "description": "A fantasy RPG set in a magical world",
  "genre": "Action RPG",
  "target_audience": "Teen",
  "platforms": [
    "PC",
    "PlayStation",
    "Xbox"
  ],
  "core_mechanics": {
    "key_features": [
      "Magic system",
      "Character progression",
      "Crafting"
    ],
    "game_modes": [
      "Single Player",
      "Cooperative"
    ],
    "controls": {
      "pc": {
        "move": "WASD",
        "attack": "Left Click"
      },
      "console": {
        "move": "Left Stick",
        "attack": "X Button"
      }
    }
  },
  "technical_specifications": {
    "supported_platforms": [
      "Windows",
      "Linux",
      "macOS"
    ],
    "minimum_system_requirements": {
      "ram": "8GB",
      "cpu": "Intel i5",
      "gpu": "GTX 1060"
    }
  },
  "glossary": {
    "items": [
      {
        "term": "Mana Potion",
        "definition": "Restores magical energy"
      }
    ],
    "characters": [
      {
        "term": "Elder Mage",
        "definition": "Wise spellcaster and quest giver"
      }
    ],
    "events": [],
    "locations": [],
    "other_terms": []
  },
  "project": {
    "id": "pr-123456",
    "name": "My Fantasy Game"
  },
  "meta": {
    "created_at": "2024-10-03T12:34:56Z",
    "updated_at": "2024-10-03T14:22:10Z"
  }
}
Forbidden. User must be a project developer or admin.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Project not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}

Updates game facts for a project with new JSON data. This endpoint can be used to programmatically import game facts from external sources or update specific sections of the game facts. If no game facts exist for the project, new ones will be created. All validation rules apply, including field length limits and required nested structures.

This is the endpoint to use for both creating and updating game facts. Note that the separate form-based create action (a plain POST to the game facts resource, used by the web UI) only applies its payload when no game facts exist yet: if a project already has game facts, that create action silently ignores the submitted fields and leaves the existing record unchanged. To modify existing game facts programmatically, always use this PATCH endpoint.

Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
Request Body
application/json
  • game_fact object optional Game facts payload. All fields are optional; only the sections you include are affected. Per-field length limits are enforced server-side and a 422 is returned if any limit is exceeded.
    • description string optional Description of the game (max 1000 characters)
      max length: 1000
    • genre string optional Game genre (max 100 characters)
      max length: 100
    • target_audience string optional Target audience for the game (max 300 characters)
      max length: 300
    • ui_ux_considerations string optional User interface and experience considerations
    • platforms array[string] optional Supported platforms (max 10 items)
    • core_mechanics object optional
      • key_features array[string] optional Key game features (each item max 500 characters)
        max length: 500
      • game_modes array[string] optional Available game modes (each item max 500 characters)
        max length: 500
      • controls object optional Control scheme, split by input device. Each device entry is an open key -> value string map: the key is a free-form action name (e.g. “move”, “attack”) and the value is the binding description (each value max 500 characters).
        • pc object optional PC control mappings as an open action -> binding string map (e.g. { “move”: “WASD”, “attack”: “Left Click” }).
          • «key» string optional
            max length: 500
        • console object optional Console control mappings as an open action -> binding string map (e.g. { “move”: “Left Stick”, “attack”: “X Button” }).
          • «key» string optional
            max length: 500
    • technical_specifications object optional
      • supported_platforms array[string] optional Technically supported platforms (each item max 100 characters)
        max length: 100
      • minimum_system_requirements object optional Minimum system requirements as an open key -> value string map. Keys are free-form requirement names (e.g. “ram”, “cpu”, “gpu”) and values are their descriptions (each value max 500 characters), e.g. { “ram”: “8GB”, “cpu”: “Intel i5” }.
        • «key» string optional
          max length: 500
    • game_progression object optional
      • level_design string optional Level design information (max 500 characters)
        max length: 500
      • progression_system string optional Character/game progression system (max 500 characters)
        max length: 500
    • bug_reporting_guidelines object optional
      • critical_components array[string] optional Critical game components for bug reporting (each item max 500 characters)
        max length: 500
      • non_critical_components array[string] optional Non-critical game components (each item max 500 characters)
        max length: 500
      • known_bugs array[string] optional List of known bugs (each item max 500 characters)
        max length: 500
    • glossary object optional
      • items array[object] optional Game items glossary (term and definition each max 1000 characters)
        • term string optional
          max length: 1000
        • definition string optional
          max length: 1000
      • characters array[object] optional Game characters glossary (term and definition each max 1000 characters)
        • term string optional
          max length: 1000
        • definition string optional
          max length: 1000
      • events array[object] optional Game events glossary (term and definition each max 1000 characters)
        • term string optional
          max length: 1000
        • definition string optional
          max length: 1000
      • locations array[object] optional Game locations glossary (term and definition each max 1000 characters)
        • term string optional
          max length: 1000
        • definition string optional
          max length: 1000
      • other_terms array[object] optional Other game terms glossary (term and definition each max 1000 characters)
        • term string optional
          max length: 1000
        • definition string optional
          max length: 1000
    • save_load_system object optional
      • save_method string optional How the game saves data (max 500 characters)
        max length: 500
      • known_issues array[string] optional Known save/load issues (each item max 500 characters)
        max length: 500
    • multiplayer_online_features object optional
      • supported_modes array[string] optional Supported multiplayer modes (each item max 500 characters)
        max length: 500
      • networking object optional
        • server_type string optional Type of server architecture (max 500 characters)
          max length: 500
        • known_issues array[string] optional Known networking issues (each item max 500 characters)
          max length: 500
    • localization object optional
      • supported_languages array[string] optional Supported languages (each item max 100 characters)
        max length: 100
      • known_issues array[string] optional Known localization issues (each item max 300 characters)
        max length: 300
cURL
curl \
  -X PATCH \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  -H "Content-Type: application/json" \
  -d '{
  "game_fact": {
    "description": "string",
    "genre": "string",
    "target_audience": "string",
    "ui_ux_considerations": "string",
    "platforms": [
      "string"
    ],
    "core_mechanics": {
      "key_features": [
        "string"
      ],
      "game_modes": [
        "string"
      ],
      "controls": {
        "pc": {},
        "console": {}
      }
    },
    "technical_specifications": {
      "supported_platforms": [
        "string"
      ],
      "minimum_system_requirements": {
        "key": null
      }
    },
    "game_progression": {
      "level_design": "string",
      "progression_system": "string"
    },
    "bug_reporting_guidelines": {
      "critical_components": [
        "string"
      ],
      "non_critical_components": [
        "string"
      ],
      "known_bugs": [
        "string"
      ]
    },
    "glossary": {
      "items": [
        {}
      ],
      "characters": [
        {}
      ],
      "events": [
        {}
      ],
      "locations": [
        {}
      ],
      "other_terms": [
        {}
      ]
    },
    "save_load_system": {
      "save_method": "string",
      "known_issues": [
        "string"
      ]
    },
    "multiplayer_online_features": {
      "supported_modes": [
        "string"
      ],
      "networking": {
        "server_type": "string",
        "known_issues": [
          null
        ]
      }
    },
    "localization": {
      "supported_languages": [
        "string"
      ],
      "known_issues": [
        "string"
      ]
    }
  }
}' \
  "https://app.betahub.io/projects/123/game_facts.json"
Ruby
require "net/http"
require "json"

uri = URI("https://app.betahub.io/projects/123/game_facts.json")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Patch.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"
request["Content-Type"] = "application/json"
request.body = {
  "game_fact": {
    "description": "string",
    "genre": "string",
    "target_audience": "string",
    "ui_ux_considerations": "string",
    "platforms": [
      "string"
    ],
    "core_mechanics": {
      "key_features": [
        "string"
      ],
      "game_modes": [
        "string"
      ],
      "controls": {
        "pc": {},
        "console": {}
      }
    },
    "technical_specifications": {
      "supported_platforms": [
        "string"
      ],
      "minimum_system_requirements": {
        "key": null
      }
    },
    "game_progression": {
      "level_design": "string",
      "progression_system": "string"
    },
    "bug_reporting_guidelines": {
      "critical_components": [
        "string"
      ],
      "non_critical_components": [
        "string"
      ],
      "known_bugs": [
        "string"
      ]
    },
    "glossary": {
      "items": [
        {}
      ],
      "characters": [
        {}
      ],
      "events": [
        {}
      ],
      "locations": [
        {}
      ],
      "other_terms": [
        {}
      ]
    },
    "save_load_system": {
      "save_method": "string",
      "known_issues": [
        "string"
      ]
    },
    "multiplayer_online_features": {
      "supported_modes": [
        "string"
      ],
      "networking": {
        "server_type": "string",
        "known_issues": [
          null
        ]
      }
    },
    "localization": {
      "supported_languages": [
        "string"
      ],
      "known_issues": [
        "string"
      ]
    }
  }
}

response = http.request(request)
puts response.body
Python
import requests

response = requests.patch(
    "https://app.betahub.io/projects/123/game_facts.json",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"},
    json={
      "game_fact": {
        "description": "string",
        "genre": "string",
        "target_audience": "string",
        "ui_ux_considerations": "string",
        "platforms": [
          "string"
        ],
        "core_mechanics": {
          "key_features": [
            "string"
          ],
          "game_modes": [
            "string"
          ],
          "controls": {
            "pc": {},
            "console": {}
          }
        },
        "technical_specifications": {
          "supported_platforms": [
            "string"
          ],
          "minimum_system_requirements": {
            "key": null
          }
        },
        "game_progression": {
          "level_design": "string",
          "progression_system": "string"
        },
        "bug_reporting_guidelines": {
          "critical_components": [
            "string"
          ],
          "non_critical_components": [
            "string"
          ],
          "known_bugs": [
            "string"
          ]
        },
        "glossary": {
          "items": [
            {}
          ],
          "characters": [
            {}
          ],
          "events": [
            {}
          ],
          "locations": [
            {}
          ],
          "other_terms": [
            {}
          ]
        },
        "save_load_system": {
          "save_method": "string",
          "known_issues": [
            "string"
          ]
        },
        "multiplayer_online_features": {
          "supported_modes": [
            "string"
          ],
          "networking": {
            "server_type": "string",
            "known_issues": [
              null
            ]
          }
        },
        "localization": {
          "supported_languages": [
            "string"
          ],
          "known_issues": [
            "string"
          ]
        }
      }
    }
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/game_facts.json", {
  method: "PATCH",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123",
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    "game_fact": {
      "description": "string",
      "genre": "string",
      "target_audience": "string",
      "ui_ux_considerations": "string",
      "platforms": [
        "string"
      ],
      "core_mechanics": {
        "key_features": [
          "string"
        ],
        "game_modes": [
          "string"
        ],
        "controls": {
          "pc": {},
          "console": {}
        }
      },
      "technical_specifications": {
        "supported_platforms": [
          "string"
        ],
        "minimum_system_requirements": {
          "key": null
        }
      },
      "game_progression": {
        "level_design": "string",
        "progression_system": "string"
      },
      "bug_reporting_guidelines": {
        "critical_components": [
          "string"
        ],
        "non_critical_components": [
          "string"
        ],
        "known_bugs": [
          "string"
        ]
      },
      "glossary": {
        "items": [
          {}
        ],
        "characters": [
          {}
        ],
        "events": [
          {}
        ],
        "locations": [
          {}
        ],
        "other_terms": [
          {}
        ]
      },
      "save_load_system": {
        "save_method": "string",
        "known_issues": [
          "string"
        ]
      },
      "multiplayer_online_features": {
        "supported_modes": [
          "string"
        ],
        "networking": {
          "server_type": "string",
          "known_issues": [
            null
          ]
        }
      },
      "localization": {
        "supported_languages": [
          "string"
        ],
        "known_issues": [
          "string"
        ]
      }
    }
  })
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/game_facts.json"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .header("Content-Type", "application/json")
    .PATCH(HttpRequest.BodyPublishers.ofString("{\"game_fact\":{\"description\":\"string\",\"genre\":\"string\",\"target_audience\":\"string\",\"ui_ux_considerations\":\"string\",\"platforms\":[\"string\"],\"core_mechanics\":{\"key_features\":[\"string\"],\"game_modes\":[\"string\"],\"controls\":{\"pc\":{},\"console\":{}}},\"technical_specifications\":{\"supported_platforms\":[\"string\"],\"minimum_system_requirements\":{\"key\":null}},\"game_progression\":{\"level_design\":\"string\",\"progression_system\":\"string\"},\"bug_reporting_guidelines\":{\"critical_components\":[\"string\"],\"non_critical_components\":[\"string\"],\"known_bugs\":[\"string\"]},\"glossary\":{\"items\":[{}],\"characters\":[{}],\"events\":[{}],\"locations\":[{}],\"other_terms\":[{}]},\"save_load_system\":{\"save_method\":\"string\",\"known_issues\":[\"string\"]},\"multiplayer_online_features\":{\"supported_modes\":[\"string\"],\"networking\":{\"server_type\":\"string\",\"known_issues\":[null]}},\"localization\":{\"supported_languages\":[\"string\"],\"known_issues\":[\"string\"]}}}"))
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Request Body
{
  "game_fact": {
    "description": "string",
    "genre": "string",
    "target_audience": "string",
    "ui_ux_considerations": "string",
    "platforms": [
      "string"
    ],
    "core_mechanics": {
      "key_features": [
        "string"
      ],
      "game_modes": [
        "string"
      ],
      "controls": {
        "pc": {},
        "console": {}
      }
    },
    "technical_specifications": {
      "supported_platforms": [
        "string"
      ],
      "minimum_system_requirements": {
        "key": null
      }
    },
    "game_progression": {
      "level_design": "string",
      "progression_system": "string"
    },
    "bug_reporting_guidelines": {
      "critical_components": [
        "string"
      ],
      "non_critical_components": [
        "string"
      ],
      "known_bugs": [
        "string"
      ]
    },
    "glossary": {
      "items": [
        {}
      ],
      "characters": [
        {}
      ],
      "events": [
        {}
      ],
      "locations": [
        {}
      ],
      "other_terms": [
        {}
      ]
    },
    "save_load_system": {
      "save_method": "string",
      "known_issues": [
        "string"
      ]
    },
    "multiplayer_online_features": {
      "supported_modes": [
        "string"
      ],
      "networking": {
        "server_type": "string",
        "known_issues": [
          null
        ]
      }
    },
    "localization": {
      "supported_languages": [
        "string"
      ],
      "known_issues": [
        "string"
      ]
    }
  }
}
Responses
Successful response
Response fields
  • description string optional Description of the game
  • genre string optional Game genre
  • target_audience string optional Target audience for the game
  • ui_ux_considerations string optional User interface and experience considerations
  • platforms array[string] optional Supported platforms
  • core_mechanics object optional
    • key_features array[string] optional Key game features
    • game_modes array[string] optional Available game modes
    • controls object optional Control scheme, split by input device. Each device entry is an open key -> value string map: the key is a free-form action name (e.g. “move”, “attack”) and the value is the binding description.
      • pc object optional PC control mappings as an open action -> binding string map (e.g. { “move”: “WASD”, “attack”: “Left Click” }).
        • «key» string optional
      • console object optional Console control mappings as an open action -> binding string map (e.g. { “move”: “Left Stick”, “attack”: “X Button” }).
        • «key» string optional
  • technical_specifications object optional
    • supported_platforms array[string] optional Technically supported platforms
    • minimum_system_requirements object optional Minimum system requirements as an open key -> value string map. Keys are free-form requirement names (e.g. “ram”, “cpu”, “gpu”) and values are their descriptions (e.g. { “ram”: “8GB”, “cpu”: “Intel i5” }).
      • «key» string optional
  • game_progression object optional
    • level_design string optional Level design information
    • progression_system string optional Character/game progression system
  • bug_reporting_guidelines object optional
    • critical_components array[string] optional Critical game components for bug reporting
    • non_critical_components array[string] optional Non-critical game components
    • known_bugs array[string] optional List of known bugs
  • glossary object optional
    • items array[object] optional Game items glossary
      • term string optional
      • definition string optional
    • characters array[object] optional Game characters glossary
      • term string optional
      • definition string optional
    • events array[object] optional Game events glossary
      • term string optional
      • definition string optional
    • locations array[object] optional Game locations glossary
      • term string optional
      • definition string optional
    • other_terms array[object] optional Other game terms glossary
      • term string optional
      • definition string optional
  • save_load_system object optional
    • save_method string optional How the game saves data
    • known_issues array[string] optional Known save/load issues
  • multiplayer_online_features object optional
    • supported_modes array[string] optional Supported multiplayer modes
    • networking object optional
      • server_type string optional Type of server architecture
      • known_issues array[string] optional Known networking issues
  • localization object optional
    • supported_languages array[string] optional Supported languages
    • known_issues array[string] optional Known localization issues
  • project object optional
    • id string optional Project ID
    • name string optional Project name
  • meta object optional
    • created_at string date-time optional Creation timestamp
    • updated_at string date-time optional Last update timestamp
application/json
{
  "description": "Updated game description",
  "genre": "Action RPG",
  "target_audience": "Teen",
  "platforms": [
    "PC",
    "PlayStation"
  ],
  "core_mechanics": {
    "key_features": [
      "Updated feature"
    ],
    "game_modes": [
      "Single Player"
    ]
  },
  "project": {
    "id": "pr-123456",
    "name": "My Fantasy Game"
  },
  "meta": {
    "created_at": "2024-10-03T12:34:56Z",
    "updated_at": "2024-10-03T16:45:30Z"
  }
}
Forbidden. User must be a project developer or admin.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Project not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Unprocessable Entity. Validation errors in the provided data.
Response fields
  • errors array[string] optional
application/json
{
  "errors": [
    "Description is too long (maximum is 1000 characters)"
  ]
}

Submits a document — either an uploaded file or pasted text — and enqueues a background job that uses AI to extract structured game facts from it and merge them into the project’s game facts. This does not return the extracted facts inline; it returns the identifier of the background job, which you poll separately to learn when extraction has completed. Once the job finishes, fetch the results via the game facts export (GET) endpoint.

Provide exactly one of the two inputs inside the game_fact object:

  • game_fact[file] — an uploaded document. Only PDF (.pdf), plain text (.txt), and Markdown (.md) files are supported; any other extension is rejected. Maximum file size is 10 MB.

  • game_fact[document] — the document contents pasted as a raw text string.

The extracted text (whether read from the file or pasted directly) must be at least 100 characters and at most 100,000 characters, otherwise the request is rejected with a 422.

Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
Request Body
multipart/form-data
  • game_fact[file] string binary optional Document to extract game facts from. Supported formats: PDF (.pdf), plain text (.txt), Markdown (.md). Max size 10 MB. Provide either this or game_fact[document].
  • game_fact[document] string optional Document contents pasted as raw text (100–100,000 characters). Provide either this or game_fact[file].
cURL
curl \
  -X POST \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  -F "game_fact[file]=@file.bin" \
  -F "game_fact[document]=string" \
  "https://app.betahub.io/projects/123/game_facts/upload_document.json"
Ruby
require "net/http"
require "json"

uri = URI("https://app.betahub.io/projects/123/game_facts/upload_document.json")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.post(
    "https://app.betahub.io/projects/123/game_facts/upload_document.json",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/game_facts/upload_document.json", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/game_facts/upload_document.json"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString("{\"game_fact[file]\":\"string\",\"game_fact[document]\":\"string\"}"))
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Request Body
{
  "game_fact[file]": "string",
  "game_fact[document]": "string"
}
Responses
Extraction job enqueued. Returns the UUID of the background job that processes the document. Poll this job to determine completion.
Response fields
  • job_id string optional UUID of the enqueued background job.
application/json
{
  "job_id": "550e8400-e29b-41d4-a716-446655440000"
}
Forbidden. User must be a project developer or admin.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Project not found.
Response fields
  • error string optional
application/json
{
  "error": "string"
}
Unprocessable Entity. Returned when no file or text was provided, the file exceeds 10 MB, the file format is unsupported, the document is shorter than 100 characters, the document exceeds 100,000 characters, or text could not be extracted from the file. The error is returned as a single error string (note: unlike the PATCH endpoint, this endpoint does not return an errors array).
Response fields
  • error string optional
Examples
File Too Large
{
  "error": "File size exceeds maximum limit (10MB)"
}
Document Too Short
{
  "error": "Document is too short to get processed. Please provide some more details."
}
Document Too Long
{
  "error": "Document is too long. Maximum length is 100000 characters."
}
Missing Input
{
  "error": "Please upload a file or paste text content"
}
Unsupported Format
{
  "error": "Unsupported file format. Please upload PDF, TXT, or MD files only."
}

Playtime Sessions

Track user playtime sessions

This endpoint is used to create a new playtime session for a project. The playtime session is used to track the user’s playtime in the project. No authentication is required.

The response is a freshly generated UUID that can be used to update the playtime session using the PUT endpoint.

Note: The request body is currently ignored by the server. Any tags or other fields you send are not consumed or stored. The endpoint always returns a newly generated session UUID regardless of the request payload.

Path Parameters
Name Type Description
project_id required string
Request Body
application/json
cURL
curl \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{}' \
  "https://app.betahub.io/projects/123/playtime_sessions"
Ruby
require "net/http"
require "json"

uri = URI("https://app.betahub.io/projects/123/playtime_sessions")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri)
request["Content-Type"] = "application/json"
request.body = {}

response = http.request(request)
puts response.body
Python
import requests

response = requests.post(
    "https://app.betahub.io/projects/123/playtime_sessions",
    json={}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/playtime_sessions", {
  method: "POST",
  headers: {
    "Content-Type": "application/json"
  },
  body: JSON.stringify({})
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/playtime_sessions"))
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString("{}"))
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Request Body
{}
Responses
Successful response
Response fields
  • id string uuid optional The newly generated playtime session ID.
application/json
{
  "id": "3f8a1c2e-6b4d-4e7a-9f1b-2c5d8e0a7b34"
}
Project not found. The project ID is invalid or does not exist.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}

This endpoint is used to update a playtime session for a project. The playtime session is used to track the user’s playtime in the project. Use the playtime session ID returned from the POST endpoint to update the playtime session. No authentication is required. Call this endpoint no longer than every 5 minutes to update the playtime session.

Note: The request body is currently ignored by the server. The endpoint echoes back the playtime session ID you supply in the path and always responds with 200.

Path Parameters
Name Type Description
project_id required string
playtime_session_id required string
cURL
curl \
  -X PUT \
  "https://app.betahub.io/projects/123/playtime_sessions/123"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/playtime_sessions/123")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Put.new(uri)

response = http.request(request)
puts response.body
Python
import requests

response = requests.put(
    "https://app.betahub.io/projects/123/playtime_sessions/123"
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/playtime_sessions/123", {
  method: "PUT"
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/playtime_sessions/123"))
    .method("PUT", HttpRequest.BodyPublishers.noBody())
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Successful response
Response fields
  • id string optional The playtime session ID, echoed back from the path parameter exactly as supplied by the caller.
application/json
{
  "id": "3f8a1c2e-6b4d-4e7a-9f1b-2c5d8e0a7b34"
}
Project not found. The project ID is invalid or does not exist.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}

Custom Fields

List custom field definitions for a project

Returns a paginated list of custom fields configured for a project, filtered by the entity type they apply to. Token-based access (FormUser, Discord bot) returns a limited set of field properties. Non-token principals that hold the required scope see the full details.

Authorization. The project.taxonomy.manage scope is required for EVERY non-token principal — including regular signed-in browser-session users, not just Personal Access Tokens. A signed-in project member who lacks the scope (e.g. a tester) is rejected with 403 and does NOT see the fields; only developers, org admins, and site admins (who hold the scope) can list. FormUser submission tokens and the Discord bot instead receive the limited-field view (no id, options, tester_viewable, timestamps) without needing that scope. Note this means a submission-token client cannot read a select field’s allowed options.values over the API.

Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
Query Parameters
Name Type Description
applies_to optional string Filter by entity type the custom fields apply to
issue feature_request ticket
Default: issue
page optional integer Page number for pagination (default: 1)
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/custom_fields.json?applies_to=issue&page=123"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/custom_fields.json?applies_to=issue&page=123")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.get(
    "https://app.betahub.io/projects/123/custom_fields.json?applies_to=issue&page=123",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/custom_fields.json?applies_to=issue&page=123", {
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/custom_fields.json?applies_to=issue&page=123"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .GET()
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Successful response
Response fields
  • custom_fields array[object] optional
    • id integer optional Custom field ID (only for regular authenticated users)
    • ident string optional Unique identifier used in API submissions (e.g. in issue creation)
    • name string optional Display name of the field
    • field_type string optional Type of the field. This is a closed set of exactly four values: text (free-form string), boolean (true/false), single_select (exactly one value chosen from options.values), and multi_select (one or more values chosen from options.values).
      text boolean single_select multi_select
    • required boolean optional Whether the field is required
    • tester_settable boolean optional Whether testers can set this field
    • tester_viewable boolean optional Whether testers can view this field (only for regular authenticated users)
    • options object optional Allowed choices for single_select and multi_select fields, wrapped in a values array: { "values": ["low", "medium", "high"] }. Empty or absent for text and boolean fields. Only returned to regular authenticated users — token-based clients (FormUser, Discord bot) do NOT receive options, so a select field’s allowed values cannot be discovered over the token API.
      • values array[string] optional The allowed values, for single_select / multi_select fields.
    • applies_to string optional Which entity type this field applies to
      issue feature_request ticket
    • created_at string date-time optional Only for regular authenticated users
    • updated_at string date-time optional Only for regular authenticated users
  • custom_issue_fields array[object] optional Backward-compatible alias for custom_fields. Only present when applies_to is “issue”.
    • id integer optional Custom field ID (only for regular authenticated users)
    • ident string optional Unique identifier used in API submissions (e.g. in issue creation)
    • name string optional Display name of the field
    • field_type string optional Type of the field. This is a closed set of exactly four values: text (free-form string), boolean (true/false), single_select (exactly one value chosen from options.values), and multi_select (one or more values chosen from options.values).
      text boolean single_select multi_select
    • required boolean optional Whether the field is required
    • tester_settable boolean optional Whether testers can set this field
    • tester_viewable boolean optional Whether testers can view this field (only for regular authenticated users)
    • options object optional Allowed choices for single_select and multi_select fields, wrapped in a values array: { "values": ["low", "medium", "high"] }. Empty or absent for text and boolean fields. Only returned to regular authenticated users — token-based clients (FormUser, Discord bot) do NOT receive options, so a select field’s allowed values cannot be discovered over the token API.
      • values array[string] optional The allowed values, for single_select / multi_select fields.
    • applies_to string optional Which entity type this field applies to
      issue feature_request ticket
    • created_at string date-time optional Only for regular authenticated users
    • updated_at string date-time optional Only for regular authenticated users
  • pagination object optional
    • current_page integer optional
    • total_pages integer optional
    • total_count integer optional
    • per_page integer optional
  • project_id integer optional
  • applies_to string optional
application/json
{
  "custom_fields": [
    {
      "id": 0,
      "ident": "string",
      "name": "string",
      "field_type": "text",
      "required": true,
      "tester_settable": true,
      "tester_viewable": true,
      "options": {
        "values": [
          null
        ]
      },
      "applies_to": "issue",
      "created_at": "2026-03-12T10:30:00Z",
      "updated_at": "2026-03-12T10:30:00Z"
    }
  ],
  "custom_issue_fields": [
    {
      "id": 0,
      "ident": "string",
      "name": "string",
      "field_type": "text",
      "required": true,
      "tester_settable": true,
      "tester_viewable": true,
      "options": {
        "values": [
          null
        ]
      },
      "applies_to": "issue",
      "created_at": "2026-03-12T10:30:00Z",
      "updated_at": "2026-03-12T10:30:00Z"
    }
  ],
  "pagination": {
    "current_page": 0,
    "total_pages": 0,
    "total_count": 0,
    "per_page": 0
  },
  "project_id": 0,
  "applies_to": "string"
}
Unauthorized. The request is not authenticated (e.g. an unauthenticated request to a non-public project).
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Forbidden. The principal lacks permission to view custom fields — e.g. a Personal Access Token without the project.taxonomy.manage scope.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}

Groups a project’s bugs and/or suggestions by the value of a single custom field and returns per-value counts with a per-type breakdown. The driving use case is ranking contributors — e.g. “top reporters by roblox_id” — but it works for any custom field (platform, game version, etc.).

The field is selected by its ident (not its numeric id), because bugs and suggestions store the same logical field under separate definitions. Discover available idents with the List custom fields endpoint (GET /projects/{project_id}/custom_fields.json): aggregating across both types requires the ident to exist for applies_to: issue and applies_to: feature_request. Fields that are auto-created from in-game / Discord submissions (such as roblox_id) only appear after the first submission that carries them.

Values stored under legacy/name-based keys are coalesced into the same logical field, so counts are not fragmented. Blank/empty values are omitted. Results are sorted by total count descending and capped by limit.

Access is restricted to developer-level principals (project developers, organization admins, site admins); a Personal Access Token (Authorization: Bearer <token>) belonging to such a user is the intended integration credential.

Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
Query Parameters
Name Type Description
field required string The ident of the custom field to group by (e.g. “roblox_id”).
types optional string Comma-separated entity types to include. Allowed tokens: bugs, suggestions. Defaults to both. Unknown tokens are ignored.
Default: bugs,suggestions
from optional string Only count entities created on or after this date (inclusive).
to optional string Only count entities created on or before this date (inclusive).
status optional string Optional exact-match status filter, applied identically to every included type. Bugs and suggestions use different status vocabularies, so a single value usually matches only one type — filter one type via types when using this. Bug (issue) statuses: open, in_progress, resolved, closed, duplicate, hidden, pending_moderation, wont_fix, needs_more_info (plus any project-defined custom statuses). Suggestion (feature_request) statuses: open, under_review, planned, started, completed, declined, duplicate, pending_moderation, rejected, muted, split, hidden. Values valid for both: open, duplicate, pending_moderation, hidden.
limit optional integer Maximum number of value buckets to return (default: 50, max: 500).
max: 500
Default: 50
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/custom_field_aggregations.json?field=example&types=bugs,suggestions&from=example&to=example&status=example&limit=50"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/custom_field_aggregations.json?field=example&types=bugs,suggestions&from=example&to=example&status=example&limit=50")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.get(
    "https://app.betahub.io/projects/123/custom_field_aggregations.json?field=example&types=bugs,suggestions&from=example&to=example&status=example&limit=50",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/custom_field_aggregations.json?field=example&types=bugs,suggestions&from=example&to=example&status=example&limit=50", {
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/custom_field_aggregations.json?field=example&types=bugs,suggestions&from=example&to=example&status=example&limit=50"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .GET()
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Successful response
Response fields
  • field string optional The custom field ident that was aggregated.
  • results array[object] optional
    • value string optional The custom field value (e.g. a Roblox user id).
    • count integer optional Total occurrences across the requested types.
    • by_type object optional Per-type breakdown; always carries every type, zero when absent.
      • bugs integer optional
      • suggestions integer optional
application/json
{
  "field": "roblox_id",
  "results": [
    {
      "value": "123456789",
      "count": 42,
      "by_type": {
        "bugs": 30,
        "suggestions": 12
      }
    },
    {
      "value": "987654321",
      "count": 8,
      "by_type": {
        "bugs": 5,
        "suggestions": 3
      }
    }
  ]
}
Bad request. The required field parameter is missing.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Unauthorized. The request is not authenticated.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Forbidden. The principal lacks developer-level access to the project.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}

Issue Statuses

List issue status definitions for a project

Returns the ordered list of issue statuses configured for a project. Each status includes its display name, behavioral category, and whether it is a hidden (system_status) status. Useful for populating status dropdowns or mapping status keys to display names.

Authorization. This is a project-taxonomy management endpoint. Two principals are accepted:

  • A Personal Access Token (Authorization: Bearer <token>) whose user holds the project.taxonomy.manage scope on the target project. Without that scope the request is rejected with 403.

  • The Discord bot, authenticated with the project’s Discord secret via Authorization: Bot <discord_secret>. The bot bypasses the scope check and always receives the full status list.

Any other caller — a game-SDK submission token (FormUser), FormUser anonymous, or a signed-in User without the scope — is rejected with 403 (not 401), since it authenticates as a principal but lacks project.taxonomy.manage.

Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/issue_statuses.json"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/issue_statuses.json")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.get(
    "https://app.betahub.io/projects/123/issue_statuses.json",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issue_statuses.json", {
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issue_statuses.json"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .GET()
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Successful response
Response fields
  • issue_statuses array[object] optional
    • key string optional Stable status key used when reading or setting an issue’s status. Every project is seeded with nine built-in keys: open, in_progress, needs_more_info, resolved, wont_fix, closed, hidden, duplicate, and pending_moderation. Projects may also define custom keys (lowercase letters, digits, underscores, and colons; must start with a letter).
    • display_name string optional Human-readable display name shown in the UI.
    • behavior_category string optional Behavioral category the status maps to, used to group statuses by how BetaHub treats issues in them. Derived from the status’s internal behavior: activein_progress, resolvedresolved, closedclosed_verified, hiddenhidden.
      in_progress resolved closed_verified hidden
    • system_status boolean optional True only when the status’s behavior is hidden (i.e. issues in this status are hidden from normal views). This reflects the status behavior, not whether it can be deleted. Note this is distinct from a status being built-in — built-in statuses that are not hidden report false here.
application/json
{
  "issue_statuses": [
    {
      "key": "in_progress",
      "display_name": "In Progress",
      "behavior_category": "in_progress",
      "system_status": true
    }
  ]
}
Unauthorized. Returned only when the request carries no authentication principal at all, or the Bearer/Bot token is itself invalid. A FormUser/game-SDK token authenticates as a limited principal and is instead rejected with 403.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Forbidden. The principal lacks permission to view this project’s issue statuses — e.g. a Personal Access Token whose user does not hold the project.taxonomy.manage scope on the project.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Not Found. No project matches project_id, or the project has been removed.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}

Issue Tags

List issue tags for a project

Returns a paginated list of issue tags configured for a project. Tags are returned as a fixed two-level tree: top-level parent tags each carry a sub_tags array of their children, and only parent tags appear at the root level (sub-tags do not have children of their own). Every tag returned here has tag_type: issue.

Authorization. This endpoint requires a signed-in User (session or Personal Access Token) holding the project.taxonomy.manage scope. Project membership is NOT required: site admins and organization admins of the project’s organization are authorized even without a project role, in addition to project members whose role grants the scope. Any caller that does not hold the scope is rejected with 403 Forbidden — this includes game-SDK submission tokens (FormUser), FormUser anonymous, Discord-bot tokens, and signed-in Users whose role lacks project.taxonomy.manage. (Only a request carrying no authentication principal at all — no Authorization header and no session — returns 401.)

Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
project_id required string
Query Parameters
Name Type Description
page optional integer Page number for pagination (default: 1)
Header Parameters
Name Type Description
BetaHub-Project-ID required string BetaHub project ID, the same as the project_id
cURL
curl \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "BetaHub-Project-ID: 123" \
  "https://app.betahub.io/projects/123/issue_tags.json?page=123"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/projects/123/issue_tags.json?page=123")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["BetaHub-Project-ID"] = "123"

response = http.request(request)
puts response.body
Python
import requests

response = requests.get(
    "https://app.betahub.io/projects/123/issue_tags.json?page=123",
    headers={"Authorization": "Bearer YOUR_API_TOKEN", "BetaHub-Project-ID": "123"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/issue_tags.json?page=123", {
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "BetaHub-Project-ID": "123"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/issue_tags.json?page=123"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("BetaHub-Project-ID", "123")
    .GET()
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Successful response
Response fields
  • tags array[object] optional
    • id integer optional Tag ID
    • name string optional Tag name
    • color string optional Tag color as a lowercase 6-digit hex code (#rrggbb, e.g. #7dd3fc). The # prefix, exactly six digits, and lowercase a-f are all required. Only parent (top-level) tags carry an independently set color; a sub-tag always inherits its parent’s color, and changing a parent’s color re-propagates to every sub-tag under it.
      pattern: ^#[a-f0-9]{6}$
    • description string optional nullable Tag description
    • parent_tag_id integer optional nullable ID of the parent tag (null for top-level tags)
    • tag_type string optional Taxonomy the tag belongs to. On the issue-tags endpoints this is always issue; feature_request tags are a separate taxonomy (Suggestions) not exposed here.
      issue feature_request
    • created_at string date-time optional
    • updated_at string date-time optional
    • sub_tags array[object] optional Child tags nested under this parent tag
      • id integer optional
      • name string optional
      • color string optional Lowercase 6-digit hex (#rrggbb) inherited from the parent tag.
        pattern: ^#[a-f0-9]{6}$
      • description string optional nullable
      • parent_tag_id integer optional ID of the parent tag this sub-tag belongs to.
      • tag_type string optional Always issue on this endpoint.
        issue feature_request
      • created_at string date-time optional
      • updated_at string date-time optional
  • project_id integer optional
  • pagination object optional
    • current_page integer optional
    • total_pages integer optional
    • total_count integer optional
    • per_page integer optional
application/json
{
  "tags": [
    {
      "id": 0,
      "name": "string",
      "color": "string",
      "description": "string",
      "parent_tag_id": 0,
      "tag_type": "issue",
      "created_at": "2026-03-12T10:30:00Z",
      "updated_at": "2026-03-12T10:30:00Z",
      "sub_tags": [
        {}
      ]
    }
  ],
  "project_id": 0,
  "pagination": {
    "current_page": 0,
    "total_pages": 0,
    "total_count": 0,
    "per_page": 0
  }
}
Unauthorized. The request carries no authentication principal at all (no Authorization header and no session).
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Forbidden. The caller does not hold the project.taxonomy.manage scope — this is the status returned for game-SDK submission tokens (FormUser), FormUser anonymous, Discord-bot tokens, and signed-in Users whose role lacks the scope.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}

Profile

View current user profile and roles

Returns the profile of the currently authenticated user, including their display name and all project role assignments.

This endpoint identifies the user from an account-level credential: a Personal Access Token (Authorization: Bearer pat-...) or an active browser session. Project Auth Tokens (FormUser tkn-...) are project form-user credentials, not user accounts, and are rejected with 401 Unauthorized.

Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

This endpoint has no query parameters or request body.
cURL
curl \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  "https://app.betahub.io/profiles/me.json"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/profiles/me.json")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"

response = http.request(request)
puts response.body
Python
import requests

response = requests.get(
    "https://app.betahub.io/profiles/me.json",
    headers={"Authorization": "Bearer YOUR_API_TOKEN"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/profiles/me.json", {
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/profiles/me.json"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .GET()
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Successful response
Response fields
  • name string optional User display name
  • roles array[object] optional List of project roles assigned to the user
    • project object required

      Summary of the project the role applies to.

      Only the fields listed below are contractual and safe to depend on. For historical reasons this object is currently serialized from the internal project record, so additional, undocumented properties (internal flags, counters, timestamps, etc.) may also appear in the response. Those extra properties are NOT part of the API contract and may change or disappear without notice — do not rely on them.

      • id integer int64 required Internal numeric database identifier of the project. Note this is the raw integer id, NOT the obfuscated pr-... project id used elsewhere in the API and UI.
      • name string required Project display name.
      • description string optional nullable Project description. May be null.
      • created_at string date-time optional When the project was created.
      • updated_at string date-time optional When the project was last updated.
    • role string required

      The user’s role on the project, as a free-form string — NOT a closed enumeration.

      For the three built-in system roles this is a stable role key: developer, tester, or support. For custom roles defined by an organization it is the role’s display name, which is arbitrary, organization-defined text and can be anything. Treat this value as an opaque string: match the known built-in keys if you need to, but do not assume the full set of possible values.

application/json
{
  "name": "string",
  "roles": [
    {
      "project": {
        "id": 0,
        "name": "string",
        "description": "string",
        "created_at": "2026-03-12T10:30:00Z",
        "updated_at": "2026-03-12T10:30:00Z"
      },
      "role": "string"
    }
  ]
}
Unauthorized. Authentication is required.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}

Support Knowledge

Ask questions and get AI-generated answers from the project’s knowledge base

Submit a question to the project’s support knowledge base and get an AI-generated answer. The response is generated using the project’s configured search engine (V1 embedding-based or V2 agentic).

Optional style parameters allow per-request overrides of the project’s default response style settings. When omitted, the project’s configured defaults are used.

Authentication: call with a Project Auth Token that has the Can search knowledge base permission, using the Authorization: FormUser tkn-YOUR_TOKEN header. The token must belong to the project in the URL. A common use is to query the knowledge base from your own game or bug reporter and suggest a solution before the player files a bug. Returns 403 Forbidden if the token lacks the permission, belongs to a different project, or has exceeded its daily search limit (default 30 per IP).

Path Parameters
Name Type Description
project_id required string Project ID (e.g., pr-1234567)
Request Body
application/json
  • query string required The question to ask the knowledge base.
  • user object optional Optional user identification for interaction tracking. When provided, an interaction record is created for analytics and knowledge gap detection.
    • discord_id string optional Discord user ID.
    • username string optional Display name of the user.
  • response_length string optional Override the project’s default response length. Controls how verbose the AI response is.
    concise standard detailed
  • response_tone string optional Override the project’s default response tone. Controls the formality of the AI response.
    formal friendly casual
  • personality string optional Free-text style instructions for the AI (max 500 characters). When provided, this takes precedence over response_length and response_tone.
    max length: 500
  • include_source_links boolean optional Override the project’s default source links setting. When true, the AI includes links to source documents in its response. Only applies to web page documents that have URLs.
  • strict_classification boolean optional

    Controls how the classification gate handles neither classifications (casual chat, opinions, meta-commentary, statements without questions).

    When true (strict mode), the endpoint returns early with has_answer: false and skip_reason: not_support_or_gameplay for neither-classified queries. Use this for passive Discord bot listeners that should ignore casual channel chat.

    When false or omitted (default — permissive mode), classification still runs (so classification and classification_reasoning are populated), but the search proceeds regardless. The answering LLM/agent decides whether to refuse off-topic queries on its own. This is the right mode for explicitly invoked callers (API testing, web help widget, slash commands, @mentions).

    Default: false
cURL
curl \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{
  "query": "How do I set up the Discord bot?",
  "user": {
    "discord_id": "123456789",
    "username": "player1"
  },
  "response_length": "concise",
  "response_tone": "formal",
  "personality": "Respond like a friendly game developer. Use casual language.",
  "include_source_links": true,
  "strict_classification": true
}' \
  "https://app.betahub.io/projects/123/support_knowledge/ask"
Ruby
require "net/http"
require "json"

uri = URI("https://app.betahub.io/projects/123/support_knowledge/ask")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri)
request["Content-Type"] = "application/json"
request.body = {
  "query": "How do I set up the Discord bot?",
  "user": {
    "discord_id": "123456789",
    "username": "player1"
  },
  "response_length": "concise",
  "response_tone": "formal",
  "personality": "Respond like a friendly game developer. Use casual language.",
  "include_source_links": true,
  "strict_classification": true
}

response = http.request(request)
puts response.body
Python
import requests

response = requests.post(
    "https://app.betahub.io/projects/123/support_knowledge/ask",
    json={
      "query": "How do I set up the Discord bot?",
      "user": {
        "discord_id": "123456789",
        "username": "player1"
      },
      "response_length": "concise",
      "response_tone": "formal",
      "personality": "Respond like a friendly game developer. Use casual language.",
      "include_source_links": true,
      "strict_classification": true
    }
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/support_knowledge/ask", {
  method: "POST",
  headers: {
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    "query": "How do I set up the Discord bot?",
    "user": {
      "discord_id": "123456789",
      "username": "player1"
    },
    "response_length": "concise",
    "response_tone": "formal",
    "personality": "Respond like a friendly game developer. Use casual language.",
    "include_source_links": true,
    "strict_classification": true
  })
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/support_knowledge/ask"))
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString("{\"query\":\"How do I set up the Discord bot?\",\"user\":{\"discord_id\":\"123456789\",\"username\":\"player1\"},\"response_length\":\"concise\",\"response_tone\":\"formal\",\"personality\":\"Respond like a friendly game developer. Use casual language.\",\"include_source_links\":true,\"strict_classification\":true}"))
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Request Body
{
  "query": "How do I set up the Discord bot?",
  "user": {
    "discord_id": "123456789",
    "username": "player1"
  },
  "response_length": "concise",
  "response_tone": "formal",
  "personality": "Respond like a friendly game developer. Use casual language.",
  "include_source_links": true,
  "strict_classification": true
}
Responses
Successful response with AI-generated answer
Response fields
  • query string optional The original query.
  • answer string optional nullable The AI-generated answer, or null if no answer was found.
  • has_answer boolean optional Whether the knowledge base had relevant information to answer the query.
  • classification string optional nullable The auto-detected classification of the query (V1 engine only). Possible values: support_issue, gameplay_question, bug_report, neither.
  • classification_reasoning string optional nullable Reasoning behind the classification decision (V1 engine only).
  • sources array[object] optional Knowledge base sources used to generate the answer.
    • id integer optional
    • name string optional
    • type string optional nullable Source type (V1 engine only, e.g., technical, gameplay).
    • url string optional nullable
  • support_knowledge_interaction_id string optional nullable Obfuscated ID of the interaction record created for this query. Only present when user data was provided and an answer was found.
  • footer string optional nullable Optional footer text configured by the project.
  • skip_reason string optional nullable Reason why the query was skipped without searching the knowledge base. Possible values: not_support_or_gameplay, no_technical_sources, no_gameplay_sources, no_v2_documents.
  • canned_response_id integer optional nullable ID of the matched canned response, if any.
  • canned_response_match_type string optional nullable How the canned response was matched. Possible values: question_similarity, rule_based.
  • tool_calls array[object] optional nullable Agent decision flow (V2 engine only). Shows the search, grep, and read operations the AI agent performed to find the answer.
    • name string optional
    • input object optional
    • iteration integer optional The agent loop iteration number during which this tool call was made.
  • limit_reached boolean optional Present and true when the organization has reached its monthly support knowledge request limit.
  • limit integer optional nullable The organization’s monthly request limit. Present when limit_reached is true.
  • requests_remaining integer optional nullable Number of requests remaining (0 when limit reached). Present when limit_reached is true.
  • reset_at string date-time optional nullable When the request counter resets. Present when limit_reached is true.
application/json
{
  "query": "string",
  "answer": "string",
  "has_answer": true,
  "classification": "string",
  "classification_reasoning": "string",
  "sources": [
    {
      "id": 0,
      "name": "string",
      "type": "string",
      "url": "string"
    }
  ],
  "support_knowledge_interaction_id": "string",
  "footer": "string",
  "skip_reason": "string",
  "canned_response_id": 0,
  "canned_response_match_type": "string",
  "tool_calls": [
    {
      "name": "string",
      "input": {},
      "iteration": 0
    }
  ],
  "limit_reached": true,
  "limit": 0,
  "requests_remaining": 0,
  "reset_at": "2026-03-12T10:30:00Z"
}
The auth token lacks the Can search knowledge base permission, belongs to a different project than the one in the URL, or has exceeded its daily search limit.
Response fields
  • error string optional
application/json
{
  "error": "Permission denied or rate limit exceeded"
}
Validation error (invalid style parameters)
Response fields
  • error string optional
application/json
{
  "error": "Invalid response_length. Valid values: concise, standard, detailed"
}

Comments

View comment details
Returns detailed information about a specific comment, including the comment body, author, and the parent entity it belongs to.
Authorization required

Authorization header for API access. Supports multiple authentication methods:

  1. Anonymous access: FormUser anonymous - for public operations
  2. Token-based access: FormUser tkn-{token} - for authenticated operations with legacy tokens
  3. Token with JWT: FormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)
  4. Personal Access Tokens: Bearer YOUR_TOKEN_HERE - recommended for API integrations

Validation Rules:

  • email: is validated only against a loose pattern (/\A[^@\s]+@[^@\s]+\z/, Devise’s default email_regexp) requiring a single @ with non-whitespace on each side — NOT full RFC 5322. Invalid emails will fall back to anonymous access.
  • discord_id: format must be numeric (digits only). Invalid discord_ids will fall back to anonymous access.
  • Invalid identifiers do not cause HTTP errors; they fall back to anonymous authentication for graceful degradation.

Personal Access Tokens

Personal Access Tokens provide enhanced security and can be created and managed in your account settings. They are ideal for API integrations, automated scripts, and CI/CD pipelines. A PAT authenticates the request as the user who created it and acts with that user’s permissions. It is NOT governed by the can_create_* boolean flags that belong to Project Auth Tokens (FormUser tkn-...). Instead, each operation is authorized against the permission scopes the user holds through their role in the target project — for example bugs.update (alias issues.update), bugs.delete (issues.delete), bugs.archive (issues.archive), suggestions.update, tickets.update, project.releases.manage, and project.taxonomy.manage. See the bearerAuth security scheme in the API description for the full scope vocabulary. A request the user’s scopes do not permit returns 403 Forbidden. For more information about Personal Access Tokens, see: https://betahub.io/docs/account/#personal-access-tokens

Path Parameters
Name Type Description
id required integer The comment ID
cURL
curl \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  "https://app.betahub.io/comments/123.json"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/comments/123.json")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"

response = http.request(request)
puts response.body
Python
import requests

response = requests.get(
    "https://app.betahub.io/comments/123.json",
    headers={"Authorization": "Bearer YOUR_API_TOKEN"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/comments/123.json", {
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/comments/123.json"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .GET()
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
Successful response
Response fields
  • id integer optional Comment ID
  • body string optional Comment body text
  • created_at string date-time optional
  • updated_at string date-time optional
  • solution boolean optional Whether this comment is marked as a solution
  • private_comment boolean optional Whether this is a private (internal) comment
  • user object optional
    • id integer optional
    • name string optional
    • discord_username string optional Present only if the user has a Discord username
  • commentable object optional The parent entity this comment belongs to
    • id integer optional
    • type string optional Polymorphic type (e.g. “Issue”, “FeatureRequest”, “Ticket”)
application/json
{
  "id": 0,
  "body": "string",
  "created_at": "2026-03-12T10:30:00Z",
  "updated_at": "2026-03-12T10:30:00Z",
  "solution": true,
  "private_comment": true,
  "user": {
    "id": 0,
    "name": "string",
    "discord_username": "string"
  },
  "commentable": {
    "id": 0,
    "type": "string"
  }
}
Forbidden. User does not have permission to view this comment.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}
Comment not found.
Response fields
  • error string optional Human-readable error message. English, not localized, and not a stable contract.
  • status object optional Optional and unreliable. When the shared render_error JSON path emits it, it is the HTTP status code as an integer (e.g. 422); a few domain endpoints instead put a domain string here (e.g. "open"); most responses omit it entirely. Do not depend on its presence or type — use the HTTP status line.
application/json
{
  "error": "string",
  "status": {}
}

Submission Tokens

Generate single-use JWT tokens for tamper-proof submissions

Generates a single-use JWT submission token that can be embedded in game clients or web forms. The token carries trusted data (email, custom fields) that cannot be tampered with by the end user.

The token must be included in the Authorization header when submitting issues, feature requests, or tickets: FormUser tkn-{auth_token},{submission_token}

Authentication: Requires a Personal Access Token (PAT) with developer access to the project. Use Authorization: Bearer pat-YOUR_TOKEN header.

When it is required vs optional. Whether a submission token is mandatory depends on the Project Auth Token used to submit:

  • If that auth token has require_submission_token enabled, then EVERY submission must include a valid submission JWT in the header (FormUser tkn-{auth_token},{submission_token}). A submission without one — or with an invalid/expired one — is rejected with 403 Forbidden (e.g. “Submission token is required for this auth token. Generate one via the API.”).

  • Otherwise the submission JWT is optional; when present, its trusted claims (email, custom fields) are still applied to the submission.

Single-use: Each token can only be used for one submission. The unique token identifier (jti) is consumed on successful submission. Reusing a consumed token carries the message “Submission token has already been used. Please generate a new one.”, but the HTTP status depends on configuration: when the auth token has require_submission_token enabled, reuse is caught pre-save and rejected with 403 Forbidden; 422 Unprocessable Entity occurs only when the submission token is optional (require_submission_token disabled) or in a concurrent-submission race, where reuse is detected later at consume time. A token is also bound to the project it was issued for — using it against a different project is rejected.

Authorization required
Personal Access Token with developer access to the project. Format: Bearer pat-YOUR_TOKEN
Path Parameters
Name Type Description
project_id required string
Request Body
application/json
  • email string email optional Player’s email address to embed in the token. When the token is used in a submission, this email is attached to the report as the reporter’s contact.
  • custom object optional

    Key-value pairs of custom field values to embed in the token. When the token is used, these values are applied to the submission and overwrite any user-provided values for the same fields.

    Each key may be a custom field’s ident OR its display name (the server resolves by ident first, then falls back to name). Discover the available fields — and which apply to issues, feature requests, or tickets — via GET /projects/{project_id}/custom_fields.json (filter by the field’s applies_to). Keys that match no field are silently dropped.

    Provide values in the shape the target field type expects (e.g. a plain string for text, one of the configured options for single_select).

    • «key» string optional
  • expires_in integer optional Token lifetime in seconds. Default: 86400 (24 hours). Valid range: 1 to 2592000 (30 days). Values outside this range are reset to the 24-hour default.
    Default: 86400
cURL
curl \
  -X POST \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "email": "player@example.com",
  "custom": {
    "platform": "windows",
    "build_number": "2024.3.1"
  },
  "expires_in": 86400
}' \
  "https://app.betahub.io/projects/123/submission_tokens.json"
Ruby
require "net/http"
require "json"

uri = URI("https://app.betahub.io/projects/123/submission_tokens.json")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"
request["Content-Type"] = "application/json"
request.body = {
  "email": "player@example.com",
  "custom": {
    "platform": "windows",
    "build_number": "2024.3.1"
  },
  "expires_in": 86400
}

response = http.request(request)
puts response.body
Python
import requests

response = requests.post(
    "https://app.betahub.io/projects/123/submission_tokens.json",
    headers={"Authorization": "Bearer YOUR_API_TOKEN"},
    json={
      "email": "player@example.com",
      "custom": {
        "platform": "windows",
        "build_number": "2024.3.1"
      },
      "expires_in": 86400
    }
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/projects/123/submission_tokens.json", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN",
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    "email": "player@example.com",
    "custom": {
      "platform": "windows",
      "build_number": "2024.3.1"
    },
    "expires_in": 86400
  })
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/projects/123/submission_tokens.json"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString("{\"email\":\"player@example.com\",\"custom\":{\"platform\":\"windows\",\"build_number\":\"2024.3.1\"},\"expires_in\":86400}"))
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Request Body
{
  "email": "player@example.com",
  "custom": {
    "platform": "windows",
    "build_number": "2024.3.1"
  },
  "expires_in": 86400
}
Responses
Submission token generated successfully.
Response fields
  • token string required Signed JWT token to include in submission requests.
  • expires_at string date-time required ISO 8601 timestamp when the token expires.
application/json
{
  "token": "eyJhbGciOiJIUzI1NiJ9.eyJwcm9qZWN0X2lkIjoxLCJqdGkiOiI...",
  "expires_at": "2026-03-27T16:00:00Z"
}
Not authenticated. Provide a valid Personal Access Token in the Authorization: Bearer header.
Not authenticated. Provide a valid Personal Access Token in the `Authorization: Bearer` header.
Not authorized. The PAT user must have developer or admin access to the project.
Not authorized. The PAT user must have developer or admin access to the project.
Project not found.
Project not found.

Authentication

Introspect and verify authentication tokens

Introspects any BetaHub token and reports what it is, without performing a business operation. Useful for a client to confirm its credentials and discover the associated project / user / permissions before making real calls.

No authentication required — this is a public endpoint. The token to inspect is supplied either in the Authorization header or in the token query/body parameter (the header takes precedence). All three token kinds are recognized automatically from their shape:

  • Personal Access Token — Bearer pat-... (or the raw pat-... value)
  • Project Auth Token — Bearer tkn-... (or the raw tkn-... value)
  • JWT — a three-part dotted token. The JWT branch recognizes ONLY JWTs that carry a user_id claim (e.g. a device-auth / session user JWT). A submission token has no user_id claim, so it is NOT recognized here — it falls through and the endpoint responds 401 with valid: false.

The response body shape depends on token_type. A valid token returns 200 with valid: true; an invalid, expired, or unrecognized token returns 401 with valid: false and an error string.

Authorization required
The token to verify. Accepts Bearer <token> or the bare token string. If absent, the token parameter is used instead.
Query Parameters
Name Type Description
token optional string The token to verify, as an alternative to the Authorization header. Ignored when the header is present.
cURL
curl \
  -X POST \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  "https://app.betahub.io/auth/verify?token=example"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/auth/verify?token=example")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer YOUR_API_TOKEN"

response = http.request(request)
puts response.body
Python
import requests

response = requests.post(
    "https://app.betahub.io/auth/verify?token=example",
    headers={"Authorization": "Bearer YOUR_API_TOKEN"}
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/auth/verify?token=example", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_TOKEN"
  }
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/auth/verify?token=example"))
    .header("Authorization", "Bearer YOUR_API_TOKEN")
    .method("POST", HttpRequest.BodyPublishers.noBody())
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
The token is valid. The payload varies by token_type.
Response fields
  • valid boolean optional
  • token_type string optional
  • user object optional The user the PAT authenticates as.
    • id integer optional
    • email string optional
    • name string optional
  • token_name string optional Human-readable name of the token.
  • expires_at string date-time optional nullable Expiry timestamp, or null if the token never expires.
  • last_used_at string date-time optional nullable
  • created_at string date-time optional
  • scopes object optional The token’s permission scopes as a map of scope key to a truthy value. Empty object when no scopes are set.
  • project object optional The single project this token is scoped to.
    • id integer optional
    • name string optional
    • slug string optional Obfuscated project id (e.g. pr-5632787018).
  • permissions object optional Boolean permission flags carried by the token.
    • can_create_bug_report boolean optional
    • can_create_feature_request boolean optional
    • can_create_ticket boolean optional
    • can_read_release_list boolean optional
    • can_create_release boolean optional
  • rate_limits object optional Per-IP daily submission caps configured on the token.
    • bug_reports_per_day integer optional
    • feature_requests_per_day integer optional
    • tickets_per_day integer optional
  • issued_at string date-time optional nullable Token issue time (from the iat claim), or null when absent.
Examples
Personal Access Token
{
  "valid": true,
  "token_type": "personal_access_token",
  "user": {
    "id": 34,
    "email": "dev@example.com",
    "name": "Jane Developer"
  },
  "token_name": "CI pipeline",
  "expires_at": "2026-12-31T23:59:59Z",
  "last_used_at": "2026-07-20T10:15:00Z",
  "created_at": "2026-01-01T00:00:00Z",
  "scopes": {
    "bugs.update": true,
    "project.releases.manage": true
  }
}
Project Auth Token
{
  "valid": true,
  "token_type": "project_auth_token",
  "project": {
    "id": 1,
    "name": "My Game",
    "slug": "pr-5632787018"
  },
  "permissions": {
    "can_create_bug_report": true,
    "can_create_feature_request": true,
    "can_create_ticket": false,
    "can_read_release_list": true,
    "can_create_release": false
  },
  "rate_limits": {
    "bug_reports_per_day": 8,
    "feature_requests_per_day": 8,
    "tickets_per_day": 8
  },
  "token_name": "Game client token"
}
Jwt
{
  "valid": true,
  "token_type": "jwt",
  "user": {
    "id": 34,
    "email": "player@example.com",
    "name": "Jane Player"
  },
  "expires_at": "2026-07-21T10:15:00Z",
  "issued_at": "2026-07-20T10:15:00Z"
}
The token is missing, invalid, or expired.
Response fields
  • valid boolean optional
  • error string optional
Examples
Invalid
{
  "valid": false,
  "error": "Invalid or expired token"
}
Missing
{
  "valid": false,
  "error": "Token is required"
}

Device Authorization

OAuth-2.0-device-style handshake for headless clients to obtain a user-scoped JWT

Begins an OAuth-2.0-device-style handshake that lets a headless or external client (a game, a launcher, a desktop app) obtain a user-scoped JWT after the user approves it in a web browser.

The full flow:

  1. The client POSTs here with a request_id it generates (a UUID) plus a human-readable entity_kind and entity_name describing what is asking for access. The request is stored and expires 5 minutes after creation.

  2. The client directs the user to open GET /device_auth/{request_id}/authorize in a browser. That page requires the user to be signed in to BetaHub and shows an approval screen; approving it POSTs to /device_auth/{request_id}/approve, which binds the request to the signed-in user.

  3. Meanwhile the client polls GET /device_auth/{request_id}/poll. Once the request is approved, the poll response returns the JWT.

No authentication is required to create the request (the browser authorize/approve steps are what authenticate the user). The request_id is the shared secret tying the three steps together, so treat it as sensitive and generate it with a strong random UUID.

Request Body
application/json
  • request_id string uuid optional Client-generated UUID identifying this authorization request. Must be unique. Used verbatim in the authorize/poll/approve URLs.
  • entity_kind string required Required. A short machine identifier for the kind of client requesting access (e.g. game_client, launcher). Shown to the user on the approval screen.
  • entity_name string required Required. A human-readable name for the requesting client, shown to the user on the approval screen.
cURL
curl \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{
  "request_id": "0f6c9b3e-4a1d-4b2c-9c8e-1234567890ab",
  "entity_kind": "game_client",
  "entity_name": "My Game (Steam build)"
}' \
  "https://app.betahub.io/device_auth/create"
Ruby
require "net/http"
require "json"

uri = URI("https://app.betahub.io/device_auth/create")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri)
request["Content-Type"] = "application/json"
request.body = {
  "request_id": "0f6c9b3e-4a1d-4b2c-9c8e-1234567890ab",
  "entity_kind": "game_client",
  "entity_name": "My Game (Steam build)"
}

response = http.request(request)
puts response.body
Python
import requests

response = requests.post(
    "https://app.betahub.io/device_auth/create",
    json={
      "request_id": "0f6c9b3e-4a1d-4b2c-9c8e-1234567890ab",
      "entity_kind": "game_client",
      "entity_name": "My Game (Steam build)"
    }
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/device_auth/create", {
  method: "POST",
  headers: {
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    "request_id": "0f6c9b3e-4a1d-4b2c-9c8e-1234567890ab",
    "entity_kind": "game_client",
    "entity_name": "My Game (Steam build)"
  })
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/device_auth/create"))
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString("{\"request_id\":\"0f6c9b3e-4a1d-4b2c-9c8e-1234567890ab\",\"entity_kind\":\"game_client\",\"entity_name\":\"My Game (Steam build)\"}"))
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Request Body
{
  "request_id": "0f6c9b3e-4a1d-4b2c-9c8e-1234567890ab",
  "entity_kind": "game_client",
  "entity_name": "My Game (Steam build)"
}
Responses
Authorization request created. The client should now start polling.
Response fields
  • status string optional
application/json
{
  "status": "created"
}
The request could not be created (e.g. missing entity_kind / entity_name, or a duplicate request_id).
Response fields
  • status string optional
  • errors array[string] optional
application/json
{
  "status": "error",
  "errors": [
    "Entity kind can't be blank"
  ]
}

Polls the state of a device authorization request. The client calls this repeatedly after creating the request. No authentication is required.

States (returned as status):

  • pending — not yet approved (and not expired). Keep polling.
  • approved — the user approved it; the response includes the user-scoped JWT in token. Stop polling and use the token. The JWT itself expires 24 hours after issuance.

  • expired — the request passed its 5-minute lifetime before approval. Start over with a new request_id.

  • not_found — no request exists for this request_id.
Path Parameters
Name Type Description
request_id required string The UUID supplied when the request was created.
cURL
curl \
  "https://app.betahub.io/device_auth/123/poll"
Ruby
require "net/http"

uri = URI("https://app.betahub.io/device_auth/123/poll")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Get.new(uri)

response = http.request(request)
puts response.body
Python
import requests

response = requests.get(
    "https://app.betahub.io/device_auth/123/poll"
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/device_auth/123/poll");
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/device_auth/123/poll"))
    .GET()
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
The current state of the request. pending, approved, or expired.
Response fields
  • status string optional
    pending approved expired
  • token string optional The user-scoped JWT. Present only when status is approved.
  • user_name string optional The approving user’s display name — their name, or their Discord username, falling back to their raw email address when no name/Discord name is set. It is a single value, NOT a combined “Name " string. Present only when `status` is `approved`.
Examples
Pending
{
  "status": "pending"
}
Approved
{
  "status": "approved",
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "user_name": "Jane Player"
}
Expired
{
  "status": "expired"
}
No authorization request exists for this request_id.
Response fields
  • status string optional
application/json
{
  "status": "not_found"
}

Crash Reports

Ingest Unreal Engine crash reports via path-token authentication

Receives a crash report from the Unreal Engine CrashReportClient (CRC). CRC POSTs a zlib-compressed binary blob to the DataRouterUrl configured in DefaultEngine.ini, e.g. https://app.betahub.io/crashes/unreal/tkn-abc123.

Authentication is different from every other endpoint. CrashReportClient cannot set custom headers, so the auth token is embedded directly in the URL PATH (there is no Authorization header). The token must be a project auth token (tkn-…) whose can_report_crash permission is enabled; otherwise the request is rejected with 401.

CRC appends its own query parameters (?AppID=CrashReporter&AppVersion=…&UploadType=crashreports&UserID=…); these are preserved for logging but are not used for authentication.

Processing is asynchronous: on success the endpoint stores the raw blob and returns an empty 200 immediately, then a background job parses the report and attaches it to the project. All responses are empty-bodied status codes (no JSON).

Path Parameters
Name Type Description
token required string The project auth token (tkn-…) with the can_report_crash permission, embedded in the URL path (not sent as a header).
Request Body
application/octet-stream
cURL
curl \
  -X POST \
  "https://app.betahub.io/crashes/unreal/example"
Ruby
require "net/http"
require "json"

uri = URI("https://app.betahub.io/crashes/unreal/example")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri)

response = http.request(request)
puts response.body
Python
import requests

response = requests.post(
    "https://app.betahub.io/crashes/unreal/example"
)
print(response.json())
JavaScript
const response = await fetch("https://app.betahub.io/crashes/unreal/example", {
  method: "POST"
});
const data = await response.json();
Java
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://app.betahub.io/crashes/unreal/example"))
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString("\"string\""))
    .build();
HttpResponse<String> response = client.send(
    request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Request Body
"string"
Responses
Accepted. The crash report was stored and queued for asynchronous processing. The response body is empty.
Accepted. The crash report was stored and queued for asynchronous processing. The response body is empty.
Unauthorized. The path token is invalid, unknown, or lacks the can_report_crash permission. Empty body.
Unauthorized. The path token is invalid, unknown, or lacks the `can_report_crash` permission. Empty body.
Payload Too Large. The compressed body exceeds the 50 MB limit. Empty body.
Payload Too Large. The compressed body exceeds the 50 MB limit. Empty body.
Too Many Requests. The token’s per-IP crash-report rate limit was exceeded. Empty body.
Too Many Requests. The token's per-IP crash-report rate limit was exceeded. Empty body.
Internal Server Error. The report could not be stored for processing. Empty body.
Internal Server Error. The report could not be stored for processing. Empty body.