The Breadbox public API lets you read and write CRM data from any tool — PSA, RMM, automation platforms, custom workflows. Every call is scoped, audit-logged, and rate-limited.

Base URL

https://app.breadboxmsp.com/api/v1

All endpoints are under /api/v1. Breaking changes ship as /api/v2 with a 6-month deprecation window. Generate keys in Settings → API & Webhooks.

OpenAPI 3.1

Pipe into openapi-generator, openapi-typescript, Stoplight, etc.

https://app.breadboxmsp.com/api/v1/openapi.json

Postman / Insomnia / Bruno

One-click import — every endpoint pre-configured with examples.

https://app.breadboxmsp.com/api/v1/postman.json

Authentication

Every request requires a Bearer token in the Authorization header.

# curl example
curl https://app.breadboxmsp.com/api/v1/accounts \
  -H "Authorization: Bearer msp_live_xxxxxxxx"

Keys are prefixed msp_live_ for production and msp_test_ for test environments. Keys are SHA-256 hashed at rest — store them securely; the raw key cannot be retrieved after creation. Lost keys must be rotated.

Response envelope

Every response — success or error — uses a consistent JSON shape with a top-level success boolean and requestId (also returned as the X-Request-Id header — include it in any support ticket).

Single object

{
  "success": true,
  "data": { "id": "cuid_xxx", "name": "Acme Healthcare", ... },
  "requestId": "req_a1b2c3d4e5f6g7h8",
  "timestamp": "2026-05-06T18:42:11.443Z"
}

Paginated list

{
  "success": true,
  "data": [ { "id": "cuid_xxx", ... }, { "id": "cuid_yyy", ... } ],
  "hasMore": true,
  "nextCursor": "cuid_yyy",
  "requestId": "req_a1b2c3d4e5f6g7h8",
  "timestamp": "2026-05-06T18:42:11.443Z"
}

Error

{
  "success": false,
  "error": {
    "code": "validation_error",
    "message": "name must be at least 1 character",
    "docsUrl": "https://app.breadboxmsp.com/docs/api#errors",
    "details": [ { "path": ["name"], "message": "..." } ]
  },
  "requestId": "req_a1b2c3d4e5f6g7h8",
  "timestamp": "2026-05-06T18:42:11.443Z"
}

Scopes

Each API key is granted one or more scopes. A request fails with 403 insufficient_scope if the key lacks the required scope. Write scopes imply the corresponding read scope.

ScopeWhat it grants
accounts:readRead accounts, sites, and account details
accounts:writeCreate, update, soft-delete accounts
contacts:readRead contacts and account-contact relationships
contacts:writeCreate and update contacts
deals:readRead pipeline deals and stages
deals:writeCreate deals, change stages, mark Won/Lost
health:readRead churn-risk scores and signal breakdowns
health:writePush churn-risk signals from external PSA/RMM tools
touchpoints:readRead touchpoints and activity timeline
touchpoints:writeLog touchpoints from external tools
devices:readRead device inventory
devices:writePush and update device data from RMM tools (incl. bulk)
tasks:readRead tasks
tasks:writeCreate, update, complete, delete tasks
notes:readRead notes on accounts, deals, contacts, leads
notes:writeCreate notes on any supported object
mrr:readRead MRR data and financial summaries
leads:readRead leads and ICP scores
leads:writeCreate and update leads
compliance:readRead compliance items and framework status
admin:readImplies all :read scopes — for full read integrations

Rate limits

Two-tier sliding window: per-key (prevents one integration hammering the API) and per-org (prevents using many keys to bypass per-key limits). Every response includes consumption headers.

PlanPer-key (req/min)Org-wide (req/min)
Starter3060
Growth60120
Scale120240
Pro / Enterprise120-300240-600
# Headers on every response
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 55
X-RateLimit-Reset: 1762470120
X-RateLimit-Policy: sliding-window
Retry-After: 30  # only on 429 responses

Idempotency

Pass an Idempotency-Key header on POST/PATCH/DELETE requests to safely retry on network failure. Keys are stored for 24 hours; replaying the same key with the same body returns the original response without re-executing the handler. Replaying the same key with a different body returns 422 idempotency_conflict.

# Safe to retry indefinitely — same key + same body = same response
curl -X POST https://app.breadboxmsp.com/api/v1/accounts \
  -H "Authorization: Bearer msp_live_xxx" \
  -H "Idempotency-Key: import-job-2026-05-06-abc123" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Acme Healthcare" }'

