TL;DR:
- I built a Python API backend that's completely decoupled from any specific CMS. Drupal is just one client.
- The API owns users, authentication, job processing, and data storage. The CMS owns UI, user sessions, and CMS-specific workflows.
- Clean separation means adding a new frontend (WordPress, mobile app, CLI tool) requires zero API changes.
- The key design decisions: stateless JWT auth, webhook-based async notifications, and a CMS-agnostic data model.
For the client, this means we can add a WordPress plugin, a mobile app, or a partner portal later without touching the backend—lower cost, faster delivery, and less risk with each new channel.
When I started the Meeting Intelligence Platform, the client needed a Drupal frontend. But I knew from experience: requirements change. Today it's Drupal. Next quarter it might be "we also need a WordPress plugin" or "can we add a Slack integration?"
Building the API as a Drupal-specific backend would mean rewriting half of it for each new frontend. Instead, I designed the API to be completely frontend-agnostic. Drupal is just one client among potentially many.
This post covers the design decisions that make multi-platform APIs work.
The Architecture
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Drupal │ │ WordPress │ │ Mobile App │
│ Frontend │ │ Plugin │ │ (Future) │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
│ HTTP + JWT │ HTTP + JWT │ HTTP + JWT
│ │ │
└───────────────────────┴───────────────────────┘
│
▼
┌─────────────────────────┐
│ Python API │
│ (FastAPI + PostgreSQL) │
│ │
│ - Authentication │
│ - Job management │
│ - AI processing │
│ - Data storage │
└─────────────────────────┘
The API doesn't know or care what's calling it. It sees HTTP requests with JWT tokens. Whether that comes from Drupal, WordPress, a mobile app, or a curl command is irrelevant.
How this differs from typical "headless CMS" setups: Many headless architectures still treat the CMS as the source of truth for users and content. Here, the API is the system of record for users, credits, and jobs; CMSs are just views and workflows on top. That's what makes adding new frontends cheap—the core logic never moves out of the API.
Design Principle 1: The API Owns Identity
A common mistake: let each CMS manage its own users and try to sync them. This creates nightmares:
- User exists in Drupal but not WordPress
- Credits are tracked separately, get out of sync
- Password resets only work on one platform
Instead, the API owns the user database. CMS platforms authenticate with the API, not independently.
# API user model - source of truth
class DemoUser(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
password_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
credits_remaining: Mapped[int] = mapped_column(default=3)
credits_used: Mapped[int] = mapped_column(default=0)
registered_via: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
The registered_via field tracks which frontend created the user ("drupal", "wordpress", "api_direct"), but the user exists in one place. Credits are centralized. Authentication is centralized.
When Drupal needs to check if a user can submit a job, it asks the API. When WordPress needs the same thing, it asks the same API and gets the same answer.
Design Principle 2: Stateless Authentication
The API uses JWT tokens with no server-side sessions:
# from auth.py
def create_access_token(user_id: uuid.UUID, email: str, credits_remaining: int) -> str:
payload = {
"sub": str(user_id),
"email": email,
"credits": credits_remaining,
"exp": datetime.utcnow() + timedelta(hours=settings.jwt_expiry_hours),
"iat": datetime.utcnow(),
}
return jwt.encode(payload, settings.jwt_secret, algorithm="HS256")
Why this matters for multi-platform:
- No session affinity — Any API server can validate any token
- Frontend flexibility — CMS can store the token however it wants (cookie, local storage, database)
- Offline validation — Token contains claims; API can authorize without database lookup for most requests
Each CMS manages its own session with the user. The API just validates the JWT on each request.
What about SSO? In enterprise setups, an external Identity Provider (IdP) like Okta or Entra ID often handles authentication via SSO. In that case, the IdP is the source of truth for who the user is, while the API remains the source of truth for what they can do in this product (credits, jobs, permissions). Frontends use SSO to log the user in, then exchange that identity for an API token. The multi-platform architecture still holds: the API owns application state, and any number of frontends can participate.
Design Principle 3: CMS-Agnostic Data Models
The API returns data in a neutral format that any frontend can consume:
# from schemas.py
class MeetingSchema(BaseModel):
"""Complete meeting data."""
id: UUID
title: str
summary: str
language: str
duration: int # seconds
processed_at: datetime
speakers: List[SpeakerSchema] = []
segments: List[TranscriptSegmentSchema] = []
action_items: List[ActionItemSchema] = []
decisions: List[DecisionSchema] = []
No Drupal node IDs. No WordPress post meta. Just clean data structures that any platform can map to its own models.
The Drupal integration maps this to Drupal entities:
// Drupal maps API response to its own entities
$meeting_entity = Meeting::create([
'title' => $api_response['title'],
'summary' => $api_response['summary'],
'api_meeting_id' => $api_response['id'], // Reference back to API
'user_id' => $current_user->id(),
]);
WordPress would do the same with custom post types. A mobile app would display it directly. The API doesn't change.
Design Principle 4: Webhooks for Async Results
Jobs take 5-10 minutes. The API can't hold an HTTP connection open that long. Instead, the CMS provides a webhook URL when creating the job:
# API endpoint
@router.post("/api/jobs", status_code=status.HTTP_202_ACCEPTED)
async def create_job(
file: UploadFile,
webhook_url: str = Form(...),
webhook_secret: str = Form(...),
current_user: DemoUser = Depends(get_current_user),
):
# Create job, queue for processing
job = Job(
webhook_url=webhook_url,
webhook_secret=webhook_secret,
audio_filename=unique_filename,
status=JobStatus.PENDING,
)
# ...
When processing completes, the API calls the webhook:
# Webhook payload - same structure regardless of which CMS
payload = {
"job_id": str(job.id),
"status": "completed",
"meeting": {
"id": str(meeting.id),
"title": meeting.title,
"summary": meeting.summary,
# ... full meeting data
}
}
Each CMS implements its own webhook handler:
// Drupal webhook controller
public function handleWebhook(Request $request) {
$secret = $request->headers->get('X-Webhook-Secret');
if ($secret !== $this->config->get('webhook_secret')) {
return new JsonResponse(['error' => 'Invalid secret'], 403);
}
$data = json_decode($request->getContent(), TRUE);
// Create Drupal entity from API data
$this->meetingService->createFromWebhook($data);
return new JsonResponse(['status' => 'ok']);
}
WordPress would have a similar endpoint. The API doesn't know or care how each platform handles the callback.
Design Principle 5: Flexible Authentication Methods
Different platforms have different auth needs. The API supports multiple methods:
1. Direct registration (API-first apps)
POST /auth/register
{"email": "user@example.com", "password": "..."}
2. Trusted site authentication (CMS integrations)
POST /auth/trusted/register
{"site_id": "drupal", "payload": "...", "signature": "...", "timestamp": ...}
The trusted site auth (covered in blog #2) lets CMS platforms register users without passwords. The CMS authenticates the user via its own system (Drupal login, WordPress login), then vouches for them to the API.
This flexibility means:
- Direct API users can register with email/password
- Drupal users authenticate via Drupal, get API tokens transparently
- A future mobile app could use OAuth or social login
The API supports all patterns without knowing which frontend is using which.
The CMS Integration Layer
Each CMS needs a thin integration layer that handles:
- Token management — Store and refresh API tokens
- User mapping — Link CMS users to API users
- Webhook handling — Process async results
- UI integration — Display data in CMS-native components
"Isn't there a module for this?" Drupal provides great primitives for consuming external APIs (Guzzle, services, caching) and contrib modules for popular third-party services like Stripe or Salesforce. But for a custom API with its own auth, credits, and webhooks, you still need a thin integration layer that knows your specific API contract. No generic module can know which endpoints your API exposes, how to build the exact multipart request with audio + webhook URL, or how to map your meeting payload into Drupal entities. That's the domain-specific adapter between "generic CMS" and "your API."
For Drupal, this is a custom module:
// from ApiClient.php
class ApiClient {
public function createJob(int $uid, string $filePath, string $webhookUrl): array {
$token = $this->getToken($uid);
if (!$token) {
$token = $this->refreshTokenForUser($uid);
}
// Call API with file upload
$response = $this->httpClient->request('POST', $this->getBaseUrl() . '/api/jobs', [
'multipart' => [
['name' => 'file', 'contents' => fopen($filePath, 'r')],
['name' => 'webhook_url', 'contents' => $webhookUrl],
['name' => 'webhook_secret', 'contents' => $this->config['webhook_secret']],
],
'headers' => ['Authorization' => 'Bearer ' . $token],
]);
return json_decode($response->getBody()->getContents(), TRUE);
}
}
A WordPress integration would look similar—different framework idioms, same API calls.
What Adding a New Platform Looks Like
Say the client wants a WordPress plugin. Here's what's needed:
API changes: None.
New code:
- WordPress plugin with admin settings (API URL, keys)
- ApiClient class making HTTP calls to the API
- Webhook endpoint to receive results
- Admin UI to display meetings
The API is already multi-platform ready. Adding WordPress is purely a WordPress development task.
Trade-offs
Complexity: Two systems to deploy instead of one monolith. More moving parts.
Latency: CMS → API → Database is slower than CMS → Database directly. For this use case (minutes-long AI jobs), milliseconds don't matter.
Data duplication: Meeting data exists in both API database and CMS database. The API is the source of truth; CMS has a cached copy for display. Webhooks keep them in sync.
When is this overkill? If you're absolutely certain you'll only ever have a single Drupal site with no API consumers, a tightly coupled module is simpler and cheaper. But once you suspect a second frontend—another CMS, a mobile app, a partner integration, or even a public API—this architecture pays for itself quickly.
Lessons Learned
1. Design for the second platform from day one
Even if you're only building Drupal today, design as if WordPress is coming tomorrow. The incremental cost is low; the flexibility payoff is high.
2. The API owns the hard stuff
Authentication, credits, job processing, AI integration—all in the API. The CMS is a thin UI layer. This keeps CMS integrations simple and interchangeable.
3. Webhooks beat polling for async results
Long-running jobs need callback-based notification. Every CMS can implement a webhook endpoint; not every CMS handles long-polling or WebSockets gracefully.
4. Test with multiple clients early
Even if you only have Drupal, write a simple CLI client or use curl to test the API directly. If the API only works through Drupal, it's not really decoupled.
Wrapping Up
Building a multi-platform API isn't about predicting the future—it's about not painting yourself into a corner. The extra design effort upfront (clean data models, stateless auth, webhook-based async) creates an API that can serve any frontend without modification.
This architecture works for any SaaS-style service: payment processing, document management, AI features. The specific domain changes; the multi-platform principles remain the same.
You can see this architecture in action on the live demo—the Drupal site is just one client of the underlying API.
Part of a series on building the Meeting Intelligence Platform. Next up: Structuring a Polyglot Project.
What I designed and built for this project:
- API architecture and data modeling (FastAPI + PostgreSQL)
- JWT auth and trusted-site RSA integration
- Webhook contracts and async job handling
- Drupal integration module (token management, job creation, webhook handling)
Building a platform that needs multiple frontends? I can help with:
- API design for multi-tenant and multi-platform architectures
- CMS integration patterns (Drupal, WordPress, headless)
- Authentication strategies for decoupled systems
[Get in touch on Upwork] | [Get in Touch Directly →] to discuss your architecture.