Build
Send useful billing notifications
Support users who never sign into the MindBill console, without turning a contact list into an email subscription.
These settings are available to every integration. Notifications are off by default; loading a widget or saving a billing profile never opts someone in. Angular and API-only integrations can use the same server contract with their own settings UI.
Choose who routes the alerts
Doctor-specific alerts: MindBill can send notifications without a console account or a partner-built webhook notification service. Choose audience: "assigned_bills" and use your trusted server to associate each recipient with the bills they can access. Never infer access from a provider NPI or accept an unchecked browser-supplied association.
Practice-wide alerts: choose audience: "practice" only for users authorized to receive updates about every bill in the managed practice. Always choose the audience explicitly when enrolling a restricted doctor.
Custom content or delivery: you can still consume signed webhooks and route notifications yourself. Verify raw-body signatures, deduplicate event IDs, tolerate retries and out-of-order delivery, and reconcile current bill state before notifying.
Courtesy copies: passing recipient options only supplies suggestions for a user-initiated bill email. It neither selects recipients automatically nor subscribes anyone to future notifications.
Invite any email address
Use @mindbill/react@0.65.0 and add NotificationRecipientsSettings (alias ConnectedNotificationRecipientsSettings) to your administrator's billing settings. Enter any authorized email address, choose practice-wide or assigned-bill alerts and optional digests, and send an invitation. The recipient needs no MindBill console account or account in your app.
This covers bill-status and payment alerts, 30/60/90-day aging reminders, and opt-in billing-activity digests. Each email includes an unsubscribe link. Patient reports and attachments are not emailed.
Scheduled billing-activity digests
Choose reportDigest: "off" | "daily" | "weekly"; the default is off. Digests are scheduled at 9 am America/Los_Angeles time, on Mondays for weekly delivery. Empty periods are skipped. They count billing updates, bills with updates and payment updates—not current statuses, financial balances or confirmed cash totals. No patient or bill details are included. Only activity since the recipient's current consent and authorized bill access is eligible.
Include reportDigest in your server adapter's allowlist, upstream requests and preference snapshots. An existing subscription does not gain digests on upgrade: the recipient must explicitly confirm the new cadence. Existing unsubscribe and assigned-bill access rules also apply to digests.
"use client";
import { useMemo } from "react";
import {
NotificationRecipientsSettings,
type NotificationRecipientsAdapter,
} from "@mindbill/react";
export function BillingRecipients({ identityKey, csrfToken }: {
identityKey: string; // changes for admin, practice OR sandbox/live environment
csrfToken: string;
}) {
const adapter = useMemo<NotificationRecipientsAdapter>(() => {
async function request(method: string, suffix = "", body?: unknown) {
// YOUR authenticated admin route; not a MindBill browser-session endpoint.
const response = await fetch("/api/billing/notification-recipients" + suffix, {
method, credentials: "same-origin", cache: "no-store",
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
if (!response.ok) throw new Error("Notification request failed");
return response.json(); // host returns API data, not the { data } envelope
}
return {
load: offset => request("GET", "?offset=" + offset),
invite: input => request("POST", "", input), // preserve input.requestId
disable: async externalUserId => {
await request("DELETE", "/" + encodeURIComponent(externalUserId));
},
};
}, [identityKey, csrfToken]);
return <NotificationRecipientsSettings identityKey={identityKey} adapter={adapter}
appearance={{ preset: "mindbill" }} />;
}The widget starts with assigned bills, no categories selected, and quiet hours from 7 pm to 7 am Pacific. It shows pending invitations as off, lists existing recipient states, and supports disabling a subscription. Pass appearance, className and style to match your app. Keep the adapter memoized and change identityKey for a different administrator, practice or environment; this is a UI reset key, not authorization.
Connect your trusted invitation adapter
Authenticate the administrator, authorize the managed practice and each recipient operation, and enforce same-origin and CSRF checks. Resolve the organization, sandbox/live environment and permanent API key from trusted server state. Use orgs:write and X-MindBill-Org-Id; browser session tokens cannot call these routes. Return Cache-Control: no-store and sanitized errors, and never log email addresses or request/response bodies.
load(offset)GET /partner/v2/notifications/recipients?offset=0
invite(input)POST /partner/v2/notifications/recipients/{externalUserId}/invitations
disable(externalUserId)DELETE /partner/v2/notifications/recipients/{externalUserId}
PUT / DELETE /partner/v2/notifications/recipients/{externalUserId}/bills/{billId}
Unwrap the API's data field in your host response. List data contains available, environment, recipients and hasMore, with 100 recipients per page. When unavailable, show that invitations cannot be sent; existing subscriptions can still be disabled. Reloading the list never sends email.
# Server only, after host administrator, practice and audience authorization.
POST https://app.mindbill.org/partner/v2/notifications/recipients/doctor_42/invitations
Authorization: Bearer <server-only-partner-key-with-orgs:write>
X-MindBill-Org-Id: <server-resolved-managed-organization>
Content-Type: application/json
{
"requestId": "<client-generated-UUID-preserved-on-unchanged-retries>",
"email": "doctor@example.test",
"audience": "assigned_bills",
"statusUpdates": true,
"agingDays": [30, 60, 90],
"quietHours": true,
"reportDigest": "off"
}
# A sent invitation is NOT an enabled subscription.
# The email owner must review and explicitly confirm the invitation.
# Do not add enabled, consent timestamps, tenant IDs or bill assignments.Strictly allowlist the seven invitation fields shown above, validate them, authorize the requested audience and forward them unchanged. Preserve the widget's UUID requestId on identical retries, including uncertain network failures. Your server resolves a stable opaque external recipient ID within the practice and environment; do not put the email in the URL. Reuse an existing private recipient directory or doctor ID. If none exists, a private keyed mapping of practice, environment and normalized email can avoid a new database migration; retain that mapping across key rotation and revoke the old recipient before changing its address.
practice covers current and future bills in the authorized practice. assigned_bills requires explicit server-owned bill associations and sends nothing when the assignment list is empty. Synchronize those associations from host access rules during setup, bill creation and access changes; never accept unchecked browser bill IDs or infer permission from an NPI. Assigning a bill grants neither consent nor access to your application.
A new invitation disables an existing subscription until reconfirmed. Changing email or audience clears prior bill assignments: explicitly resynchronize authorized assignments. Unsubscribe and administrator disable revoke old invitation links, including already-consumed ones. Expired invitations require a deliberate new invitation with a new UUID; do not renew consent in a background job.
Sandbox sends neither invitation nor alert email. A sandbox response may contain a capability-bearing previewUrl; strip it from ordinary host responses unless you implement a purpose-built, private preview. Never log or publish it. Live responses do not return a confirmation URL. The developer console uses its own owner/admin session routes; partner apps should use the server-key routes above.
Configure a signed-in person's own preferences
Place NotificationSettings (also exported as ConnectedNotificationSettings) on your existing settings page. Its adapter calls your authenticated host server, never the administrative Partner API directly. The component handles loading, save errors, default-off preferences, fresh consent, and unsubscribe. Requires @mindbill/react 0.49.0 or later; upgrade older installations first.
"use client";
import { useMemo } from "react";
import {
NotificationSettings,
type NotificationSettingsAdapter,
type NotificationSettingsSnapshot,
} from "@mindbill/react";
export function BillingNotificationSettings({ identityKey, csrfToken }: {
identityKey: string; // Change when signed-in user, practice, or environment changes.
csrfToken: string; // Issued by your existing host CSRF protection.
}) {
const adapter = useMemo<NotificationSettingsAdapter>(() => {
const endpoint = "/api/mindbill/notification-settings";
let pending: { payload: string; id: string } | null = null;
async function request(method: string, body?: unknown, requestId?: string) {
const response = await fetch(endpoint, {
method, credentials: "same-origin", cache: "no-store",
headers: {
"Content-Type": "application/json",
...(method === "GET" ? {} : { "X-CSRF-Token": csrfToken }),
...(requestId ? { "X-Notification-Request-Id": requestId } : {}),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
if (!response.ok) {
if (response.status === 409) pending = null; // Requires a NEW checkbox confirmation.
throw new Error("Could not confirm notification settings");
}
return response;
}
async function load(): Promise<NotificationSettingsSnapshot> {
return (await request("GET")).json();
}
return {
load,
save: async update => {
const payload = JSON.stringify(update);
if (pending?.payload !== payload) pending = { payload, id: crypto.randomUUID() };
await request(update.enabled ? "PUT" : "DELETE",
update.enabled ? update : undefined, pending.id);
const snapshot = await load(); // PUT is not a complete settings response.
pending = null;
return snapshot;
},
unsubscribe: async () => { pending = null; await request("DELETE"); return load(); },
};
}, [identityKey, csrfToken]);
return <NotificationSettings identityKey={identityKey} adapter={adapter}
appearance={{ preset: "calm-clinical" }} />;
}Keep the adapter stable with useMemo. Change identityKey when the host user, practice, or environment changes so stale requests cannot replace the next account's settings. This key is only a UI reset key, not authorization. Pass appearance, className, and style to match your product. onSaved(snapshot) can refresh your own settings summary.
The adapter returns NotificationSettingsSnapshot: preferences (null or an object with enabled, statusUpdates, agingDays, quietHours, and reportDigest), a display-only email, server-authorized audience, environment, and canEnable. The widget sends only those preference fields plus a consent boolean. It never supplies identity, verification proof, an audience grant, or bill assignments. All changed enabled settings, including quiet hours and digest cadence, require fresh explicit consent.
Connect your existing authenticated server
This Next.js route is a copyable adapter template. Implement the four application-owned helpers below using your current authentication, permissions, CSRF, and audit facilities; they are not MindBill exports. Until these checks exist, fail closed. Express, FastAPI, and other servers should implement the same contract; do not create a new user database just for this widget.
// app/api/mindbill/notification-settings/route.ts (Next.js App Router)
import "server-only";
import {
requireNotificationAccess,
assertHostCsrf,
recordExplicitNotificationConsent,
syncAuthorizedBillAssignments,
} from "@/server/notification-host-auth"; // YOUR existing auth/access/audit integration.
export const dynamic = "force-dynamic";
const json = (data: unknown, status = 200) => Response.json(data, {
status, headers: { "Cache-Control": "no-store" },
});
function parseUpdate(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value)) throw json({}, 400);
const x = value as Record<string, unknown>;
const fields = ["enabled", "statusUpdates", "agingDays", "quietHours", "reportDigest", "consent"];
const reportDigest = x.reportDigest === undefined ? "off" : x.reportDigest;
if (Object.keys(x).some(key => !fields.includes(key)) || x.enabled !== true
|| typeof x.statusUpdates !== "boolean" || typeof x.quietHours !== "boolean"
|| x.consent !== true || !Array.isArray(x.agingDays)
|| x.agingDays.some(day => ![30, 60, 90].includes(day))
|| !["off", "daily", "weekly"].includes(reportDigest as string)
|| (!x.statusUpdates && x.agingDays.length === 0 && reportDigest === "off")) throw json({}, 400);
const days = x.agingDays as number[];
return { enabled: true as const, statusUpdates: x.statusUpdates,
agingDays: [30, 60, 90].filter(day => days.includes(day)),
quietHours: x.quietHours, reportDigest };
}
async function handle(request: Request) {
try {
// Resolve identity, active membership, managed organization, authorized audience,
// verified email + verification time, environment and its server key from YOUR auth.
// No external user ID, org ID, address, audience, timestamps, or bill IDs from JSON.
const access = await requireNotificationAccess(request);
if (request.method !== "GET") {
if (request.headers.get("origin") !== process.env.APP_ORIGIN) throw json({}, 403);
await assertHostCsrf(request, access); // Verify token against this host session.
}
const url = "https://app.mindbill.org/partner/v2/notifications/recipients/"
+ encodeURIComponent(access.externalUserId);
async function upstream(method: string, body?: unknown) {
const response = await fetch(url, {
method, cache: "no-store", signal: AbortSignal.timeout(15000),
headers: { Authorization: "Bearer " + access.serverApiKey,
"X-MindBill-Org-Id": access.orgId, "Content-Type": "application/json" },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
// Do not relay administrative response bodies or log addresses/credentials.
if (!response.ok) throw json({ error: response.status === 409
? "fresh_consent_required" : "notification_request_failed" }, response.status);
return (await response.json()).data;
}
if (request.method === "DELETE") {
await upstream("DELETE"); // Opt-out does not require fresh consent/verification.
return json({ ok: true });
}
if (request.method === "PUT") {
const update = parseUpdate(await request.json());
if (!access.emailVerifiedAt || !access.canEnable) throw json({}, 403);
const state = await upstream("GET");
if (!state.available) throw json({ error: "notifications_unavailable" }, 503);
// Persist this REAL explicit user confirmation in your existing audit store.
// Bind it to identity/email/audience/settings; retain its receipt for exact retries.
// Never fabricate verification or replace a stale receipt with the current time.
const requestId = request.headers.get("X-Notification-Request-Id");
if (!requestId || !/^[0-9a-f-]{36}$/.test(requestId)) throw json({}, 400);
const consent = await recordExplicitNotificationConsent(access, update, requestId);
await upstream("PUT", { ...update, email: access.email, audience: access.audience,
consent: { grantedAt: consent.grantedAt, version: consent.version,
emailVerifiedAt: access.emailVerifiedAt } });
// Reads authoritative host bill permissions, not browser choices; see below.
if (access.audience === "assigned_bills") await syncAuthorizedBillAssignments(access);
return json({ ok: true });
}
const state = await upstream("GET");
const p = state.recipient;
return json({ email: access.email, audience: access.audience,
environment: access.environment,
canEnable: state.available && access.canEnable && !!access.emailVerifiedAt,
preferences: p ? { enabled: p.enabled, statusUpdates: p.statusUpdates,
agingDays: p.agingDays, quietHours: p.quietHours, reportDigest: p.reportDigest ?? "off" } : null });
} catch (error) {
return error instanceof Response ? error : json({ error: "notification_settings_failed" }, 500);
}
}
export const GET = handle;
export const PUT = handle;
export const DELETE = handle;requireNotificationAccess(request)must authenticate the host session and resolve a stable opaque externalUserId, active managed orgId, authorized audience, current email, actual emailVerifiedAt, environment, canEnable, and that environment's serverApiKey. Practice-wide access requires permission to every bill. Never derive these from unchecked JSON, URL parameters, or a caller-selected organization header. Fail unauthorized requests with a safe 401/403 response.assertHostCsrf(request, access)must validate your session-bound CSRF token for PUT/DELETE, in addition to matching the configured exactAPP_ORIGIN. Do not treat the example header name alone as protection.recordExplicitNotificationConsent(access, update, requestId)must persist the actual confirmation and consent wording/version, bound to this authenticated user, practice, environment, verified address, authorized audience, and exact preferences. Return its server-recorded grantedAt/version. Atomically reuse the receipt for an identical request ID; reject reuse with different identity or preferences. The request ID deduplicates retries, but is not proof of identity or consent. Do not mint fresh consent while retrying a job. After a 409, request a new user confirmation. Verification must precede consent; changed addresses need fresh verification. Existing audit storage is fine.syncAuthorizedBillAssignments(access)must reconcile the GET response's assignedBillIds against your trusted host bill-access records: DELETE removed assignments first, then PUT authorized missing assignments. Never take that list from this widget. Preserve unchanged assignments and their original start times. Retry failed sync durably and do not claim an empty or partially synced audience is fully configured.
Keep assignments synchronized when case permissions change and after creating a new authorized bill, not only when saving preferences. Revoke with DELETE when membership ends or the verified email or authorized audience changes; then obtain the appropriate new verification/consent before re-enrollment. A GET settings read must not grant access or silently renew consent. Also revoke stale deliveries when host access changes—the upstream service cannot discover those changes for you.
The server key needs orgs:write and stays in your secret store. Do not use a browser submission session here. Return only the UI projection, not administrative assignment IDs, consent records, or credentials. Use no-store responses and sanitized errors; do not log email addresses or request/response bodies.
Enroll from your trusted server
Use GET, PUT, and DELETE /partner/v2/notifications/recipients/{externalUserId} with a server API key granting orgs:write and X-MindBill-Org-Id selecting a partner-managed organization. Browser sessions are not accepted. IDs and preferences are scoped by partner, organization, and sandbox/live environment.
The external user ID must be stable and opaque: 1–128 letters, digits, underscores or hyphens. Do not use an email address, patient name, or other sensitive identifier. GET returns feature availability and preferences/eligibility; absent preferences are null.
Before PUT, your server must authenticate the user, resolve their authorized audience, verify ownership of their email address, and record explicit consent for billing alerts. Do not trust a request-body email, organization, or verification timestamp. New consent must be within 24 hours, not in the future, and after verification.
# Your authenticated SERVER calls this, never the browser.
PUT https://app.mindbill.org/partner/v2/notifications/recipients/doctor_42
Authorization: Bearer <server-only-partner-key-with-orgs:write>
X-MindBill-Org-Id: <server-resolved-managed-organization>
Content-Type: application/json
{
"enabled": true,
"email": "doctor@example.test",
"audience": "assigned_bills",
"statusUpdates": true,
"agingDays": [30, 60, 90],
"quietHours": true,
"reportDigest": "off",
"consent": {
"grantedAt": "<actual-server-recorded-ISO-consent-time>",
"emailVerifiedAt": "<actual-server-recorded-ISO-verification-time>",
"version": "billing-alerts-v1"
}
}For assigned_bills, call PUT /partner/v2/notifications/recipients/{externalUserId}/bills/{billId} from the same trusted server to associate a bill. Call DELETE on that association when access ends. The bill must belong to the selected partner, organization, and environment. Recipient GET includes assigned bill IDs so you can reconcile host permissions. Adding an association does not enroll a recipient or bypass verified consent.
Preserve consent and make stopping easy
Identical retries preserve the original consent boundary, including after 24 hours. Any changed enabled PUT—including email, audience, categories, quiet hours or digest cadence—needs fresh explicit consent; changing email also needs fresh verification. Invalid updates leave the previous preference unchanged.
DELETE revokes the subscription and cancels pending delivery. A consent tombstone prevents old PUT retries from silently re-enrolling someone. Call DELETE when the user loses access or leaves your app: MindBill cannot independently observe partner-only account deactivation.
Bill-specific access is checked when work is queued and again before delivery. Removing an association suppresses pending notifications for that bill. Your server must keep these assignments current when case access changes; billing components do not grant email access.
An empty assignment list sends nothing. Associate existing authorized bills after opt-in and new bills as part of your trusted creation workflow. Repeating an association PUT preserves its start time. Status events and aging milestones before assignment are not replayed; reassigning a bill does not backfill the removed-access period. Changing audience clears prior bill assignments and pending mail.
Every external notification includes an account-free unsubscribe link. Opening the link is read-only; the recipient confirms with a button, so email scanners do not unsubscribe users. Delivery already in flight may still arrive.
Verify without sending real customer mail
Sandbox creates preview ledger entries and never sends email. Test explicit opt-in, duplicate requests, email changes, revocation, stale retries, cross-tenant denial, and unavailable-feature handling before enabling live delivery. MindBill does not backfill consent or automatically enroll your users.
Notification ownership (choose deliberately):
Notifications default OFF for every integration.
Admin recipient list (React >=0.52.0, including digests): add NotificationRecipientsSettings (alias
ConnectedNotificationRecipientsSettings). Any authorized email can be invited; no
console or host account is needed. Use memoized { load, invite, disable } with an
identityKey that changes for administrator/practice/environment. The adapter calls
your authenticated admin server, not the Partner API from a browser. Protect CSRF.
GET /partner/v2/notifications/recipients?offset=0 lists recipient states.
POST /partner/v2/notifications/recipients/{externalUserId}/invitations accepts only
requestId, email, audience, statusUpdates, agingDays, quietHours, reportDigest. Validate/authorize
these fields and preserve the same UUID requestId on unchanged/uncertain retries.
Resolve a stable opaque recipient ID and organization/environment from server state.
Return upstream data, not its envelope. Strip private sandbox previewUrl unless you
are implementing a purpose-built protected preview. Never log capability URLs.
Defaults: assigned_bills, no categories, reportDigest off, quiet hours 7 pm-7 am Pacific.
The email owner reviews scope/categories and confirms a 48-hour invitation; opening
the link alone does nothing. Pending invitations remain OFF. An administrator cannot
consent for somebody else. Unsubscribe/disable invalidates old invitation links.
Invitation deliveryStatus is not enrollment: even sent is only transport acknowledgment.
Do not automatically resend unknown outcomes or loop on rate limits. Changed input
with the same UUID is a conflict. Sandbox sends no invitations or alerts.
Supports status/payment and 30/60/90-day aging alerts, plus opt-in billing-activity
digests: reportDigest is off | daily | weekly. Scheduled at 9 am Pacific (Monday
for weekly), skipping empty periods. Counts only: no patient details, attachments,
financial balances or confirmed cash totals. Changing cadence needs fresh consent.
Forward reportDigest through your host adapter and include it in preference snapshots.
Practice scope includes future bills; assigned_bills
requires trusted server assignment sync (below). A new invitation disables the old
subscription until reconfirmed; email/scope changes require assignment resync.
Personal preferences: add NotificationSettings (ConnectedNotificationSettings alias) to your existing
settings page. Use a memoized host-server adapter { load, save, unsubscribe } and an
identityKey that changes with user/practice/environment. No admin key in the browser.
The widget submits preference fields + explicit consent only; identity/email/audience,
verification timestamps and bill IDs are server-owned. Protect mutations with CSRF.
GET -> PUT/DELETE -> reload GET; mutation acknowledgments are not settings snapshots.
Any changed enabled settings require fresh consent (including quiet hours). Persist
the real consent receipt and reuse it for identical retries; do not renew consent in jobs.
Copyable React and trusted server templates: https://docs.mindbill.org/guides/notifications
1. MindBill can send alerts to users without console accounts, including doctors
authorized for only specific bills. Your SERVER supplies the trusted assignments.
Choose audience: "assigned_bills" for those doctors, "practice" only for users
authorized to receive updates about every bill in the managed practice.
GET/PUT/DELETE /partner/v2/notifications/recipients/{externalUserId}
Server API key with orgs:write + X-MindBill-Org-Id for a partner-managed practice.
Check GET availability first. This feature requires MindBill rollout/activation;
do not claim success while disabled. Browser session tokens cannot enroll users.
Authenticate the host user, verify their email and the requested access scope,
and collect explicit opt-in before PUT. Use real server-recorded consent and
verification timestamps, never Date.now() as a substitute for proof of consent.
For assigned_bills, PUT/DELETE the server-only association at
/partner/v2/notifications/recipients/{externalUserId}/bills/{billId}.
Resolve bill/user access from existing host permissions, not browser input or NPI.
Assignment is not consent; enrollment is still required. Remove associations when
access ends and reconcile them when your host bill/user assignment changes.
Associate existing bills after opt-in and new bills during trusted creation.
Empty assignments send nothing; earlier events/milestones are not replayed.
Revoke via recipient DELETE when membership ends. Email/contact suggestions are
NOT notification consent. Never automatically enroll courtesy-copy recipients.
https://docs.mindbill.org/guides/notifications
2. For custom content or delivery, signed webhooks remain optional: verify raw-body
signatures, deduplicate event IDs, handle retries/out-of-order delivery and reconcile
current bill state. Never broadcast all practice events to a case-only user.
3. Keep notification messages PHI-free and route users through authenticated screens.
Test sandbox consent, unsubscribe, stale retries, tenant isolation and disabled gates.
Rendering billing components does not automatically turn notification delivery on.