Channels API
A channel takes every report of the organization that its pre-filter matches and runs a workflow over it: triage steps, and transmit steps that deliver to a webhook or a local app, encrypted to a public key when the transmit step names one. The pre-filter is the channel’s only routing rule; the brief a report came through is one condition inside it. The workflow has numbered, immutable versions; the running version is the one dispatch executes. This page documents the channel and workflow version resources. Everything here is also in the Platform API reference.
Authentication and base URL
Section titled “Authentication and base URL”Requests are JSON:API over HTTPS against https://api.disclosebot.io/v1 with a service account
bearer token, as described in the API quickstart.
curl https://api.disclosebot.io/v1/channels \ -H "Authorization: Bearer disclosebot_svc_xxxxxxxx"The channel object
Section titled “The channel object”| Attribute | Type | Meaning |
|---|---|---|
name | string | Unique within the organization. |
short_description | string, nullable | Shown in the app. |
enabled | boolean | Whether dispatch routes reports to the channel. |
enabled_at | date-time, nullable | When it was last enabled. |
prefilter_query | object, nullable | A custom pre-filter (see below). null when the channel references a saved search or has no pre-filter. |
prefilter_saved_search_id | uuid, nullable | The saved search the pre-filter reads. Its query applies as it is now: editing the search changes every channel that references it. Never set together with prefilter_query. |
running_version_number | integer, nullable | The workflow version dispatch runs. |
steps_summary | array of strings | For example ["Pre-Filter", "Triage x5", "Transmit x1"]. |
created_at, updated_at | date-time | Timestamps. |
Relationships: organization, workflow, and prefilter_saved_search. Pass
include=workflow.versions or include=prefilter_saved_search to embed them.
The workflow definition
Section titled “The workflow definition”A definition is a flat object: one entry per step, keyed by the step name, with needs naming the
steps it runs after. Steps with no needs start together; a step with several needs waits for
all of them. Names are lowercase letters, digits, and underscores, starting with a letter.
| Kind | Fields |
|---|---|
triage | task (summary, scope, duplicate, validity, severity), engine (ai, or rule for scope and duplicate); severity takes scale (standard or custom with instructions, up to 2000 characters); duplicate takes lookback_days (1 to 365) and, with the ai engine, method (prompt or embeddings). One step per task and engine. |
transmit | integration (`{ “type”: “Webhook" |
Allowed only_when values: severity is critical, high, medium, or low; validity is
valid or invalid; scope is in_scope or out_of_scope; duplicate is true or false.
The definition must draw as lanes: a step may not depend on two parallel lanes while both continue.
A definition that cannot be drawn, has a cycle, or breaks any rule above is refused with 422 and
one error per problem, each naming the step. At most 40 steps.
The standard workflow, four triage steps in parallel, severity after them, and a webhook delivery on critical findings:
{ "steps": { "summary": { "kind": "triage", "task": "summary", "engine": "ai" }, "scope": { "kind": "triage", "task": "scope", "engine": "rule" }, "duplicate": { "kind": "triage", "task": "duplicate", "engine": "ai", "method": "embeddings", "lookback_days": 90 }, "validity": { "kind": "triage", "task": "validity", "engine": "ai" }, "severity": { "kind": "triage", "task": "severity", "engine": "ai", "scale": "standard", "needs": ["summary", "scope", "duplicate", "validity"] }, "jira": { "kind": "transmit", "integration": { "type": "Webhook", "id": "5d0c8a2e-…" }, "only_when": { "severity": ["critical"] }, "needs": ["severity"] } }}Encryption on a transmit step
Section titled “Encryption on a transmit step”A transmit step decides what the destination receives, on its own, without reading any other step:
| The report | The step | What the destination receives |
|---|---|---|
| Ordinary | No public_key_id | The report as it stands. |
| Ordinary | public_key_id set | The report’s description and the file names of its attachments, encrypted to that key (NaCl box, the format of libsodium and tweetnacl). The title travels readable. |
| Cloaked (encrypted in the reporter’s browser) | cloaked is send | The ciphertext the reporter’s browser produced, as it stands, encrypted to the brief’s Cloak Mode key. The step’s own public_key_id is not used: the platform cannot read a cloaked report, so it cannot re-encrypt one. |
| Cloaked | cloaked is drop | Nothing of the report. The delivery request records dropped and carries no body. |
An encrypted body is stored once per report and key, so two channels that encrypt to the same key
deliver the same body. A step whose public_key_id names a key that no longer exists fails; the
report is not delivered in the clear in its place.
Each channel gets its own transmit step run, so two channels sharing a destination both deliver. Triage steps are still shared: a report is triaged once, however many channels take it.
Routing and the pre-filter
Section titled “Routing and the pre-filter”When a report crosses the dispatch gate, every enabled channel of the organization evaluates its
pre-filter against it, as the pre-filter is at that moment. Each channel whose pre-filter matches
processes the report; a channel with no pre-filter takes every report. The routing is recorded on
the report’s event stream as Channel::Matched or Channel::NotMatched per channel.
The pre-filter is one of:
- a reference to a saved search (
prefilter_saved_search_id), whose query is read live; - a custom query of the channel’s own (
prefilter_query); - nothing, when both are
null.
Sending both answers 422 with the detail Prefilter query must be blank when the pre-filter references a saved search.
A query is two parts, the same shape a saved search stores:
{ "q": { "title_or_description_cont": "sql", "created_at_gteq": "2026-09-01" }, "results": { "severity": { "eq": "critical" } }}q holds ransack predicates over the report’s own fields (title_or_description, title,
description, submitted_through_id (the brief, an array of ids with in or not_in),
created_at, encrypted); results holds conditions on result keys. A flat object of ransack
predicates is accepted and stored as q.
A channel keeps results conditions but ignores them at dispatch, because a result has no
value before the workflow runs; the app shows them struck through. A brief condition
(submitted_through_id_in or submitted_through_id_not_in) is how a channel chooses which
briefs’ reports it takes; without one it takes reports from every brief.
Results on a report
Section titled “Results on a report”A report carries a results attribute: the current value of each result key, one entry per
key. A triage step, an integration, or the organization API writes a result; a later write
replaces the earlier value.
{ "data": { "id": "8e2298b2-f504-4249-9875-ef1a6d894e0e", "type": "report", "attributes": { "title": "SQL injection in the login form", "shortcode": "9d6147", "inbox_state": "dispatched", "processing_state": "open", "processed_at": "2026-09-10T14:02:11.000Z", "discarded_at": null, "results": { "severity": { "value": "critical", "reason": "Unauthenticated SQL injection in a login form", "source": "triage", "written_at": "2026-09-10T14:02:11.000Z" }, "scope": { "value": "in_scope", "reason": "app.example.com is a listed asset", "source": "triage", "written_at": "2026-09-10T14:01:58.000Z" } }, "created_at": "2026-09-10T13:58:40.000Z", "updated_at": "2026-09-10T14:02:11.000Z" } }}Each entry has:
| Field | Meaning |
|---|---|
value | The current value. Triage keys use the values below; other writers choose their own. |
reason | The writer’s explanation, or null. |
source | triage (a workflow step), integration (a connected app), or api (the organization API). |
written_at | When the value was written. |
The triage steps of a workflow write these keys:
| Key | Values |
|---|---|
summary | free text |
severity | critical, high, medium, low |
validity | valid, invalid |
scope | in_scope, out_of_scope |
duplicate | true, false |
A report has no entries until a channel processes it; results is then {}. processed_at
is the time the last triage step completed, or null. discarded_at is set when a brief
with Self Destruct purges the report once every channel that took it has completed. A report
whose processing failed in any channel (for example a delivery that failed) keeps its content
until that processing is retried and completes, and a report that matches no channel is not
purged. A discarded
report keeps its shortcode, timestamps, and result keys; its title becomes
Report <received date (UTC)> <shortcode> (for example Report 2026-09-08 9d6147), and its
description, reporter email, attached files, and result values and reasons are removed.
Deliveries already made are not recalled.
Result conditions in a saved search or a channel’s pre-filter read these same values.
Endpoints
Section titled “Endpoints”List channels
Section titled “List channels”GET /v1/channels
Filters: q[name_cont], q[short_description_cont], q[enabled_at_null] (true for disabled,
false for enabled), q[created_at_gteq], and q[s] to sort (for example created_at desc). Pagination: page[number] and page[size] (at most 30).
curl "https://api.disclosebot.io/v1/channels?q[name_cont]=triage&q[enabled_at_null]=false&page[size]=20" \ -H "Authorization: Bearer disclosebot_svc_xxxxxxxx"{ "data": [ { "id": "3a4f…", "type": "channel", "attributes": { "name": "Standard triage", "short_description": "Triage and page", "enabled": true, "enabled_at": "2026-09-08T10:12:03.000Z", "prefilter_query": { "q": { "title_or_description_cont": "sql", "submitted_through_id_in": ["9b1e…"] }, "results": {} }, "prefilter_saved_search_id": null, "running_version_number": 2, "steps_summary": ["Pre-Filter", "Triage x5", "Transmit x1"], "created_at": "2026-09-01T09:00:00.000Z", "updated_at": "2026-09-08T10:12:03.000Z" }, "relationships": { "organization": { "data": { "id": "6d4d…", "type": "organization" } }, "workflow": { "data": { "id": "c2d0…", "type": "workflow" } }, "prefilter_saved_search": { "data": null } } } ], "links": { "self": "…", "first": "…", "last": "…" }}Create a channel
Section titled “Create a channel”POST /v1/channels
Creates the channel with its pre-filter, records definition as workflow version 1, and enables
the channel when enabled is true. Without definition the workflow starts empty.
curl -X POST https://api.disclosebot.io/v1/channels \ -H "Authorization: Bearer disclosebot_svc_xxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "data": { "type": "channel", "attributes": { "name": "Standard triage", "short_description": "Triage and page", "enabled": true, "prefilter_query": { "q": { "title_or_description_cont": "sql", "submitted_through_id_in": ["9b1e…"] }, "results": {} }, "definition": { "steps": { "summary": { "kind": "triage", "task": "summary", "engine": "ai" } } } } } }'To read a saved search live instead, send prefilter_saved_search_id and no prefilter_query;
the Saved Searches API page
has the example.
201 answers with the channel object above (running_version_number is 1). Add
?include=workflow.versions to embed the workflow and version 1.
{ "errors": [ { "status": "422", "title": "Unprocessable Entity", "detail": "Step \"a\": kind must be one of triage, transmit", "source": { "pointer": "/data/attributes/definition" } } ]}A taken name answers 422 with the detail Name has already been taken.
Show a channel
Section titled “Show a channel”GET /v1/channels/{id} answers 200 with the channel object, or 404 for a channel of another
organization or an unknown id.
Update a channel
Section titled “Update a channel”PATCH /v1/channels/{id}
Updates the fields and the pre-filter. The workflow is never changed here; commit a workflow
version instead. To switch a channel from a saved search to a custom query, send the query with
prefilter_saved_search_id: null; to switch it to a saved search, send the id with
prefilter_query: null.
curl -X PATCH https://api.disclosebot.io/v1/channels/3a4f… \ -H "Authorization: Bearer disclosebot_svc_xxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "data": { "type": "channel", "attributes": { "short_description": "Triage, then page the on-call", "prefilter_saved_search_id": null, "prefilter_query": { "q": { "title_cont": "sql" }, "results": {} } } } }'Enable and disable a channel
Section titled “Enable and disable a channel”Enabling and disabling is the enabled attribute on the same PATCH:
curl -X PATCH https://api.disclosebot.io/v1/channels/3a4f… \ -H "Authorization: Bearer disclosebot_svc_xxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "data": { "type": "channel", "attributes": { "enabled": false } } }'curl -X PATCH https://api.disclosebot.io/v1/channels/3a4f… \ -H "Authorization: Bearer disclosebot_svc_xxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "data": { "type": "channel", "attributes": { "enabled": true } } }'Both answer 200 with the channel object; enabled and enabled_at reflect the new state.
Disabling lets reports already in flight finish on the running version; nothing new is dispatched.
Delete a channel
Section titled “Delete a channel”DELETE /v1/channels/{id} answers 204 and removes the channel with its workflow and every
version. Reports already processed keep their history.
List workflow versions
Section titled “List workflow versions”GET /v1/channels/{channel_id}/workflow_versions
Oldest first. Filters: q[number_gteq], q[s] (for example number desc). Pagination as above.
{ "data": [ { "id": "e7a1…", "type": "workflow_version", "attributes": { "number": 1, "definition": { "steps": { "summary": { "kind": "triage", "task": "summary", "engine": "ai" } } }, "definition_hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "committed_at": "2026-09-01T09:00:00.000Z", "committed_by_id": null, "summary": "Initial version", "created_at": "2026-09-01T09:00:00.000Z" }, "relationships": { "workflow": { "data": { "id": "c2d0…", "type": "workflow" } } } } ]}Show a workflow version
Section titled “Show a workflow version”GET /v1/channels/{channel_id}/workflow_versions/{id} answers 200 with one version, or 404.
Commit a workflow version
Section titled “Commit a workflow version”POST /v1/channels/{channel_id}/workflow_versions
Records the definition as the next version and makes it the running version. The response
summary lists what changed against the previous running version. Reports already in flight
finish on the version they started on.
curl -X POST https://api.disclosebot.io/v1/channels/3a4f…/workflow_versions \ -H "Authorization: Bearer disclosebot_svc_xxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "data": { "type": "workflow_version", "attributes": { "definition": { "steps": { "…": "…" } } } } }'201 answers with the new version (number is the previous number plus one).
{ "errors": [ { "status": "409", "title": "Conflict", "detail": "No changes to commit", "source": { "pointer": "/data/attributes/definition" } } ]}A definition that fails the checks answers 422 in the same shape as create, one error per
problem.
Errors
Section titled “Errors”Errors are a JSON:API errors array of objects with status, title, detail, and, for
definition and attribute problems, source.pointer.
| Status | When |
|---|---|
401 | Missing or invalid bearer token. |
403 | Channels are not enabled for the account (Feature not available). |
404 | Unknown id, or a channel or version of another organization. |
409 | Committing a definition equal to the running version. |
422 | A definition that breaks a rule, a taken name, or an invalid attribute. |
Pagination, filtering, and includes
Section titled “Pagination, filtering, and includes”page[number]andpage[size](default and maximum 30); responses carrymeta.pagination(current,records= the total, andfirst,prev,next,lastwhen they exist) andlinksfor the same pages. See Pagination.q[...]filters use ransack predicates on the attributes listed per endpoint;q[s]sorts.includeembeds related records:workflow,workflow.versions, andprefilter_saved_searchon channels.