API Reference
Integrate baref00t.io into your stack. Manage customers, trigger assessments, and automate security workflows through the Partner and Distributor APIs.
Overview
baref00t.io exposes two REST APIs that let you automate security assessment workflows. The Partner API is for MSPs who manage their own customers and run assessments against those tenants. The Distributor API is for organisations that provision and manage multiple partner accounts underneath a single billing relationship.
Both APIs accept and return JSON. All requests must be made over HTTPS. HTTP requests are rejected.
Authentication
Every request must include an API key in a custom header. Partner keys and Distributor keys use separate headers and separate prefixes so the server can route your request to the correct API surface.
Partner API Key
Pass your key in the X-Partner-Key header. Partner keys always start with pk_live_.
curl https://api.baref00t.io/api/v1/partner/me \
-H "X-Partner-Key: pk_live_abc123def456"
Distributor API Key
Pass your key in the X-Distributor-Key header. Distributor keys always start with dk_live_.
curl https://api.baref00t.io/api/v1/distributor/me \
-H "X-Distributor-Key: dk_live_xyz789ghi012"
Key Management
Each partner account can hold a maximum of two active API keys at any time. Use the Generate API Key and Revoke API Key endpoints to rotate keys with zero downtime: generate a new key in slot 2 while slot 1 is still active, migrate your systems, then revoke slot 1.
Never expose your API key in client-side code, public repositories, or browser requests. Keys carry the same privileges as your partner or distributor account.
Base URLs
| API | Base URL |
|---|---|
| Partner | https://api.baref00t.io/api/v1/partner/ |
| Distributor | https://api.baref00t.io/api/v1/distributor/ |
All endpoint paths in this reference are shown relative to the base URL. For example, GET /v1/partner/me means you send the request to https://api.baref00t.io/api/v1/partner/me.
Rate Limits
| API | Limit | Window |
|---|---|---|
| Partner | 60 requests | per minute, per key |
| Distributor | 120 requests | per minute, per key |
When you exceed the limit, the API returns a 429 status with the error code RATE_LIMITED. The response includes a Retry-After header indicating how many seconds to wait before retrying.
{
"error": {
"code": "RATE_LIMITED",
"message": "Rate limit exceeded. Retry after 12 seconds."
}
}
Error Handling
All error responses follow a consistent envelope. The HTTP status code indicates the error category, and the response body contains a machine-readable code and a human-readable message.
{
"error": {
"code": "ERROR_CODE",
"message": "Human readable description of what went wrong."
}
}
Error Codes
| Code | Status | Description |
|---|---|---|
| MISSING_KEY | 401 | No API key header was provided in the request. |
| INVALID_KEY | 401 | The API key is malformed, expired, or revoked. |
| RATE_LIMITED | 429 | You have exceeded the rate limit for your API tier. |
| PARTNER_INACTIVE | 403 | The partner account is suspended or pending activation. |
| DISTRIBUTOR_INACTIVE | 403 | The distributor account is suspended or pending activation. |
| INVALID_JSON | 400 | The request body is not valid JSON. |
| MISSING_FIELD | 400 | A required field is missing from the request body. |
| INVALID_PRODUCT | 400 | The product identifier is not recognised. |
| PRODUCT_NOT_ALLOWED | 403 | Your plan does not include the requested product. |
| RUN_LIMIT_REACHED | 403 | You have reached the monthly assessment run limit for your plan. |
| NOT_FOUND | 404 | The requested resource does not exist or you do not have access. |
| MAX_KEYS | 409 | You already have the maximum number of API keys (2). |
| TOO_MANY | 400 | The bulk request exceeds the maximum batch size. |
| FEATURE_NOT_ENABLED | 403 | The partner record lacks an admin-granted add-on (e.g. prospecting) required for this endpoint. |
| MAIL_DISABLED | 400 | Partner mailProvider is off. Pass skipEmail: true to mint URLs without sending, or change the mail mode to resend / microsoft. |
| EMAIL_FAILED | 502 | The email provider rejected all recipient sends. The response includes a recipients breakdown plus assessment/customer/run ids so the run row is recoverable. |
Webhooks
Configure a webhook URL in your partner or distributor portal to receive real-time event notifications. Webhooks are delivered as POST requests with a JSON body to the URL you specify.
Events
| Event | When it fires |
|---|---|
| assessment.started | An assessment run has been queued and is beginning execution. |
| assessment.completed | An assessment run has finished and results are available. |
| assessment.failed | An assessment run encountered an error and could not complete. |
| customer.created | A new customer record was created. |
| customer.updated | A customer record was modified. |
| customer.deleted | A customer record was removed. |
| partner.activated | A sub-partner account has been activated (Distributor only). |
| partner.suspended | A sub-partner account has been suspended (Distributor only). |
| key.created | A new API key was generated for the account. |
| key.revoked | An API key was revoked. |
| lead.created | A new pipeline lead was created. |
| lead.enriched | Apollo + Hunter + M365 OIDC enrichment completed for a lead. |
| lead.outreach_sent | At least one consent email succeeded after send-consent (or skipEmail: true was used). |
| lead.stage_changed | Server-side or partner-set stage transition (Won/Lost are partner-driven). |
| lead.consented | The customer clicked the consent URL and granted Microsoft admin consent. |
| lead.report_delivered | The assessment report was delivered to the customer. |
Payload Format
{
"id": "evt_a1b2c3d4e5f6",
"type": "assessment.completed",
"timestamp": "2026-04-03T08:15:22Z",
"data": {
"assessmentId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"customerId": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"product": "essential-eight",
"status": "completed",
"score": 72
}
}
Signature Verification
Every webhook request includes an X-Baref00t-Signature header containing an HMAC-SHA256 signature of the raw request body. Verify this signature using the webhook secret displayed in your portal to ensure the request originated from baref00t.io.
// Pseudocode: verify webhook signature
expected = HMAC-SHA256(webhook_secret, raw_request_body)
received = request.headers["X-Baref00t-Signature"]
// Use a constant-time comparison to prevent timing attacks
valid = constant_time_equal(expected, received)
Delivery
Webhooks are delivered with a timeout of 10 seconds. If your endpoint returns a non-2xx status code or times out, the delivery is retried up to 5 times with exponential backoff (1 min, 5 min, 30 min, 2 hr, 12 hr). After all retries are exhausted, the event is marked as failed in your portal's webhook log.
Your webhook endpoint should return a 200 status code as quickly as possible. Process the event payload asynchronously to avoid timeouts.
Partner API
The Partner API lets MSPs manage customers and run security assessments through a single API key. All endpoints require the X-Partner-Key header.
Response 200
{
"partnerId": "c3d4e5f6-a7b8-9012-cdef-123456789012",
"company": "Acme MSP",
"plan": "professional",
"planName": "Professional",
"billing": "monthly",
"status": "active",
"billingMonth": "2026-04",
"runsUsed": 42,
"runLimit": 100,
"customerCount": 18,
"distributorId": null,
"billedByDistributor": false,
"keys": [
{ "slot": 1, "suffix": "abcd" }
]
}
Notes
Added in v2.1.8: keys array surfaces populated API key slots so dashboards can render the actual list (max 2). Each entry is {slot, suffix} — slot is 1 or 2 (max 2 active keys per partner), suffix is the last 4 chars of the raw key for visual identification only. Falls back to an empty array if no keys exist (shouldn't happen for an authenticated partner). Pre-v2.1.8 responses omit the field entirely.
Request body
{
"plan": "professional",
"billing": "monthly",
"currency": "usd"
}
plan is required ('starter' | 'professional' | 'enterprise'). billing ('monthly' | 'rolling') and currency ('usd' | 'aud' | 'gbp' | 'eur' | 'sgd') are optional and default to your current values.
Response 200
{
"partnerId": "c3d4e5f6-a7b8-9012-cdef-123456789012",
"plan": "professional",
"billing": "monthly",
"currency": "usd",
"runLimit": 100,
"subscriptionStatus": "active",
"currentPeriodEnd": "2026-05-09T00:00:00Z"
}
Errors
400 INVALID_JSON — request body could not be parsed
400 PLAN_CHANGE_FAILED — invalid plan/billing, no active subscription, already on target plan,
or subscription is in a state that cannot be modified (canceled, etc)
401 Missing or invalid X-Partner-Key
500 Price lookup or Stripe API error
Rate-limited to 10 plan changes per hour per partner. Upgrades/downgrades are audited. Dashboard UI is available on the Billing tab of the partner portal.
Response 200
{
"products": [
{
"id": "essential-eight",
"name": "Essential Eight Assessment",
"category": "compliance",
"estimatedDuration": 180
},
{
"id": "nist-csf",
"name": "NIST CSF Assessment",
"category": "compliance",
"estimatedDuration": 240
}
]
}
Response 200
{
"company": "Acme Cloud Security",
"name": "Acme MSP",
"brandColor": "#00cc66",
"footerText": "© 2026 Acme Cloud Security",
"contactEmail": "security@acme.com",
"logoBlobPath": "partners/acme-msp/logo.svg",
"logoUrl": "https://<account>.blob.core.windows.net/branding/partners/acme-msp/logo.svg?sv=...&sig=..."
}
logoUrl (added v2.1.9+) is a freshly-minted 30-day SAS URL for the uploaded logo blob — embed it directly in your own UIs. It is null when no logo has been uploaded or SAS minting failed (the underlying logoBlobPath stays available as a fallback). To upload or replace a logo, use the multipart endpoint POST /api/partner/branding/logo (MSAL only — partner dashboard).
Request body
{
"brandColor": "#00cc66",
"footerText": "© 2026 Acme Cloud Security",
"contactEmail": "security@acme.com",
"company": "Acme Cloud Security"
}
Response 200
{ "updated": true, "fields": 3 }
Errors
400 INVALID_JSON — request body could not be parsed
400 INVALID_COLOR — brandColor isn't a 6-digit hex (e.g. #00cc66)
400 INVALID_FOOTER — footerText > 500 chars
400 INVALID_EMAIL — contactEmail not a valid email address
400 INVALID_COMPANY — company > 200 chars
400 NO_FIELDS — body had no recognised fields to update
401 Missing or invalid X-Partner-Key
Response 200
{
"customers": [
{
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"name": "Contoso Ltd",
"tenantId": "d4f5a678-1234-5678-9abc-def012345678",
"email": "it@contoso.com",
"createdAt": "2026-02-10T14:00:00Z"
}
]
}
Request Body
| Field | Type | Description | |
|---|---|---|---|
| name | string | required | Display name for the customer organisation. |
| tenantId | string (GUID) | required | Microsoft 365 tenant ID for the customer. |
| string | optional | Primary contact email address. | |
| receivers | string[] | optional | Email addresses that receive assessment report notifications. |
| questionnaireRecipients | string[] | optional | Email addresses that receive questionnaire invitations. |
| emailReportsEnabled | boolean | optional | Default true. Set to false to suppress all report-completion emails for this customer. |
| emailConsentEnabled | boolean | optional | Default true. Set to false to suppress consent-invitation emails for this customer. |
| emailQuestionnaireEnabled | boolean | optional | Default true. Set to false to suppress questionnaire-link emails for this customer. |
Two-stage email gate (v2.1.5+): outbound mail to a customer is only sent when (1) your partner mail sender under /portal/branding is NOT set to off, AND (2) the customer's per-channel toggle above is not false. Stage 1 always wins. Set the partner-level mail provider to off for a brand-wide kill-switch; use the per-customer toggles for individual exemptions.
{
"name": "Contoso Ltd",
"tenantId": "d4f5a678-1234-5678-9abc-def012345678",
"email": "it@contoso.com",
"receivers": ["ciso@contoso.com", "it@contoso.com"],
"questionnaireRecipients": ["ciso@contoso.com"],
"emailReportsEnabled": true,
"emailConsentEnabled": true,
"emailQuestionnaireEnabled": true
}
Response 201
{
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"name": "Contoso Ltd",
"tenantId": "d4f5a678-1234-5678-9abc-def012345678",
"email": "it@contoso.com",
"receivers": ["ciso@contoso.com", "it@contoso.com"],
"questionnaireRecipients": ["ciso@contoso.com"],
"createdAt": "2026-04-03T10:00:00Z"
}
curl Example
curl -X POST https://api.baref00t.io/api/v1/partner/customers \
-H "X-Partner-Key: pk_live_abc123def456" \
-H "Content-Type: application/json" \
-d '{
"name": "Contoso Ltd",
"tenantId": "d4f5a678-1234-5678-9abc-def012345678",
"email": "it@contoso.com",
"receivers": ["ciso@contoso.com"],
"questionnaireRecipients": ["ciso@contoso.com"]
}'
Response 200
{
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"name": "Contoso Ltd",
"tenantId": "d4f5a678-1234-5678-9abc-def012345678",
"email": "it@contoso.com",
"receivers": ["ciso@contoso.com", "it@contoso.com"],
"questionnaireRecipients": ["ciso@contoso.com"],
"recentRuns": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"product": "essential-eight",
"status": "completed",
"completedAt": "2026-03-28T14:22:00Z"
}
],
"createdAt": "2026-02-10T14:00:00Z"
}
Request Body
{
"name": "Contoso Corporation",
"receivers": ["ciso@contoso.com", "security@contoso.com"]
}
Response 200
{
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"name": "Contoso Corporation",
"tenantId": "d4f5a678-1234-5678-9abc-def012345678",
"email": "it@contoso.com",
"receivers": ["ciso@contoso.com", "security@contoso.com"],
"questionnaireRecipients": ["ciso@contoso.com"],
"updatedAt": "2026-04-03T10:05:00Z"
}
Response 204
No content. The customer has been deleted.
Query Parameters
| Parameter | Type | Description | |
|---|---|---|---|
| product | string | required | Product slug (e.g. tenant-health). |
| maturity_target | string | optional | Product-specific maturity target (e.g. ML2). |
Response 200
{
"consentUrl": "https://www.baref00t.io/consent?product=tenant-health&assessment_id=...&partner_id=...",
"assessmentId": "c9d0e1f2-a3b4-5678-defa-789012345678",
"product": "tenant-health",
"customerId": "b2c3d4e5-f6a7-8901-bcde-f12345678901"
}
What to do with the URL: share it with the customer (chat, ticket, your own email) so a Microsoft 365 Global Administrator on their tenant clicks it once to grant read-only consent. After that, you can call Trigger Assessment for any product. To send the URL via partner-branded email instead, use Send Consent Email below.
Request Body
| Field | Type | Description | |
|---|---|---|---|
| product | string | required | Product slug (e.g. tenant-health). |
| maturityTarget | string | optional | Product-specific maturity target. |
Response 200
{
"sent": 1,
"skipped": 0,
"total": 1,
"recipients": ["it@contoso.com"],
"failed": [],
"assessmentId": "c9d0e1f2-a3b4-5678-defa-789012345678",
"product": "tenant-health",
"consentUrl": "https://www.baref00t.io/consent?product=tenant-health&assessment_id=...&partner_id=...",
"emailConsentEnabled": true,
"skipReason": null
}
Two-stage gate: when emailConsentEnabled is false in the response, no email was sent — skipReason identifies why (partner_mail_off when the partner mail provider is set to off; customer_channel_off when customer.emailConsentEnabled is false). The consentUrl is always returned so you can share it manually.
Request Body
| Field | Type | Description | |
|---|---|---|---|
| customerId | string | required | The customer to run the assessment against. |
| product | string | required | Product identifier (e.g. essential-eight, nist-csf). |
| maturityTarget | number | optional | Target maturity level (1-5). Defaults to the product default. |
| attestations | object | optional | Procedural-attestation answers for products that carry a procedural questionnaire (e8, ransomware, cyber-essentials, mcsb, mas-trm, nis2). Map of questionId: boolean. A true answer upgrades the matching control's automated warn verdict to pass and records the attestation in the report audit trail. Catalogue: PROCEDURAL_QUESTIONS_BY_SLUG exported from @baref00t/assessments/attestation-questions. Ignored for products without a procedural questionnaire. (v2.4.6+, #526) |
{
"customerId": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"product": "essential-eight",
"maturityTarget": 3,
"attestations": {
"e8_backup_restore_drills": true,
"e8_backup_3_2_1": true,
"e8_app_control_servers": false
}
}
Response 202 — assessment queued
{
"assessmentId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"runId": "abc-...",
"product": "essential-eight",
"status": "queued",
"runsUsed": 7,
"runLimit": 100
}
Response 200 — consent required (v2.1.7+)
When the customer hasn't granted Microsoft admin consent on their tenant yet (no prior completed or running run for this customer), no run is created and no quota is burned. Share the returned consentUrl with the customer (or use POST /v1/partner/customers/{id}/send-consent to email it on the partner's behalf), then re-trigger.
{
"status": "consent_required",
"assessmentId": null,
"consentUrl": "https://www.baref00t.io/consent?product=essential-eight&assessment_id=...&partner_id=...",
"customerId": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"product": "essential-eight",
"message": "Customer has not granted Microsoft admin consent on their tenant. Share the consentUrl with them, then re-trigger."
}
Query Parameters
| Parameter | Type | Description | |
|---|---|---|---|
| month | string | optional | Filter by month in YYYY-MM format. |
| product | string | optional | Filter by product identifier. |
| customerId | string | optional | Filter by customer ID. |
Response 200
{
"runs": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"customerId": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"product": "essential-eight",
"status": "completed",
"score": 72,
"createdAt": "2026-04-03T10:10:00Z",
"completedAt": "2026-04-03T10:13:22Z"
}
]
}
Response 200
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"customerId": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"customerName": "Contoso Ltd",
"product": "essential-eight",
"maturityTarget": 3,
"status": "completed",
"score": 72,
"findings": 14,
"critical": 2,
"high": 5,
"medium": 4,
"low": 3,
"reportUrl": "https://api.baref00t.io/api/r/{token}",
"reportToken": "rT7xQz8kPnH2vL4w",
"createdAt": "2026-04-03T10:10:00Z",
"completedAt": "2026-04-03T10:13:22Z"
}
Customer-share URLs (v2.1.9+)
When status === "completed", the response includes a public reportToken. Build a customer-shareable short URL on your own portal domain as https://<your-portal-domain>/r/{reportToken} — the portal route 302-redirects to the platform's branded report viewer at https://www.baref00t.io/r/{token}. Anyone with the token can view the report (rate-limited per IP); rotate by re-running the assessment if a token leaks.
Response 200
{
"deleted": true,
"assessmentId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"reportBlobsRemoved": 2
}
Response 404
Returned when the assessment id is unknown or belongs to a different partner.
{
"error": { "code": "NOT_FOUND", "message": "Assessment not found" }
}
Query Parameters
| Parameter | Type | Description | |
|---|---|---|---|
| format | string | optional | Either html (default) or pdf. PDF rendering is not yet supported and currently returns 501. |
Response 302
HTTP/1.1 302 Found
Location: https://<account>.blob.core.windows.net/reports/<tenantId>/<assessmentId>.html?sv=...&sig=...
Cache-Control: no-store
Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | INVALID_FORMAT | format query value is neither html nor pdf |
| 404 | NOT_FOUND | Assessment is not owned by this partner |
| 404 | REPORT_NOT_READY | Assessment exists but the report blob is not yet rendered |
| 501 | NOT_IMPLEMENTED | format=pdf — pipeline not yet wired |
Request Body
{
"customers": [
{ "name": "Contoso Ltd", "tenantId": "d4f5a678-1234-5678-9abc-def012345678", "email": "it@contoso.com" },
{ "name": "Fabrikam Inc", "tenantId": "a1b2c3d4-5678-9abc-def0-123456789abc", "email": "admin@fabrikam.com" }
]
}
Response 201
{
"created": 2,
"failed": 0,
"customers": [
{ "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "name": "Contoso Ltd", "status": "created" },
{ "id": "d4e5f6a7-b8c9-0123-defa-234567890123", "name": "Fabrikam Inc", "status": "created" }
]
}
Maximum batch size is 100 customers. Requests exceeding this limit return a TOO_MANY error.
Request Body
{
"product": "essential-eight",
"customerIds": ["b2c3d4e5-f6a7-8901-bcde-f12345678901", "d4e5f6a7-b8c9-0123-defa-234567890123"],
"maturityTarget": 3
}
Response 202
{
"queued": 3,
"failed": 0,
"runs": [
{ "id": "f6a7b8c9-d0e1-2345-abcd-456789012345", "customerId": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "status": "queued" }
]
}
Maximum batch size is 50 customer IDs. Requests exceeding this limit return a TOO_MANY error.
Request Body
No request body required.
Response 201
{
"slot": 2,
"key": "pk_live_newkey789xyz",
"createdAt": "2026-04-03T10:20:00Z"
}
The full key is only returned once at creation time. Store it securely. You cannot retrieve it again.
Path Parameters
| Parameter | Type | Description | |
|---|---|---|---|
| slot | number | required | Key slot to revoke: 1 or 2. |
Response 204
No content. The key has been revoked.
You cannot revoke a key if it is the only active key on the account. Generate a new key first, then revoke the old one.
Response 200
{
"provider": "microsoft",
"connectedUserEmail": "admin@acme.com",
"sharedMailbox": "noreply@acme.com",
"hasValidToken": true,
"tokenExpiresAt": "2026-05-02T14:30:00Z"
}
provider is 'resend' | 'microsoft' | 'off'. connectedUserEmail is null when provider isn't microsoft. sharedMailbox is null when not configured. hasValidToken: false means reconnect via /mail/connect-init.
Errors
401 Missing or invalid auth (X-Partner-Key or MSAL Bearer)
404 Partner record not found
Request body
{ "mode": "microsoft" }
Response 200
{ "ok": true, "mode": "microsoft" }
Errors
400 Invalid JSON body
400 mode must be 'resend', 'microsoft', or 'off'
403 Admin role required to manage mail sending (MSAL only)
412 NO_CONNECTION — switching to 'microsoft' before /mail/connect-init has been completed
Request body
None.
Response 200
{ "ok": true }
Request body
None.
Response 200
{
"delivered": true,
"via": "microsoft",
"recipient": "admin@acme.com",
"fromMailbox": "noreply@acme.com"
}
Errors
403 Admin role required to manage mail sending (MSAL only)
412 NOT_CONNECTED — Microsoft mail sending isn't connected (or refresh failed)
502 GRAPH_<status> — Graph rejected the test send (check sharedMailbox UPN or reconnect)
502 GRAPH_NETWORK — network error reaching Graph
503 APP_NOT_CONFIGURED — platform's Partner Mail Sender Entra app isn't configured
Request body
{ "returnTo": "https://portal.acme.com/settings/mail" }
returnTo must be HTTPS (or http://localhost:* for dev) and must not embed credentials. The state token is signed and partner-key-gated; no per-partner allowlist is enforced today.
Response 200
{ "authorizeUrl": "https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=...&state=..." }
Flow
- Partner navigates the browser to
authorizeUrl. - Microsoft consent prompt appears in their tenant.
- baref00t.io callback exchanges the auth code, persists access + refresh tokens, and 302-redirects back to
state.returnTowith?mailConnected=1on success or?mailError=<reason>on failure. - After the callback returns, call
PUT /api/partner/mail/modewith{ "mode": "microsoft" }to switch the provider on.
Errors
400 BAD_RETURN_TO — returnTo not HTTPS / localhost, or contains credentials
400 INVALID_JSON — body could not be parsed
400 TENANT_UNSET — partner record has no tenantId on file
503 MAIL_APP_NOT_CONFIGURED — platform's Partner Mail Sender Entra app isn't configured
401 Missing or invalid X-Partner-Key
Response 200
{
"members": [
{
"emailHash": "f1c2…",
"email": "alice@acme.com",
"role": "Admin",
"status": "Active",
"invitedAt": "2026-04-12T10:00:00Z",
"acceptedAt": "2026-04-12T10:05:33Z",
"inviteExpires": null,
"hasOpenInvite": false,
"createdAt": "2026-04-12T10:00:00Z",
"updatedAt": "2026-04-12T10:05:33Z"
}
],
"callerRole": "Admin",
"callerEmailHash": "*"
}
emailHash is a stable HMAC of the lowercased email — use it as the path id for member ops. email is the decrypted email; empty string when decryption failed (legacy rows). role is 'Admin' | 'Member' | 'Viewer'. status is 'Pending' | 'Active' | 'Suspended'.
Request body
{
"email": "bob@acme.com",
"role": "Member"
}
Response 201
{ "member": { } }
Errors
400 Invalid JSON body
400 Valid email required
400 role must be Admin, Member, or Viewer
403 Admin role required (MSAL)
409 Member already exists / open invite present
Request body
{
"role": "Admin",
"status": "Suspended"
}
Response 200
{ "member": { } }
Errors
400 emailHash required
400 Invalid JSON body
400 Invalid role
400 status must be Active or Suspended
400 Provide role or status to update
403 Admin role required (MSAL)
404 Member not found
409 Conflict (e.g. demoting the last Admin)
Response 200
{ "ok": true }
Removal is permanent. The audit trail of past actions remains, but the member record (and any pending invite) is deleted.
Response 200
{ "ok": true }
Response 200
{
"subscriptions": [
{
"id": "sub_1Q...",
"status": "active",
"product": "essential-eight",
"plan": "professional",
"amount": 49900,
"currency": "aud",
"interval": "month",
"interval_count": 1,
"current_period_start": "2026-04-01T00:00:00Z",
"current_period_end": "2026-05-01T00:00:00Z",
"cancel_at_period_end": false,
"created": "2026-01-15T10:00:00Z"
}
],
"invoices": [
{
"id": "in_1Q...",
"number": "BAREFOOT-0042",
"status": "paid",
"amount_due": 49900,
"amount_paid": 49900,
"currency": "aud",
"invoice_pdf": "https://stripe.com/.../invoice.pdf",
"hosted_invoice_url": "https://invoice.stripe.com/i/...",
"period_start": "2026-04-01T00:00:00Z",
"period_end": "2026-05-01T00:00:00Z",
"created": "2026-04-01T00:00:00Z"
}
],
"charges": [
{
"id": "ch_1Q...",
"amount": 49900,
"currency": "aud",
"description": "Professional · monthly",
"receipt_url": "https://pay.stripe.com/receipts/...",
"created": "2026-04-01T00:00:05Z"
}
],
"portal_url": "https://billing.stripe.com/p/session/..."
}
When the partner has no Stripe customer linked yet (e.g. still on a free trial), all three arrays are empty and portal_url is null. Use POST /api/partner/billing-portal to mint a guaranteed-fresh portal session at click time.
Request body
None.
Response 200
{ "url": "https://billing.stripe.com/p/session/test_..." }
Errors
400 No billing account linked (partner has no Stripe customer record)
401 Missing or invalid auth
500 Payment not configured
500 Failed to create billing session
Response 200
{
"endpoints": [{
"id": "wh_01HX...",
"url": "https://api.acme.com/baref00t/webhooks",
"events": ["assessment.completed", "customer.created"],
"enabled": true,
"description": "prod handler",
"secretPreview": "whsec_…X4iY",
"createdAt": "2026-04-12T10:00:00Z",
"lastSuccessAt": "2026-04-30T22:11:04Z",
"lastFailureAt": null,
"failureCount": 0
}],
"total": 1,
"availableEvents": [
"assessment.completed", "assessment.failed",
"customer.created", "customer.updated", "customer.deleted",
"lead.created", "lead.enriched", "lead.outreach_sent",
"lead.stage_changed", "lead.consented", "lead.report_delivered"
]
}
Request Body
{
"url": "https://api.acme.com/baref00t/webhooks",
"events": ["assessment.completed"],
"description": "prod handler"
}
Response 201
{
"endpoint": { },
"secret": "whsec_<base64url>"
}
Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | INVALID_URL | url is missing, not https, or targets localhost |
| 400 | INVALID_EVENTS | events array is missing or empty |
| 400 | UNSUPPORTED_EVENT | events contains a value not in availableEvents |
The full secret is only returned at creation time. Store it securely — you cannot retrieve it again. Use update { rotateSecret: true } to rotate.
Request Body
{
"enabled": false,
"rotateSecret": true
}
Response 200
{
"endpoint": { },
"secret": "whsec_<new>"
}
Response 200
{ "deleted": true, "endpointId": "wh_01HX..." }
Response 200 (delivered)
{
"delivered": true,
"statusCode": 200,
"durationMs": 142,
"lastError": null,
"responseBody": "ok"
}
Signature header
X-Baref00t-Signature: t=<unix_seconds>,v1=<hex_sha256>
HMAC-SHA256 over ${timestamp}.${rawBody} using the endpoint secret. Use verifyWebhookSignature() from @baref00t/sdk/webhooks to validate.
Query Parameters
| Parameter | Type | Description | |
|---|---|---|---|
| limit | number | optional | 1–200, default 50. |
Response 200
{
"deliveries": [{
"id": "del_01HX...",
"eventType": "assessment.completed",
"status": "Delivered",
"statusCode": 200,
"attemptCount": 3,
"durationMs": 142,
"lastError": null,
"deliveredAt": "2026-04-30T22:35:04Z",
"nextRetryAt": null,
"createdAt": "2026-04-30T22:11:04Z"
}],
"total": 1,
"limit": 50
}
Delivery retries (v2.1.7+)
Failed deliveries are retried automatically with exponential backoff. attemptCount tracks the current attempt number; status can be Delivered, Failed (still retrying), or Abandoned (max attempts exceeded). Schedule:
| Attempt | When | From event creation |
|---|---|---|
| 1 | Inline (when event fires) | 0 |
| 2 | +1 minute | ~1 min |
| 3 | +5 minutes | ~6 min |
| 4 | +30 minutes | ~36 min |
| 5 | +2 hours | ~2.5 hr |
| 6 | +24 hours | ~26.5 hr |
A retry is considered successful on any HTTP 2xx response within 10 seconds. After attempt 6 still failing, the delivery is marked Abandoned and no further retries occur — your handler must be idempotent and the event is dropped after that. Each retry sets the header X-Baref00t-Attempt: N and re-signs with the current timestamp (so partners' replay-window protection still works against the X-Baref00t-Timestamp header).
Partner — Pipeline (Leads)
Programmatic access to the partner prospecting pipeline — Apollo + Hunter contact enrichment, M365 OIDC tenant discovery, and the New → Enriched → OutreachSent → Consented → ReportDelivered → Won | Lost stage machine.
Every endpoint in this section requires the partner record to have prospecting in enabledFeatures. The flag is admin-granted only — no plan tier auto-includes it. Calling these endpoints without the flag returns 403 FEATURE_NOT_ENABLED.
Lead PII (primary contact name/email, contacts list, partner overrides) is encrypted at rest with AES-256-GCM since v2.2.7. Plaintext columns dropped in v2.2.13 — encrypted shadows are the sole source of truth on reads.
Customer materialisation lifecycle (#369)
Two flows coexist, gated by the per-partner lead-deferred-customer feature flag:
| Stage / event | Legacy flow (default) | Deferred-customer flow (lead-deferred-customer set) |
|---------------------------|-----------------------------------------------|------------------------------------------------------------|
| send-consent | partner_customer row created (or attached) | No customer created. partner_runs.lead_id populated. |
| Consent grant (M365 OIDC) | partner_customer.tenant_id backfilled | partner_lead.tenant_id backfilled. Run stays orphan. |
| Report delivered | Recipient = customer.receivers[] | Recipient = lead.primaryContactEmail. Stage-2 gate skipped (Stage-1 partner mail provider still applies). |
| setStage('Won') | No-op on customer | partner_customer row created from lead; partner_runs.customer_id backfilled in same transaction. Fires lead.won + customer.created. Idempotent on re-Win. |
| setStage('Lost') | Cancel cadence | Cancel cadence. No customer materialised. |
In both flows, the assessmentId returned by send-consent is the same id used throughout the assessment + report webhooks; the difference is purely when the partner_customer row exists. SDK callers should treat SendLeadConsentResponse.customerId as nullable.
Request body
{
"domain": "acme.com", // required, bare domain. Protocol/path/www. stripped server-side
"product": "e8", // optional product slug
"maturityTarget": "level-2", // optional product-specific level
"notes": "Met at Sydney CSC meetup", // optional, up to 5000 chars
"enrich": true // default true; false to skip and call /enrich later
}
Response 201
{
"lead": {
"id": "uuid",
"domain": "acme.com",
"stage": "Enriched",
"tenantId": "guid-or-null",
"isOnM365": true,
"primaryContactName": "Jane Doe",
"primaryContactEmail": "jane@acme.com",
"contacts": [ ],
"createdAt": "...",
"updatedAt": "..."
},
"enrichmentError": null
}
Errors
400 INVALID_DOMAIN — domain didn't canonicalise
403 FEATURE_NOT_ENABLED — partner lacks 'prospecting' flag
409 DUPLICATE — lead exists for this domain. Response includes existingLeadId.
Request body
{
"domains": ["acme.com", "globex.com", "initech.com"],
"product": "e8",
"maturityTarget": "level-2",
"enrich": true
}
Response 202
{
"created": [
{ "id": "uuid", "domain": "acme.com", "stage": "New" }
],
"skipped": [
{ "domain": "globex.com", "reason": "duplicate", "existingId": "..." }
],
"enrichmentQueued": 2
}
skipped[].reason is "duplicate" (existing lead for this partner) or "invalid" (domain didn't canonicalise).
Query parameters
| Parameter | Type | Required | Notes |
|---|---|---|---|
| stage | string | optional | Single stage filter. |
| search | string | optional | Substring on domain or company name (case-insensitive on company). |
| limit | number | optional | Default 50, max 200. |
| cursor | string | optional | Opaque cursor returned as nextCursor in the prior page. |
Response 200
{
"leads": [{ "id": "...", "domain": "...", "stage": "Enriched" }],
"nextCursor": "2026-05-04T12:00:00.000Z",
"counts": {
"New": 14, "Enriched": 8, "OutreachSent": 3,
"Consented": 1, "ReportDelivered": 0, "Won": 0, "Lost": 2
},
"total": 28
}
Response 200
{
"lead": { },
"activities": [
{ "id": "...", "type": "enriched", "detail": "Enriched from apollo, hunter, m365",
"metadata": { "sources": ["apollo","hunter","m365"], "hasTenantId": true },
"actorEmail": null, "createdAt": "..." }
]
}
Settable fields
companyName, industry, sizeBand, revenueBand, primaryContactName, primaryContactEmail, primaryContactRole, product, maturityTarget, notes, lostReason, partnerPrimaryOverride, partnerContactOverrides.
Request body example
{
"partnerPrimaryOverride": {
"name": "Jane Doe",
"email": "jane@acme.com",
"role": "CTO"
},
"notes": "Spoke at the Sydney meetup; warm intro from John."
}
Pass partnerPrimaryOverride: null to revert to the enrichment-picked primary. Pass partnerContactOverrides: null to clear all per-contact edits.
Response 200
{ "lead": { } }
Response 200
{ "deleted": true }
Request body (optional)
{ "force": false }
Response 200
{
"lead": { },
"sources": ["apollo", "hunter", "m365"],
"fromCache": false
}
On successful enrichment a New lead auto-advances to Enriched and the lead.enriched webhook fires (cache hits suppressed).
Request body
{
"stage": "Lost",
"lostReason": "Customer chose competitor"
}
Setting Won or Lost cancels any in-flight outreach cadence and fires lead.stage_changed.
Errors
400 INVALID_STAGE — tried to set a non-terminal stage
400 MISSING_FIELD — stage='Lost' without lostReason
Costs 1 quota credit — the assessment row counts against the partner's monthly run limit.
Request body
{
"recipientEmail": "ciso@acme.com",
"recipientEmails": ["ciso@acme.com", "it@acme.com"],
"product": "e8",
"maturityTarget": "level-2",
"skipEmail": false
}
skipEmail: true runs the full consent flow (creates customer + assessment + run row, advances stage to OutreachSent, fires lead.outreach_sent) but skips the email send entirely. Per-recipient consentUrl strings are still returned so you can dispatch via your own channel (your own Resend account, Microsoft Graph, CRM merge fields, etc). Still costs 1 quota credit. Each recipient returns sent: false, skipped: "email_disabled". Required when the partner's mailProvider is off — without it the call fails with MAIL_DISABLED (HTTP 400).
Mail mode routing
Dispatch follows the partner's mailProvider setting (Portal → Settings → Mail mode):
resend (default) — baref00t Resend; partner brand-name in From, Reply-To = partner contact email
microsoft — partner's connected Microsoft Graph mailbox (OAuth-consented user / shared mailbox)
off — no send. Call fails with MAIL_DISABLED unless skipEmail: true
Each per-recipient result reports via: "resend" | "microsoft" so the caller can confirm which channel ran.
Response 200
{
"lead": { "stage": "OutreachSent" },
"consentUrl": "https://www.baref00t.io/consent?...",
"recipients": [
{ "email": "ciso@acme.com", "sent": true, "via": "resend", "consentUrl": "https://..." },
{ "email": "it@acme.com", "sent": false, "skipped": "suppressed", "consentUrl": "https://..." }
],
"assessmentId": "uuid",
"customerId": "uuid",
"runId": "uuid",
"totalSent": 1,
"totalRecipients": 2
}
recipients[].skipped is "suppressed" when the address is on the partner's bounce/unsub list, "send_failed" when provider dispatch returned non-ok, or "email_disabled" when the caller passed skipEmail: true. recipients[].via is "resend" or "microsoft" for sent rows and absent when skipped is set.
Errors
400 TERMINAL_STAGE — lead is in Won or Lost
400 MISSING_PRODUCT — no product on lead and none in body
400 NO_VALID_RECIPIENTS — all resolved addresses failed validation
400 TOO_MANY_RECIPIENTS — more than 10 in recipientEmails
400 MAIL_DISABLED — partner mailProvider is "off"; pass skipEmail: true
404 NOT_FOUND — lead doesn't exist for this partner
502 EMAIL_FAILED — all recipient sends failed; response includes recipients breakdown
Query parameters
limit — default 50, max 200
cursor — opaque cursor from a prior call
Response 200
{
"activities": [
{ "id": "...", "type": "email_sent", "detail": "Consent email sent to ciso@acme.com",
"metadata": { "touchNumber": 1, "recipient": "ciso@acme.com" },
"actorEmail": "partner@msp.com", "createdAt": "..." }
]
}
Distributor API
The Distributor API lets you provision and manage multiple partner accounts under a single billing relationship. All endpoints require the X-Distributor-Key header.
Response 200
{
"id": "dist_xyz789",
"company": "Global Security Distribution",
"email": "ops@globalsecdist.com",
"status": "active",
"partnersCount": 12,
"usage": {
"month": "2026-04",
"totalRuns": 384,
"totalCustomers": 156
},
"createdAt": "2025-11-01T08:00:00Z"
}
Query Parameters
| Parameter | Type | Description | |
|---|---|---|---|
| status | string | optional | Filter by status: active, suspended, pending. |
Response 200
{
"partners": [
{
"id": "c3d4e5f6-a7b8-9012-cdef-123456789012",
"company": "Acme MSP",
"email": "admin@acmemsp.com",
"plan": "professional",
"status": "active",
"customersCount": 18,
"createdAt": "2026-01-15T09:30:00Z"
}
]
}
Request Body
| Field | Type | Description | |
|---|---|---|---|
| string | required | Admin email for the new partner account. | |
| company | string | required | Company name for the partner. |
| name | string | required | Contact name for the partner admin. |
| plan | string | required | Plan to assign: starter, professional, enterprise. |
| billing | object | required | Billing configuration for the partner. |
| billing.currency | string | required | ISO 4217 currency code: AUD, USD, GBP, EUR, SGD. |
| billing.interval | string | optional | Billing interval: monthly (default), quarterly, or annual. |
{
"email": "admin@newmsp.com",
"company": "New MSP Pty Ltd",
"name": "Jane Smith",
"plan": "professional",
"billing": {
"currency": "AUD",
"interval": "monthly"
}
}
Response 201
{
"id": "prt_new456",
"company": "New MSP Pty Ltd",
"email": "admin@newmsp.com",
"plan": "professional",
"status": "pending",
"billing": {
"currency": "AUD",
"interval": "monthly"
},
"createdAt": "2026-04-03T11:00:00Z"
}
Path Parameters
| Parameter | Type | Description | |
|---|---|---|---|
| id | string | required | Partner ID (e.g. c3d4e5f6-a7b8-9012-cdef-123456789012). |
Response 200
{
"id": "c3d4e5f6-a7b8-9012-cdef-123456789012",
"company": "Acme MSP",
"email": "admin@acmemsp.com",
"name": "John Doe",
"plan": "professional",
"status": "active",
"billing": { "currency": "AUD", "interval": "monthly" },
"usage": {
"month": "2026-04",
"runsUsed": 42,
"runsLimit": 200,
"customersCount": 18
},
"createdAt": "2026-01-15T09:30:00Z"
}
Request Body
{
"plan": "enterprise",
"billing": { "interval": "annual" }
}
Response 200
{
"id": "c3d4e5f6-a7b8-9012-cdef-123456789012",
"plan": "enterprise",
"billing": { "currency": "AUD", "interval": "annual" },
"updatedAt": "2026-04-03T11:15:00Z"
}
Request Body
No request body required.
Response 200
{
"id": "c3d4e5f6-a7b8-9012-cdef-123456789012",
"status": "active",
"activatedAt": "2026-04-03T11:20:00Z"
}
Request Body
No request body required.
Response 200
{
"id": "c3d4e5f6-a7b8-9012-cdef-123456789012",
"status": "suspended",
"suspendedAt": "2026-04-03T11:25:00Z"
}
Suspending a partner immediately invalidates all of their API keys. The partner will need to generate new keys after reactivation.
Request body
{
"plan": "enterprise",
"billing": "rolling",
"currency": "aud"
}
Response 200
{
"partnerId": "c3d4e5f6-a7b8-9012-cdef-123456789012",
"plan": "enterprise",
"billing": "rolling",
"currency": "aud",
"runLimit": -1,
"subscriptionStatus": "active",
"currentPeriodEnd": "2027-04-09T00:00:00Z"
}
runLimit: -1 indicates unlimited.
Errors
404 NOT_FOUND — partner does not exist or is not owned by this distributor
400 PLAN_CHANGE_FAILED — invalid plan/billing, no active subscription, already on target plan,
or subscription is in a state that cannot be modified
401 Missing or invalid X-Distributor-Key
500 Price lookup or Stripe API error
Plan changes against sub-partners are audited under the distributor's ID. If the distributor has StripeBillingEnabled=false, the partner's own Stripe subscription is still updated — revenue-share settlement is tracked separately.
Response 200
{
"customers": [
{
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"name": "Contoso Ltd",
"tenantId": "d4f5a678-1234-5678-9abc-def012345678",
"email": "it@contoso.com",
"createdAt": "2026-02-10T14:00:00Z"
}
]
}
Request Body
{
"name": "Contoso Ltd",
"tenantId": "d4f5a678-1234-5678-9abc-def012345678",
"email": "it@contoso.com",
"receivers": ["ciso@contoso.com"],
"questionnaireRecipients": ["ciso@contoso.com"]
}
Response 201
{
"id": "cust_new789",
"name": "Contoso Ltd",
"tenantId": "d4f5a678-1234-5678-9abc-def012345678",
"email": "it@contoso.com",
"receivers": ["ciso@contoso.com"],
"questionnaireRecipients": ["ciso@contoso.com"],
"partnerId": "c3d4e5f6-a7b8-9012-cdef-123456789012",
"createdAt": "2026-04-03T11:30:00Z"
}
v2.1.5 (#309): distributor assessment endpoints removed. Distributors manage partners + customers + view aggregated usage. Per-customer assessment trigger and run listing is partner-key only — distributors must not see or perform per-customer assessment work on behalf of their downstream partners without the partner's own consent. Migrate aggregate roll-up to GET /v1/distributor/usage; per-run / per-customer detail must use the partner's own pk_live_… key against the Partner API.
Query Parameters
| Parameter | Type | Description | |
|---|---|---|---|
| month | string | optional | Month in YYYY-MM format. Defaults to current month. |
Response 200
{
"month": "2026-04",
"totalPartners": 12,
"activePartners": 10,
"totalCustomers": 156,
"totalRuns": 384,
"byPartner": [
{
"partnerId": "c3d4e5f6-a7b8-9012-cdef-123456789012",
"company": "Acme MSP",
"runs": 42,
"customers": 18
},
{
"partnerId": "prt_def456",
"company": "SecureIT Partners",
"runs": 67,
"customers": 24
}
]
}
Path Parameters
| Parameter | Type | Description | |
|---|---|---|---|
| slot | string | required | Key slot to revoke: 1 or 2. |
Query Parameters
| Parameter | Type | Description | |
|---|---|---|---|
| distributorId | string | required | UUID of the distributor whose key to revoke. |
Response 200
{ "revoked": true, "slot": 1 }
Request Body
{
"partners": [
{
"email": "admin@mspalpha.com",
"company": "MSP Alpha",
"name": "Alice Wong",
"plan": "professional",
"billing": { "currency": "AUD", "interval": "monthly" }
},
{
"email": "admin@mspbeta.com",
"company": "MSP Beta",
"name": "Bob Chen",
"plan": "starter",
"billing": { "currency": "USD", "interval": "monthly" }
}
]
}
Response 201
{
"created": 2,
"failed": 0,
"partners": [
{ "id": "prt_alpha01", "company": "MSP Alpha", "status": "pending" },
{ "id": "prt_beta02", "company": "MSP Beta", "status": "pending" }
]
}
Maximum batch size is 50 partners. Requests exceeding this limit return a TOO_MANY error.
TypeScript SDK
The official typed client for both APIs is published as @baref00t/sdk on npm. It wraps auth, retries, error shapes, and webhook signature verification — and tracks the API shape via a CI contract test against staging, so SDK and platform never drift.
Source: npmjs.com/package/@baref00t/sdk — Apache-2.0, no workspace deps, dual ESM + CJS.
Install
npm install @baref00t/sdk
# or
pnpm add @baref00t/sdk
Partner client
import { PartnerClient, BareF00tApiError } from '@baref00t/sdk/partner'
const client = new PartnerClient({ apiKey: process.env.BAREF00T_PARTNER_KEY! })
// List customers
const { customers, total } = await client.customers.list()
// Trigger an assessment
const run = await client.assessments.create({
customerId: customers[0].customerId,
product: 'tenant-health',
})
// Fetch the rendered report — returns the SAS URL without following the 302
const { url, expiresAt } = await client.assessments.getReport(run.assessmentId)
// Permanently delete an assessment + its report (irreversible, ownership-checked)
await client.assessments.delete(run.assessmentId)
// Manage webhooks
const ep = await client.webhooks.create({
url: 'https://api.acme.com/baref00t/webhooks',
events: ['assessment.completed', 'customer.created'],
})
console.log(ep.secret) // store this — only returned ONCE
Pipeline (leads)
// Create a lead — sync enrichment by default
const { lead } = await client.leads.create({ domain: 'acme.com', product: 'e8' })
// Send the partner-branded outreach (costs 1 credit)
const sent = await client.leads.sendConsent(lead.id, {
recipientEmails: ['ciso@acme.com'],
})
console.log('via', sent.recipients[0].via) // 'resend' or 'microsoft'
// Or — mint URLs without sending (still 1 credit)
const minted = await client.leads.sendConsent(lead.id, {
recipientEmails: ['ciso@acme.com'],
skipEmail: true,
})
const consentUrl = minted.recipients[0].consentUrl
Distributor client
import { DistributorClient } from '@baref00t/sdk/distributor'
const client = new DistributorClient({ apiKey: process.env.BAREF00T_DISTRIBUTOR_KEY! })
const { partners } = await client.partners.list()
Webhook verification
import { verifyWebhookSignature, BareF00tWebhookError } from '@baref00t/sdk/webhooks'
try {
const event = verifyWebhookSignature({
rawBody: req.body, // Buffer or string — NOT parsed JSON
signature: req.header('X-Baref00t-Signature')!,
secret: process.env.BAREF00T_WEBHOOK_SECRET!,
})
if (event.type === 'assessment.completed') {
// event.data is typed
}
} catch (err) {
if (err instanceof BareF00tWebhookError) {
// 'malformed' | 'invalid_signature' | 'timestamp_too_old'
return res.status(400).end()
}
throw err
}
Errors: every non-2xx throws a typed BareF00t{Api,Network,Config,RateLimit,Webhook}Error subclass with status, code, and body fields. BareF00tRateLimitError exposes retryAfterSeconds for backoff.
MCP server
The baref00t MCP (Model Context Protocol) server exposes the full Partner + Distributor SDK surface as tools that AI assistants like Claude Desktop, Claude Code, and any MCP-aware client can call directly. Tools are scoped by the API key prefix you authenticate with — pk_* partner keys see partner tools, dk_* distributor keys see distributor tools.
| Environment | URL |
|---|---|
| prod | https://mcp.baref00t.io/mcp |
| sandbox | https://mcp.sandbox.baref00t.io/mcp |
Authentication
OAuth (recommended for AI clients) — Claude Desktop / Claude.ai / Cursor / ChatGPT discover the auth surface automatically via /.well-known/oauth-authorization-server. Add the MCP URL and the client pops a browser to sign in via Microsoft. No JSON, no API key paste. Manage authorized apps at /portal/developer/connected-apps.
API key (server-to-server) — for SDK, CI, or your own backend, send Authorization: Bearer pk_live_… (or X-Partner-Key: pk_live_… / X-Distributor-Key: dk_live_…). Get keys at /portal/developer/api-keys.
Inspect
Before wiring into a desktop client, run the official MCP inspector:
# OAuth (pops browser for Microsoft sign-in)
npx -y @modelcontextprotocol/inspector https://mcp.baref00t.io/mcp
# API key (server-to-server)
npx -y @modelcontextprotocol/inspector https://mcp.baref00t.io/mcp \
--header "X-Partner-Key: pk_live_..."
Available tools (51 total)
scripts/gen-mcp-docs.mjs. Edits here will be overwritten by CI.Customers (6)
| Tool | What it does |
|---|---|
| partner_list_customers | List all customers in the partner account. Returns customerId, name, email, tenantId, status and createdAt. Use when the user asks "show me my customers" or wants to look up a customer id. |
| partner_get_customer | Fetch a single customer by id, including notification toggles + creation date. Use when the user asks for details about a specific customer. |
| partner_create_customer | Create a new customer record. Requires the customer's display name and Microsoft Entra tenant id (GUID). Returns the new customerId. Use when the user asks to add or onboard a customer. |
| partner_update_customer | Update whitelisted fields on an existing customer. Partial — only fields you pass are changed. Note: changing tenantId rebinds the customer to a different Microsoft Entra tenant; existing consents tied to the old tenant will not transfer. |
| partner_delete_customer | Delete a customer. Idempotent — re-deleting returns 404. Past assessments + reports tied to the customer remain queryable through partner_list_assessments / partner_get_assessment_report until their own retention windows expire; only the customer record itself is removed. Granted consents on the Microsoft Entra tenant are NOT revoked here — the customer admin must remove the baref00t app from Enterprise Applications separately. |
| partner_bulk_create_customers | Bulk-create up to 100 customers in one request. Per-row failures do NOT fail the whole call — inspect results[] for the per-customer outcome (status: "created" | "error", with customerId or error string). Returns total / succeeded / failed counts plus the per-row results array. Use partner_create_customer for one-offs; this is for onboarding spreadsheets / CSV imports. |
Consent (2)
| Tool | What it does |
|---|---|
| partner_get_consent_url | Mint a consent URL the customer can visit to grant the baref00t app read-only Microsoft Graph access on their tenant. Required before the first assessment for any customer who hasn't already consented — partner_trigger_assessment will silently fail (orchestrator graph token-exchange 401s) without consent. Returns { consentUrl, assessmentId, product, customerId }. Hand the URL to the partner to share with the customer manually, or use partner_send_consent_email to email it on the partner's behalf. |
| partner_send_consent_email | Send a partner-branded consent invitation email to the customer's configured consentRecipients (or primary email as fallback). The email contains a Grant Consent button linking to the consent URL. Returns { sent, skipped, recipients, failed, consentUrl, emailConsentEnabled, skipReason }. Honours the 2-stage email gate: if the partner's mail provider is 'off' OR the customer's emailConsentEnabled is false, no email is sent and emailConsentEnabled is returned as false with skipReason set; consentUrl is always returned so it can be shared manually. |
Assessments (6)
| Tool | What it does |
|---|---|
| partner_list_assessments | List assessment runs for the partner. Filter by month (YYYY-MM), product, and/or customerId. Also returns runsUsed/runLimit so the LLM knows how much quota remains. Use when the user asks "show me recent runs" or wants to find a specific assessment. |
| partner_get_assessment | Fetch one assessment run by id. Returns product, status, customer id, and timestamps. Use to check the live status of a previously triggered run. |
| partner_get_assessment_report | Get a 30-day SAS URL for the rendered HTML report of a completed assessment. Returns { url, expiresAt }. Hand the URL to the user as a clickable link or fetch it in a browser. The assessment must be in completed status — call partner_get_assessment_status first to confirm. Only HTML format is supported today; PDF format returns NOT_IMPLEMENTED. |
| partner_trigger_assessment | Start a new assessment for the given customer. The platform performs a live consent pre-flight (client-credentials token mint against the customer tenant) before accepting the run. Inspect the response's "status" field: "queued"/"completed"/"failed" means the run was accepted (assessmentId + runId populated, COSTS 1 CREDIT — poll via partner_get_assessment_status). "consent_required" means the per-product Entra app does NOT currently exist in the customer tenant (never consented OR the customer admin removed it from Enterprise Applications) — no credit was burned, assessmentId is null, but consentUrl is returned. In that case call partner_send_consent_email or share consentUrl directly with the customer, then re-trigger. |
| partner_delete_assessment | Permanently delete one of your assessments. Removes the run record, any linked proposal / narrative rows, AND the rendered report (the customer’s report link stops working). Irreversible — there is no undo and no soft-delete. Ownership is enforced: deleting an assessment that is not yours returns 404. Use partner_list_assessments to find the id first. |
| partner_get_assessment_status | Poll the status of a previously-triggered assessment. Returns the current status (queued, completed, failed) and the runAt timestamp. Use to wait for a triggered assessment to finish. |
Plan & Billing (3)
| Tool | What it does |
|---|---|
| partner_get_plan_billing | Fetch the partner's current plan, billing cycle, monthly assessment quota usage, and list of allowed products. Use when the user asks about their plan, quota, or available products before triggering an assessment. |
| partner_get_billing | Read-only Stripe snapshot for the partner: active subscriptions, last 20 invoices, last 20 successful charges, plus a freshly-minted Stripe Customer Portal URL valid ~1 hour. When the partner has no Stripe customer linked yet, all arrays are empty and portal_url is null. For just the portal URL (or a guaranteed-fresh one) use partner_get_payment_portal_url. The platform owns the subscription lifecycle — plan changes still go through partner_get_plan_billing / the change-plan SDK call. |
| partner_get_payment_portal_url | Mint a fresh, short-lived Stripe Customer Portal URL. The partner opens it in a new tab to manage payment methods, download invoices, and update billing details directly on Stripe — no card data ever touches baref00t.io. Use this when the cached portal_url from partner_get_billing has expired (or any time you want a guaranteed-fresh session). Errors 400 if the partner has no Stripe customer linked yet. |
Branding (2)
| Tool | What it does |
|---|---|
| partner_get_branding | Fetch the partner-wide branding (company name, brand colour, footer text, contact email, and a 30-day SAS URL for the uploaded logo when present). Drives customer-facing emails + the report viewer chrome. Use when the user asks "what does our branding look like?" or before mutating a single field via partner_update_branding. |
| partner_update_branding | Update one or more partner-branding text fields. All fields optional — provide only what you want to change. Affects customer-facing emails (consent invitation, report-ready, questionnaire) and the report viewer chrome. Logo upload is a separate multipart flow (POST /api/partner/branding/logo, MSAL only) and is not covered by this tool. |
Members (5)
| Tool | What it does |
|---|---|
| partner_list_members | List all members on the partner account, with role (Admin / Member / Viewer), status (Pending / Active / Suspended), and lifecycle timestamps. Also returns the calling principal's effective role — for X-Partner-Key callers this is always "Admin". Each member row exposes an emailHash that downstream member tools (update / remove / resend) use as the path identifier. |
| partner_invite_member | Invite a new member to the partner account. Sends an invitation email with a one-time accept link valid for 14 days. Returns the new (Pending) member record including the emailHash needed for downstream update/remove/resend calls. Errors 409 if the address is already a member or has an open invite. |
| partner_update_member | Update a member's role and/or status (provide at least one). Suspending revokes their ability to sign in but retains the row for audit; reactivate by setting status="Active". Cannot demote the last Admin — errors 409 in that case. |
| partner_remove_member | WARNING — permanent. Removes a member from the partner account; the row is deleted (audit history of past actions is retained on the audit log, but the member entry is gone — re-add via partner_invite_member). Cannot remove the last Admin (errors 409). Prefer partner_update_member with status="Suspended" if you might re-enable later. |
| partner_resend_invite | Re-issue the invitation email and reset the 14-day accept window for a Pending member. Errors 409 if the member is not in Pending status (use partner_update_member to change role on already-Active members instead). |
Webhooks (6)
| Tool | What it does |
|---|---|
| partner_list_webhooks | List all webhook endpoints configured on the partner account, plus the catalog of event types you can subscribe to (assessment.completed / failed, customer.created / updated / deleted, etc.). Each row includes lastSuccessAt, lastFailureAt, and failureCount — handy for spotting endpoints that are silently broken. |
| partner_create_webhook | Create a new webhook endpoint. WARNING — the signing secret is returned ONCE in response.secret; store it immediately (e.g. in your secrets manager). It cannot be re-fetched. Use partner_update_webhook with rotateSecret=true to mint a fresh secret if lost. Verify incoming deliveries against X-Baref00t-Signature using verifyWebhookSignature() from @baref00t/sdk/webhooks. |
| partner_update_webhook | Partial update for a webhook endpoint — change the URL, events, enabled flag, description, and/or rotate the signing secret. When rotateSecret=true, the new secret is returned ONCE in response.secret and the previous one stops working immediately. |
| partner_delete_webhook | WARNING — permanent. Removes the webhook endpoint and STOPS any in-flight retries (events currently mid-backoff are abandoned). Historical delivery rows remain queryable until the retention window expires. If you only want to pause deliveries, prefer partner_update_webhook with enabled=false instead. |
| partner_test_webhook | Fire a synthetic test.ping delivery to the endpoint with a real signed payload — useful while wiring up your handler. The 10-second response is returned inline so you can see exactly what your server replied (statusCode + first ~500 bytes of responseBody). Does NOT cost a credit and does NOT count toward retry backoff. |
| partner_list_webhook_deliveries | Recent delivery attempts for a single webhook endpoint. Useful for debugging when a partner's receiver is silently failing — surfaces statusCode, durationMs, lastError, attemptCount (with v2.1.7+ retry-backoff schedule: 1m, 5m, 30m, 2h, 24h), and a 500-byte slice of the response body. Status can be Pending / Delivered / Failed / Abandoned. |
Mail (6)
| Tool | What it does |
|---|---|
| partner_mail_status | Fetch the current partner-mail configuration: provider ("resend" / "microsoft" / "off"), connected Microsoft mailbox UPN (when provider="microsoft"), shared-mailbox UPN, whether the cached refresh token can still mint a Graph access token, and the access-token expiry. Use to check status before mode/sharedMailbox changes or to decide whether to re-run partner_mail_start_connect. |
| partner_mail_set_mode | Set the partner-wide mail provider. Switching to "microsoft" before completing partner_mail_start_connect errors 412 NO_CONNECTION (no refresh token on file). "off" is the recommended kill-switch — flipping it back to "resend" or "microsoft" resumes sending immediately. |
| partner_mail_disconnect | Revoke the cached Microsoft refresh token and revert the provider to Resend. Use when the connected mailbox is compromised or the partner admin wants to re-consent under a different account. After disconnect, partner_mail_start_connect must be run again before partner_mail_set_mode can flip back to "microsoft". |
| partner_mail_send_test | Send a one-off test email via Microsoft Graph using the connected mailbox (or sharedMailbox if set). Recipient is always the calling user's own email — no body to specify. Use to confirm the OAuth flow + Graph permissions are wired correctly. Errors 412 NOT_CONNECTED if the partner hasn't connected a Microsoft mailbox yet, or 502 GRAPH_<status> if Graph rejects the send (typically a missing Send-As permission on the shared mailbox). |
| partner_mail_set_shared_mailbox | Set or clear the shared-mailbox UPN that customer-facing emails are sent FROM. When unset (null), mail is sent from the connected user's own mailbox. Confirm permission is wired correctly afterwards via partner_mail_send_test — Graph errors 502 if Send-As permission is missing on the shared mailbox. |
| partner_mail_start_connect | Returns the Microsoft authorize URL for the partner admin to navigate to. The OAuth callback handles token persistence on the platform; no further MCP calls are needed for the consent step itself. After the callback bounces back to returnTo with ?mailConnected=1, call partner_mail_set_mode with mode="microsoft" to switch the provider on. Errors 503 MAIL_APP_NOT_CONFIGURED if the platform's Partner Mail Sender Entra app isn't configured server-side. |
API Keys (3)
| Tool | What it does |
|---|---|
| partner_list_keys | List the active API key slots (1 and 2) for the partner. Each slot exposes its number + a 4-char suffix of the raw key for visual identification — the raw key itself is never re-fetchable. Returned as part of the partner profile (the same shape partner_get_plan_billing already exposes). Use to confirm which slot is in use before partner_create_key (max 2) or partner_revoke_key. |
| partner_create_key | WARNING — raw key returned ONCE in response.key. Store immediately (e.g. in a secrets manager); it cannot be re-fetched. Costs an active slot (max 2). Throws MAX_KEYS if both slots are already in use — call partner_revoke_key on the unused slot first. Recommended rotation flow: create slot 2, deploy with new key, verify, then revoke slot 1. |
| partner_revoke_key | WARNING — irreversible. Revoke the API key in the given slot (1 or 2). The key is invalidated immediately; any caller still using it gets 401 on the next request. Foot- gun: revoking the slot the SDK / MCP server is currently using locks subsequent calls out of the platform. Always rotate (partner_create_key) and verify the new key works before revoking the old one. |
Pipeline / Leads (10)
Requires the partner record to have prospecting in enabledFeatures (admin-granted only — no plan tier auto-includes it).
| Tool | What it does |
|---|---|
| partner_list_leads | List partner prospecting leads with optional stage / search filters and stage-count rollup. Requires the partner record to have prospecting in enabledFeatures (admin-granted only — no plan tier auto-includes it). Returns leads with domain, company, contact info, stage, and timestamps. Use when the user asks "show me my pipeline", "leads in stage X", or wants to look up a leadId. |
| partner_get_lead | Fetch one lead with its full contact list, partner overrides, lifecycle timestamps, and the most recent 50 activity log entries. Requires prospecting feature flag. Use when the user wants details on a specific lead (by id) or to inspect activity / enrichment results. |
| partner_create_lead | Create a single lead from a domain. Requires prospecting feature flag. By default runs enrichment synchronously: Apollo + Hunter populate company/contact fields, and an OIDC probe against login.microsoftonline.com fills tenantId/isOnM365 when the domain is on Entra. Returns 409 with existingLeadId if a lead for the same domain already exists for this partner. |
| partner_bulk_create_leads | Bulk-create up to 50 leads from a list of domains. Requires prospecting feature flag. Returns created (lead rows) + skipped (duplicates / invalid domains with reasons). Enrichment runs in the background — partner_get_lead will return enriched data once Apollo / Hunter / M365 finish (typically <30s per lead). |
| partner_update_lead | Update whitelisted lead fields. Requires prospecting feature flag. Stage cannot be set via this tool — use partner_set_lead_stage for terminal Won/Lost transitions; other stages are server-managed by the assessment lifecycle. Identity links (customerId, assessmentId) are also server-managed and not partner-settable. |
| partner_delete_lead | Soft-delete a lead. Requires prospecting feature flag. Cancels any in-flight outreach cadence. The row stays in Postgres for audit; re-creating the same domain after deletion creates a fresh lead row with a new id. Idempotent — re-deleting an already-deleted lead returns 404. |
| partner_enrich_lead | Re-run enrichment for a lead: Apollo people search + Hunter domain search + Microsoft 365 OIDC tenant discovery (login.microsoftonline.com/{domain}/.well-known/openid-configuration). Requires prospecting feature flag. Returns the updated lead, the contributing sources (apollo, hunter, m365), and fromCache: true when results were served from the freshness cache. On successful enrichment the lead auto-advances from New to Enriched. |
| partner_set_lead_stage | Mark a lead Won or Lost — the two terminal states. Requires prospecting feature flag. Other stages (Enriched / OutreachSent / Consented / ReportDelivered) are server-managed via the assessment lifecycle and cannot be set explicitly. Setting Won or Lost cancels any in-flight outreach cadence for the lead. WON SIDE-EFFECTS: fires the lead.won webhook event. If the partner has the lead-deferred-customer feature flag AND the lead has no customer attached yet, also materialises a partner_customer row from the lead's primary contact + tenant, backfills any orphan partner_runs rows with the new customerId, and fires customer.created once. Idempotent — re-Winning a lead that already has a customer is a no-op (no second customer, no duplicate events). |
| partner_list_lead_activity | Read the activity log for a lead — created, enriched, email_sent, email_delivered, email_opened, email_clicked, consented, report_ready, stage_changed, note, etc. Each entry has type / detail / metadata / actorEmail / createdAt. Requires prospecting feature flag. Use when the user asks "what happened to this lead" or to debug an outreach sequence. |
| partner_send_lead_consent | Kick off the partner-branded outreach cadence for a lead. Mints an assessment + run row (COSTS 1 CREDIT), sends touch-1 of the consent email to each recipient, advances the lead to OutreachSent, and fires the lead.outreach_sent webhook event. Touches 2 + 3 fire from the background cadence worker on day 3 + 7. Suppressed addresses are skipped silently with skipped: "suppressed" in the per-recipient result. CUSTOMER CREATION: depends on the partner's lead-deferred-customer feature flag. OFF (default): a partner_customer row is created (or attached by email match) at this call and returned as customerId. ON (#369): no customer is created here — customerId in the response is null, the run row carries lead_id, and a customer is materialised only when the partner later marks the lead Won via partner_set_lead_stage. Dispatch is routed by the partner's mail mode (mailProvider): resend (default — baref00t Resend with partner From-name + Reply-To), microsoft (partner's connected Graph mailbox), or off (call fails with MAIL_DISABLED unless skipEmail: true). Each per-recipient result reports via: "resend" | "microsoft" so the caller can confirm which channel ran. Pass skipEmail: true to bypass the email send entirely — the response still carries the per-recipient consentUrl strings so the partner can dispatch via their own channel. Requires the prospecting feature flag. Reject if the lead is in a terminal stage (Won / Lost). |
Distributor (2)
| Tool | What it does |
|---|---|
| distributor_list_partners | List all sub-partners owned by this distributor. Optionally filter by status. Returns each partner's id, company, email, plan, billing, and status. |
| distributor_get_usage | Aggregated assessment usage across all sub-partners for a billing month. Returns totalPartners, activePartners, totalRuns, and per-partner breakdown (runs vs limit). Use to answer "how much have my sub-partners used this month?". |
PII: customer lists, lead contact lists, and recipient breakdowns include email addresses (and other contact data from Apollo / Hunter enrichment) which flow to the LLM provider's pipeline as tool inputs / outputs. baref00t doesn't control how the AI provider stores those interactions. Confirm your contractual basis (e.g. Anthropic Zero Data Retention, OpenAI no-train terms) with your AI provider before connecting a partner key that has access to real customer data — emails are returned plaintext, not hashed.
Self-host the partner portal
White-label, open-source partner portal you can run on your own infrastructure with your own Microsoft Entra SSO, your own domain, and your own outbound mail sender. Built on Next.js, uses @baref00t/sdk against the Partner API.
Source: github.com/becloudsmart-com/baref00t-portal — Apache-2.0.
Run with Docker
docker pull ghcr.io/becloudsmart-com/baref00t-portal:latest
docker run -p 3000:3000 \
-e BAREF00T_API_KEY=pk_live_... \
-e AUTH_SECRET=$(openssl rand -base64 32) \
-e AUTH_URL=https://portal.acme.com \
-e AZURE_AD_TENANT_ID=<your-entra-tenant-guid> \
-e AZURE_AD_CLIENT_ID=<your-app-reg-guid> \
-e AZURE_AD_CLIENT_SECRET=<your-app-secret> \
-e BRAND_NAME="Acme Cloud Security" \
-e BRAND_PRIMARY_COLOR="#00cc66" \
-e BRAND_LOGO_URL="https://cdn.acme.com/logo.svg" \
-e MAIL_FROM_ADDRESS="security-no-reply@acme.com" \
ghcr.io/becloudsmart-com/baref00t-portal:latest
What's included
Customer management (CRUD + bulk import)
Assessment trigger wizard + report viewer (uses /assessments/:id/report)
Webhook configuration (uses /webhooks CRUD)
API key rotation UI
Plan + usage display (read-only — billing happens on the hosted dashboard)
Branding from env (logo / primary color / footer / favicon)
Outbound mail via your Microsoft Graph mailbox
See docs/AZURE-SETUP.md, docs/MAIL-SETUP.md, and docs/ENV-REFERENCE.md in the public repo for step-by-step setup.