Browse documentation

Specifications

RankFlow AI — Backend API Documentation

// src/server/api/trpc.ts

docs/specs/backend-api.md
On this page

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.getRelated returns 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.md Section 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