Specifications
RankFlow AI — Backend API Documentation
// src/server/api/trpc.ts
docs/specs/backend-api.mdOn this page
- 1. tRPC Base Setup
- Context Builder
- tRPC Instance
- Procedure Types
- 2. Auth Router
- Schemas
- 3. Practice Router
- Schemas
- 4. Location Router
- Schemas
- 5. GBP Router
- Schemas
- 6. Social Router
- Schemas
- 7. Content Router
- Schemas
- 8. Citation Router
- Schemas
- 9. Directory Profile Router
- Schemas
- DirectoryProfile Model (Response)
- 10. Approval Router
- Schemas
- ApprovalRequest Model
- 11. City & Specialty Router
- Schemas
- City Model
- 12. Profile Router
- Schemas
- 13. Report Router
- Schemas
- 14. Lead Router
- Schemas
- 15. Billing Router
- Schemas
- 16. Skill Router
- Schemas
- ExecutionResult
- 17. Admin Router
- Schemas
- 18. Middleware & Procedures
- Middleware Stack
- Rate Limiting
- Request ID Injection
- 19. Error Handling
- tRPC Error Codes
- Zod Error Format
- Migration Notes (Landing Page → Directory Profile)
Version: 1.0.0
Stack: Next.js 14 + tRPC + Prisma + Zod
Base URL: https://rankflow.ai/api/trpc
Auth: Better Auth (OAuth + Credentials)
1. tRPC Base Setup#
Context Builder#
// src/server/api/trpc.ts
export const createTRPCContext = async (opts: { headers: Headers }) => {
const session = await getServerSession();
return {
db, // Prisma client
redis, // Redis client
session, // Better Auth session
...opts,
};
};
tRPC Instance#
const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
Procedure Types#
| Procedure | Auth Required | Practice Context | Role | Use For |
|---|---|---|---|---|
publicProcedure |
❌ | ❌ | Any | Health, webhooks |
protectedProcedure |
✅ | ❌ | Any | User-scoped reads |
practiceProcedure |
✅ | ✅ | CLIENT+ | Practice-scoped ops |
adminProcedure |
✅ | ❌ | ADMIN only | System-wide ops |
2. Auth Router#
File: src/server/api/routers/auth.ts
| Endpoint | Type | Input | Output | Auth |
|---|---|---|---|---|
auth.me |
query | — | User |
protected |
auth.updateProfile |
mutation | { name?, image? } |
User |
protected |
auth.changePassword |
mutation | { currentPassword, newPassword } |
{ success } |
protected |
auth.listSessions |
query | — | Session[] |
protected |
auth.revokeSession |
mutation | { sessionToken } |
{ success } |
protected |
Schemas#
const updateProfileSchema = z.object({
name: z.string().min(1).max(100).optional(),
image: z.string().url().optional(),
});
const changePasswordSchema = z.object({
currentPassword: z.string().min(8),
newPassword: z.string().min(8).max(100),
});
3. Practice Router#
File: src/server/api/routers/practice.ts
| Endpoint | Type | Input | Output | Auth |
|---|---|---|---|---|
practice.create |
mutation | CreatePracticeInput |
Practice |
protected |
practice.get |
query | { id } |
Practice |
practiceProcedure |
practice.update |
mutation | { id, ...fields } |
Practice |
practiceProcedure |
practice.delete |
mutation | { id } |
{ success } |
practiceProcedure (owner) |
practice.list |
query | — | Practice[] |
protected |
practice.inviteMember |
mutation | { email, role } |
{ inviteUrl } |
practiceProcedure (owner) |
practice.removeMember |
mutation | { userId } |
{ success } |
practiceProcedure (owner) |
practice.updateMemberRole |
mutation | { userId, role } |
PracticeMember |
practiceProcedure (owner) |
Schemas#
const createPracticeSchema = z.object({
name: z.string().min(2).max(100),
type: z.enum(["CLINIC", "HOSPITAL", "DIAGNOSTIC_CENTER", "DENTAL_CLINIC",
"PHYSIOTHERAPY", "AYURVEDIC_CENTER", "HOMEOPATHY_CLINIC",
"CA", "LAWYER", "WEDDING_PHOTOGRAPHER"]),
slug: z.string().regex(/^[a-z0-9-]+$/).optional(),
});
const updatePracticeSchema = z.object({
id: z.string(),
name: z.string().min(2).max(100).optional(),
logoUrl: z.string().url().optional(),
primaryColor: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(),
settings: z.record(z.any()).optional(),
});
4. Location Router#
File: src/server/api/routers/location.ts
| Endpoint | Type | Input | Output | Auth |
|---|---|---|---|---|
location.create |
mutation | CreateLocationInput |
Location |
practiceProcedure |
location.get |
query | { id } |
Location |
practiceProcedure |
location.update |
mutation | { id, ...fields } |
Location |
practiceProcedure |
location.delete |
mutation | { id } |
{ success } |
practiceProcedure |
location.list |
query | — | Location[] |
practiceProcedure |
location.setPrimary |
mutation | { id } |
Location |
practiceProcedure |
Schemas#
const createLocationSchema = z.object({
name: z.string().min(1).max(100),
businessName: z.string().min(1).max(100),
address: z.string().min(5).max(500),
city: z.string().min(1).max(100),
state: z.string().min(1).max(100),
postalCode: z.string().min(4).max(10),
country: z.string().default("IN"),
phone: z.string().min(10).max(15),
phoneSecondary: z.string().optional(),
email: z.string().email().optional(),
website: z.string().url().optional(),
category: z.string().default("Medical Clinic"),
services: z.array(z.string()).default([]),
businessHours: z.record(z.any()).default({}),
targetKeywords: z.array(z.string()).default([]),
serviceAreas: z.array(z.string()).default([]),
languages: z.array(z.string()).default(["English", "Hindi"]),
latitude: z.number().optional(),
longitude: z.number().optional(),
});
5. GBP Router#
File: src/server/api/routers/gbp.ts
| Endpoint | Type | Input | Output | Auth |
|---|---|---|---|---|
gbp.getAuthUrl |
query | — | { url } |
practiceProcedure |
gbp.listAccounts |
query | — | GbpAccount[] |
practiceProcedure |
gbp.listLocations |
query | — | GbpLocation[] |
practiceProcedure |
gbp.createPost |
mutation | CreateGbpPostInput |
GbpPost |
practiceProcedure |
gbp.listPosts |
query | { locationId?, status? } |
GbpPost[] |
practiceProcedure |
gbp.deletePost |
mutation | { postId } |
{ success } |
practiceProcedure |
gbp.listReviews |
query | { locationId?, status? } |
Review[] |
practiceProcedure |
gbp.replyToReview |
mutation | { reviewId, replyText } |
Review |
practiceProcedure |
gbp.getInsights |
query | { locationId, dateFrom?, dateTo? } |
GbpInsight[] |
practiceProcedure |
gbp.listQA |
query | { locationId } |
GbpQA[] |
practiceProcedure |
gbp.answerQuestion |
mutation | { qaId, answerText } |
GbpQA |
practiceProcedure |
gbp.uploadPhoto |
mutation | { locationId, url, category } |
GbpPhoto |
practiceProcedure |
Schemas#
const createGbpPostSchema = z.object({
practiceId: z.string(),
locationId: z.string(),
content: z.string().max(1500).optional(),
mediaUrls: z.array(z.string().url()).max(10).optional(),
ctaType: z.enum(["BOOK", "CALL", "LEARN_MORE", "SIGN_UP", "ORDER"]).optional(),
topicType: z.enum(["STANDARD", "OFFER", "EVENT"]).default("STANDARD"),
scheduledFor: z.string().datetime().optional(),
});
const replyToReviewSchema = z.object({
reviewId: z.string(),
replyText: z.string().min(1).max(2000),
generateAI: z.boolean().default(false),
});
6. Social Router#
File: src/server/api/routers/social.ts
| Endpoint | Type | Input | Output | Auth |
|---|---|---|---|---|
social.getAuthUrl |
query | { platform } |
{ url } |
practiceProcedure |
social.listAccounts |
query | — | SocialAccount[] |
practiceProcedure |
social.disconnect |
mutation | { accountId } |
{ success } |
practiceProcedure |
social.createPost |
mutation | CreateSocialPostInput |
SocialPost |
practiceProcedure |
social.listPosts |
query | { accountId?, status? } |
SocialPost[] |
practiceProcedure |
social.deletePost |
mutation | { postId } |
{ success } |
practiceProcedure |
social.schedulePosts |
mutation | { posts[] } |
{ scheduledIds[] } |
practiceProcedure |
Schemas#
const createSocialPostSchema = z.object({
socialAccountId: z.string(),
content: z.string().min(1).max(5000),
mediaUrls: z.array(z.string().url()).max(10).optional(),
mediaType: z.enum(["NONE", "IMAGE", "VIDEO", "CAROUSEL", "REEL"]).default("NONE"),
hashtags: z.array(z.string()).default([]),
linkUrl: z.string().url().optional(),
scheduledFor: z.string().datetime().optional(),
});
const schedulePostsSchema = z.object({
posts: z.array(z.object({
platform: z.enum(["FACEBOOK", "INSTAGRAM", "LINKEDIN", "TWITTER"]),
content: z.string(),
mediaUrls: z.array(z.string().url()).optional(),
scheduledFor: z.string().datetime(),
})),
});
7. Content Router#
File: src/server/api/routers/content.ts
| Endpoint | Type | Input | Output | Auth |
|---|---|---|---|---|
content.generate |
mutation | { taskType, variables } |
ContentPiece |
practiceProcedure |
content.list |
query | { status?, type? } |
ContentPiece[] |
practiceProcedure |
content.get |
query | { id } |
ContentPiece |
practiceProcedure |
content.update |
mutation | { id, content?, title? } |
ContentPiece |
practiceProcedure |
content.approve |
mutation | { id } |
ContentPiece |
practiceProcedure |
content.reject |
mutation | { id, reason? } |
ContentPiece |
practiceProcedure |
content.delete |
mutation | { id } |
{ success } |
practiceProcedure |
Schemas#
const generateContentSchema = z.object({
taskType: z.enum([
"GBP_POST", "GBP_REPLY", "SOCIAL_POST", "PROFILE_CONTENT",
"FAQ", "SCHEMA_MARKUP", "META_DESCRIPTION", "CITATION_DESCRIPTION",
"REVIEW_TEMPLATE", "BLOG_POST"
]),
locationId: z.string().optional(),
variables: z.record(z.any()).default({}),
model: z.enum(["claude-sonnet", "claude-haiku", "gpt-4o", "gpt-4o-mini"]).optional(),
});
const updateContentSchema = z.object({
id: z.string(),
title: z.string().optional(),
content: z.string().optional(),
seoTitle: z.string().optional(),
seoDescription: z.string().optional(),
focusKeywords: z.array(z.string()).optional(),
});
8. Citation Router#
File: src/server/api/routers/citation.ts
| Endpoint | Type | Input | Output | Auth |
|---|---|---|---|---|
citation.listDirectories |
query | — | CitationDirectory[] |
practiceProcedure |
citation.list |
query | { locationId } |
Citation[] |
practiceProcedure |
citation.submit |
mutation | { locationId, directoryNames[] } |
{ jobId } |
practiceProcedure |
citation.verifyNap |
mutation | { citationId } |
{ jobId } |
practiceProcedure |
citation.delete |
mutation | { citationId } |
{ jobId } |
practiceProcedure |
citation.getSnapshot |
query | { citationId } |
Citation |
practiceProcedure |
Schemas#
const submitCitationsSchema = z.object({
locationId: z.string(),
directoryNames: z.array(z.string()).min(1).max(50),
});
const citationActionSchema = z.object({
citationId: z.string(),
});
9. Directory Profile Router#
File: src/server/api/routers/directoryProfile.ts
Replaces the old Site Router (individual landing pages on subdomains). Each practice now gets a rich profile page on the unified directory website (/clinics/{city-slug}/{profile-slug}). No custom domains, no subdomains, no site editor.
| Endpoint | Type | Input | Output | Auth |
|---|---|---|---|---|
directoryProfile.create |
mutation | CreateDirectoryProfileInput |
DirectoryProfile |
practiceProcedure |
directoryProfile.publish |
mutation | { profileId } |
{ url } |
practiceProcedure |
directoryProfile.unpublish |
mutation | { profileId } |
{ success } |
practiceProcedure |
directoryProfile.update |
mutation | UpdateDirectoryProfileInput |
DirectoryProfile |
practiceProcedure |
directoryProfile.delete |
mutation | { profileId } |
{ success } |
practiceProcedure |
directoryProfile.getByPractice |
query | { practiceId } |
DirectoryProfile |
practiceProcedure |
directoryProfile.list |
query | ListDirectoryProfilesInput |
DirectoryProfile[] |
practiceProcedure |
directoryProfile.revalidate |
mutation | { profileId } |
{ revalidated } |
practiceProcedure |
Schemas#
const createDirectoryProfileSchema = z.object({
practiceId: z.string().uuid(),
citySlug: z.string(),
slug: z.string(),
content: z.object({
title: z.string().max(70),
metaDescription: z.string().max(160),
bio: z.string().min(300).max(10000),
services: z.array(z.object({
name: z.string(),
description: z.string(),
})),
faqs: z.array(z.object({
question: z.string(),
answer: z.string(),
})),
}),
media: z.object({
photoUrl: z.string().url().optional(),
logoUrl: z.string().url().optional(),
galleryUrls: z.array(z.string().url()).optional(),
}),
contact: z.object({
phone: z.string(),
whatsapp: z.string().optional(),
email: z.string().email().optional(),
address: z.string(),
hours: z.record(z.string()),
}),
keywords: z.array(z.string()),
});
const updateDirectoryProfileSchema = z.object({
profileId: z.string().uuid(),
content: z.object({
bio: z.string().optional(),
services: z.array(z.object({
name: z.string(),
description: z.string(),
})).optional(),
faqs: z.array(z.object({
question: z.string(),
answer: z.string(),
})).optional(),
}).partial(),
});
const publishDirectoryProfileSchema = z.object({
profileId: z.string().uuid(),
});
const unpublishDirectoryProfileSchema = z.object({
profileId: z.string().uuid(),
});
const getByPracticeSchema = z.object({
practiceId: z.string().uuid(),
});
const listDirectoryProfilesSchema = z.object({
status: z.enum(["DRAFT", "PENDING_REVIEW", "PUBLISHED", "PAUSED", "ARCHIVED"]).optional(),
citySlug: z.string().optional(),
specialty: z.string().optional(),
limit: z.number().default(50),
offset: z.number().default(0),
});
const revalidateDirectoryProfileSchema = z.object({
profileId: z.string().uuid(),
});
DirectoryProfile Model (Response)#
interface DirectoryProfile {
id: string;
practiceId: string;
status: "DRAFT" | "PENDING_REVIEW" | "PUBLISHED" | "PAUSED" | "ARCHIVED";
citySlug: string;
slug: string;
profileUrl: string; // e.g. /clinics/kochi/dr-smith-dental
title: string;
metaDescription: string;
bio: string;
services: Service[];
faqs: FAQ[];
reviews: Review[];
photoUrl?: string;
logoUrl?: string;
galleryUrls: string[];
phone: string;
whatsapp?: string;
email?: string;
address: string;
hours: Record<string, string>;
schemaMarkup: JsonObject;
keywords: string[];
canonicalUrl: string;
gbpUrl?: string;
gbpPlaceId?: string;
viewCount: number;
lastViewedAt?: Date;
createdAt: Date;
updatedAt: Date;
publishedAt?: Date;
}
interface Service {
id: string;
name: string;
description: string;
icon?: string;
}
interface FAQ {
id: string;
question: string;
answer: string;
}
10. Approval Router#
File: src/server/api/routers/approval.ts
Doctor approval gate for AI-generated directory profile content. Profiles cannot be published until the doctor reviews and approves (or auto-approves after 48 hours).
| Endpoint | Type | Input | Output | Auth |
|---|---|---|---|---|
approval.get |
query | { practiceId } |
ApprovalRequest |
practiceProcedure |
approval.approve |
mutation | { profileId, editedContent? } |
DirectoryProfile |
practiceProcedure |
approval.reject |
mutation | { profileId, feedback? } |
ApprovalRequest |
practiceProcedure |
approval.edit |
mutation | { profileId, content } |
DirectoryProfile |
practiceProcedure |
Schemas#
const getApprovalSchema = z.object({
practiceId: z.string().uuid(),
});
const approveContentSchema = z.object({
profileId: z.string().uuid(),
editedContent: z.object({
bio: z.string().optional(),
services: z.array(z.object({
name: z.string(),
description: z.string(),
})).optional(),
faqs: z.array(z.object({
question: z.string(),
answer: z.string(),
})).optional(),
}).optional(),
});
const rejectContentSchema = z.object({
profileId: z.string().uuid(),
feedback: z.string().optional(),
});
const editContentSchema = z.object({
profileId: z.string().uuid(),
content: z.object({
bio: z.string().optional(),
services: z.array(z.object({
name: z.string(),
description: z.string(),
})).optional(),
faqs: z.array(z.object({
question: z.string(),
answer: z.string(),
})).optional(),
}),
});
ApprovalRequest Model#
interface ApprovalRequest {
id: string;
profileId: string;
practiceId: string;
status: "PENDING" | "APPROVED" | "REJECTED" | "AUTO_APPROVED";
contentSnapshot: JsonObject;
feedback?: string;
reviewedAt?: Date;
autoApproveAt: Date; // 48 hours from creation
createdAt: Date;
}
11. City & Specialty Router#
File: src/server/api/routers/city.ts
Directory taxonomy endpoints for city pages and specialty listings. Used by the admin dashboard and directory frontend.
| Endpoint | Type | Input | Output | Auth |
|---|---|---|---|---|
city.list |
query | { state?, limit? } |
City[] |
publicProcedure |
city.create |
mutation | CreateCityInput |
City |
adminProcedure |
specialty.list |
query | { category? } |
Specialty[] |
publicProcedure |
Schemas#
const createCitySchema = z.object({
slug: z.string().regex(/^[a-z0-9-]+$/),
name: z.string().min(1).max(100),
state: z.string().min(1).max(100),
description: z.string().optional(),
});
const listCitiesSchema = z.object({
state: z.string().optional(),
limit: z.number().default(100),
});
const listSpecialtiesSchema = z.object({
category: z.enum(["MEDICAL", "DENTAL", "ALTERNATIVE", "LEGAL", "FINANCIAL", "OTHER"]).optional(),
});
City Model#
interface City {
slug: string;
name: string;
state: string;
description?: string;
profileCount: number;
}
interface Specialty {
slug: string;
name: string;
category: string;
description?: string;
profileCount: number;
}
12. Profile Router#
File: src/server/api/routers/profile.ts
Public-facing profile queries for the directory website. These endpoints are used by the Next.js frontend (server components) and external widgets.
| Endpoint | Type | Input | Output | Auth |
|---|---|---|---|---|
profile.getRelated |
query | { profileId, limit? } |
DirectoryProfile[] |
publicProcedure |
Schemas#
const getRelatedProfilesSchema = z.object({
profileId: z.string().uuid(),
limit: z.number().default(6),
});
Notes:
profile.getRelatedreturns 3–6 other profiles in the same city and specialty (excluding the source profile) for internal linking and SEO juice distribution.- Additional public REST endpoints (for external integrations) are documented in
directory-website-architecture.mdSection 6.
13. Report Router#
File: src/server/api/routers/report.ts
| Endpoint | Type | Input | Output | Auth |
|---|---|---|---|---|
report.list |
query | — | Report[] |
practiceProcedure |
report.get |
query | { id } |
Report |
practiceProcedure |
report.generate |
mutation | { periodStart, periodEnd } |
{ jobId } |
practiceProcedure |
report.downloadPdf |
query | { id } |
{ url } |
practiceProcedure |
report.email |
mutation | { id, recipients[] } |
{ success } |
practiceProcedure |
Schemas#
const generateReportSchema = z.object({
periodStart: z.string().datetime(),
periodEnd: z.string().datetime(),
});
const emailReportSchema = z.object({
id: z.string(),
recipients: z.array(z.string().email()).min(1).max(10),
});
14. Lead Router#
File: src/server/api/routers/lead.ts
| Endpoint | Type | Input | Output | Auth |
|---|---|---|---|---|
lead.list |
query | { status? } |
Lead[] |
practiceProcedure |
lead.get |
query | { id } |
Lead |
practiceProcedure |
lead.updateStatus |
mutation | { id, status } |
Lead |
practiceProcedure |
lead.delete |
mutation | { id } |
{ success } |
practiceProcedure |
Schemas#
const updateLeadStatusSchema = z.object({
id: z.string(),
status: z.enum(["NEW", "CONTACTED", "CONVERTED", "LOST"]),
});
15. Billing Router#
File: src/server/api/routers/billing.ts
| Endpoint | Type | Input | Output | Auth |
|---|---|---|---|---|
billing.getPlan |
query | — | { tier, features } |
practiceProcedure |
billing.createSubscription |
mutation | { plan, paymentMethod } |
{ clientSecret } |
practiceProcedure |
billing.cancel |
mutation | — | { success, effectiveDate } |
practiceProcedure |
billing.listInvoices |
query | — | Invoice[] |
practiceProcedure |
billing.getInvoice |
query | { id } |
Invoice |
practiceProcedure |
billing.updatePaymentMethod |
mutation | { paymentMethodId } |
{ success } |
practiceProcedure |
Schemas#
const createSubscriptionSchema = z.object({
plan: z.enum(["STARTER", "STANDARD", "PREMIUM", "ENTERPRISE"]),
paymentMethod: z.enum(["stripe", "razorpay"]),
});
16. Skill Router#
File: src/server/api/routers/skill.ts
| Endpoint | Type | Input | Output | Auth |
|---|---|---|---|---|
skill.list |
query | — | SkillSummary[] |
protected |
skill.get |
query | { id } |
Skill |
protected |
skill.execute |
mutation | { skillId, payload, async? } |
ExecutionResult |
practiceProcedure |
skill.executeAdmin |
mutation | { skillId, practiceId, payload } |
ExecutionResult |
adminProcedure |
skill.getStatus |
query | { jobId } |
ExecutionResult |
protected |
Schemas#
const executeSkillSchema = z.object({
skillId: z.string(),
payload: z.record(z.any()).default({}),
async: z.boolean().default(false),
});
const executeAdminSchema = z.object({
skillId: z.string(),
practiceId: z.string(),
payload: z.record(z.any()).default({}),
});
ExecutionResult#
interface ExecutionResult {
status: "SUCCESS" | "FAILED" | "QUEUED" | "WAITING";
data?: any;
error?: string;
jobId?: string;
durationMs?: number;
costUsd?: number;
}
17. Admin Router#
File: src/server/api/routers/admin.ts
| Endpoint | Type | Input | Output | Auth |
|---|---|---|---|---|
admin.getKPIs |
query | — | AdminKPIs |
adminProcedure |
admin.listClients |
query | { status?, tier? } |
Practice[] |
adminProcedure |
admin.getClient |
query | { id } |
Practice + relations |
adminProcedure |
admin.updateClient |
mutation | { id, ...fields } |
Practice |
adminProcedure |
admin.listJobs |
query | { status?, practiceId? } |
Job[] |
adminProcedure |
admin.retryJob |
mutation | { jobId } |
{ success } |
adminProcedure |
admin.listSocialConnections |
query | — | SocialAccount[] |
adminProcedure |
admin.listContent |
query | { status? } |
ContentPiece[] |
adminProcedure |
admin.approveContent |
mutation | { contentId } |
ContentPiece |
adminProcedure |
admin.updatePrompt |
mutation | { taskType, systemPrompt, userPromptTemplate } |
PromptTemplate |
adminProcedure |
admin.getRevenue |
query | { period } |
RevenueData |
adminProcedure |
admin.getSystemHealth |
query | — | HealthCheck |
adminProcedure |
Schemas#
const updateClientSchema = z.object({
id: z.string(),
status: z.enum(["TRIAL", "ACTIVE", "PAST_DUE", "SUSPENDED", "CANCELLED", "EXPIRED"]).optional(),
tier: z.enum(["STARTER", "STANDARD", "PREMIUM", "ENTERPRISE"]).optional(),
trialEndsAt: z.string().datetime().optional(),
subscriptionEndsAt: z.string().datetime().optional(),
});
const updatePromptSchema = z.object({
taskType: z.string(),
systemPrompt: z.string().max(10000),
userPromptTemplate: z.string().max(20000),
modelConfig: z.object({
provider: z.enum(["anthropic", "openai"]),
model: z.string(),
temperature: z.number().min(0).max(2),
maxTokens: z.number().min(100).max(8000),
}).optional(),
});
18. Middleware & Procedures#
Middleware Stack#
Request → Rate Limit → Request ID → Auth → Practice Resolve → Role Check → Handler
Rate Limiting#
| Route Pattern | Limit | Window |
|---|---|---|
skill.execute |
10 | 1 minute |
content.generate |
5 | 1 minute |
gbp.createPost |
30 | 1 minute |
social.createPost |
30 | 1 minute |
directoryProfile.create |
5 | 1 minute |
directoryProfile.update |
10 | 1 minute |
directoryProfile.revalidate |
20 | 1 minute |
| All other | 100 | 1 minute |
Request ID Injection#
Every request gets x-request-id header. Propagated to:
- Audit logs
- Job records
- External API calls (as
X-RankFlow-Request-ID)
19. Error Handling#
tRPC Error Codes#
| Code | HTTP | When |
|---|---|---|
BAD_REQUEST |
400 | Invalid input, missing practice ID |
UNAUTHORIZED |
401 | No session, expired token |
FORBIDDEN |
403 | Wrong role, not practice member |
NOT_FOUND |
404 | Resource doesn't exist |
CONFLICT |
409 | Duplicate slug, already exists |
TOO_MANY_REQUESTS |
429 | Rate limit exceeded |
INTERNAL_SERVER_ERROR |
500 | Unhandled exception |
Zod Error Format#
{
"message": "Invalid input",
"code": "BAD_REQUEST",
"zodError": {
"fieldErrors": {
"email": ["Invalid email address"],
"phone": ["Must be at least 10 characters"]
},
"formErrors": []
}
}
Migration Notes (Landing Page → Directory Profile)#
| Old (Removed) | New (Replaced) |
|---|---|
site.create |
directoryProfile.create |
site.publish |
directoryProfile.publish |
site.update |
directoryProfile.update |
site.setCustomDomain |
Removed — no custom domains |
site.delete |
directoryProfile.delete |
site.get |
directoryProfile.getByPractice |
site.list |
directoryProfile.list |
site.unpublish |
directoryProfile.unpublish |
site.regenerate |
directoryProfile.revalidate |
landing_page_article.generate |
profile_content.generate (taskType: "PROFILE_CONTENT") |
site.updateSection |
Removed — no site editor |
site.setVisibility |
Removed — no site editor |
site.reorderSections |
Removed — no site editor |
site.setTemplate |
Removed — no templates |
subdomain field |
profileUrl field (path on directory) |
customDomain field |
Removed |
admin.listDomains |
Removed |
End of Backend API Documentation