Developing a REST API for a 1C-Bitrix Website
Bitrix has a built-in REST API for Bitrix24, but for a "1C-Bitrix: Site Management" website there is no native REST — it must be built. This need arises regularly: a mobile application requires catalog data, a third-party service wants to receive orders, a React or Vue frontend needs to be fed data without page reloads. Let's walk through how to build an API on D7 that handles load and does not turn into a mess six months later.
Architecture: Controller + Router
The foundation of the API is controllers based on \Bitrix\Main\Engine\Controller. Each controller is responsible for one resource:
/local/modules/my.api/lib/
├── Controllers/
│ ├── ProductController.php → GET /api/v1/products
│ ├── OrderController.php → GET/POST /api/v1/orders
│ ├── CategoryController.php → GET /api/v1/categories
│ └── AuthController.php → POST /api/v1/auth/token
├── Services/
│ ├── ProductService.php
│ └── OrderService.php
├── Transformers/
│ ├── ProductTransformer.php → response formatting
│ └── OrderTransformer.php
└── Middleware/
├── AuthMiddleware.php
└── RateLimitMiddleware.php
Controller Implementation
namespace MyApi\Controllers;
use Bitrix\Main\Engine\Controller;
use Bitrix\Main\Engine\ActionFilter;
use MyApi\Services\ProductService;
use MyApi\Middleware\AuthMiddleware;
class ProductController extends Controller
{
public function configureActions(): array
{
return [
'list' => ['prefilters' => [new AuthMiddleware()]],
'detail' => ['prefilters' => [new AuthMiddleware()]],
'create' => ['prefilters' => [new AuthMiddleware(), new ActionFilter\HttpMethod(['POST'])]],
];
}
public function listAction(int $page = 1, int $perPage = 20, string $category = ''): array
{
$service = new ProductService();
$result = $service->getList($page, $perPage, $category);
return [
'data' => $result['items'],
'meta' => [
'total' => $result['total'],
'page' => $page,
'per_page' => $perPage,
'pages' => ceil($result['total'] / $perPage),
],
];
}
public function detailAction(int $id): array
{
$service = new ProductService();
$product = $service->getById($id);
if (!$product) {
$this->addError(new \Bitrix\Main\Error('Product not found', 404));
return [];
}
return ['data' => $product];
}
}
Authentication
API keys — the simplest option for server-to-server integrations:
namespace MyApi\Middleware;
use Bitrix\Main\Engine\ActionFilter\Base;
use Bitrix\Main\Event;
use Bitrix\Main\EventResult;
class AuthMiddleware extends Base
{
public function onBeforeAction(Event $event): ?EventResult
{
$request = \Bitrix\Main\Application::getInstance()->getContext()->getRequest();
$apiKey = $request->getHeader('X-Api-Key');
$validKey = \Bitrix\Main\Config\Option::get('my.api', 'api_key');
if ($apiKey !== $validKey) {
$this->addError(new \Bitrix\Main\Error('Unauthorized', 401));
return new EventResult(EventResult::ERROR);
}
return null;
}
}
JWT — for user requests (mobile app, SPA). The firebase/php-jwt library via Composer in /local/:
$decoded = \Firebase\JWT\JWT::decode(
substr($authHeader, 7), // strip 'Bearer '
new \Firebase\JWT\Key($secret, 'HS256')
);
$userId = (int)$decoded->sub;
Tokens are stored on the client side. Refresh tokens are saved in b_option or in a custom ORM table bound to the user.
Response Format
A consistent format is the foundation of predictable API behavior:
// Successful response
{
"status": "ok",
"data": { ... },
"meta": { "total": 150, "page": 1 }
}
// Error
{
"status": "error",
"errors": [
{ "code": "NOT_FOUND", "message": "Product not found" }
]
}
The Bitrix controller formats the response automatically, but the default format is opinionated. For full control — override processAfterAction:
protected function processAfterAction(Action $action, $result)
{
$response = \Bitrix\Main\Application::getInstance()->getContext()->getResponse();
$response->addHeader('Content-Type', 'application/json; charset=utf-8');
if ($this->getErrors()) {
echo json_encode([
'status' => 'error',
'errors' => array_map(fn($e) => [
'code' => $e->getCode(),
'message' => $e->getMessage(),
], $this->getErrors()),
], JSON_UNESCAPED_UNICODE);
} else {
echo json_encode([
'status' => 'ok',
'data' => $result,
], JSON_UNESCAPED_UNICODE);
}
exit;
}
CORS
For an API called from a different domain:
// At the beginning of the controller or in a separate middleware
$response = \Bitrix\Main\Application::getInstance()->getContext()->getResponse();
$response->addHeader('Access-Control-Allow-Origin', 'https://app.example.com');
$response->addHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
$response->addHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Api-Key');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
Documentation: OpenAPI / Swagger
An API without documentation is an API only for the person who wrote it. Endpoint descriptions in OpenAPI 3.0 format are created manually or generated from annotations (via libraries such as zircote/swagger-php). Swagger UI is deployed as a static page in /local/swagger/.
Rate Limiting
Request counter based on Redis or via b_option (for low load):
$key = 'api_ratelimit_' . md5($apiKey);
$count = (int)\Bitrix\Main\Data\Cache::createInstance()->get($key);
if ($count > 1000) { // 1000 requests per hour
http_response_code(429);
exit(json_encode(['error' => 'Rate limit exceeded']));
}
// increment via Redis or custom counter
Timeline
| Task | Timeline |
|---|---|
| Basic REST API (3–5 resources, API key, JSON responses) | 1.5–2 weeks |
| API with JWT authentication, user permissions, documentation | 3–5 weeks |
| Full-featured API with versioning, rate limiting, tests, CI | 6–10 weeks |
A REST API on Bitrix is built from standard building blocks — controllers, ORM, cache. The complexity lies not in the technology but in the design: correct endpoints, consistent response formats, handling edge cases. A poorly designed API becomes a burden that drags down both sides of the integration.







