Tutorials
Agency dashboards
How to build a multi-creator dashboard on top of the FrontRow API. This walks through the auth model agencies should use, how to sync creator data into your own database, and the queries that drive the most common dashboard widgets — revenue rollups, subscriber segments, and broadcast scheduling.
Example repo
Auth model
FrontRow API keys are issued per creator. Each creator on your roster generates a scoped key and hands it to the agency. Your dashboard stores those keys encrypted at rest and uses the right one per request. Keys can be rotated or revoked by the creator at any time without involving your support team.
Code samples below use Node.js / TypeScript. The patterns translate directly to any HTTP client.
01
Roster & key storage
Model each creator as a row in your database with the FrontRow API key stored encrypted (KMS, libsodium sealed-box, or your platform’s secrets manager). Never store keys in plaintext and never ship them to the browser — every FrontRow call must originate from a server you control.
| 1 | interface Creator { |
| 2 | id: string; // your internal id |
| 3 | handle: string; // FrontRow handle |
| 4 | apiKey: string; // encrypted at rest, decrypted only in memory |
| 5 | status: "active" | "paused" | "removed"; |
| 6 | addedAt: string; |
| 7 | } |
| 8 | |
| 9 | export async function listRoster(): Promise<Creator[]> { |
| 10 | return db.creator.findMany({ where: { status: "active" } }); |
| 11 | } |
| 12 | |
| 13 | export function frontrow(creator: Creator) { |
| 14 | return { |
| 15 | headers: { |
| 16 | Authorization: `Bearer ${decrypt(creator.apiKey)}`, |
| 17 | "Content-Type": "application/json", |
| 18 | }, |
| 19 | }; |
| 20 | } |
02
Aggregate analytics across creators
For any rollup — total revenue, total subscribers, active chats — iterate the roster, fan out per-creator requests in parallel, and combine the results. Cap concurrency so a 200-creator agency doesn’t accidentally trip the per-key rate limits or your egress.
| 1 | import pLimit from "p-limit"; |
| 2 | |
| 3 | const API_BASE = "https://frontrow.center/api/v1"; |
| 4 | const limit = pLimit(8); // up to 8 concurrent creators |
| 5 | |
| 6 | export async function rollupAnalytics(roster: Creator[]) { |
| 7 | const results = await Promise.all( |
| 8 | roster.map((creator) => |
| 9 | limit(async () => { |
| 10 | const res = await fetch(`${API_BASE}/analytics`, frontrow(creator)); |
| 11 | if (!res.ok) { |
| 12 | return { creator, error: res.status as number }; |
| 13 | } |
| 14 | return { creator, data: await res.json() }; |
| 15 | }), |
| 16 | ), |
| 17 | ); |
| 18 | |
| 19 | return { |
| 20 | activeSubscribers: sum(results, (r) => r.data?.activeSubscribers ?? 0), |
| 21 | totalLikes: sum(results, (r) => r.data?.totalLikes ?? 0), |
| 22 | totalPosts: sum(results, (r) => r.data?.totalPosts ?? 0), |
| 23 | failedCreators: results.filter((r) => "error" in r), |
| 24 | }; |
| 25 | } |
| 26 | |
| 27 | function sum<T>(arr: T[], pick: (t: T) => number): number { |
| 28 | return arr.reduce((acc, t) => acc + pick(t), 0); |
| 29 | } |
03
Sync subscribers and segment
Pull each creator’s subscriber list and persist a normalized copy in your own database. With local data you can run arbitrary SQL for segments — new this week, expiring soon, top spenders — without burning rate limits on every dashboard render. Re-sync on a schedule and on relevant webhooks.
| 1 | export async function syncSubscribers(creator: Creator) { |
| 2 | const res = await fetch( |
| 3 | `${API_BASE}/subscribers`, |
| 4 | frontrow(creator), |
| 5 | ); |
| 6 | if (!res.ok) throw new Error(`subscribers ${res.status}`); |
| 7 | const { subscribers } = (await res.json()) as { subscribers: any[] }; |
| 8 | |
| 9 | await db.$transaction([ |
| 10 | db.subscription.deleteMany({ where: { creatorId: creator.id } }), |
| 11 | db.subscription.createMany({ |
| 12 | data: subscribers.map((s) => ({ |
| 13 | creatorId: creator.id, |
| 14 | fanId: s.id, |
| 15 | handle: s.handle, |
| 16 | priceCents: s.priceInCents, |
| 17 | subscribedAt: new Date(s.subscribedAt), |
| 18 | currentPeriodEnd: new Date(s.currentPeriodEnd), |
| 19 | })), |
| 20 | }), |
| 21 | ]); |
| 22 | } |
| 23 | |
| 24 | // Example segment query (Prisma) |
| 25 | export function expiringSoon(creatorId: string) { |
| 26 | const cutoff = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000); |
| 27 | return db.subscription.findMany({ |
| 28 | where: { |
| 29 | creatorId, |
| 30 | currentPeriodEnd: { lte: cutoff }, |
| 31 | }, |
| 32 | orderBy: { currentPeriodEnd: "asc" }, |
| 33 | }); |
| 34 | } |
Hook your sync to subscriber.new and subscriber.cancelled webhooks (see Webhooks) to keep the local copy fresh between full syncs.
04
Schedule broadcasts on behalf of a creator
Most agency dashboards offer some form of scheduled outreach — pick a creator, pick a smart list, draft a DM, send now or later. Persist the schedule in your own queue (BullMQ, Inngest, SQS, Cloud Tasks) and have the worker call FrontRow’s mass-message endpoint when the time arrives.
| 1 | interface ScheduledBroadcast { |
| 2 | id: string; |
| 3 | creatorId: string; |
| 4 | text: string; |
| 5 | includeLists: { type: "smart" | "custom"; id: string }[]; |
| 6 | excludeLists?: { type: "smart" | "custom"; id: string }[]; |
| 7 | scheduledFor: Date; |
| 8 | } |
| 9 | |
| 10 | export async function runBroadcast(b: ScheduledBroadcast) { |
| 11 | const creator = await db.creator.findUniqueOrThrow({ |
| 12 | where: { id: b.creatorId }, |
| 13 | }); |
| 14 | |
| 15 | const res = await fetch( |
| 16 | `${API_BASE}/chats/mass-messages`, |
| 17 | { |
| 18 | method: "POST", |
| 19 | ...frontrow(creator), |
| 20 | body: JSON.stringify({ |
| 21 | text: b.text, |
| 22 | includeLists: b.includeLists, |
| 23 | excludeLists: b.excludeLists, |
| 24 | }), |
| 25 | }, |
| 26 | ); |
| 27 | |
| 28 | if (res.status === 429) { |
| 29 | // Honor Retry-After and re-queue |
| 30 | const retryAfter = Number(res.headers.get("Retry-After") ?? 60); |
| 31 | await queue.delay(b, retryAfter * 1000); |
| 32 | return; |
| 33 | } |
| 34 | |
| 35 | if (!res.ok) { |
| 36 | throw new Error(`broadcast ${res.status}: ${await res.text()}`); |
| 37 | } |
| 38 | |
| 39 | const { id, recipientCount } = await res.json(); |
| 40 | await db.broadcast.update({ |
| 41 | where: { id: b.id }, |
| 42 | data: { sentAt: new Date(), apiId: id, recipientCount }, |
| 43 | }); |
| 44 | } |
See Sending mass messages for the full broadcast endpoint, smart-list IDs, and PPV mechanics.
Production checklist
- Encrypt keys at rest. Use a managed KMS or libsodium sealed-box. Decrypt only in memory at the moment of use; never log the plaintext.
- Cap concurrency. A worker that fans out across the whole roster can knock you offline. Eight concurrent creators is a sensible starting point.
- Honor 429s. Read
Retry-Afterand back off. A single chatty creator can block the rest of the roster if you retry tightly. - Audit log every action. Persist who-did-what (which staff member, which creator, what endpoint) so a creator can review activity at any time. Trust is the entire product.
- Webhook-driven freshness. Long-poll syncs are wasteful and lag behind reality. Subscribe to
subscriber.*,tip.received, andpurchase.receivedfor live counters. - Per-creator role boundaries. A staff member who only handles three creators should not see the others. Enforce row-level filters in your queries.