Replays are returned with X-Idempotent-Replay: true so you can tell when a response came from cache. Idempotency-Key format: [a-zA-Z0-9_-]{1,255}— typically a UUID or your job ID. Don't reuse keys for different operations.

Pagination, sorting, filtering

All list endpoints use cursor-based pagination. Pass cursor from the previous response to fetch the next page. Default page size is 50, max 100.

# First page
GET /api/v1/accounts?limit=50

# Next page (cursor from previous response's nextCursor)
GET /api/v1/accounts?limit=50&cursor=cuid_xxxxx

# Incremental sync — only records changed since timestamp
GET /api/v1/devices?updatedAfter=2026-05-01T00:00:00Z

# Sort — comma-separated field:direction pairs
GET /api/v1/deals?sort=estimatedMrr:desc,createdAt:asc

# Filter — endpoint-specific query params
GET /api/v1/deals?stage=NEGOTIATION&ownerId=user_xyz&type=RENEWAL

When hasMore is false, you've reached the end. Sortable fields are restricted per endpoint — invalid fields are silently ignored (response uses the default sort). See the OpenAPI spec for each endpoint's allowed sort fields.

Endpoints

GET/api/v1/accountsaccounts:readList accounts (filter by lifecycle, industry; cursor pagination)
POST/api/v1/accountsaccounts:writeCreate an account
GET/api/v1/accounts/{id}accounts:readGet a single account
PATCH/api/v1/accounts/{id}accounts:writeUpdate an account
DELETE/api/v1/accounts/{id}accounts:writeSoft-delete (sets lifecycle to CHURNED). See ?force=true for hard delete.
GET/api/v1/contactscontacts:readList contacts (filter by accountId)
POST/api/v1/contactscontacts:writeCreate a contact (optionally link to account)
GET/api/v1/contacts/{id}contacts:readGet a single contact with account relationships
PATCH/api/v1/contacts/{id}contacts:writeUpdate a contact
GET/api/v1/dealsdeals:readList deals (filter by stage, accountId)
POST/api/v1/dealsdeals:writeCreate a deal
GET/api/v1/deals/{id}deals:readGet a single deal
PATCH/api/v1/deals/{id}deals:writeUpdate a deal (including stage change and Won/Lost outcome)
GET/api/v1/devicesdevices:readList devices (filter by accountId, isOnline, isEndOfLife, updatedAfter)
POST/api/v1/devicesdevices:writeCreate a device
GET/api/v1/devices/{id}devices:readGet a single device
PATCH/api/v1/devices/{id}devices:writeUpdate a device (fires device.offline / end_of_life on transitions)
DELETE/api/v1/devices/{id}devices:writeHard-delete a device
POST/api/v1/devices/bulkdevices:writeBulk upsert devices by rmmAgentId (max 500/request)
GET/api/v1/taskstasks:readList tasks (filter by status, assigneeId, accountId, dealId, updatedAfter)
POST/api/v1/taskstasks:writeCreate a task
GET/api/v1/tasks/{id}tasks:readGet a single task
PATCH/api/v1/tasks/{id}tasks:writeUpdate a task (auto-stamps completedAt on status=DONE)
DELETE/api/v1/tasks/{id}tasks:writeDelete a task
GET/api/v1/notesnotes:readList notes (filter by objectType, objectId)
POST/api/v1/notesnotes:writeCreate a note on Account, Deal, Contract, or Lead
GET/api/v1/health-scoreshealth:readList accounts with their current churn risk
GET/api/v1/health-scores/{accountId}health:readGet churn risk + signal breakdown + 90-day history
POST/api/v1/health-scores/{accountId}/signalshealth:writePush an external churn-risk signal
GET/api/v1/touchpointstouchpoints:readList touchpoints (filter by accountId, type, updatedAfter)
POST/api/v1/touchpointstouchpoints:writeLog a touchpoint from an external tool
GET/api/v1/mrrmrr:readOrg-level MRR summary by service category
POST/api/v1/accounts/bulkaccounts:writeBulk upsert accounts (max 500/req, idempotent on externalId)
POST/api/v1/contacts/bulkcontacts:writeBulk upsert contacts (max 500/req, idempotent on email)
POST/api/v1/deals/bulkdeals:writeBulk create deals (max 500/req)

Worked examples

Create an account, contact, and deal

# 1. Create the account
curl -X POST https://app.breadboxmsp.com/api/v1/accounts \
  -H "Authorization: Bearer msp_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Acme Healthcare", "lifecycleStatus": "PROSPECT", "industry": "Healthcare", "employeeCount": 65 }'

