REST API Reference
All MCP tools map 1:1 to REST endpoints. Use the REST API directly for CI/CD pipelines, non-MCP integrations, webhook handlers, server-side automation, or any HTTP client.
Base URL: https://api.briefgate.dev
All requests and responses use JSON (Content-Type: application/json). All timestamps are ISO 8601 in UTC.
Authentication
Include your API key in every request as a Bearer token:
Authorization: Bearer bg_live_xxxxxManage API keys
POST /v1/keys Create a new key
GET /v1/keys List all keys
DELETE /v1/keys/:id Revoke a key
DELETE /v1/keys/current Revoke the key making the request (what the MCP package's logout calls)Create a key:
curl -X POST https://api.briefgate.dev/v1/keys \
-H "Authorization: Bearer bg_live_xxxxx" \
-H "Content-Type: application/json" \
-d '{
"name": "CI/CD pipeline",
"mode": "live",
"scopes": ["intakes:read", "intakes:write"]
}'Key modes. mode is live (default) or test. A test key (bg_test_…) only sees the intakes it created, and those intakes never email or text a client — invitations and reminders are recorded as skipped, everything else (portal, webhooks, results) behaves normally. Use it for CI and for trying things out; there is no separate inbox, check the intake in the dashboard instead.
Available scopes: admin, intakes:read, intakes:write, secrets:read. The admin scope includes all others. Key management itself is session-only: POST /v1/keys, GET /v1/keys and DELETE /v1/keys/:id all require a signed-in dashboard user — an API key cannot create or list other keys. DELETE /v1/keys/current is the one exception, since it revokes the very key making the request.
Error format
All errors return a JSON body:
{
"error": "not_found",
"message": "Intake in_8f3k not found or does not belong to this account.",
"request_id": "req_abc123"
}Some errors carry extra fields: a stable reason slug and machine-readable params, or — for a request that failed field validation — a details array of { path, message } pairs, one per offending field:
{
"error": "unprocessable",
"message": "items.0.constraints: min_count must not exceed max_count",
"reason": "validation_failed",
"params": { "field": "items.0.constraints" },
"details": [
{ "path": "items.0.constraints", "message": "min_count must not exceed max_count" }
],
"request_id": "req_abc123"
}Include request_id when contacting support.
Error codes
| HTTP status | error value |
Meaning |
|---|---|---|
| 400 | invalid_request |
Malformed JSON body, or a request that is well-formed but invalid given the resource's current state (e.g. revoking an already-revoked key). Check message for details. |
| 401 | unauthorized |
Missing or invalid API key. |
| 403 | forbidden |
Key does not have the required scope for this operation. |
| 404 | not_found |
Resource does not exist or belongs to a different account. |
| 409 | conflict |
A resource with the same identifier already exists (e.g. duplicate idempotency_key within the expiry window). Returns the existing resource. |
| 410 | gone |
A one-time value has expired and been purged — e.g. a submitted secret past its reveal window. Re-reading an already-revealed secret is not this case: it returns HTTP 200 with already_revealed: true instead. |
| 413 | payload_too_large |
Reserved for future use — no endpoint returns it for an oversized request body. An oversized uploaded file is rejected as 422 unprocessable with a file.too_large issue instead. |
| 413 | download_too_large |
The intake exceeds the size limit for a ZIP download (GET /v1/intakes/:id/download) — see Download Everything as a ZIP for fetching a large intake's items individually instead. |
| 422 | unprocessable |
Request failed validation: a missing or malformed required field, a value outside its allowed range, or one that is structurally valid but semantically invalid (e.g. min_count greater than max_count). This is also what a missing required field returns, not 400 — see details for which field. |
| 429 | rate_limited |
Too many requests. Honour the Retry-After header (seconds) before retrying. |
| 402 | quota_exceeded |
Monthly intake or file-storage quota reached. |
| 402 | plan_required |
Feature is not available on the current plan. |
| 500 | internal_error |
Unexpected server error. Safe to retry with exponential backoff. |
| 503 | service_unavailable |
The database is temporarily unreachable. Honour Retry-After (seconds) and retry. |
Intakes
POST /v1/intakes
Create a new intake and send the client their portal link.
Request headers
| Header | Description |
|---|---|
Idempotency-Key |
Optional. A unique string (UUID recommended). If an intake was already created with this key, the original is returned (HTTP 200) instead of creating a duplicate. |
Key request fields
| Field | Type | Required | Description |
|---|---|---|---|
project_name |
string | Yes | Human-readable project title. |
client.email |
string | Yes | Client email address. |
client.name |
string | Yes | Client display name — every invitation opens by addressing them, so this cannot be blank. |
client.language |
string | No | cs, sk, pl, de, es, en. Omitted, it falls back to the account's default_language, then English. |
client.phone |
string | No | E.164 format, e.g. +420601123456. Reserved for SMS chases (chase_schedule channel sms, see Chase schedules). |
client.timezone |
string | No | IANA timezone, e.g. Europe/Prague. Anchors respect_quiet_hours and chase_at_time to the client's local clock. Default Europe/Prague. |
client.also_notify |
array | No | Up to 4 more people ({ email, name? }) who get the same portal link and reminders as the primary client. Add or remove them after creation with POST/DELETE /v1/intakes/:id/recipients (see Recipients). |
email_copy |
object | No | Overrides the subject and intro lines for this intake. See chase.md. |
client_brief |
string | No | Information and context for the client — shown at the top of the portal, before the requested items, as "Information from <your name>". Max 5000 characters; trimmed; an empty string is stored as null. Attach files with POST /v1/intakes/:id/brief/files once the intake exists — see Client brief. |
items |
array | Yes | Item definitions. See item-types.md. Still required (min. 1) even when template is set. |
template |
string | No | Slug of a saved template (templates.md). Template items merge with items by key. Every other field the template's settings sets applies as a default — only where this request itself leaves that field unset. |
chase_schedule |
string | No | default, gentle, aggressive, custom, or off. |
chase_interval |
integer | No | How often to remind, only with chase_schedule: "custom". Pair with chase_interval_unit. Default: every 3 days. Sending it with any other schedule returns 422. |
chase_interval_unit |
string | No | minutes, hours or days (default days). The interval must work out to at least 5 minutes and at most 90 days. |
respect_quiet_hours |
boolean | No | Hold reminders to the client's 08:00–19:00 window. Default true. |
max_reminders |
integer | string | No | Reminders before the intake is marked stalled (1–1000, default 3, or "unlimited"). |
chase_at_time |
string | No | Local time of day for the reminder, HH:MM. Requires chase_schedule: "custom" with a whole-day interval. |
due_date |
string | No | ISO 8601 date shown to the client. |
send |
boolean | No | Set to false to create a draft without sending. Call POST /v1/intakes/:id/send to send later. Default: true. |
retention |
object | No | How long to keep data after completion. { mode: "days"|"on_delivery", days?: number, anonymize?: boolean }. Default: purge 90 days after intake.completed. Use mode: "on_delivery" for intakes with credentials — contents are removed ~24 h after you collect results. |
folder_id |
string | No | File the intake under this folder — an id from GET /v1/folders. Omitted means Unfiled. Must name a folder on your own account, or 400 invalid_request (reason validation_error). |
Chase schedules
| Schedule | Reminders |
|---|---|
default |
T+2 days, T+5 days, T+9 days, then weekly |
gentle |
T+3 days, T+8 days, then bi-weekly (also skips weekends, moving to Monday 08:00) |
aggressive |
T+1 day, T+3 days, T+5 days, then every other day |
custom |
Every chase_interval chase_interval_unit (default every 3 days), starting one interval after the invite |
off |
No automatic reminders |
Response (HTTP 201)
{
"intake_id": "in_8f3kQmR2",
"portal_url": "https://p.briefgate.dev/8f3kqmr2",
"status": "sent",
"items": [
{ "key": "logo", "status": "pending" },
{ "key": "hero_copy", "status": "pending" }
],
"follow_up": {
"recommended": "schedule",
"reason": "No active webhook endpoints are registered on this account.",
"webhook": {
"active_endpoints": 0,
"events": ["item.submitted", "intake.completed"],
"register_with": "manage_webhook"
},
"schedule": {
"check_with": "get_intake_status",
"every_hours": 24,
"until": "2024-03-29T10:22:00Z"
}
}
}follow_up is always present and tells you how to learn about progress without polling blindly. recommended is "webhook" when an active endpoint already covers the relevant events, or "schedule" when it does not — in that case, poll GET /v1/intakes/:id/status every follow_up.schedule.every_hours hours until follow_up.schedule.until, or register a webhook (POST /v1/webhooks, see Webhooks) instead.
Complete curl example
curl -X POST https://api.briefgate.dev/v1/intakes \
-H "Authorization: Bearer bg_live_xxxxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: bella-napoli-website-2024" \
-d '{
"project_name": "Bella Napoli — Website",
"client": {
"email": "owner@bellanapoli.com",
"name": "Marco Esposito",
"language": "en"
},
"items": [
{
"key": "logo",
"label": "Restaurant logo",
"help": "SVG or PNG, transparent background, minimum 512px.",
"type": "image",
"required": true,
"constraints": {
"formats": ["svg", "png"],
"min_width": 512,
"transparent_background": true
}
},
{
"key": "hero_copy",
"label": "Hero section tagline",
"type": "longtext",
"required": true,
"constraints": { "max_chars": 400 }
},
{
"key": "opening_hours",
"label": "Opening hours",
"type": "structured",
"required": true,
"schema": {
"type": "object",
"required": ["mon_fri", "sat", "sun"],
"properties": {
"mon_fri": { "type": "string" },
"sat": { "type": "string" },
"sun": { "type": "string" }
}
}
},
{
"key": "photos",
"label": "Food and interior photos",
"type": "file_list",
"required": true,
"constraints": {
"formats": ["jpg", "png", "heic"],
"min_count": 5,
"max_count": 15
}
}
],
"chase_schedule": "default",
"due_date": "2024-04-01"
}'GET /v1/intakes
List intakes with optional filters.
Query parameters
| Parameter | Description |
|---|---|
status |
Filter by status: draft, sent, in_progress, completed, archived. |
client_email |
Filter to a specific client. |
folder_id |
Filter to one folder — an id from GET /v1/folders, or the literal none for intakes with no folder. |
q |
Case-insensitive substring search across project_name, client.name and client.email (1–100 chars). Combines with the other filters. |
limit |
Results per page (default 20, max 100). |
offset |
Pagination offset (default 0). |
Response
{
"intakes": [
{
"intake_id": "in_8f3kQmR2",
"project_name": "Bella Napoli — Website",
"status": "in_progress",
"client": {
"email": "owner@bellanapoli.com",
"name": "Marco Esposito"
},
"portal_url": "https://p.briefgate.dev/8f3kqmr2",
"created_at": "2024-03-15T10:22:00Z",
"progress": { "submitted": 3, "total": 7 }
}
],
"total": 47,
"limit": 20,
"offset": 0
}Note: Each intake object matches the same snake_case shape used everywhere else in this API, plus a per-intake
progresssummary — see GET /v1/intakes/:id and GET /v1/intakes/:id/status for the full field reference.
GET /v1/intakes/:id
Retrieve the full intake definition including all item definitions and metadata.
Response
{
"intake": {
"intake_id": "in_8f3kQmR2",
"project_name": "Bella Napoli — Website",
"status": "in_progress",
"mode": "live",
"portal_url": "https://p.briefgate.dev/8f3kqmr2",
"client": {
"email": "owner@bellanapoli.com",
"name": "Marco Esposito",
"phone": null,
"language": "en",
"timezone": "Europe/Rome",
"also_notify": [
{ "email": "chef@bellanapoli.com", "name": "Giulia", "bounced_at": null }
],
"client_bounced_at": null
},
"branding": null,
"folder_id": null,
"chase_schedule": "default",
"chase_interval_minutes": null,
"respect_quiet_hours": true,
"max_reminders": 3,
"chase_at_time": null,
"due_date": "2024-04-01",
"retention": { "mode": "days", "days": 90, "anonymize": true },
"created_at": "2024-03-15T10:22:00Z",
"sent_at": "2024-03-15T10:22:05Z",
"completed_at": null,
"delivered_at": null,
"anonymized_at": null,
"client_last_seen": "2024-03-16T14:05:33Z",
"stalled_at": null,
"purge_at": null,
"client_note": null,
"owner_note": null,
"client_brief": null,
"brief_files": []
},
"items": [
{
"key": "hero_copy",
"type": "longtext",
"assignee": "client",
"label": "Hero section tagline",
"help": null,
"required": true,
"status": "approved",
"value": "Real Napoli-style pizza, right in your neighbourhood.",
"constraints": { "max_chars": 400 },
"options": null,
"pattern": null,
"submitted_at": "2024-03-16T11:00:00Z",
"approved_at": "2024-03-16T11:05:00Z",
"revision_note": null,
"revision_count": 0,
"waiver": { "state": "none", "reason": null, "requested_by": null, "requested_at": null, "decided_at": null },
"client_note": null,
"owner_note": null
}
],
"progress": { "submitted": 2, "total": 4, "outstanding": 2, "percent": 50 },
"owner_tasks": { "done": 0, "total": 0 }
}chase_interval_minutes is the normalised value of chase_interval/chase_interval_unit; it is null unless chase_schedule is "custom". A file, file_list or image item additionally carries a files array (id, filename, mime, size, width/height, av_status, checksum_sha256) instead of a meaningful value — no signed URL is minted here; download one via GET /v1/intakes/:id/items/:key/files/:fileId (below) or GET /v1/intakes/:id/results. An owner-assigned select/multiselect (a "decision") additionally carries a decision object: { decided_by: "agent"|"owner", value, proposed_value, proposed_rationale }.
brief_files — the attachments on client_brief — is present on this detail response only, not on GET /v1/intakes (a query per row on every list page would run for every intake, most of which have none). Each entry is { id, filename, mime, size, av_status, created_at }, same servability rule as an item file (av_status gates whether a download will actually work). See Client brief below for managing the text and the files.
PATCH /v1/intakes/:id
Edits an existing intake instead of deleting it and creating a new one — which used to be the only option, and re-sent the invitation to the client. Every field is optional, but at least one is required (400 invalid_request otherwise). null clears owner_note, client_brief, due_date and chase_at_time; omitting a field always leaves it untouched.
| Field | Type | Notes |
|---|---|---|
owner_note |
string | null | Never shown to the client. |
client_brief |
string | null | Information for the client, shown at the top of the portal — see client_brief under POST /v1/intakes. null clears it; an empty string is also stored as null. |
project_name |
string | |
due_date |
string | null | YYYY-MM-DD. |
chase_schedule |
string | default, gentle, aggressive, custom, off. |
chase_interval / chase_interval_unit |
integer / string | Same rules as POST /v1/intakes — only meaningful with chase_schedule: "custom". |
chase_at_time |
string | null | HH:MM, needs a whole-day interval. |
max_reminders |
integer | "unlimited" |
|
respect_quiet_hours |
boolean | |
client.name, .phone, .language, .timezone |
No client.email. The portal link and magic token are bound to the primary address — changing it here would strand the client's existing link. Add, remove or reinstate addresses with the recipients endpoints instead. |
|
folder_id |
string | null | Move the intake to this folder, or null to move it to Unfiled. Must name a folder on your own account (400 invalid_request, reason validation_error, otherwise). Never touches status, chases, or anything else. |
Cadence changes on a live intake. On a sent, in_progress, or stalled intake, changing any of chase_schedule, chase_interval, chase_interval_unit, chase_at_time, max_reminders, respect_quiet_hours, client.timezone or due_date cancels every still-scheduled reminder and re-plans under the new settings. Reminders already sent still count toward max_reminders — attempt numbering is not reset.
Un-stalling. An intake that ran out of max_reminders stops chasing and sets stalled_at (see Reminder cap). Raising max_reminders (or setting it to "unlimited") past the number of reminders already sent clears stalled_at and resumes automatic chasing. Changing other fields on a still-stalled intake updates the stored settings but does not resume chasing by itself.
Returns 409 conflict (reason intake_archived) on an archived intake.
Request
{
"chase_schedule": "custom",
"chase_interval": 6,
"chase_interval_unit": "hours",
"max_reminders": 12
}Response is { "intake": { ... } }, the same shape as GET /v1/intakes/:id's intake object.
GET /v1/intakes/:id/items/:key/files/:fileId
Downloads or previews one file a client uploaded. Responds with an HTTP 302 redirect to a signed URL (24-hour validity) rather than JSON — use it directly as an <a href>/<img src>. Returns 422 unprocessable if the file is still pending its antivirus scan or was flagged as infected.
POST /v1/intakes/:id/brief/files
Attaches a file to the client brief — the paragraphs and documents you hand to the client, shown at the top of the portal before the requested items (see client_brief under POST /v1/intakes). multipart/form-data with one field, file. Goes through the same pipeline as a client's own upload: the real content type is sniffed rather than trusted, HEIC is converted to JPEG, and the antivirus scan runs before the file is stored — an infected upload is refused outright (422 unprocessable), not stored and hidden.
A brief holds at most 10 files; uploading an 11th returns 422 unprocessable. Counts against the same storage quota as everything else on the account. Refused with 409 conflict (reason intake_archived) on an archived intake.
Response (HTTP 201)
{
"file": {
"id": "bff_9k2mQ7xR",
"filename": "contract-offer.pdf",
"mime": "application/pdf",
"size": 184320,
"av_status": "clean",
"created_at": "2024-03-15T10:20:00Z"
}
}DELETE /v1/intakes/:id/brief/files/:fileId
Removes one brief attachment and releases its bytes from the storage quota. Returns 204 No Content. Refused with 409 conflict (reason intake_archived) on an archived intake.
GET /v1/intakes/:id/brief/files/:fileId/url
Downloads or previews one brief attachment. Same convention as GET /v1/intakes/:id/items/:key/files/:fileId above: an HTTP 302 redirect to a signed URL, and 422 unprocessable while the file is still pending its antivirus scan or once it has been flagged as infected.
GET /v1/intakes/:id/status
Lightweight status endpoint. Returns item statuses, chase history, and progress without transferring file content or signed URLs.
progress.total counts what the intake is waiting on: an item the owner has waived, and an optional item the client has not touched, both leave the denominator. That keeps percent at 100 exactly when status turns completed.
Response
{
"status": "in_progress",
"due_date": "2024-04-01",
"client_last_seen": "2024-03-16T14:05:33Z",
"progress": {
"submitted": 2,
"total": 4,
"outstanding": 2,
"percent": 50
},
"items": [
{ "key": "logo", "status": "approved", "submitted_at": "2024-03-16T10:00:00Z", "label": "Restaurant logo", "assignee": "client", "required": true },
{ "key": "hero_copy", "status": "needs_revision", "submitted_at": "2024-03-16T11:00:00Z", "label": "Hero section tagline", "assignee": "client", "required": true },
{ "key": "opening_hours", "status": "pending", "submitted_at": null, "label": "Opening hours", "assignee": "client", "required": true },
{ "key": "photos", "status": "submitted", "submitted_at": "2024-03-16T12:00:00Z", "label": "Food and interior photos", "assignee": "client", "required": true }
],
"chases": [
{ "id": "chs_a1", "channel": "email", "kind": "invite", "scheduled_at": "2024-03-15T10:22:00Z", "sent_at": "2024-03-15T10:22:05Z", "status": "sent", "attempt_no": 1, "missing_items_count": null, "error": null },
{ "id": "chs_a2", "channel": "email", "kind": "reminder", "scheduled_at": "2024-03-17T09:00:00Z", "sent_at": "2024-03-17T09:00:00Z", "status": "sent", "attempt_no": 2, "missing_items_count": 2, "error": null },
{ "id": "chs_a3", "channel": "email", "kind": "reminder", "scheduled_at": "2024-03-20T09:00:00Z", "sent_at": null, "status": "scheduled", "attempt_no": 3, "missing_items_count": null, "error": null }
]
}items[].assignee (client/owner) and .required are always present; an owner-assigned decision item additionally carries the same decision object described under GET /v1/intakes/:id. chases[].kind is one of invite, reminder, revision, manual; .status is one of scheduled, sent, failed, bounced, complained, cancelled, skipped. scheduled_at is when the next pending reminder is due — poll it instead of guessing from chase_schedule.
GET /v1/intakes/:id/results
Returns typed results for approved items. File and image items include signed URLs (24-hour validity).
API key only — unlike every other intake endpoint, this one does not accept a dashboard session. It reveals secrets and advances the per-key only_new cursor, both of which only make sense for an agent's own API key; the dashboard uses GET /v1/intakes/:id and POST /v1/intakes/:id/items/:key/reveal instead.
Query parameters
| Parameter | Type | Description |
|---|---|---|
only_new |
boolean | Only return items approved since the last call to this endpoint (tracked per API key). |
include_pending |
boolean | Also include items in submitted status (not yet approved). |
exclude_secrets |
boolean | Leave secret items out without revealing them — meta reports secret_unavailable: true and the one-time reveal stays available. Use it from integrations that log every response (Zapier, Make, n8n). |
Response
{
"intake_id": "in_8f3kQmR2",
"status": "in_progress",
"progress": { "submitted": 2, "total": 4, "outstanding": 2, "percent": 50 },
"missing": ["opening_hours"],
"results": {
"logo": { "url": "https://...", "filename": "logo.svg", "mime": "image/svg+xml", "size": 4821, "checksum_sha256": "..." },
"hero_copy": "Real Napoli-style pizza, right in your neighbourhood."
},
"meta": {
"logo": { "type": "image", "status": "approved", "submitted_at": "2024-03-16T10:00:00Z" },
"hero_copy": { "type": "longtext", "status": "approved", "submitted_at": "2024-03-16T11:00:00Z" }
}
}results holds one entry per included item, keyed by key, shaped per its type — a secret reveals its value here exactly once (see below) and every subsequent read reports it in meta instead as { secret_unavailable: true, reason, revealed_at }. missing lists the keys of required client items still outstanding. Reading a secret item requires the secrets:read scope (or admin); without it the item is omitted from results and meta reports { secret_unavailable: true, reason: "Scope 'secrets:read' or 'admin' required." } instead. A file/file_list item not yet servable (pending antivirus scan, or infected) is likewise left out of its file list and meta[key].files_pending counts how many.
POST /v1/intakes/:id/items/:key/reveal
Reveals one secret item's value. Session only — the dashboard's way to read a credential; an API key must use GET /v1/intakes/:id/results with the secrets:read scope instead, and only the account owner may call it (a non-owner team member gets 403 forbidden, reason secrets_owner_only). Like /results, the value is released exactly once.
Response (first reveal)
{ "key": "admin_password", "value": "hunter2", "first_reveal": true, "expires_at": "2024-03-23T10:00:00Z" }Response (already revealed — HTTP 200, not an error)
{ "key": "admin_password", "value": null, "already_revealed": true, "revealed_at": "2024-03-16T10:05:00Z", "message": "This credential was already revealed. Secrets are shown once and cannot be shown again." }POST /v1/intakes/:id/items
Add items to an existing intake. The client is notified by email.
Request body
{
"items": [
{
"key": "favicon",
"label": "Favicon",
"type": "image",
"required": true,
"constraints": { "formats": ["png","svg"], "min_width": 32 }
}
]
}Response
{
"intake_id": "in_8f3kQmR2",
"items_added": [
{ "key": "favicon", "status": "pending" }
]
}PATCH /v1/intakes/:id/items/:key
Changes one item on an intake that is already with the client — its type, label, hint, whether it is required, and which formats it accepts. Also carries owner_note.
Reach for this when the field turns out to be the wrong shape: you asked for an image and the client only has their logo as a PDF, or what you asked for as text is really a file. Widening the formats or switching the type unblocks the client without adding a duplicate item and waiving the original.
key cannot be changed — results come back under it. Add a new item instead.
If the client has already answered and the change would make that answer invalid, the call fails with 409 item_answer_would_be_discarded and nothing is touched. Repeat it with discard_submitted_value: true to clear the answer and ask again. A change that leaves the answer valid — a new label, a wider limit — never discards anything.
Request
{
"type": "file",
"label": "Logo",
"constraints": { "formats": ["svg", "png", "pdf"] }
}POST /v1/intakes/:id/revision
Request a revision on a specific item. Only valid when the item's status is submitted or approved (otherwise 409 conflict). Requires the revisions feature — not available on the Free plan (402 plan_required otherwise).
Request body
{
"item_key": "logo",
"note": "The logo appears blurry at 320px. Please re-export at minimum 512px, ideally as SVG."
}Response
{
"intake_id": "in_8f3kQmR2",
"item_key": "logo",
"status": "needs_revision",
"client_notified": true
}POST /v1/intakes/:id/items/:key/done
Tick off one of your own tasks — an item created with "assignee": "owner".
Request body
{
"done": true
}done is optional and defaults to true. Send false to re-open the task.
Response
{
"key": "call_client",
"done": true,
"status": "approved"
}Only owner items are accepted. A client item returns 400 — that answer belongs to the client and is given in the portal. Ticking a task off never completes or delivers an intake: owner tasks are deliberately outside the client's progress.
POST /v1/intakes/:id/items/:key/received-outside
Mark a client item as received outside BriefGate — the client e-mailed the file, handed it over on a call, or sent it through another channel. From now on the item counts as complete, so the intake can finish even though nothing came through the portal. If it was the last outstanding item, the intake completes exactly as if the client had clicked "Send assets": intake.completed fires and the optional thank-you e-mail goes out.
Request body
{
"note": "Sent as an e-mail attachment on 5 Sept"
}note is optional — a short reminder of where the material actually is. It is shown next to the item in the dashboard and never to the client.
Response
{
"key": "logo",
"status": "approved",
"received_outside": true,
"intake_status": "completed"
}Only client items are accepted; an owner task returns 400 — tick those off with /done. An item that is already approved or waived returns 409 (revert the waiver first), and so does an archived intake. Every item in GET /v1/intakes/:id and in the results carries received_outside and received_outside_note, so an agent reading the results knows the material exists but is not stored in BriefGate.
DELETE /v1/intakes/:id/items/:key/received-outside
Undo the mark. The item goes back to waiting for the client and the progress counter drops accordingly; an intake that had completed because of the mark is reopened, the same way reverting a waiver reopens it. Returns 404 if the item was never marked.
POST /v1/intakes/:id/items/:key/answer
Settles a decision — an owner-assigned select/multiselect item, optionally carrying the agent's own proposed answer (see items[].proposed under POST /v1/intakes). value is validated against the item's own options, same as a client submission.
Session only, deliberately. An API key cannot call this: it is what the AGENT holds, and accepting it here would let an agent confirm its own proposal and have the result recorded as decided_by: "owner" — exactly what separating a proposal from an answer exists to prevent. Non-decision items return 400 invalid_request.
Request
{ "value": "29" }Response
{ "key": "attendee_count", "value": "29", "status": "approved", "decided_by": "owner" }POST /v1/intakes/:id/items/:key/waive
The owner closes an item outright ("we're not going to have this"). Only the owner can waive directly — a client can only propose a waiver in the portal, which the owner then accepts or rejects with the endpoint below.
Request
{ "reason": "Client doesn't have a logo yet; using a text wordmark instead." }reason is optional. Response is { "item": { ... } }, the same item shape as GET /v1/intakes/:id.
POST /v1/intakes/:id/items/:key/waive/decision
The owner accepts or rejects a waiver the client proposed. Returns 409 conflict if the item has no waiver proposal pending.
Request
{ "accept": true, "note": "Confirmed with the client by phone." }Accepting moves the item to waived; rejecting leaves its status unchanged but clears the pending-waiver flag, so it is chased again like any other outstanding item. note is for the audit log only. Response is { "item": { ... } }.
DELETE /v1/intakes/:id/items/:key/waive
Reverts a waiver back to pending, discarding whatever value, submission and revision note the item had — the client has to be asked again either way. If the intake had reached completed, it reopens to in_progress and chases are rescheduled. Response is { "item": { ... } }.
POST /v1/intakes/:id/recipients
Adds another person to an intake that has already gone out. They get the same portal link and future reminders as the primary client; no mail is sent by this call itself — send one with POST /v1/intakes/:id/send or .../chase. At most 4 additional recipients (5 total including the primary client); 409 conflict (reason recipient_exists) if the address is already on the intake.
Request
{ "email": "chef@bellanapoli.com", "name": "Giulia" }Response (HTTP 201)
{
"intake_id": "in_8f3kQmR2",
"also_notify": [
{ "email": "chef@bellanapoli.com", "name": "Giulia", "bounced_at": null }
]
}DELETE /v1/intakes/:id/recipients/:email
Removes an additional recipient (URL-encode the address). The primary client cannot be removed this way — use PATCH /v1/intakes/:id to change it, or POST /v1/intakes to start over — and returns 400 invalid_request (reason recipient_is_primary) if attempted. Returns HTTP 204 on success, 404 not_found if the address isn't on the intake.
POST /v1/intakes/:id/recipients/:email/reinstate
Clears the bounce flag on one address (URL-encode it), primary or additional — for a bounce that was a false positive, or a mailbox the client has since fixed and asked to be re-added to. If the intake had nobody left to chase before this call (every address had bounced) and the intake is sent or in_progress, automatic chasing resumes.
Returns 404 not_found (reason recipient_not_found) if the address isn't on the intake, 409 conflict (reason recipient_not_bounced) if it never bounced.
Response (HTTP 200)
{
"email": "owner@bellanapoli.com",
"bounced_at": null,
"still_chasing": true
}still_chasing reports whether this address will actually receive further automatic reminders — false if the intake is completed, archived, stalled, or chase_schedule is "off".
GET /v1/intakes/preview
Session only. Renders what the client will actually see in their inbox — subject line and sender name — before anything is sent, using the account's current branding. Useful for catching a missing sender name or wrong language before a real client sees it.
Query parameters: language (defaults to the account's default_language), project_name (defaults to a localized placeholder).
Response
{
"language": "en",
"from_name": "Marco @ Bella Napoli (via BriefGate)",
"subject": "Marco needs a few things from you — Bella Napoli — Website",
"sender_name": "Marco @ Bella Napoli",
"using_fallback_sender_name": false
}POST /v1/intakes/:id/chase
Manually trigger a chase message outside the automatic schedule.
Request body
{
"channel": "email"
}channel is optional: "email" (default) or "sms". SMS requires the sms feature (not available on Free) and a positive SMS credit balance — 402 plan_required or 402 quota_exceeded (reason sms_credits) otherwise. Rate limited to 1 manual chase per intake per hour and 20 per account per hour (429 rate_limited, reason manual_chase_intake/manual_chase_account), independent of channel.
POST /v1/intakes/:id/send
Send a draft intake to the client. Only valid when status is draft (409 conflict otherwise). Rate limited to 30 invitations per account per hour (429 rate_limited, reason intake_invites) — the same limit POST /v1/intakes counts against when it sends immediately.
POST /v1/intakes/:id/archive
Closes an intake without deleting it. Works from any status (draft, sent, in_progress, completed); 409 conflict (reason intake_archived) if it is already archived. Empty request body.
Side effects:
- Cancels every pending or scheduled chase reminder.
- Blocks further activity:
PATCH /v1/intakes/:id, adding/updating items, waivers, and manual chases all return409 conflict(reasonintake_archived), the same error an archived intake already returned from those endpoints. - On the client portal, the magic link keeps resolving — the client still sees everything they already submitted, with
status: "archived"— but item submissions, file uploads, notes, waivers, and completion are all refused with the same409/intake_archived. - Fires an
intake.archivedwebhook (see Webhooks) and anintake.archivedaudit log entry.
Nothing is deleted: the row, its items, files, and history stay exactly as they were, and GET /v1/intakes/:id keeps returning it. Returns { intake } — the same single intake object PATCH /v1/intakes/:id returns, not the fuller GET /v1/intakes/:id bundle (no items/progress/owner_tasks) — reflecting the new archived status.
This is the reversible counterpart to DELETE /v1/intakes/:id below, which is permanent — there is currently no "unarchive".
GET /v1/intakes/:id/download/preflight
Reports what a ZIP download (below) would contain, without generating it — the byte count, whether it exceeds the size limit, and how many secrets are available to include. See Download Everything as a ZIP for the ZIP's contents and the secrets tradeoff.
Response
{
"files": 6,
"bytes": 18420531,
"skipped_files": 1,
"secrets": { "total": 2, "unrevealed": 1, "already_revealed": 1 },
"too_large": false,
"max_bytes": 524288000,
"filename": "podklady-bella-napoli-website-in_8f3kQmR2.zip"
}skipped_files counts uploads still pending the antivirus scan — left out of the ZIP and listed by name in podklady.pdf/podklady.md instead. secrets.unrevealed is how many secret items would still be available for a one-time reveal if left out of the download; secrets.already_revealed were shown earlier and always render as already revealed on <date> regardless of include_secrets. too_large mirrors the 413 the download call below would return.
GET /v1/intakes/:id/download
Downloads every submitted item as one ZIP: podklady.pdf and podklady.md (the same content — one for reading, one for machines) covering every item's label, type, status, submitted value, decisions, and waiver reasons, plus one subfolder per item that has files, holding the client's uploads exactly as submitted.
Query parameters
| Parameter | Type | Description |
|---|---|---|
include_secrets |
boolean | Reveal and include secret item values in podklady.pdf. Default false. Never affects podklady.md, which never carries secret values. |
Accepts a dashboard session or an API key with the intakes:read scope (or admin), same as reading the intake. include_secrets=true additionally requires the account owner's own session, or a key with secrets:read/admin; any other caller gets 403 forbidden, reason secrets_owner_only. Including secrets consumes each unrevealed one's one-time reveal, exactly like GET /v1/intakes/:id/results or POST /v1/intakes/:id/items/:key/reveal — see Secrets vault. A secret already revealed earlier renders as already revealed on <date> instead of its value either way.
Returns application/zip with Content-Disposition: attachment; filename="podklady-<project>-<intake id>.zip". Refused with 413 download_too_large above the size limit reported by the preflight endpoint's max_bytes. Logged as an intake.downloaded audit event.
DELETE /v1/intakes/:id
Permanently delete an intake and all associated uploaded files. This action is irreversible. Returns HTTP 204 on success.
Clients
The address book of people this account has sent intakes to. There is no clients table — it's a view over intakes, grouped case-insensitively by email, so it already knows about every client from before this endpoint existed and can never drift from what was really sent. The price of that is the same as any view: a mistyped address becomes its own entry, and disappears only when the intakes carrying it are deleted.
GET /v1/clients List clients derived from past intakesGET /v1/clients
List clients, most active first.
Query parameters
| Parameter | Description |
|---|---|
q |
Case-insensitive substring search across the client's name and email (1–100 chars). |
limit |
Results per page (default 20, max 100). |
offset |
Pagination offset (default 0). |
Response
{
"clients": [
{
"email": "jan@firma.cz",
"name": "Jan Novák",
"language": "cs",
"phone": null,
"timezone": "Europe/Prague",
"folder_id": "fld_a1b2c3",
"last_intake_id": "int_x9y8z7",
"last_intake_at": "2026-09-18T10:00:00.000Z",
"intake_count": 3
}
],
"total": 1,
"limit": 20,
"offset": 0
}name, phone and folder_id can be null when the most recent intake never set them. name, language, phone, timezone and folder_id come from that client's most recent intake; intake_count and last_intake_at/last_intake_id are computed over all of that client's intakes. Results are ordered by intake_count descending, then last_intake_at descending.
Note: Accepts a dashboard session or an API key with the
intakes:readscope, same as GET /v1/intakes — an API key sees only clients from intakes in its own mode (test/live).
Folders
A single level of organisation for intakes, shared by everyone on the account — not per-user, and not visible to the client: the portal never learns an intake has a folder. Use folder_id on POST /v1/intakes and PATCH /v1/intakes/:id to file an intake, and folder_id/q on GET /v1/intakes to filter or search the list.
GET /v1/folders List all folders
POST /v1/folders Create a folder
PATCH /v1/folders/:id Rename or reorder a folder
DELETE /v1/folders/:id Delete a folderGET /v1/folders
List every folder on the account, ordered by sort_order then name.
Response
{
"folders": [
{ "id": "fld_9k2mQxR7", "name": "Website projects", "sort_order": 0, "intake_count": 4, "created_at": "2024-03-01T09:00:00Z" },
{ "id": "fld_2wq8LpN3", "name": "Archived clients", "sort_order": 1, "intake_count": 0, "created_at": "2024-03-10T14:30:00Z" }
]
}intake_count counts intakes currently filed there that still exist and are not part of a deleted account.
POST /v1/folders
Create a new folder.
Request
{ "name": "Website projects" }name is trimmed and 1–80 characters. Folder names are unique per account, case-insensitively — a duplicate returns 409 conflict (reason folder_exists).
Response (HTTP 201) is the folder object, the same shape as one entry in GET /v1/folders.
PATCH /v1/folders/:id
Rename a folder, change its sort_order, or both. Every field is optional, but at least one is required (400 invalid_request otherwise).
{ "name": "Website projects 2024" }Renaming to a name already used by another folder on the account returns 409 conflict (reason folder_exists). Response is the updated folder object.
DELETE /v1/folders/:id
Delete a folder. Every intake filed in it moves to Unfiled (folder_id becomes null) — the intakes themselves, and their files, are untouched. Returns HTTP 204 on success.
Templates
Save an intake definition as a reusable template to avoid re-specifying items — and every other create-intake setting — for recurring project types. See templates.md for the full field reference and the settings-as-defaults rule.
GET /v1/templates List all templates
POST /v1/templates Create a template from an item definition array, plus optional settingsCreate a template
POST /v1/templates requires a logged-in dashboard session, not an API key — there is currently no way to create a template as an agent. name and items are required; description, language, and settings are optional.
curl -X POST https://api.briefgate.dev/v1/templates \
-H "Cookie: bg_session=..." \
-H "Content-Type: application/json" \
-d '{
"name": "Restaurant Website",
"items": [...],
"settings": {
"chase_schedule": "custom",
"chase_interval": 5,
"chase_interval_unit": "days",
"due_in_days": 14
}
}'Use the template slug in define_intake (or POST /v1/intakes) with "template": "web-restaurant" instead of specifying items manually — items is still required in the request even then (see the known gap noted in templates.md). Any setting the template carries applies as a default: it fills in only the fields the create-intake request itself leaves unset, and due_in_days becomes a real due_date at creation time.
Webhooks
BriefGate fires webhooks on intake state changes. Every payload carries event, intake_id and timestamp, plus the fields belonging to that event.
Every route below needs the admin scope on an API key (an endpoint receives every event on the whole account) — intakes:read/intakes:write are not enough. A dashboard session may read the roster, but only the account owner may register, remove, test or retry (403 forbidden for any other member).
GET /v1/webhooks List configured endpoints
POST /v1/webhooks Register a new endpoint
DELETE /v1/webhooks/:id Remove an endpoint
GET /v1/webhooks/:id/deliveries Delivery history (last 100 attempts)
POST /v1/webhooks/:id/test Send a test event to the endpoint
POST /v1/webhooks/:id/deliveries/:deliveryId/retry Retry one delivery by handRegister a webhook
curl -X POST https://api.briefgate.dev/v1/webhooks \
-H "Authorization: Bearer bg_live_xxxxx" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.com/webhooks/briefgate",
"events": ["intake.completed", "item.submitted"]
}'The signing secret is generated by BriefGate and returned once in the response — you do not supply it. Add "format": "slack" or "format": "discord" to deliver into a chat channel instead of your own server.
BriefGate signs each webhook request with an HMAC-SHA256 signature in the X-BriefGate-Signature header. Verify it using your secret before processing the payload.
Events
| Event | Fired when |
|---|---|
item.submitted |
Client uploaded or filled in an item. |
intake.completed |
All required items have been submitted. |
client.viewed |
Client opened their portal. |
chase.bounced |
A reminder hard-bounced or was reported as spam. |
intake.stalled |
The intake used up its reminder allowance without being completed. |
intake.overdue |
The due date passed with required items still outstanding (fires once per intake). |
intake.archived |
The intake was archived via POST /v1/intakes/:id/archive. |
These seven are the only accepted values; anything else is rejected when you register the endpoint. See webhooks.md for the payloads.
Retry a delivery
If an endpoint didn't return 2xx, BriefGate already retried it on the automatic schedule (immediate, then +1m, +5m, +30m, +2h, +6h — 6 attempts total) before marking it failed. This re-queues that same delivery by hand, through the same path as any other send — SSRF re-check, a freshly computed signature, logged like any other attempt. It does not reset attempts: a delivery that already used all 6 keeps climbing from there, so this buys exactly one more try, not a fresh ladder — fix the endpoint first if you want more than that.
curl -X POST https://api.briefgate.dev/v1/webhooks/whe_2f9k/deliveries/whd_9f8e7d6c5b4a/retry \
-H "Authorization: Bearer bg_live_xxxxx"{
"delivery": {
"id": "whd_9f8e7d6c5b4a",
"event": "intake.completed",
"status": "pending",
"attempts": 6,
"response_code": 500,
"error": null,
"next_retry_at": "2026-07-18T09:12:00Z",
"created_at": "2026-07-18T09:11:00Z",
"delivered_at": null
}
}404 not_found if the endpoint or the delivery doesn't exist on this account. 409 conflict (reason already_delivered) if that delivery already succeeded — sending it again would duplicate the event downstream. 409 conflict (reason endpoint_inactive) if the endpoint has since been switched off — turn it back on first.
Account and billing
GET /v1/account Account info and current tier
PATCH /v1/account Update account settings (name, default language, retention_days, portal_link_ttl_days, etc.)
POST /v1/account/logo Upload the logo shown in the portal and e-mails (multipart `file`; dashboard session only)
DELETE /v1/account/logo Remove the logo
POST /v1/account/signature Upload the e-mail signature image (business card) rendered at the bottom of every e-mail your clients get (multipart `file`, PNG/JPEG/WebP; dashboard session only)
DELETE /v1/account/signature Remove the signature image
GET /v1/usage Current period usage against plan limits
GET /v1/audit Paginated audit log of API and user actions (session only, account owner only)
POST /v1/billing/checkout Create a Stripe Checkout session to upgrade
POST /v1/billing/portal Create a Stripe Customer Portal session to manage subscriptionUsage response
{
"period": "2024-03",
"intakes": { "used": 12, "limit": 50 },
"storage_bytes": { "used": 524288000, "limit": 5368709120 }
}Team seats. A seat is a person who signs in to the dashboard — clients never take one, since they only ever open the link they were emailed. Every route below is dashboard session only; no API key can call any of them, not even one with the admin scope — except accepting an invite, which instead proves itself with the invite token and needs no session at all. Any signed-in member may read the roster; inviting, cancelling an invite, and removing a member are owner-only (403 forbidden for anyone else).
GET /v1/team List members and pending invites
POST /v1/team/invites Invite an email into a seat (owner-only)
DELETE /v1/team/invites/:id Cancel a pending invite (owner-only)
POST /v1/team/invites/accept Accept an invite — no session; the token is the proof
DELETE /v1/team/members/:id Remove a member (owner-only)GET /v1/team
Lists current members plus invites that are still pending and unexpired.
Response
{
"members": [
{ "id": "usr_9f3kqmr2", "email": "marco@bellanapoli.com", "name": "Marco Esposito", "role": "owner", "created_at": "2026-01-10T09:00:00Z", "last_login_at": "2026-09-04T08:12:00Z" }
],
"invites": [
{ "id": "tin_7h2jvw4x", "email": "giulia@bellanapoli.com", "role": "member", "created_at": "2026-09-01T10:00:00Z", "expires_at": "2026-09-08T10:00:00Z" }
]
}POST /v1/team/invites
Invites an email address into a purchased seat. Every invite grants the member role — there's no flow for inviting a second owner.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
email |
string | Yes | Address to invite. Lowercased; max 320 characters. |
Response (HTTP 201)
{ "id": "tin_7h2jvw4x", "email": "giulia@bellanapoli.com", "role": "member", "created_at": "2026-09-05T10:00:00Z", "expires_at": "2026-09-12T10:00:00Z" }The invite is emailed with a link valid for 7 days. Inviting the same address again replaces the earlier invite — only the newest link ever works.
402 quota_exceeded (reason seats) once members plus pending invites already fill the plan's seat count — cancel a pending invite, remove a member, or buy an extra seat. 400 invalid_request if that address already belongs to a member on this account. 429 rate_limited (reason team_invites) past 20 invitations per account per hour.
DELETE /v1/team/invites/:id
Cancels a pending invite. 404 not_found if it doesn't exist on this account. 400 invalid_request if it was already accepted — there's nothing left to cancel. Returns { "ok": true, "id": "tin_7h2jvw4x" }.
POST /v1/team/invites/accept
No authentication — the invite token is the proof, the same way a password-reset link works. Creates the member user, signs them in, and sets the session cookie.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
token |
string | Yes | The token from the invite email's link. |
name |
string | Yes | The new member's display name. |
password |
string | Yes | At least 12 characters. |
Response (HTTP 201)
{
"user": { "id": "usr_2k9fjbrt", "email": "giulia@bellanapoli.com", "name": "Giulia", "role": "member" },
"account": { "id": "acc_8j3nq2mv", "name": "Bella Napoli", "tier": "agency" }
}401 unauthorized ("That invitation link is not valid or has expired.") for a token that's unknown, already accepted, or expired — deliberately one answer for all three, same as password reset. 400 invalid_request ("An account with this email already exists.") if the invited address already has a user elsewhere. 402 quota_exceeded (reason seats) if the seat filled up between the invite being sent and accepted — the quota is re-checked here, not just when the invite was created.
DELETE /v1/team/members/:id
Removes a member: soft-deletes the user and ends every one of their sessions immediately. 400 invalid_request if :id is your own user id ("You cannot remove your own account this way.") or names the account owner ("The account owner cannot be removed."). 404 not_found if it doesn't match an existing member on this account. Returns { "ok": true, "id": "usr_2k9fjbrt" }.
Public endpoints
These endpoints require no authentication and are designed for programmatic consumption.
| Endpoint | Description |
|---|---|
GET /pricing.json |
Machine-readable pricing tiers, limits, and feature flags. Intended for agents to decide which plan to recommend or to check quota before creating intakes. |
GET /v1/openapi.json |
OpenAPI 3.1 specification for the entire API. Import into Postman, generate an SDK, or pass to an agent for full API introspection. |
GET /healthz |
Liveness probe. Returns HTTP 200 with {"status":"ok"} when the API process is running. |
GET /readyz |
Readiness probe. Returns HTTP 200 only when the API process and all downstream dependencies (database, object storage, email provider) are healthy. Returns HTTP 503 during deployments or outages. |