API Contracts

This guide covers API design patterns, request/response formats, and best practices. For complete endpoint reference, see .context/api/endpoints.md.

API Format

The CMS API uses REST + JSON-LD/Hydra format powered by API Platform.

JSON-LD Structure

All responses include JSON-LD context and type information:
{
  "@context": "/contexts/Article",
  "@id": "/admin/articles/019505e5-c5d0-7000-8000-000000000001",
  "@type": "Article",
  "id": "019505e5-c5d0-7000-8000-000000000001",
  "slug": "my-article",
  "title": {"fr": "Mon Article", "en": "My Article"}
}
Fields:
  • @context: Schema definition URL
  • @id: Unique identifier (IRI)
  • @type: Resource type

Hydra Collections

Collection endpoints return Hydra format:
{
  "@context": "/contexts/Article",
  "@type": "hydra:Collection",
  "@id": "/admin/articles",
  "hydra:totalItems": 42,
  "hydra:member": [
    {
      "@id": "/admin/articles/019505e5-c5d0-7000-8000-000000000001",
      "@type": "Article",
      "id": "019505e5-c5d0-7000-8000-000000000001",
      "title": {"fr": "Article 1"}
    }
  ],
  "hydra:view": {
    "@id": "/admin/articles?page=1",
    "@type": "hydra:PartialCollectionView",
    "hydra:first": "/admin/articles?page=1",
    "hydra:last": "/admin/articles?page=3",
    "hydra:next": "/admin/articles?page=2"
  }
}

Request Patterns

Headers

Required for all authenticated requests:
Authorization: Bearer <jwt-token>
X-Tenant-Id: <tenant-slug>
Optional:
X-Site-Id: <site-uuid>       # Multi-site filtering (defaults to first active site)
Content-Type: application/json  # For POST/PUT/PATCH
If-Match: "<etag>"           # Optimistic locking
X-Preview-Token: <token>     # Preview mode

Create Resource (POST)

POST /admin/articles
Content-Type: application/json
Authorization: Bearer <token>
X-Tenant-Id: demo

{
  "slug": "my-article",
  "title": {"fr": "Mon Article", "en": "My Article"},
  "excerpt": {"fr": "Résumé", "en": "Excerpt"},
  "body_html": {"fr": "<p>Contenu...</p>", "en": "<p>Content...</p>"},
  "status": "draft",
  "category_id": "019505e5-c5d0-7000-8000-000000000010",
  "tags": ["/admin/tags/019505e5-c5d0-7000-8000-000000000020", "/admin/tags/019505e5-c5d0-7000-8000-000000000021"],
  "featuredImage": "/admin/media/019505e5-c5d0-7000-8000-000000000030"
}
Response (201 Created):
{
  "@context": "/contexts/Article",
  "@id": "/admin/articles/019505e5-c5d0-7000-8000-000000000042",
  "@type": "Article",
  "id": "019505e5-c5d0-7000-8000-000000000042",
  "slug": "my-article",
  "title": {"fr": "Mon Article", "en": "My Article"},
  "version": 1,
  "created_at": "2026-02-09T12:00:00+00:00"
}

Update Resource (PUT)

Full Replacement:
PUT /admin/articles/019505e5-c5d0-7000-8000-000000000042
Content-Type: application/json
Authorization: Bearer <token>
X-Tenant-Id: demo

{
  "slug": "my-article-updated",
  "title": {"fr": "Titre modifié", "en": "Updated Title"},
  "excerpt": {"fr": "Nouveau résumé", "en": "New excerpt"},
  "status": "draft"
}
Response (200 OK): Full updated resource

Partial Update (PATCH)

Update Specific Fields:
PATCH /admin/articles/019505e5-c5d0-7000-8000-000000000042
Content-Type: application/json
Authorization: Bearer <token>
X-Tenant-Id: demo

{
  "title": {"fr": "Nouveau titre"}
}
Response (200 OK): Full updated resource

Delete Resource (DELETE)

DELETE /admin/articles/019505e5-c5d0-7000-8000-000000000042
Authorization: Bearer <token>
X-Tenant-Id: demo
Response (204 No Content): Empty body

