Most monitors hand you a page diff and leave the interpretation to you. BriefPanel resolves a page into the items that repeat on it — a listing, a coin, a job, a plan — and records each change as a typed update: which field moved, from what to what, and how much it matters.
This API is the read side of that history: every read is a GET, returns JSON, and paginates the
same way. It also has one write — resuming a paused page — so an agent that paused a page can start
it again without a browser.
Base URL
https://actions.briefpanel.com/v1
Authentication
Send a personal API key as a Bearer token:
Authorization: Bearer YOUR_BRIEFPANEL_API_KEY
Create a key under Settings → API & integrations in the app (https://app.briefpanel.com/integrations).
Keys start with bp_mcp_ and the same key works for the MCP server — one credential
for both surfaces.
Keys carry scopes: every GET needs read, and the write endpoint needs write. A read-only key
gets 403 on a write.
OAuth access tokens (bp_oat_) are not accepted here. They are audience-bound to the MCP resource,
so they authenticate MCP calls only.
A missing or invalid key returns 401 with a WWW-Authenticate header. A live MCP OAuth token
returns 401 too, but with its own message: the credential works on the MCP server, and this API
needs a personal API key instead. Nothing is wrong with the token.
Quick start
List what you monitor. If this returns rows, your key works:
curl https://actions.briefpanel.com/v1/sources \
-H "Authorization: Bearer YOUR_BRIEFPANEL_API_KEY"
Then read what changed. This is the endpoint most integrations poll:
curl "https://actions.briefpanel.com/v1/updates?importance=material&limit=50" \
-H "Authorization: Bearer YOUR_BRIEFPANEL_API_KEY"
A trimmed response — the full shape is in the OpenAPI document:
{
"data": [
{
"id": "j57e...",
"at": 1750000000000,
"updateType": "modified",
"importance": "material",
"field": "price",
"valueChange": {
"type": "number",
"previous": 149.9,
"current": 129.9,
"unit": "BRL"
},
"entry": { "id": "k17c...", "title": "Plano Pro", "kind": "pricing plan" },
"source": { "id": "m92a...", "url": "https://example.com/pricing" }
}
],
"pagination": { "nextCursor": "eyJ...", "hasMore": true }
}
Endpoints
| Endpoint | Query parameters | Returns |
|---|---|---|
GET /v1/account |
— | The account behind your key, with the credit balance left on it. |
GET /v1/sources |
limit, cursor |
The pages you monitor, newest first, each with its derived status and the entry kinds learned for that page. |
GET /v1/sources/{id}/entries |
limit, cursor |
The items currently resolved on one page, most recently updated first. In-flight onboarding stubs are omitted. |
GET /v1/updates |
since, importance, limit, cursor |
Every item-level change across all your sources, newest first. The feed to poll. |
GET /v1/entries/{id}/updates |
field, importance, limit, cursor |
One entry's full event stream. Filter by field for a single field's history — the read behind a price chart. |
GET /v1/entries/{id}/changes |
limit, cursor |
One card per fetch that changed this entry: diff churn counts, the derived price move, and pointers to the captured evidence. |
GET /v1/changes/{id}/snapshot |
state, format |
The captured page state behind a change. Returns bytes, not the JSON envelope. |
POST /v1/sources/{id}/resume |
— | Start a paused page again. Needs the write scope. |
An id belonging to another account returns 404, not 403 — the API does not confirm that an id
it will not serve exists.
Two reads are on the MCP server only for now: a source's writable settings
(userPrompt, notificationsEnabled, updateSensitivity), and narrowing the update feed to one
source. GET /v1/sources carries health and cadence, and GET /v1/updates is account-wide.
Resuming a paused page
POST /v1/sources/{id}/resume takes no body and answers with the page's monitoring state:
{
"data": {
"id": "m92a...",
"status": "active",
"alreadyActive": false,
"nextFetchTime": 1789648163769
}
}
Checks restart on the page's own monitoringFrequency and spend credits again, one per page read.
A page that was paused for longer than its frequency is due immediately, so nextFetchTime can be
in the past.
Resuming a page that is already being checked is a success, not a conflict: alreadyActive is
true, nothing is changed, and no extra check is scheduled — so a retry is safe.
status is the page's stored state, not a forecast — the same value GET /v1/sources reports. It
comes back creditPaused only for a page that was already credit-paused. Resuming a paused page on
an account with no credits answers active, and the scheduler re-parks it as creditPaused on the
next check it comes due for. To know whether checks will actually run, read the credit balance
rather than this field.
Resuming does not rebaseline. The page is still compared against how it looked when it was paused, so the first check after a long pause can report everything that changed in between. That is deliberate: the alternative silently adopts those changes as the new normal, and you could never see them. Items collected before the pause are untouched and are not re-notified.
Pagination
Every collection takes limit and cursor and answers with a pagination object:
{ "pagination": { "nextCursor": "eyJ...", "hasMore": true } }
Pass nextCursor back as cursor to get the next page, and keep going until hasMore is false.
A cursor belongs to the request that issued it: the same endpoint, account and filters. Keep the
filters the same from page to page; limit is the only parameter you may change. A cursor sent with
a different since, sourceId or field, with a different importance on GET /v1/updates
(without sourceId), or from another account, answers 400 invalid_request. Retrying the same
request never helps. Drop cursor to start again from the first page, for example when a poll moves
since forward.
Treat hasMore as the only end-of-collection signal. A page can come back empty, or shorter than
your limit, while there is still more to read — stopping on a short page will silently truncate
your sync.
Polling the feed
GET /v1/updates is ordered newest first. Read at off the newest item and pass it back as
since on the next poll to get only what has moved since:
curl "https://actions.briefpanel.com/v1/updates?since=1750000000000" \
-H "Authorization: Bearer YOUR_BRIEFPANEL_API_KEY"
since accepts epoch milliseconds (the form at returns) or an ISO-8601 instant. importance
accepts critical, material, or minor.
Each item carries its parent entry and source inline, so an update is actionable without a
second call.
Outbound webhooks
If you do not want to poll, add a public HTTPS address that your system owns under
Settings → API & integrations → Outbound webhooks. That address is not a BriefPanel API route:
BriefPanel sends an HTTP POST to it whenever a new item-level content.update event is recorded.
The body reuses the canonical update fields from GET /v1/updates, including updateType,
importance, source and entry context, and the field plus typed valueChange pair. Its
webhook envelope also carries the stable event id and occurredAt.
Every request includes:
| Header | Meaning |
|---|---|
BriefPanel-Event-Id |
Stable content_updates id; unchanged across attempts. Use it as your idempotency key. |
BriefPanel-Timestamp |
Delivery-attempt time in Unix seconds. Use it to reject stale signed requests. |
BriefPanel-Signature |
v1=<hex HMAC-SHA256> of <event-id>.<timestamp>.<raw-body>, keyed by the signing secret shown once when the endpoint is created. |
A 2xx response completes the delivery. Timeouts, network/DNS failures, 408, 425, 429, and
5xx responses are attempted up to five times with exponential delays of 1, 2, 4, and 8 minutes.
Other responses, including redirects, fail immediately. Delivery is at least once, so receivers
must deduplicate by event id. A receiver failure never rolls back content ingestion or blocks
another endpoint. The app shows the latest delivery status and attempt count; finished delivery
records expire after 30 days.
Entry kinds
Each entry carries a kind — pricing plan, job posting, cryptocurrency. The names are
learned per source, so compare them only within one source; the vocabulary for a given page is
published as entryKinds on that source, along with hasPrice so you know when a missing price is
meaningful.
entryKinds is absent until a source has been analyzed.
Source health
GET /v1/sources returns a derived status:
status |
Meaning |
|---|---|
active |
Being checked on schedule. |
autoPaused |
Paused after repeated fetch failures. |
creditPaused |
Paused for lack of credits. |
disabled |
Switched off by you. |
lastFetchOutcome, lastFetchErrorReason, consecutiveFetchFailures and nextCheckTime carry the
detail behind it.
nextCheckTime is when the next check is expected, and it is absent when there is none to
report rather than a time nothing will act on. That is two states, and status separates them: an
autoPaused or disabled page has no check coming at all, while an active page that has never
been checked has none to name until its first one lands. Checks are
started by a scheduler that ticks on a fixed interval, so the check lands at or after that time, and
a page set to a cadence faster than the tick is still read at the tick:
effectiveMonitoringFrequency is that cadence, beside the monitoringFrequency you stored.
Credits
GET /v1/account returns what is left on the account:
curl https://actions.briefpanel.com/v1/account \
-H "Authorization: Bearer YOUR_BRIEFPANEL_API_KEY"
{ "data": { "credits": 420 } }
A check spends one credit per page it reads — the page you monitor, plus each item page it follows.
At zero, checks stop and every source reports creditPaused.
This is the read that answers "will my pages actually be checked?". A source's status reports what
is stored right now, not what the next check will decide: a source can still read active on an
empty account and be parked as creditPaused on its next due check. The balance is what tells the
two apart ahead of time.
It is a point-in-time reading, like any balance: it says whether checks can run now, not what the
next check will decide. The same number is on the MCP server as get_credit_balance.
Evidence
GET /v1/changes/{id}/snapshot serves the raw page state a change was derived from, so you can show
where a value came from rather than asking someone to trust it.
Snapshots are deduped across accounts and have no owner of their own, so they are addressed through
the change they belong to rather than by their own id. Take changeId from a timeline card and
check hasCurrentSnapshot / hasPreviousSnapshot before requesting a state.
state—current(default) orprevious.previousreturns404on a first change, which has no prior state.format—markdown(default),html, orscreenshot.screenshotanswers with a302to a short-lived signed image URL.
Errors
Failures return an error object:
{ "error": { "code": "not_found", "message": "No source with that id." } }
Branch on code rather than on the HTTP status — the set is fixed, each code answers with exactly
one status, and neither the set nor that pairing will change under you. message is written for
humans and may be reworded; do not parse it.
code |
HTTP | When |
|---|---|---|
invalid_request |
400 | An unparseable or out-of-range parameter. |
unauthorized |
401 | Missing, malformed, or revoked key — or an MCP OAuth token, which this surface does not take. |
forbidden |
403 | The key lacks the scope this endpoint needs. |
not_found |
404 | No such id, including one that belongs to another account. |
method_not_allowed |
405 | A method this endpoint does not serve. |
rate_limited |
429 | Too many requests for this key. |
internal |
500 | An error on our side. |
GET, the OPTIONS preflight, and POST on the write endpoint above are the methods this API
serves. PUT, PATCH and DELETE answer 405 method_not_allowed, and so does a POST to a path
that only reads — the Allow header names what that path does serve, in the same error envelope.
Rate limits
Reads are limited per API key, in a fixed window of 300 requests per minute. Writes have their own budget of 60 per minute, so a write loop never spends the allowance a poller needs, or the other way round.
Every response the limit counted carries the current quota, so you can pace a client instead of discovering the ceiling by hitting it:
| Header | Meaning |
|---|---|
X-RateLimit-Limit |
Requests allowed per window, in the bucket this request was counted against. |
X-RateLimit-Remaining |
Requests left in this window, after the one you just made. |
X-RateLimit-Reset |
Seconds until the window resets — a delta, not a Unix timestamp. |
These headers accompany a counted response, which includes the errors a request had to reach a
handler to earn: a 400 or 404 reports the quota just like a 200. A request rejected for a bad
key (401/403) is turned away before the limit is counted, so it reports none.
Treat their absence as unknown, never as zero. They are also omitted if our limiter is momentarily unreachable, in which case the request is served without being counted.
Over the limit you get 429 with a Retry-After header, in seconds and always at least one —
honor it rather than retrying immediately.
OpenAPI
The full specification is served as a live OpenAPI 3.0 document:
https://actions.briefpanel.com/v1/openapi.json
Import it into Postman, point an SDK generator at it, or hand the URL to an agent. It describes every endpoint, the auth scheme, the pagination contract, and the complete response schemas.
It is served unauthenticated on purpose — tooling has to be able to read an API's description before it holds a key.