# Response
{
  "success": true,
  "data": {
    "id": "cuid_acc_abc123",
    "name": "Acme Healthcare",
    "lifecycleStatus": "PROSPECT",
    ...
  },
  "requestId": "req_xxx",
  "timestamp": "..."
}

Push device inventory from your RMM (idempotent)

# Bulk upsert by rmmAgentId — re-sending the same agent IDs updates, doesn't duplicate
curl -X POST https://app.breadboxmsp.com/api/v1/devices/bulk \
  -H "Authorization: Bearer msp_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "devices": [
    { "rmmAgentId": "ninja-12345", "accountId": "cuid_acc_xxx", "hostname": "WS-SMITH-01", "os": "Windows 11", "isOnline": true, "patchCompliant": true },
    { "rmmAgentId": "ninja-12346", "accountId": "cuid_acc_xxx", "hostname": "WS-JONES-01", "os": "Windows 11", "isOnline": false }
  ] }'

# Response
{
  "success": true,
  "data": {
    "created": 1,
    "updated": 1,
    "total": 2,
    "results": [
      { "rmmAgentId": "ninja-12345", "id": "cuid_dev_aaa", "action": "updated" },
      { "rmmAgentId": "ninja-12346", "id": "cuid_dev_bbb", "action": "created" }
    ]
  }, ...
}

Migrate accounts from another CRM (bulk + idempotent)

# Use externalId from your source CRM. Re-running the same payload is safe — same IDs update, new IDs create.
curl -X POST https://app.breadboxmsp.com/api/v1/accounts/bulk \
  -H "Authorization: Bearer msp_live_xxx" \
  -H "Idempotency-Key: hubspot-migration-batch-001" \
  -H "Content-Type: application/json" \
  -d '{ "accounts": [
    { "externalId": "hub-12345", "name": "Acme Healthcare", "industry": "Healthcare", "lifecycleStatus": "ACTIVE" },
    { "externalId": "hub-12346", "name": "BluePeak Legal", "industry": "Legal", "lifecycleStatus": "AT_RISK" }
  ] }'

Log a touchpoint from an external tool

curl -X POST https://app.breadboxmsp.com/api/v1/touchpoints \
  -H "Authorization: Bearer msp_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "accountId": "cuid_acc_xxx", "type": "EMAIL", "direction": "OUTBOUND", "subject": "Renewal terms", "notes": "Sent draft contract to Sarah" }'

Error codes

HTTPCodeMeaning
400bad_requestMissing or malformed path parameter
400invalid_jsonRequest body is not valid JSON
401unauthorizedMissing or invalid API key
403insufficient_scopeKey lacks the required scope for this endpoint
404not_foundRecord not found, or it belongs to another org
405method_not_allowedHTTP method not supported on this endpoint
422validation_errorRequest body failed schema validation. See error.details.
422idempotency_conflictIdempotency-Key reused with a different request body
429rate_limitedRate limit exceeded — see Retry-After header
500internal_errorUnexpected server error — retry with exponential backoff

Webhooks

Breadbox pushes real-time events to your endpoints as data changes. Configure endpoints in Settings → API & Webhooks. Each endpoint subscribes to specific events (or * for all). Failed deliveries retry with exponential backoff up to 5 attempts; an endpoint that fails 5 consecutive times is auto-disabled.

Payload envelope

{
  "id": "evt_1762470120_a1b2c3d4",
  "event": "deal.closed_won",
  "timestamp": "2026-05-06T18:42:11.443Z",
  "orgId": "org_xxxxx",
  "data": { /* the same shape as the corresponding GET response */ }
}

Available events

account.createdAn account was created via the API or UI
account.updatedAny field on an account changed
account.deletedAccount soft-deleted (lifecycle → CHURNED)
contact.createdA contact was created
contact.updatedA contact was updated
deal.createdA new deal entered the pipeline
deal.stage_changedA deal moved between stages (excluding Won/Lost)
deal.closed_wonA deal was marked Closed Won
deal.closed_lostA deal was marked Closed Lost
device.createdA device was added to inventory
device.updatedA device's fields changed
device.offlineA device transitioned from online to offline
device.end_of_lifeA device was flagged end-of-life
touchpoint.createdA touchpoint was logged

Headers on every webhook

Breadbox-Signature: t=1762470120,v1=a3f2b1c4...
Breadbox-Event: deal.closed_won
Breadbox-Event-Id: evt_1762470120_abc123
Content-Type: application/json
User-Agent: Breadbox-Webhooks/1.1

