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_xxxxx

Manage 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:

bash
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:

json
{
  "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:

json
{
  "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)

json
{
  "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

bash
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

json
{
  "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 progress summary — 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

json
{
  "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

json
{
  "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)

json
{
  "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

json
{
  "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

json
{
  "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)

json
{ "key": "admin_password", "value": "hunter2", "first_reveal": true, "expires_at": "2024-03-23T10:00:00Z" }

Response (already revealed — HTTP 200, not an error)

json
{ "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

json
{
  "items": [
    {
      "key": "favicon",
      "label": "Favicon",
      "type": "image",
      "required": true,
      "constraints": { "formats": ["png","svg"], "min_width": 32 }
    }
  ]
}

Response

json
{
  "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

json
{
  "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

json
{
  "item_key": "logo",
  "note": "The logo appears blurry at 320px. Please re-export at minimum 512px, ideally as SVG."
}

Response

json
{
  "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

json
{
  "done": true
}

done is optional and defaults to true. Send false to re-open the task.

Response

json
{
  "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

json
{
  "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

json
{
  "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

json
{ "value": "29" }

Response

json
{ "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

json
{ "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

json
{ "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

json
{ "email": "chef@bellanapoli.com", "name": "Giulia" }

Response (HTTP 201)

json
{
  "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)

json
{
  "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

json
{
  "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

json
{
  "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:

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

json
{
  "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 intakes

GET /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

json
{
  "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:read scope, 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 folder

GET /v1/folders

List every folder on the account, ordered by sort_order then name.

Response

json
{
  "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

json
{ "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).

json
{ "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 settings

Create 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.

bash
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 hand

Register a webhook

bash
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.

bash
curl -X POST https://api.briefgate.dev/v1/webhooks/whe_2f9k/deliveries/whd_9f8e7d6c5b4a/retry \
  -H "Authorization: Bearer bg_live_xxxxx"
json
{
  "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 subscription

Usage response

json
{
  "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

json
{
  "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)

json
{ "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)

json
{
  "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.