designed differently
A backoffice API and a public API share HTTP as transport, but their requirements for stability, backward compatibility, security, and error models differ so fundamentally that a unified design inevitably makes one of the two worse.
Table of Contents
- 1. Why Backoffice and Public APIs Are Fundamentally Different
- 2. Target Audience as the Primary Design Driver
- 3. Authentication: Session vs. API Key vs. OAuth2
- 4. Versioning: When It Is Necessary, When It Is Counterproductive
- 5. Rate Limiting and Throttling
- 6. Error Models and Response Granularity
- 7. Pagination and Filtering Strategy
- 8. Stability and Breaking Changes
- 9. Direct Comparison: Backoffice vs. Public
- 10. Summary
- 11. FAQ
1. Why Backoffice and Public APIs Are Fundamentally Different
The most common mistake in API design is designing a single API that serves both internal backoffice requirements and external developers. That sounds like good adherence to the DRY principle, but in practice it produces a poor compromise for both sides. A backoffice API serves known, controlled clients, typically an internal admin panel, an ERP system, or a mobile app developed by your own team. A public API has to deal with unknown clients, undocumented usage patterns, and a wide range of programming languages and platforms that nobody can fully anticipate.
The second major difference lies in who controls change. For a backoffice API, a team can simply decide: "We are changing this endpoint tomorrow, and all clients will be updated at the same time." For a public API, that is impossible. External developers rely on stability, have their own deployment cycles, and cannot react to an API change within 24 hours. This different pace of change runs through every design decision that follows, and it is the reason a unified API for both use cases fails.
2. Target Audience as the Primary Design Driver
Every good API design decision starts with the question: "Who calls this endpoint, and what do they actually need?" For a backoffice API, the answers are precise: the caller is a known internal system with defined permissions, it often needs more detailed data than an external consumer, and it tolerates more complex request structures because the calling team speaks the same domain language. A backoffice endpoint for order details can easily return internal data such as warehouse location, purchase price, and internal notes.
A public API, on the other hand, has to account for data protection concerns and must never let internal data leak into responses. The target audience consists of external developers who may never have had a personal conversation with the API design team. Response fields must be self-explanatory, error messages must be understandable without requiring internal system knowledge, and the documentation has to be complete because there is no Slack channel to ask follow-up questions in. These cognitive requirements shape the entire API design from the ground up.
<?php
// Backoffice API Controller: rich internal data, no public exposure
#[Route('/api/backoffice/orders/{id}', methods: ['GET'])]
public function getOrderDetail(
int $id,
OrderRepository $orders,
#[CurrentUser] User $admin,
): JsonResponse {
$this->denyAccessUnlessGranted('ROLE_BACKOFFICE');
$order = $orders->findWithInternalDetails($id);
return $this->json([
'id' => $order->getId(),
'status' => $order->getStatus()->value,
'customer_email' => $order->getCustomer()->getEmail(),
'purchase_price' => $order->getInternalPurchasePrice(), // internal only
'margin_percent' => $order->getMarginPercent(), // internal only
'warehouse_slot' => $order->getWarehouseSlot(), // internal only
'internal_notes' => $order->getInternalNotes(), // internal only
'created_at' => $order->getCreatedAt()->format('c'),
]);
}
// Public API Controller: filtered, safe, documented fields only
#[Route('/api/v1/orders/{id}', methods: ['GET'])]
public function getPublicOrderDetail(
int $id,
OrderRepository $orders,
Security $security,
): JsonResponse {
$order = $orders->findPublicById($id);
// Ensure only the owner can access
if ($order->getCustomerId() !== $security->getUser()?->getId()) {
throw new AccessDeniedException();
}
return $this->json([
'id' => $order->getId(),
'status' => $order->getStatus()->value,
'items' => $this->serializeItems($order->getItems()),
'total' => $order->getTotal(),
'created_at' => $order->getCreatedAt()->format('c'),
// No internal fields, no pricing details, no warehouse data
]);
}
3. Authentication: Session vs. API Key vs. OAuth2
Authentication is one of the clearest areas where backoffice and public APIs need different solutions. A backoffice API that is only called from the company's own admin panel can work perfectly well with session cookies or a simple bearer token bound to an internal user object. It is easy to implement, easy to revoke, and offers full traceability because every request can be attributed to a specific admin user. Symfony's security component with an internal user provider and `ROLE_BACKOFFICE` is sufficient and does not introduce unnecessary overhead.
A public API for external developers needs a different approach. API keys with scoped permissions are the minimum: every external developer gets a key that has specific scopes (`orders:read`, `products:write`) and can be revoked at any time without deleting the user account. For APIs that act on behalf of end users, OAuth2 with the Authorization Code flow is the right choice. It gives end users control over which applications can access their data and matches the expected standard for modern public APIs. Both models coexist, but in a public API there is no reason to offer session authentication.
4. Versioning: When It Is Necessary, When It Is Counterproductive
Backoffice APIs rarely need a formal versioning strategy. If all clients are under your own control and can be updated at the same time, a `/api/backoffice/` URL structure without a version number is simpler to maintain. Breaking changes can be coordinated, and carrying multiple API versions costs maintenance effort without proportional benefit. The one exception: if several internal systems with independent deployment cycles consume the same backoffice API, at least a rough versioning strategy is needed.
Public APIs, on the other hand, need versioning as a promise of stability. `/api/v1/` in the URL is the most widely used pattern and has the advantage of being immediately visible to developers. The alternative, versioning via the Accept header (`Accept: application/vnd.mironsoft.v2+json`), is semantically cleaner but harder to test and document. Important: a new API version does not mean the old one is switched off immediately. A deprecation period of at least 12 months with clear deprecation warnings in the response header (`Sunset: Sat, 31 Dec 2027 23:59:59 GMT`) is good API citizenship.
<?php
// Symfony API version detection via URL prefix
// config/routes/api_public.yaml:
// api_v1:
// resource: '../src/Controller/Api/V1/'
// prefix: /api/v1
//
// api_v2:
// resource: '../src/Controller/Api/V2/'
// prefix: /api/v2
// EventSubscriber: inject deprecation headers for v1 responses
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
#[AsEventListener(event: KernelEvents::RESPONSE)]
final class ApiDeprecationSubscriber
{
public function __invoke(ResponseEvent $event): void
{
$path = $event->getRequest()->getPathInfo();
if (str_starts_with($path, '/api/v1/')) {
$response = $event->getResponse();
$response->headers->set('Deprecation', 'true');
$response->headers->set('Sunset', 'Sat, 31 Dec 2027 23:59:59 GMT');
$response->headers->set('Link', '</api/v2/>; rel="successor-version"');
}
}
}
5. Rate Limiting and Throttling
Rate limiting is existential for public APIs and optional for backoffice APIs. A public API has to protect itself against both accidental and deliberate overload. An external developer who accidentally builds an infinite loop can, without rate limiting, affect the entire infrastructure. The usual model: limit requests per API key and time window, communicate the limits in response headers (`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`), and respond with HTTP 429 and a `Retry-After` header when the limit is exceeded. Symfony's RateLimiter component implements token bucket and sliding window algorithms without external dependencies.
Backoffice APIs benefit from rate limiting as protection against bugs in internal scripts, but the limits can be significantly more generous. An internal batch operation that imports 10,000 records must not be blocked by a rate limit that is too tight. The more sensible approach for backoffice APIs is connection pooling at the database level and circuit breakers for dependent external services, not artificial limits at the HTTP level. Asking "who calls this, and what load patterns are expected?" leads to more sensible limits than a single rule applied to every endpoint.
6. Error Models and Response Granularity
The error model is another area with deep differences. A backoffice API can return detailed technical information on error: stack trace, SQL query, internal exception message. That is valuable for debugging during development and acceptable for internal teams because there is no risk of the information becoming public. A public API must never return stack traces, SQL errors, or internal system paths in error responses; that is both a security risk and a poor developer experience.
For public APIs, RFC 9457 (Problem Details for HTTP APIs) is recommended: a standardized JSON object with `type`, `title`, `status`, `detail`, and optional extensions. External developers know this format, can parse it automatically, and receive consistent error descriptions without internal system details. Validation errors across multiple fields should be returned in full (an `errors` array), not just the first error encountered. A developer who filled in four fields incorrectly and needs four API calls to discover all the errors will not have a positive developer experience.
7. Pagination and Filtering Strategy
Pagination is necessary in both API types, but with different strategies. Backoffice APIs for admin panels can use offset-based pagination (`?page=2&per_page=50`) because the dataset is controlled and the use case typically has no high-volume streaming requirements. Cursor-based pagination performs better on large datasets, but it requires a different client implementation and is often overkill for simple admin interfaces.
Public APIs, used by developers for all sorts of use cases, should offer cursor-based pagination. If someone iterates through a product list with 500,000 entries, offset-based pagination is not just slow, it also produces inconsistent results during concurrent write operations (a record can be skipped or appear twice). Cursor-based pagination delivers deterministically complete results. The cursor information belongs in a `meta` block of the response body, not in HTTP headers, because it can be a complex structure.
| Aspect | Backoffice API | Public API | Rationale |
|---|---|---|---|
| Authentication | Session / internal JWT | API key + OAuth2 | Controlled vs. unknown client |
| Versioning | Optional / coordinated | URL prefix /v1/ mandatory | Own vs. third-party deployment cycles |
| Rate Limiting | Generous / circuit breaker | Strict per API key | Known vs. unknown load patterns |
| Error Details | Stack trace allowed | RFC 9457 Problem Details | Useful internally vs. safe externally |
| Pagination | Offset-based is sufficient | Cursor-based preferred | Controllable vs. arbitrary volume |
8. Stability and Breaking Changes
Stability may be the most fundamental difference in API design. A backoffice API can introduce breaking changes as long as all clients are updated in a coordinated way. That makes iterative development much easier: a new data model, a renamed field, a changed status format, these are refactorings for an internal API, not API design problems. The team has full control over the migration window and can, if necessary, run a big bang deploy.
Public APIs, in contrast, carry the burden of backward compatibility as a primary design requirement. A field that has once appeared in a response must not simply be removed. Renaming a field requires a deprecation process in which the old field is still returned alongside the new name for at least 12 months. New optional fields in responses are backward compatible. New required fields in requests are not. This asymmetry requires API designers to think in terms of stability categories from the start and to deliberately mark fields as stable or experimental.
<?php
// Symfony EventSubscriber: log deprecated field usage from public API clients
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Psr\Log\LoggerInterface;
#[AsEventListener]
final class DeprecatedFieldUsageListener
{
public function __construct(
private readonly LoggerInterface $logger,
) {}
public function onKernelRequest(RequestEvent $event): void
{
$request = $event->getRequest();
if (!str_starts_with($request->getPathInfo(), '/api/v1/')) {
return;
}
// Track if client sends old field name
$body = json_decode($request->getContent(), true) ?? [];
if (isset($body['customer_name'])) {
// 'customer_name' was renamed to 'full_name' in v1.3
$this->logger->warning('Deprecated field used: customer_name', [
'api_key' => $request->headers->get('X-Api-Key', 'unknown'),
'endpoint' => $request->getPathInfo(),
]);
}
}
}
Mironsoft
REST API design, Symfony backend development, and API strategy
REST APIs designed for your specific use case?
We design and implement backoffice and public APIs with Symfony that get the priorities right from the start: stability, security, and developer experience matched to your specific client requirements.
API Audit
Analyze existing APIs, identify security gaps and design flaws
API Design
Design new APIs from the ground up, with a clear backoffice/public split
Migration
Split existing APIs into v1/v2 with a deprecation strategy and client communication
10. Summary
The central takeaway of this article: backoffice APIs and public APIs share HTTP as transport, but their design requirements are fundamentally different. A backoffice API can be rich in internal detail, can forgo formal versioning, and benefits from verbose error messages for debugging. A public API needs strict data separation, formal versioning as a promise of stability, standardized error models following RFC 9457, and rate limiting to protect everyone involved.
The organizational consequence of this design decision follows directly: whoever builds the same API for internal and external consumers optimizes for neither use case. Splitting into separate endpoints, or even separate services, is more effort up front, but it pays off through lower ongoing maintenance complexity. A backoffice API that cannot return stack traces because of public API requirements is worse for internal operations. A public API that returns internal fields because of backoffice requirements is a security risk.
Backoffice vs. Public API: The Essentials at a Glance
Backoffice API
Internal clients, coordinated breaking changes, verbose error messages, generous rate limiting. No versioning requirement, no OAuth2 overhead.
Public API
URL versioning (/v1/), API keys + OAuth2, RFC 9457 error model, strict rate limiting, cursor pagination, 12-month deprecation period.
Data Separation
Internal fields (purchase price, warehouse location, notes) never in public API responses; separate serialization layers enforce this.
Stability
Public API: additive changes are safe, breaking changes require a new version plus a Sunset header. Backoffice: a coordinated big bang is possible.