Verifying signatures (recommended)

The Breadbox-Signature header carries a Unix timestamp and a v1 HMAC-SHA256 of <timestamp>.<raw_body>. Verify both the signature AND that the timestamp is recent (within 5 minutes) to mitigate replay attacks.

// Node.js / TypeScript
import { createHmac, timingSafeEqual } from 'crypto';

const TOLERANCE_SECONDS = 5 * 60;

function verifyWebhook(rawBody: string, header: string, secret: string): boolean {
  // Parse "t=<unix_seconds>,v1=<hmac_hex>"
  const parts = Object.fromEntries(
    header.split(',').map((p) => {
      const [k, v] = p.split('=');
      return [k, v];
    })
  );
  const ts = parseInt(parts.t ?? '', 10);
  const sig = parts.v1 ?? '';
  if (!ts || !sig) return false;

  // Reject stale events (replay protection)
  const ageSec = Math.abs(Date.now() / 1000 - ts);
  if (ageSec > TOLERANCE_SECONDS) return false;

  const expected = createHmac('sha256', secret)
    .update(`${ts}.${rawBody}`)
    .digest('hex');
  if (sig.length !== expected.length) return false;
  return timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}

// Express handler
app.post('/webhooks/breadbox', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.header('Breadbox-Signature') ?? '';
  if (!verifyWebhook(req.body.toString(), sig, process.env.BREADBOX_WEBHOOK_SECRET!)) {
    return res.status(401).end();
  }
  const event = JSON.parse(req.body.toString());
  // ... handle event.event, event.data
  res.status(200).end();
});

Legacy X-MSP-Signature / X-MSP-Timestamp headers (HMAC of just the raw body) are still sent for backwards compatibility but will be removed in a future release. Migrate to Breadbox-Signature for replay protection.

Test mode

Keys prefixed msp_test_ operate against the same data as live keys but are flagged in audit logs as test traffic. Test keys do not fire outbound webhooks — useful for development without flooding your real endpoints. Full sandbox isolation (separate test database) ships in a future release.

SDK examples

The API follows standard REST conventions — any HTTP client works. Official SDKs (TypeScript, Python) are on the roadmap. Quickstarts:

Python (requests)

import requests

BASE = "https://app.breadboxmsp.com/api/v1"
HEADERS = {"Authorization": "Bearer msp_live_xxxxx"}

# List accounts
resp = requests.get(f"{BASE}/accounts?limit=50", headers=HEADERS)
resp.raise_for_status()
body = resp.json()
assert body["success"] is True
accounts = body["data"]

# Create a deal
resp = requests.post(f"{BASE}/deals", headers=HEADERS, json={
    "name": "Acme Corp — Managed IT",
    "accountId": "cuid_xxxxx",
    "estimatedMrr": 3500,
    "probability": 60,
})
deal = resp.json()["data"]

PowerShell (for MSP automation)

$headers = @{ Authorization = "Bearer msp_live_xxxxx" }
$base = "https://app.breadboxmsp.com/api/v1"

# Push device data from RMM
$body = @{
  rmmAgentId = "ninja-12345"
  accountId = "cuid_xxxxx"
  hostname = "WS-SMITH-01"
  deviceType = "Workstation"
  os = "Windows 11"
} | ConvertTo-Json

Invoke-RestMethod -Uri "$base/devices" -Method POST `
  -Headers $headers -Body $body -ContentType "application/json"

TypeScript (fetch)

const BASE = "https://app.breadboxmsp.com/api/v1";
const KEY = process.env.BREADBOX_API_KEY!;

async function api<T>(path: string, init?: RequestInit): Promise<T> {
  const res = await fetch(`${BASE}${path}`, {
    ...init,
    headers: {
      Authorization: `Bearer ${KEY}`,
      "Content-Type": "application/json",
      ...(init?.headers ?? {}),
    },
  });
  const body = await res.json();
  if (!body.success) throw new Error(body.error?.message ?? "API error");
  return body.data as T;
}

// Usage
const accounts = await api<Account[]>("/accounts?limit=50");
const deal = await api<Deal>("/deals", {
  method: "POST",
  body: JSON.stringify({ name: "Acme renewal", accountId: "cuid_xxx", estimatedMrr: 3500 }),
});

Need help?

API questions, feature requests, and bug reports: api@breadboxmsp.com. Always include the requestId from a recent failing call — it lets us trace your exact request in our logs.