Pagination

Query Parameters

GET /admin/articles?page=2&per_page=20
Parameters:
  • page: Page number (default: 1)
  • per_page: Items per page (default: 30, max: 100)

Response Structure

{
  "@context": "/contexts/Article",
  "@type": "hydra:Collection",
  "hydra:totalItems": 150,
  "hydra:member": [...],
  "hydra:view": {
    "hydra:first": "/admin/articles?page=1&per_page=20",
    "hydra:last": "/admin/articles?page=8&per_page=20",
    "hydra:previous": "/admin/articles?page=1&per_page=20",
    "hydra:next": "/admin/articles?page=3&per_page=20"
  }
}

Filtering

Exact Match

GET /admin/articles?status=published
GET /admin/articles?category.uuid=019505e5-c5d0-7000-8000-000000000010

Multiple Filters

GET /admin/articles?status=published&category.uuid=019505e5-c5d0-7000-8000-000000000010&tags.slug=symfony
GET /public/search?q=symfony&content_type=article&lang=fr

Relationships

Embedded Resources (IRIs)

Request with Relationships:
{
  "slug": "my-article",
  "title": {"fr": "Article"},
  "category_id": "019505e5-c5d0-7000-8000-000000000010",
  "tags": ["/admin/tags/019505e5-c5d0-7000-8000-000000000020", "/admin/tags/019505e5-c5d0-7000-8000-000000000021"],
  "featuredImage": "/admin/media/019505e5-c5d0-7000-8000-000000000030"
}
IRIs (Internationalized Resource Identifiers):
  • Format: /admin/{resource}/{uuid}
  • API Platform resolves IRIs to actual entities

Response with Embedded Data

Admin Endpoint (includes related data):
{
  "@id": "/admin/articles/019505e5-c5d0-7000-8000-000000000001",
  "id": "019505e5-c5d0-7000-8000-000000000001",
  "title": {"fr": "Article"},
  "tags": [
    {
      "@id": "/admin/tags/019505e5-c5d0-7000-8000-000000000020",
      "id": "019505e5-c5d0-7000-8000-000000000020",
      "slug": "symfony",
      "name": {"fr": "Symfony"}
    }
  ],
  "featuredImage": {
    "@id": "/admin/media/019505e5-c5d0-7000-8000-000000000030",
    "id": "019505e5-c5d0-7000-8000-000000000030",
    "filename": "image.jpg",
    "urls": {"thumbnail": "https://..."}
  }
}

Multi-Language Content

Request Format

All multi-language fields use JSON objects with locale keys:
{
  "title": {
    "fr": "Titre en français",
    "en": "Title in English"
  },
  "body_html": {
    "fr": "<p>Contenu français</p>",
    "en": "<p>English content</p>"
  }
}

Supported Locales

  • fr (French)
  • en (English)
Configurable in config/services.yaml:
parameters:
    app.locales: ['fr', 'en']

Fallback Strategy

Frontend should implement fallback:
function getLocalized(field: Record<string, string>, locale: string): string {
  return field[locale] || field['fr'] || field['en'] || Object.values(field)[0] || '';
}

Error Responses

RFC7807 Problem Details

All errors follow RFC7807 format:
{
  "type": "https://tools.ietf.org/html/rfc7807",
  "title": "Validation Failed",
  "status": 422,
  "detail": "slug: This value is already used.",
  "violations": [
    {
      "propertyPath": "slug",
      "message": "This value is already used.",
      "code": "23bd9dbf-6b9b-41cd-a99e-4844bcf3077f"
    }
  ]
}

Common Status Codes

CodeMeaningExample
200OKSuccessful GET, PUT, PATCH
201CreatedSuccessful POST
204No ContentSuccessful DELETE
400Bad RequestInvalid JSON, missing required header
401UnauthorizedMissing/invalid JWT token
403ForbiddenInsufficient permissions
404Not FoundResource doesn’t exist
409ConflictUnique constraint violation
412Precondition FailedETag mismatch (optimistic locking)
422Unprocessable EntityValidation errors
429Too Many RequestsRate limit exceeded
500Internal Server ErrorServer error

