This specification extends HATEOAS (Hypermedia as the Engine of Application State) with business context metadata, enabling APIs to share meaningful business information that has presentation implications. This allows clients to make intelligent UI decisions without duplicating business logic, while maintaining proper separation of concerns between backend business rules and frontend presentation logic.
If you've built a HATEOAS API, you know the dirty secret: clients hardcode URLs anyway. They ignore your carefully crafted links. Meanwhile, frontend developers duplicate your business logic just to figure out whether to show a button as disabled or hide it entirely.
This specification tackles both problems head-on. We enrich hypermedia responses with business context that actually helps frontends make intelligent decisions. The backend stays in charge of business rules, the frontend stays in charge of pixels and animations, and everyone's happy.
This specification has strong opinions about what belongs where. The backend should never tell the frontend to "show a red button" or "use a modal dialog." That's none of the backend's business. What the backend SHOULD communicate is "this action will permanently delete data" or "this needs to happen in the next 3 hours."
The frontend owns everything visual: colors, animations, whether something slides in from the left or fades in from above. The backend owns the business reality: this customer is high-value, this order expires tomorrow, this action requires manager approval.
When you understand this distinction, everything else falls into place.
We built this spec around four non-negotiable principles. First, everything is business-first. If you catch yourself thinking about buttons or colors, you're doing it wrong. Second, context is always optional. Your API works without it, but it works better with it. Third, we describe meaning, never appearance. And fourth, this works everywhere: web, mobile, voice assistants, CLI tools, whatever comes next.
The backend provides:
{
"subscription": {
"status": "trial",
"expiresIn": "3 days",
"_links": {
"upgrade": {
"href": "/subscription/upgrade",
"_context": {
"urgency": "time_sensitive",
"reason": "trial_ending_soon",
"businessImpact": "service_interruption"
}
}
}
}
}The frontend decides:
- Show urgency as red button, banner, or modal?
- What animation to use?
- Where to position the element?
- What icon or imagery to include?
The backend provides facts: "Trial expires in 3 days, upgrade available." The frontend interprets those facts through its design system. Maybe that's a red banner on web, a push notification on mobile, or an urgent voice prompt on Alexa. The backend doesn't know or care.
The _context object is where the magic happens. It can appear at three levels: on resources (what kind of data is this?), on links (what does this action mean?), and on fields (what rules apply here?).
{
"data": { /* resource data */ },
"_links": {
"self": { "href": "/resource/123" },
"action": {
"href": "/resource/123/action",
"method": "POST",
"_context": { /* action business context */ }
}
},
"_context": { /* resource business context */ }
}Your API knows what kind of data it's serving. Share that knowledge:
{
"users": [...],
"_context": {
"dataStructure": "collection",
"businessPurpose": "operational",
"capabilities": ["sortable", "filterable", "exportable"],
"businessProperties": {
"identityField": "email",
"operationalSummary": ["name", "role", "lastActive"],
"auditDetails": ["createdAt", "permissions", "department"]
},
"businessOrdering": {
"defaultSort": "lastActive",
"businessRationale": "operational_efficiency_prioritizes_active_users"
}
}
}{
"invoice": {
"id": "INV-2024-001",
"amount": 1500.00,
"status": "overdue",
"_context": {
"businessState": "requires_immediate_attention",
"relatedConcepts": {
"customer": {
"importance": "high",
"reason": "key_account"
},
"payment_history": {
"importance": "relevant",
"reason": "multiple_late_payments"
}
},
"possibleOutcomes": [
"payment_received",
"send_to_collections",
"negotiate_payment_plan"
]
}
}
}{
"_links": {
"cancel_subscription": {
"href": "/subscription/cancel",
"method": "POST",
"_context": {
"businessImpact": "permanent_data_loss",
"requiresConfirmation": true,
"confirmationContext": {
"dataAtRisk": "2.5GB of stored data",
"alternativeActions": ["downgrade", "pause"],
"coolingOffPeriod": "30 days"
},
"availability": {
"reason": "active_subscription",
"restrictions": ["cannot_cancel_during_billing_cycle"]
}
}
}
}
}{
"_links": {
"submit_order": {
"href": "/orders",
"method": "POST",
"_context": {
"processType": "user_input_required",
"businessRules": {
"requiredInformation": [
{
"field": "shipping_address",
"reason": "physical_delivery",
"businessRule": "required_for_physical_products"
},
{
"field": "tax_id",
"reason": "business_purchase",
"condition": "when_amount_exceeds_1000"
}
],
"validationContext": {
"inventory": "real_time_check",
"pricing": "promotional_rules_apply"
}
}
}
}
}
}{
"fields": [
{
"name": "credit_limit",
"_context": {
"businessType": "currency",
"constraints": {
"minimum": 0,
"maximum": 50000,
"reason": "regulatory_compliance",
"increment": 100
},
"businessLogic": {
"automaticAdjustment": true,
"adjustmentFactors": ["payment_history", "credit_score"],
"requiresApproval": "when_exceeds_10000"
}
}
},
{
"name": "tax_jurisdiction",
"_context": {
"businessType": "regulatory_selection",
"selectionContext": {
"dependsOn": ["business_address", "customer_type"],
"dataSource": "tax_authority_api",
"updateFrequency": "quarterly"
}
}
}
]
}{
"fields": [
{
"name": "international_shipping",
"_context": {
"availability": {
"condition": "product_type_allows_export",
"additionalRequirements": ["customs_declaration", "export_license"]
},
"businessImpact": {
"cost": "additional_fees_apply",
"timeline": "extended_delivery",
"compliance": "export_regulations"
}
}
}
]
}-
businessImpact: The business consequence of an actionreversible- Can be undonepermanent_data_loss- Unrecoverable deletionfinancial_commitment- Involves moneyservice_interruption- Affects availabilitycompliance_violation- Regulatory impact
-
urgency: Business-driven time sensitivityimmediate- Requires action nowtime_sensitive- Has a deadlineconvenience- At user's discretion
-
importance: Business prioritycritical- Core business functionprimary- Main user journeysecondary- Supporting functionoptional- Nice to have
-
dataStructure: The nature of the datasingle_entity- One objectcollection- Multiple related itemshierarchical- Tree structuregrouped- Categorized datatime_series- Temporal data
-
businessPurpose: Why this data existstransactional- Records business transactionsreference- Lookup/configuration dataanalytical- Reporting/insightsoperational- Day-to-day operations
{
"order": {
"id": "ORD-2024-1234",
"status": "pending_payment",
"total": 2499.99,
"items": [...],
"_links": {
"cancel": {
"href": "/orders/ORD-2024-1234/cancel",
"method": "POST",
"_context": {
"availability": {
"available": true,
"reason": "payment_not_processed",
"expires": "2024-01-20T15:00:00Z"
},
"businessImpact": "reversible",
"alternativeActions": ["modify", "hold"]
}
},
"pay": {
"href": "/orders/ORD-2024-1234/payment",
"method": "POST",
"_context": {
"urgency": "time_sensitive",
"deadline": "2024-01-20T15:00:00Z",
"requiredInformation": [
{
"type": "payment_method",
"acceptedMethods": ["credit_card", "bank_transfer"],
"restrictions": {
"minimum_amount": 100,
"currency": "USD"
}
}
],
"promotions": {
"available": true,
"description": "10% off for immediate payment"
}
}
}
},
"_context": {
"businessState": "requires_customer_action",
"stateTransitions": {
"next_states": ["paid", "cancelled", "expired"],
"automatic_transition": {
"to": "expired",
"condition": "payment_deadline_passed"
}
},
"customerContext": {
"is_returning": true,
"lifetime_value": "high",
"payment_reliability": "excellent"
}
}
}
}{
"user": {
"id": 456,
"name": "Jane Smith",
"role": "analyst",
"_links": {
"grant_admin": {
"href": "/users/456/roles",
"method": "PUT",
"_context": {
"available": false,
"reason": "insufficient_privileges",
"requirements": {
"requester_role": "super_admin",
"approval_needed": true,
"business_justification": "required"
},
"businessImpact": "security_elevation",
"auditTrail": "required_by_compliance"
}
},
"reset_password": {
"href": "/users/456/password-reset",
"method": "POST",
"_context": {
"triggerType": "administrative_action",
"userNotification": "required",
"securityImplications": {
"forces_relogin": true,
"invalidates_sessions": "all_devices",
"notification_channels": ["email", "sms"]
}
}
}
},
"_context": {
"accountStatus": {
"security": "good_standing",
"lastSecurityReview": "2024-01-01",
"riskFactors": [],
"complianceFlags": ["gdpr_consent_current", "terms_accepted"]
},
"organizationalContext": {
"department": "finance",
"dataAccess": "sensitive_financial",
"managementLevel": "individual_contributor"
}
}
}
}When implementing this, ask yourself what the business knows, not how things should look. If you're tempted to include "widget" or "red_button" in your API response, stop and think about what you're really trying to communicate.
❌ Bad: UI-specific
{"widget": "red_button", "position": "top_right"}✅ Good: Business context
{"urgency": "time_sensitive", "reason": "service_expires_today"}Same goes for data presentation. Don't dictate tables and columns. Describe what you're serving:
❌ Bad: Prescriptive
{"display": "table", "columns": ["name", "email"]}✅ Good: Descriptive
{
"dataStructure": "collection",
"businessProperties": {
"identityField": "email",
"operationalSummary": ["name", "lastActive"]
}
}Context informs your UI decisions but doesn't dictate them. When you see urgency: time_sensitive, that might become a prominent banner on web, a push notification on mobile, or red text with
And remember: context is a enhancement, not a requirement. Your frontend should work fine without it.
Accept: application/hal+json; profile="https://hateoas-context.org/v1"
Before: Just links
{
"_links": {
"cancel": {"href": "/orders/123/cancel"}
}
}After: Links with context
{
"_links": {
"cancel": {
"href": "/orders/123/cancel",
"_context": {
"availability": {
"reason": "order_not_shipped"
},
"businessImpact": "reversible"
}
}
}
}// Before: Business logic in frontend
if (order.status === 'pending' && !order.shipped) {
showCancelButton();
}
if (user.accountType === 'premium' || user.trialEndsAt > Date.now()) {
hideUpgradePrompt();
}
// After: Context from backend
if (links.cancel?._context?.available) {
showCancelButton();
}
if (links.upgrade?._context?.urgency === 'high') {
showUpgradePrompt();
}Business context must not reveal sensitive information:
❌ Bad: Reveals too much
{
"_context": {
"unavailable_reason": "user_456_has_higher_bid"
}
}✅ Good: Generic business rule
{
"_context": {
"unavailable_reason": "already_claimed"
}
}Context should reflect what the user is allowed to know. Note that this doesn't violate progressive enhancement - the core functionality remains available without context, but authorized users receive richer business information:
Regular user sees:
{
"_links": {
"approve_expense": {
"href": "/expenses/123/approve"
}
},
"_context": {
"availability": {
"available": false,
"reason": "insufficient_privileges"
}
}
}Financial manager sees:
{
"_links": {
"approve_expense": {
"href": "/expenses/123/approve"
}
},
"_context": {
"availability": {
"available": false,
"reason": "requires_senior_approval",
"pendingApprover": "CFO",
"businessContext": "amount_exceeds_delegation_limit"
}
}
}The key principle: base functionality (links and data) remains consistent, but business context is tailored to the user's role and permissions.
Here's what happens without HATEOAS+Context:
// Frontend (duplicated business logic)
function getShippingOptions(cart) {
if (cart.items.some(item => item.hazmat)) {
return ['ground'];
}
if (cart.total > 100) {
return ['ground', 'express', 'overnight', 'free_standard'];
}
return ['ground', 'express', 'overnight'];
}With HATEOAS+Context:
{
"cart": {
"total": 150.00,
"_links": {
"shipping_options": {
"href": "/cart/shipping-options",
"_context": {
"availableOptions": {
"ground": {
"businessReason": "always_available",
"estimatedDays": 5
},
"express": {
"businessReason": "standard_offering",
"estimatedDays": 2
},
"overnight": {
"businessReason": "premium_service",
"estimatedDays": 1
},
"free_standard": {
"businessReason": "qualified_by_order_total",
"qualification": "order_exceeds_100",
"estimatedDays": 7
}
},
"restrictions": [],
"recommendation": "free_standard"
}
}
}
}
}HATEOAS+Context fixes what's broken about HATEOAS. Your frontend developers stop duplicating business logic. Your backend developers stop pretending they don't know why certain actions matter. Everyone stays in their lane, but now they're actually communicating.
The magic happens when you realize that "this subscription expires in 3 days" is business context, while "show a red warning banner" is a UI decision. Once you internalize that distinction, building APIs that frontends actually want to use becomes straightforward.
The backend owns business logic. The frontend owns presentation. Business context is the bridge between them.
Version: 1.0 Status: Draft Authors: [Your Name] License: CC BY 4.0
| ❌ Avoid (UI Instruction) | ✅ Prefer (Business Context) |
|---|---|
"display": "red_button" |
"urgency": "time_sensitive" |
"columns": ["name", "email"] |
"businessProperties": {"identityField": "email", "operationalSummary": ["name"]} |
"position": "top_right" |
"importance": "primary" |
"use_modal": true |
"processType": "user_input_required" |
"icon": "trash" |
"businessImpact": "permanent_data_loss" |
- Availability: Why something can/cannot be done
- Impact: What happens if this action is taken
- Urgency: Time-sensitive business factors
- Requirements: What's needed for this action
- Relationships: How this connects to other concepts
- State: Current business state and transitions
A formal JSON Schema for validating HATEOAS+Context responses is included in this repository as hateoas-context-v1.json.