This is the official BetaHub API documentation.
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.
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.
The API supports multiple authentication methods:
FormUser anonymous for public operationsFormUser tkn-{token} for authenticated operationsFormUser 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.FormUser tkn-{token},{jwt_token} where the JWT contains a user_id claim to authenticate as an existing BetaHub userBearer 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
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:
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.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.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.
image/png, image/jpeg, image/jpg.text/plain, text/csv, application/json, application/xml, application/zip, application/gzip, application/x-tar, …), with application/octet-stream as a fallback.video/mp4, video/quicktime, video/webm, video/avi, video/mov.
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.
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.
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.
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.
formUserTokenProject 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 tokenFormUser 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_report — POST /projects/{project_id}/issues.jsoncan_create_feature_request — POST /projects/{project_id}/feature_requests.jsoncan_create_ticket — POST /projects/{project_id}/tickets.jsoncan_report_crash — crash-report submission (e.g. POST /crashes/unreal/{token})can_search_knowledge_base — POST /projects/{project_id}/support_knowledge/askcan_read_release_list — GET /projects/{project_id}/releases.jsoncan_create_release — allows a submission that names a new release via
release_label to create that release on the flyA 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.
bearerAuthPersonal 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.update, suggestions.moderatetickets.view, tickets.updateProject 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
access_requests.reviewThe issues.* names are legacy aliases that resolve to the canonical bugs.* keys. A request the user’s scopes do not permit returns 403 Forbidden.
all=true, returns publicly discoverable projects.Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| 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 \ -H "Authorization: Bearer YOUR_API_TOKEN" \ "https://app.betahub.io/projects.json?page=1&all=true"
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
import requests response = requests.get( "https://app.betahub.io/projects.json?page=1&all=true", headers={"Authorization": "Bearer YOUR_API_TOKEN"} ) print(response.json())
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();
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());
{
"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
}
}
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| id required | string | The project ID (obfuscated format, e.g. “pr-1234567”) |
curl \ -H "Authorization: Bearer YOUR_API_TOKEN" \ "https://app.betahub.io/projects/123.json"
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
import requests response = requests.get( "https://app.betahub.io/projects/123.json", headers={"Authorization": "Bearer YOUR_API_TOKEN"} ) print(response.json())
const response = await fetch("https://app.betahub.io/projects/123.json", { headers: { "Authorization": "Bearer YOUR_API_TOKEN" } }); const data = await response.json();
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());
private — the project is only visible to its members and organization admins. public — the project is publicly discoverable and viewable without membership.
bugs and tickets enabled returns ["bugs", "tickets"]). The enum lists every module key that may appear.
{} and none of the sub-fields below are present — check for the presence of id (or an empty object) before reading release details.
{
"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"
}
}
{"error":"You must be signed in to perform this action."}) — the unauthenticated case is handled here, before any 403.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.
{
"error": "string",
"status": {}
}
{"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.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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string |
| 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. |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
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"
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
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())
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();
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());
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.
g-{id} (see url) if you need a permanently stable identifier.
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, resolved against the project’s (possibly custom) status names.
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.
issue[extras][device_info] is submitted on create (see the create endpoint).
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.
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.
developer_private controls only whether this URL appears in the listing, not access to the URL once known.
falseissue.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.)
developer_private controls only whether this URL appears in the listing, not access to the URL once known.
falsetrue 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).
developer_private controls only whether this URL appears in the listing, not access to the URL once known.
falsedeveloper_private controls only whether this URL appears in the listing, not access to the URL once known.
false{
"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=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 visibleIMPORTANT NOTES:
Bearer {jwt_token} for subsequent API calls after issue creationapplication/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.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: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: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: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 header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
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.
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.
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).
strict rejects on unparseable input, loose (the fallback for an unrecognized mode) parses best-effort, and optional is the most permissive.
allow_discord_id_for_issues enabled and the caller is an anonymous token-authenticated FormUser; silently ignored otherwise.
issue[discord_id], used when creating the attributed reporter. Subject to the same gating as issue[discord_id].
issue[extras][device_info] (documented above) instead.
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.
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:
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.include_* flags below. WARNING: enabling this WITHOUT setting the corresponding include flags deletes the attached media. Leave unset unless you specifically intend media pruning.
process_include_flags=true, keep (true) or drop (false) attached screenshots.
process_include_flags=true, keep (true) or drop (false) attached video clips.
process_include_flags=true, keep (true) or drop (false) other attached media (logs / binary files).
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 \ -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"
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
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())
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();
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());
{
"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"
}
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.
g-{id} (see url) if you need a permanently stable identifier.
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, resolved against the project’s (possibly custom) status names.
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.
issue[extras][device_info] is submitted on create (see the create endpoint).
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.
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.
developer_private controls only whether this URL appears in the listing, not access to the URL once known.
falseissue.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.)
developer_private controls only whether this URL appears in the listing, not access to the URL once known.
falsetrue 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).
developer_private controls only whether this URL appears in the listing, not access to the URL once known.
falsedeveloper_private controls only whether this URL appears in the listing, not access to the URL once known.
false{
"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
}
{
"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.”).
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.
{
"error": "Not allowed to report a bug.",
"status": "forbidden"
}
{
"error": "NDA acceptance required.",
"status": "forbidden"
}
{
"error": "Auth token does not have permission to create releases",
"status": "forbidden"
}
{
"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.”).
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.
{
"error": "Custom fields Severity is required, Custom fields Platform is required",
"status": 422
}
{
"error": "Custom fields Severity must be one of: Blocker, Major, Minor, Cosmetic",
"status": 422
}
{
"error": "You have reached your 24 hours limit for bug submissions. You can submit again later.",
"status": 422
}
{
"error": "Submission token has already been used. Please generate a new one.",
"status": 422
}
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 header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string |
| 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”) |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
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"
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
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())
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();
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());
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.
g-{id} (see url) if you need a permanently stable identifier.
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, resolved against the project’s (possibly custom) status names.
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.
issue[extras][device_info] is submitted on create (see the create endpoint).
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.
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.
developer_private controls only whether this URL appears in the listing, not access to the URL once known.
falseissue.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.)
developer_private controls only whether this URL appears in the listing, not access to the URL once known.
falsetrue 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).
developer_private controls only whether this URL appears in the listing, not access to the URL once known.
falsedeveloper_private controls only whether this URL appears in the listing, not access to the URL once known.
falseg-{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.
g-{id} (see url) if you need a permanently stable identifier.
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, resolved against the project’s (possibly custom) status names.
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.
issue[extras][device_info] is submitted on create (see the create endpoint).
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.
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.
developer_private controls only whether this URL appears in the listing, not access to the URL once known.
falseissue.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.)
developer_private controls only whether this URL appears in the listing, not access to the URL once known.
falsetrue 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).
developer_private controls only whether this URL appears in the listing, not access to the URL once known.
falsedeveloper_private controls only whether this URL appears in the listing, not access to the URL once known.
false{
"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
}
{
"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"
}
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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
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"
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
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())
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();
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());
{
"query": "string",
"skip_ids": "string",
"partial": "true",
"scoped_id": "string"
}
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.
g-{id} (see url) if you need a permanently stable identifier.
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, resolved against the project’s (possibly custom) status names.
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.
issue[extras][device_info] is submitted on create (see the create endpoint).
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.
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.
developer_private controls only whether this URL appears in the listing, not access to the URL once known.
falseissue.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.)
developer_private controls only whether this URL appears in the listing, not access to the URL once known.
falsetrue 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).
developer_private controls only whether this URL appears in the listing, not access to the URL once known.
falsedeveloper_private controls only whether this URL appears in the listing, not access to the URL once known.
falseg-{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.
g-{id} (see url) if you need a permanently stable identifier.
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, resolved against the project’s (possibly custom) status names.
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.
issue[extras][device_info] is submitted on create (see the create endpoint).
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.
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.
developer_private controls only whether this URL appears in the listing, not access to the URL once known.
falseissue.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.)
developer_private controls only whether this URL appears in the listing, not access to the URL once known.
falsetrue 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).
developer_private controls only whether this URL appears in the listing, not access to the URL once known.
falsedeveloper_private controls only whether this URL appears in the listing, not access to the URL once known.
false{
"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"
]
}
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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string | The obfuscated ID of the issue to find similar issues for |
| 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 |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
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"
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
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())
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();
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());
{
"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
}
]
}
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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
hidden, open, in_progress, resolved, closed, duplicate, pending_moderation, wont_fix, needs_more_info) or a project custom status — not a closed enum.
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 \ -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"
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
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())
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();
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());
{
"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
]
}
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.
g-{id} (see url) if you need a permanently stable identifier.
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, resolved against the project’s (possibly custom) status names.
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.
issue[extras][device_info] is submitted on create (see the create endpoint).
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.
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.
developer_private controls only whether this URL appears in the listing, not access to the URL once known.
falseissue.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.)
developer_private controls only whether this URL appears in the listing, not access to the URL once known.
falsetrue 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).
developer_private controls only whether this URL appears in the listing, not access to the URL once known.
falsedeveloper_private controls only whether this URL appears in the listing, not access to the URL once known.
false{
"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"
}
{
"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"
}
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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
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 header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
curl \ -X DELETE \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "BetaHub-Project-ID: 123" \ "https://app.betahub.io/projects/123/issues/g-123.json"
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
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())
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();
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());
issues.delete scope.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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
archived=true to the list endpoint to see them. Requires a Personal Access Token with the issues.archive scope.Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
curl \ -X POST \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "BetaHub-Project-ID: 123" \ "https://app.betahub.io/projects/123/issues/g-123/archive"
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
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())
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();
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());
{
"success": true
}
issues.archive scope.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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
{
"success": false
}
issues.archive scope.Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
curl \ -X POST \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "BetaHub-Project-ID: 123" \ "https://app.betahub.io/projects/123/issues/g-123/unarchive"
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
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())
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();
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());
{
"success": true
}
issues.archive scope.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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
{
"success": false
}
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
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"
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
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())
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();
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());
{
"email": "user@example.com",
"discord_id": "string"
}
{
"success": true,
"message": "string",
"issue_id": "string",
"reporter": {
"id": 0,
"email": "string",
"discord_id": "string",
"virtual": true
}
}
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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
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"
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
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())
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();
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());
{
"email": "user@example.com",
"discord_id": "string"
}
{
"email": "reporter@example.com"
}
{
"discord_id": "123456789"
}
{
"success": true,
"message": "Contact information assigned successfully",
"issue_id": "1234abc",
"reporter": {
"id": 56,
"email": "reporter@example.com",
"discord_id": null,
"virtual": true
}
}
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.
{
"error": "Invalid email format"
}
{
"error": "Invalid discord_id format"
}
{
"error": "Either email or discord_id is required"
}
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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
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"
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
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())
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();
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());
{
"email_my_report": true
}
{
"email_my_report": true
}
{
"success": true
}
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
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"
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
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())
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();
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());
{
"what": "steps",
"comment": "string"
}
{
"what": "steps",
"comment": "Could you please provide detailed steps to reproduce this issue?"
}
{
"success": true,
"message": "Request for steps sent to reporter"
}
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.
{
"error": "Invalid request type"
}
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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
curl \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "BetaHub-Project-ID: 123" \ "https://app.betahub.io/projects/123/issues/g-123/screenshots"
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
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())
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();
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());
[
{
"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"
}
}
]
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.
{
"error": "string",
"status": {}
}
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
falsecurl \ -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"
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
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())
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();
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());
{
"screenshot[image]": "string",
"screenshot[name]": "string",
"screenshot[developer_private]": true
}
{
"screenshot[image]": "(binary data representing an image)"
}
{
"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"
}
PUT the raw file bytes to the returned direct_upload_url, replaying the
returned headers, before calling confirm_upload.| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string |
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"
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
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())
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();
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());
{
"filename": "screenshot.png",
"byte_size": 1048576,
"checksum": "1B2M2Y8AsgTpgAmY7PhCfg==",
"content_type": "image/png",
"name": "Player Death Screenshot"
}
{
"blob_signed_id": "string",
"direct_upload_url": "https://example.com",
"headers": {
"key": "string"
},
"blob_id": 0
}
filename, byte_size, checksum, or content_type). Body is a flat { "error": "<message>" }.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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
{ "error": "<message>" }.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.
{
"error": "string",
"status": {}
}
{ "error": "Internal server error" }.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.
{
"error": "string",
"status": {}
}
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.
{
"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.
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string |
falsecurl \ -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"
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
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())
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();
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());
{
"blob_signed_id": "string",
"name": "Player Death Screenshot",
"developer_private": true
}
developer_private controls only whether this URL appears in the listing, not access to the URL once known.
falseissue.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": 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"
}
}
blob_signed_id parameter. Body is a flat { "error": "<message>" }.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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
blob_signed_id signature is invalid/expired, or no blob was found for it. Body is a flat { "error": "<message>" }.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.
{
"error": "string",
"status": {}
}
{ "error": "<message>" }; model-validation failures instead return { "errors": { "<field>": ["<message>"] } }.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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string | |
| id required | integer | The screenshot ID. |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
curl \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "BetaHub-Project-ID: 123" \ "https://app.betahub.io/projects/123/issues/g-123/screenshots/123"
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
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())
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();
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());
developer_private controls only whether this URL appears in the listing, not access to the URL once known.
falseissue.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": 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"
}
}
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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
200 response is the raw ActiveRecord record.Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string | |
| id required | integer | The screenshot ID. |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
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"
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
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())
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();
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());
{
"screenshot": {
"description": "string",
"name": "string",
"developer_private": true
}
}
{
"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"
}
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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
422). Unreliable and often absent — rely on the HTTP status line instead.
{
"error": "string",
"status": {},
"errors": {}
}
200 response is the raw ActiveRecord record (not the jbuilder shape — it has no url/type/filename).Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string | |
| id required | integer | The screenshot ID. |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
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"
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
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())
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();
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());
{
"screenshot": {
"description": "string",
"name": "string",
"developer_private": true
}
}
{
"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"
}
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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
422). Unreliable and often absent — rely on the HTTP status line instead.
{
"error": "string",
"status": {},
"errors": {}
}
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string | |
| id required | integer | The screenshot ID. |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
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"
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
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())
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();
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());
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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
curl \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "BetaHub-Project-ID: 123" \ "https://app.betahub.io/projects/123/issues/g-123/log_files"
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
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())
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();
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());
[
{
"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"
}
}
]
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.
{
"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 header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
log_file[contents] is provided.
log_file[file] when file uploads are not possible. The text will be stored as a text/plain file.
falsetext/plain file.
falsefalsecurl \ -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"
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
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())
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();
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());
{
"log_file[file]": "string",
"log_file[contents]": "string",
"log_file[name]": "string",
"log_file[developer_private]": true
}
developer_private controls only whether this URL appears in the listing, not access to the URL once known.
false{
"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"
}
}
422). Unreliable and often absent — rely on the HTTP status line instead.
{
"error": "string",
"status": {},
"errors": {}
}
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.
{
"error": "string",
"status": {}
}
PUT the raw file bytes to the returned direct_upload_url, replaying the
returned headers, before calling confirm_upload.| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string |
application/octet-stream as a fallback for unrecognized types.
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"
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
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())
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();
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());
{
"filename": "application.log",
"byte_size": 2048000,
"checksum": "1B2M2Y8AsgTpgAmY7PhCfg==",
"content_type": "text/plain",
"name": "Debug Log"
}
{
"blob_signed_id": "string",
"direct_upload_url": "https://example.com",
"headers": {
"key": "string"
},
"blob_id": 0
}
filename, byte_size, checksum, or content_type). Body is a flat { "error": "<message>" }.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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
{ "error": "<message>" }.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.
{
"error": "string",
"status": {}
}
{ "error": "Internal server error" }.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.
{
"error": "string",
"status": {}
}
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.
{
"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.
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string |
falsecurl \ -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"
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
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())
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();
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());
{
"blob_signed_id": "string",
"name": "Debug Log",
"developer_private": true
}
{ "reclassified": true, "binary_file": { … } }) instead of the raw log-file record.developer_private controls only whether this URL appears in the listing, not access to the URL once known.
false{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.
developer_private controls only whether this URL appears in the listing, not access to the URL once known.
false{
"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"
}
}
}
blob_signed_id parameter. Body is a flat { "error": "<message>" }.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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
blob_signed_id signature is invalid/expired, or no blob was found for it. Body is a flat { "error": "<message>" }.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.
{
"error": "string",
"status": {}
}
{ "error": "<message>" }; model-validation failures instead return { "errors": { "<field>": ["<message>"] } }.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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string | |
| id required | integer | The log file ID. |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
curl \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "BetaHub-Project-ID: 123" \ "https://app.betahub.io/projects/123/issues/g-123/log_files/123"
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
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())
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();
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());
developer_private controls only whether this URL appears in the listing, not access to the URL once known.
false{
"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"
}
}
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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string | |
| id required | integer | The log file ID. |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
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"
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
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())
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();
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());
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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
302) to the file’s CDN (CloudFront) download URL. The redirect targets a different host, so clients must follow cross-origin redirects.Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string | |
| id required | integer | The log file ID. |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
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"
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
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())
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();
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());
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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
curl \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "BetaHub-Project-ID: 123" \ "https://app.betahub.io/projects/123/issues/g-123/binary_files"
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
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())
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();
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());
[
{
"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"
}
}
]
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.
{
"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 header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
falsecurl \ -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"
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
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())
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();
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());
{
"binary_file[file]": "string",
"binary_file[name]": "string",
"binary_file[developer_private]": true
}
developer_private controls only whether this URL appears in the listing, not access to the URL once known.
false{
"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"
}
}
422). Unreliable and often absent — rely on the HTTP status line instead.
{
"error": "string",
"status": {},
"errors": {}
}
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.
{
"error": "string",
"status": {}
}
PUT the raw file bytes to the returned direct_upload_url, replaying the
returned headers, before calling confirm_upload.| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string |
application/octet-stream.
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"
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
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())
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();
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());
{
"filename": "crashdump.bin",
"byte_size": 2048000,
"checksum": "1B2M2Y8AsgTpgAmY7PhCfg==",
"content_type": "application/octet-stream",
"name": "Crash Dump"
}
{
"blob_signed_id": "string",
"direct_upload_url": "https://example.com",
"headers": {
"key": "string"
},
"blob_id": 0
}
filename, byte_size, checksum, or content_type). Body is a flat { "error": "<message>" }.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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
{ "error": "<message>" }.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.
{
"error": "string",
"status": {}
}
{ "error": "Internal server error" }.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.
{
"error": "string",
"status": {}
}
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.
{
"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.
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string |
falsecurl \ -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"
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
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())
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();
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());
{
"blob_signed_id": "string",
"name": "Crash Dump",
"developer_private": true
}
developer_private controls only whether this URL appears in the listing, not access to the URL once known.
false{
"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"
}
}
blob_signed_id parameter. Body is a flat { "error": "<message>" }.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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
blob_signed_id signature is invalid/expired, or no blob was found for it. Body is a flat { "error": "<message>" }.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.
{
"error": "string",
"status": {}
}
{ "error": "<message>" }; model-validation failures instead return { "errors": { "<field>": ["<message>"] } }.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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string | |
| id required | integer | The binary file ID. |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
curl \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "BetaHub-Project-ID: 123" \ "https://app.betahub.io/projects/123/issues/g-123/binary_files/123"
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
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())
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();
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());
developer_private controls only whether this URL appears in the listing, not access to the URL once known.
false{
"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"
}
}
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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string | |
| id required | integer | The binary file ID. |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
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"
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
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())
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();
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());
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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
302) to the file’s CDN (CloudFront) download URL. The redirect targets a different host, so clients must follow cross-origin redirects.Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string | |
| id required | integer | The binary file ID. |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
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"
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
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())
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();
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());
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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
curl \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "BetaHub-Project-ID: 123" \ "https://app.betahub.io/projects/123/issues/g-123/video_clips"
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
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())
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();
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());
[
{
"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"
}
}
]
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.
{
"error": "string",
"status": {}
}
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
falsecurl \ -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"
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
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())
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();
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());
{
"video_clip[video]": "string",
"video_clip[name]": "string",
"video_clip[developer_private]": true
}
{
"video_clip[video]": "(binary data representing a video file)"
}
{
"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
}
PUT the raw file bytes to the returned direct_upload_url, replaying the
returned headers, before calling confirm_upload.| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string |
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"
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
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())
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();
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());
{
"filename": "gameplay.mp4",
"byte_size": 52428800,
"checksum": "1B2M2Y8AsgTpgAmY7PhCfg==",
"content_type": "video/mp4",
"name": "Boss Fight Gameplay"
}
{
"blob_signed_id": "string",
"direct_upload_url": "https://example.com",
"headers": {
"key": "string"
},
"blob_id": 0
}
filename, byte_size, checksum, or content_type). Body is a flat { "error": "<message>" }.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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
{ "error": "<message>" }.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.
{
"error": "string",
"status": {}
}
{ "error": "Internal server error" }.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.
{
"error": "string",
"status": {}
}
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.
{
"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.
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string |
falsecurl \ -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"
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
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())
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();
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());
{
"blob_signed_id": "string",
"name": "Boss Fight Gameplay",
"developer_private": true
}
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).
developer_private controls only whether this URL appears in the listing, not access to the URL once known.
false{
"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"
}
}
blob_signed_id parameter. Body is a flat { "error": "<message>" }.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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
blob_signed_id signature is invalid/expired, or no blob was found for it. Body is a flat { "error": "<message>" }.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.
{
"error": "string",
"status": {}
}
{ "error": "<message>" }; model-validation failures instead return { "errors": { "<field>": ["<message>"] } }.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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string | |
| id required | integer | The video clip ID. |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
curl \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "BetaHub-Project-ID: 123" \ "https://app.betahub.io/projects/123/issues/g-123/video_clips/123"
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
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())
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();
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());
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).
developer_private controls only whether this URL appears in the listing, not access to the URL once known.
false{
"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"
}
}
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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string | |
| id required | integer | The video clip ID. |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
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"
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
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())
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();
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());
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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
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 header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| issue_id required | string |
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"
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
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())
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();
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());
{
"client_event": {
"level": "info",
"message": "string",
"event_type": "string",
"metadata": {}
}
}
created.
{
"id": 4821,
"status": "created"
}
{ "error": "Unauthorized" }.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.
{
"error": "string",
"status": {}
}
{ "error": "Unauthorized" }.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.
{
"error": "string",
"status": {}
}
level, blank message, or the per-issue cap of 100 events reached). Body is { "errors": ["<full message>", ...] } — a flat array of full-message strings.422). Unreliable and often absent — rely on the HTTP status line instead.
{
"error": "string",
"status": {},
"errors": {}
}
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string |
| 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) |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
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"
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
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())
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();
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());
{id} path parameter, use the scoped_id field below (the obfuscated/project-scoped id).
{id} path parameter (e.g. “123” or “fr-456”). Always emitted.
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 is true.
null if the file is not attached.
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.
{
"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
}
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.
{
"error": "string",
"status": {}
}
Creates a new feature request (suggestion).
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.multipart/form-data with a repeatable feature_request[files][] field (one entry per file).Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
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.
suggestions.moderate scope; sets the status directly and skips auto-moderation. Ignored (stripped from permitted params) for everyone else.
open. Only honored on Discord-bot submissions.
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.
suggestions.update scope holders, the record owner, and submission-form (GameFormUser / SDK) token clients.
FIELD_IDENT with the field’s identifier.
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"
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
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())
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();
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());
{
"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"
}
{id} path parameter, use the scoped_id field below (the obfuscated/project-scoped id).
{id} path parameter (e.g. “123” or “fr-456”). Always emitted.
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 is true.
null if the file is not attached.
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.
{
"feature_request[description]": "Add a dark mode to the app",
"user[discord_id]": "123456789",
"user[discord_username]": "username",
"user[discord_discriminator]": "1234"
}
{
"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.”
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.
{
"error": "Auth token does not belong to this project."
}
{
"error": "Auth token does not have permission to submit suggestions, or rate limit exceeded."
}
{
"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”).
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.
{
"error": "You have reached your 24 hours limit for suggestion submissions. You can submit again later."
}
{
"error": "Submission token has already been used. Please generate a new one."
}
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string |
| 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.
|
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
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"
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
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())
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();
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());
{id} path parameter, use the scoped_id field below (the obfuscated/project-scoped id).
{id} path parameter (e.g. “123” or “fr-456”). Always emitted.
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 is true.
null if the file is not attached.
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.
{
"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
}
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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| id required | string | The feature request ID or scoped ID |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
curl \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "BetaHub-Project-ID: 123" \ "https://app.betahub.io/projects/123/feature_requests/123.json"
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
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())
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();
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());
{id} path parameter, use the scoped_id field below (the obfuscated/project-scoped id).
{id} path parameter (e.g. “123” or “fr-456”). Always emitted.
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 is true.
null if the file is not attached.
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.
{
"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"
]
}
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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| id required | string | The feature request ID or scoped ID |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
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"
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
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())
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();
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());
{
"email": "user@example.com",
"discord_id": "user@example.com"
}
{
"success": true,
"message": "Contact information assigned successfully",
"feature_request_id": 1234,
"user": {
"id": 56,
"email": "user@example.com",
"discord_id": null,
"virtual": true
}
}
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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| id required | string | The feature request ID or scoped ID |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
curl \ -X POST \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "BetaHub-Project-ID: 123" \ "https://app.betahub.io/projects/123/feature_requests/123/publish"
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
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())
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();
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());
{
"success": true
}
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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string |
| 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.
|
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
curl \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "BetaHub-Project-ID: 123" \ "https://app.betahub.io/projects/123/tickets/search.json?query=example"
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
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())
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();
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());
[
{
"id": 123,
"title": "Cannot access user settings"
},
{
"id": 456,
"title": "Password reset not working"
}
]
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.
{
"error": "string",
"status": {}
}
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.
{
"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.
ticket[reporter_email] in form data so the reporter can be notified about the ticket and its replies.ticket[reporter_email] in form data. Anonymous ticket creation without contact info is not allowed.
multipart/form-data only when attaching files — a JSON body works for text-only tickets.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.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).FormUser tkn-{token},{jwt}. Missing/invalid/already-consumed tokens are rejected with 403 (see responses below).Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
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.
{"platform": "windows"}). Same behavior as the multipart ticket[custom][FIELD_IDENT] parameter.
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"
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
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())
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();
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());
{
"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"
}
{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.
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.
null when unassigned.
null when unassigned. May be an identity-masked placeholder when the project hides team identities from players.
null if the file is not attached.
{
"ticket[description]": "I need help resetting my password",
"ticket[priority]": "high"
}
{
"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”).
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.
{
"error": "Description can't be blank"
}
{
"error": "Description is too short (minimum is 30 characters)"
}
{
"error": "Email address is required for support tickets"
}
{
"error": "Invalid email format"
}
{
"error": "Support tickets are not enabled for this project. Ask the server administrator to enable them."
}
{
"error": "You have reached your 24 hours limit for ticket submissions. You can submit again later."
}
{
"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.”
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.
{
"error": "Not allowed to create a ticket."
}
{
"error": "Submission token is required for this auth token. Generate one via the API."
}
{
"error": "This project is not currently accepting new support tickets. Please try again later."
}
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| id required | string | The ticket ID. Can be a numeric ID or scoped ID format. |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
curl \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "BetaHub-Project-ID: 123" \ "https://app.betahub.io/projects/123/tickets/123.json"
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
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())
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();
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());
{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.
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.
null when unassigned.
null when unassigned. May be an identity-masked placeholder when the project hides team identities from players.
null if the file is not attached.
{
"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"
}
}
]
}
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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
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.
{
"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:
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| id required | string | The ticket ID. Can be a numeric ID or scoped ID format. |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
tickets.update scope) may change status; reporters cannot.
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"
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
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())
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();
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());
{
"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"
]
}
{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.
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.
null when unassigned.
null when unassigned. May be an identity-masked placeholder when the project hides team identities from players.
null if the file is not attached.
{
"ticket[status]": "solved",
"ticket[description]": "Issue resolved - password reset email sent"
}
{
"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": []
}
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.
{
"error": "Description can't be blank"
}
{
"error": "Description is too short (minimum is 30 characters)"
}
{
"error": "Status cannot be manually set to 'new'"
}
{
"error": "Status can only be set to 'open' from 'closed'"
}
{
"error": "Status cannot mark as awaiting feedback - the last comment must be from a team member, not the reporter"
}
{
"error": "Support tickets are not enabled for this project. Ask the server administrator to enable them."
}
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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
show_drafts parameter (developers only) to include draft releases, or show_archived to include archived releases.Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string |
| 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 |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
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"
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
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())
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();
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());
[
{
"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"
}
]
can_read_release_list permission enabled; otherwise the list request is rejected.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.
{
"error": "string",
"status": {}
}
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.
{
"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 header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
regular for minor updates, major for significant new features, hotfix for critical bug fixes. Affects the notification style. Defaults to regular.
regularwindows, macos, linux). Sent as repeated release[platforms][] form fields. Blank values are stripped server-side.
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.
publishedcurl \ -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"
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
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())
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();
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());
{
"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"
}
123 — not a prefixed string).
download_link column). Null when no link is set. Note this is a singular string column — there is no per-platform download_links array.
["windows", "macos"]). May be null when unset.
release_label on issue submission) rather than created explicitly through the releases UI/API.
{
"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
}
]
}
{
"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"
}
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.
{
"error": "string",
"status": {}
}
{ "<attribute>": ["<message>"] } (e.g. { "label": ["can't be blank"] }), with NO error/errors/status wrapper.422). Unreliable and often absent — rely on the HTTP status line instead.
{
"label": [
"can't be blank"
]
}
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| release_id required | string |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
curl \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "BetaHub-Project-ID: 123" \ "https://app.betahub.io/projects/123/releases/123.json"
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
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())
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();
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());
123 — not a prefixed string).
download_link column). Null when no link is set. Note this is a singular string column — there is no per-platform download_links array.
["windows", "macos"]). May be null when unset.
release_label on issue submission) rather than created explicitly through the releases UI/API.
{
"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"
}
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.
{
"error": "string",
"status": {}
}
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.
{
"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 header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| release_id required | string |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
regular, major, or hotfix. Note - unlike create, release[status] cannot be changed here (use the publish/archive/restore actions to change status).
release[platforms][] form fields. Blank values are stripped server-side.
id), deletes that existing download link from the release.
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"
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
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())
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();
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());
{
"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"
]
}
500 (missing template) instead.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.
{
"error": "string",
"status": {}
}
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.
{
"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 header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| release_id required | string |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
curl \ -X DELETE \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "BetaHub-Project-ID: 123" \ "https://app.betahub.io/projects/123/releases/123.json"
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
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())
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();
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());
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.
{
"error": "string",
"status": {}
}
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.
{
"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 header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| release_id required | string |
| 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. |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
curl \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "BetaHub-Project-ID: 123" \ "https://app.betahub.io/projects/123/releases/123/download?platform=example"
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
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())
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();
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());
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.platform is specified)."string"
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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| release_id required | string |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
curl \ -X POST \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "BetaHub-Project-ID: 123" \ "https://app.betahub.io/projects/123/releases/123/publish.json"
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
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())
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();
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());
123 — not a prefixed string).
download_link column). Null when no link is set. Note this is a singular string column — there is no per-platform download_links array.
["windows", "macos"]). May be null when unset.
release_label on issue submission) rather than created explicitly through the releases UI/API.
{
"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
}
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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| release_id required | string |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
curl \ -X POST \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "BetaHub-Project-ID: 123" \ "https://app.betahub.io/projects/123/releases/123/archive.json"
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
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())
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();
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());
123 — not a prefixed string).
download_link column). Null when no link is set. Note this is a singular string column — there is no per-platform download_links array.
["windows", "macos"]). May be null when unset.
release_label on issue submission) rather than created explicitly through the releases UI/API.
{
"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
}
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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
{ "errors": ["<full message>"] } (plural errors, an array of full-message strings) — NOT a singular error string.422). Unreliable and often absent — rely on the HTTP status line instead.
{
"errors": [
"Cannot archive the only published release for this project."
]
}
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| release_id required | string |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
curl \ -X POST \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "BetaHub-Project-ID: 123" \ "https://app.betahub.io/projects/123/releases/123/restore.json"
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
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())
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();
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());
123 — not a prefixed string).
download_link column). Null when no link is set. Note this is a singular string column — there is no per-platform download_links array.
["windows", "macos"]). May be null when unset.
release_label on issue submission) rather than created explicitly through the releases UI/API.
{
"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
}
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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
{ "errors": ["<full message>"] } (plural errors, an array of full-message strings) — NOT a singular error string.422). Unreliable and often absent — rely on the HTTP status line instead.
{
"error": "string",
"status": {},
"errors": {}
}
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
curl \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "BetaHub-Project-ID: 123" \ "https://app.betahub.io/projects/123/game_facts.json"
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
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())
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();
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());
{
"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"
}
}
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.
{
"error": "string",
"status": {}
}
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.
{
"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 header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
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"
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
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())
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();
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());
{
"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"
]
}
}
}
{
"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"
}
}
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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
{
"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 header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
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"
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
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())
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();
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());
{
"game_fact[file]": "string",
"game_fact[document]": "string"
}
{
"job_id": "550e8400-e29b-41d4-a716-446655440000"
}
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.
{
"error": "string",
"status": {}
}
{
"error": "string"
}
error string (note: unlike the PATCH endpoint, this endpoint does not return an errors array).{
"error": "File size exceeds maximum limit (10MB)"
}
{
"error": "Document is too short to get processed. Please provide some more details."
}
{
"error": "Document is too long. Maximum length is 100000 characters."
}
{
"error": "Please upload a file or paste text content"
}
{
"error": "Unsupported file format. Please upload PDF, TXT, or MD files only."
}
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.
| Name | Type | Description |
|---|---|---|
| project_id required | string |
curl \ -X POST \ -H "Content-Type: application/json" \ -d '{}' \ "https://app.betahub.io/projects/123/playtime_sessions"
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
import requests response = requests.post( "https://app.betahub.io/projects/123/playtime_sessions", json={} ) print(response.json())
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();
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());
{}
{
"id": "3f8a1c2e-6b4d-4e7a-9f1b-2c5d8e0a7b34"
}
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.
{
"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.
| Name | Type | Description |
|---|---|---|
| project_id required | string | |
| playtime_session_id required | string |
curl \ -X PUT \ "https://app.betahub.io/projects/123/playtime_sessions/123"
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
import requests response = requests.put( "https://app.betahub.io/projects/123/playtime_sessions/123" ) print(response.json())
const response = await fetch("https://app.betahub.io/projects/123/playtime_sessions/123", { method: "PUT" }); const data = await response.json();
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());
{
"id": "3f8a1c2e-6b4d-4e7a-9f1b-2c5d8e0a7b34"
}
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.
{
"error": "string",
"status": {}
}
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 header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string |
| 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) |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
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"
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
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())
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();
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());
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).
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.
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).
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.
{
"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"
}
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.
{
"error": "string",
"status": {}
}
project.taxonomy.manage scope.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.
{
"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 header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string |
| 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 |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
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"
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
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())
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();
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());
{
"field": "roblox_id",
"results": [
{
"value": "123456789",
"count": 42,
"by_type": {
"bugs": 30,
"suggestions": 12
}
},
{
"value": "987654321",
"count": 8,
"by_type": {
"bugs": 5,
"suggestions": 3
}
}
]
}
field parameter is missing.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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
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 header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
curl \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "BetaHub-Project-ID: 123" \ "https://app.betahub.io/projects/123/issue_statuses.json"
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
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())
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();
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());
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).
active → in_progress, resolved → resolved, closed → closed_verified, hidden → hidden.
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.
{
"issue_statuses": [
{
"key": "in_progress",
"display_name": "In Progress",
"behavior_category": "in_progress",
"system_status": true
}
]
}
403.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.
{
"error": "string",
"status": {}
}
project.taxonomy.manage scope on the project.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.
{
"error": "string",
"status": {}
}
project_id, or the project has been removed.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.
{
"error": "string",
"status": {}
}
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 header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| project_id required | string |
| Name | Type | Description |
|---|---|---|
| page optional | integer | Page number for pagination (default: 1) |
| Name | Type | Description |
|---|---|---|
| BetaHub-Project-ID required | string | BetaHub project ID, the same as the project_id |
curl \ -H "Authorization: Bearer YOUR_API_TOKEN" \ -H "BetaHub-Project-ID: 123" \ "https://app.betahub.io/projects/123/issue_tags.json?page=123"
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
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())
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();
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());
#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.
issue; feature_request tags are a separate taxonomy (Suggestions) not exposed here.
#rrggbb) inherited from the parent tag.
issue on this endpoint.
{
"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
}
}
Authorization header and no session).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.
{
"error": "string",
"status": {}
}
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.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.
{
"error": "string",
"status": {}
}
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 header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
curl \ -H "Authorization: Bearer YOUR_API_TOKEN" \ "https://app.betahub.io/profiles/me.json"
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
import requests response = requests.get( "https://app.betahub.io/profiles/me.json", headers={"Authorization": "Bearer YOUR_API_TOKEN"} ) print(response.json())
const response = await fetch("https://app.betahub.io/profiles/me.json", { headers: { "Authorization": "Bearer YOUR_API_TOKEN" } }); const data = await response.json();
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());
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.
pr-... project id used elsewhere in the API and UI.
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.
{
"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"
}
]
}
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.
{
"error": "string",
"status": {}
}
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).
| Name | Type | Description |
|---|---|---|
| project_id required | string |
Project ID (e.g., pr-1234567)
|
response_length and response_tone.
true, the AI includes links to source documents in its response. Only applies to web page documents that have URLs.
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).
falsecurl \ -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"
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
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())
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();
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());
{
"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
}
null if no answer was found.
support_issue, gameplay_question, bug_report, neither.
technical, gameplay).
user data was provided and an answer was found.
not_support_or_gameplay, no_technical_sources, no_gameplay_sources, no_v2_documents.
question_similarity, rule_based.
true when the organization has reached its monthly support knowledge request limit.
limit_reached is true.
limit_reached is true.
limit_reached is true.
{
"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"
}
{
"error": "Permission denied or rate limit exceeded"
}
{
"error": "Invalid response_length. Valid values: concise, standard, detailed"
}
Authorization header for API access. Supports multiple authentication methods:
FormUser anonymous - for public operationsFormUser tkn-{token} - for authenticated operations with legacy tokensFormUser tkn-{token},{jwt_token} - user identification with JWT token (legacy)Bearer YOUR_TOKEN_HERE - recommended for API integrationsValidation 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.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
| Name | Type | Description |
|---|---|---|
| id required | integer | The comment ID |
curl \ -H "Authorization: Bearer YOUR_API_TOKEN" \ "https://app.betahub.io/comments/123.json"
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
import requests response = requests.get( "https://app.betahub.io/comments/123.json", headers={"Authorization": "Bearer YOUR_API_TOKEN"} ) print(response.json())
const response = await fetch("https://app.betahub.io/comments/123.json", { headers: { "Authorization": "Bearer YOUR_API_TOKEN" } }); const data = await response.json();
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());
{
"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"
}
}
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.
{
"error": "string",
"status": {}
}
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.
{
"error": "string",
"status": {}
}
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.
Bearer pat-YOUR_TOKEN| Name | Type | Description |
|---|---|---|
| project_id required | string |
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).
86400curl \ -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"
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
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())
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();
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());
{
"email": "player@example.com",
"custom": {
"platform": "windows",
"build_number": "2024.3.1"
},
"expires_in": 86400
}
{
"token": "eyJhbGciOiJIUzI1NiJ9.eyJwcm9qZWN0X2lkIjoxLCJqdGkiOiI...",
"expires_at": "2026-03-27T16:00:00Z"
}
Authorization: Bearer header.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:
Bearer pat-... (or the raw pat-... value)Bearer tkn-... (or the raw tkn-... value)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.
Bearer <token> or the bare token string. If absent, the token parameter is used instead.| Name | Type | Description |
|---|---|---|
| token optional | string |
The token to verify, as an alternative to the Authorization header. Ignored when the header is present.
|
curl \ -X POST \ -H "Authorization: Bearer YOUR_API_TOKEN" \ "https://app.betahub.io/auth/verify?token=example"
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
import requests response = requests.post( "https://app.betahub.io/auth/verify?token=example", headers={"Authorization": "Bearer YOUR_API_TOKEN"} ) print(response.json())
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();
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());
token_type.pr-5632787018).
iat claim), or null when absent.
{
"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
}
}
{
"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"
}
{
"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"
}
{
"valid": false,
"error": "Invalid or expired token"
}
{
"valid": false,
"error": "Token is required"
}
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:
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.
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.
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.
game_client, launcher). Shown to the user on the approval screen.
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"
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
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())
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();
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_id": "0f6c9b3e-4a1d-4b2c-9c8e-1234567890ab",
"entity_kind": "game_client",
"entity_name": "My Game (Steam build)"
}
{
"status": "created"
}
entity_kind / entity_name, or a duplicate request_id).{
"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.| Name | Type | Description |
|---|---|---|
| request_id required | string | The UUID supplied when the request was created. |
curl \ "https://app.betahub.io/device_auth/123/poll"
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
import requests response = requests.get( "https://app.betahub.io/device_auth/123/poll" ) print(response.json())
const response = await fetch("https://app.betahub.io/device_auth/123/poll"); const data = await response.json();
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());
pending, approved, or expired.status is approved.
{
"status": "pending"
}
{
"status": "approved",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user_name": "Jane Player"
}
{
"status": "expired"
}
request_id.{
"status": "not_found"
}
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).
| 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).
|
curl \ -X POST \ "https://app.betahub.io/crashes/unreal/example"
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
import requests response = requests.post( "https://app.betahub.io/crashes/unreal/example" ) print(response.json())
const response = await fetch("https://app.betahub.io/crashes/unreal/example", { method: "POST" }); const data = await response.json();
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());
"string"
can_report_crash permission. Empty body.