Error Examples

Validation Error (422):
{
  "type": "https://tools.ietf.org/html/rfc7807",
  "title": "Validation Failed",
  "status": 422,
  "violations": [
    {
      "propertyPath": "title",
      "message": "This value should not be blank.",
      "code": "c1051bb4-d103-4f74-8988-acbcafc7fdc3"
    }
  ]
}
Authentication Error (401):
{
  "type": "https://tools.ietf.org/html/rfc7807",
  "title": "Unauthorized",
  "status": 401,
  "detail": "JWT Token not found"
}
Rate Limit Error (429):
{
  "type": "https://tools.ietf.org/html/rfc7807",
  "title": "Too Many Requests",
  "status": 429,
  "detail": "Rate limit exceeded",
  "retry_after": 300,
  "rate_limit": {
    "limit": 100,
    "remaining": 0,
    "reset": 1707480000
  }
}

Rate Limiting

Limits

Anonymous Users:
  • 100 requests / 15 minutes per IP
Authenticated Users:
  • 1000 requests / 15 minutes per user

Headers

Response Headers:
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 950
X-RateLimit-Reset: 1707480000

Handling Rate Limits

async function apiRequest(url: string) {
  const response = await fetch(url);
  
  if (response.status === 429) {
    const retryAfter = response.headers.get('Retry-After');
    console.log(`Rate limited. Retry after ${retryAfter} seconds`);
    
    await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
    return apiRequest(url); // Retry
  }
  
  return response;
}

Optimistic Locking

Using ETags

1. Get Resource with ETag:
GET /admin/articles/019505e5-c5d0-7000-8000-000000000001
Response:
HTTP/1.1 200 OK
ETag: "686897696a7c876b7e"
Content-Type: application/ld+json

{"id": "019505e5-c5d0-7000-8000-000000000001", "title": {"fr": "Article"}}
2. Update with If-Match:
PUT /admin/articles/019505e5-c5d0-7000-8000-000000000001
If-Match: "686897696a7c876b7e"

{"title": {"fr": "Updated Title"}}
Success (200 OK): Update applied
Conflict (412 Precondition Failed): Resource modified by another user

Conflict Resolution

{
  "type": "https://tools.ietf.org/html/rfc7807",
  "title": "Precondition Failed",
  "status": 412,
  "detail": "Resource has been modified. Please refresh and try again."
}
Frontend Handling:
async function updateArticle(uuid: string, data: any, etag: string) {
  const response = await fetch(`/admin/articles/${uuid}`, {
    method: 'PUT',
    headers: {
      'If-Match': etag,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(data)
  });

  if (response.status === 412) {
    // Conflict: reload and show diff
    const latest = await fetch(`/admin/articles/${uuid}`);
    showConflictDialog(data, await latest.json());
  }
}

Best Practices

For API Consumers

  1. Always include required headers
    • Authorization for authenticated requests
    • X-Tenant-Id for all requests
  2. Handle pagination
    • Use hydra:next for navigation
    • Don’t construct page URLs manually
  3. Use IRIs for relationships
    • Send /admin/tags/{uuid} not {"id": 1}
  4. Implement error handling
    • Check status code
    • Parse violations for validation errors
  5. Respect rate limits
    • Check X-RateLimit-* headers
    • Implement exponential backoff
  6. Cache appropriately
    • Public content: 1 hour
    • Admin data: 5 minutes
    • Invalidate on updates

For API Developers

  1. Use serialization groups
    • Control exposed fields
    • Separate public/admin/write contexts
  2. Validate input
    • Use Symfony Validator
    • Return meaningful error messages
  3. Document endpoints
    • Add OpenAPI descriptions
    • Provide examples
  4. Test thoroughly
    • Unit tests for logic
    • Functional tests for endpoints
    • Integration tests for workflows
  5. Monitor performance
    • Track slow queries
    • Monitor cache hit rates
    • Alert on high error rates

Further Reading