Definition
HATEOAS (Hypermedia as the Engine of Application State) is the most advanced and often-ignored constraint of REST architecture. It means that clients interact with a REST API entirely through hypermedia links provided dynamically by the server. Instead of hardcoding URLs or following a fixed sequence of API calls from documentation, clients discover available actions by examining links in responses.
Think of HATEOAS like browsing the web. You don’t start with a list of every URL on a website - you land on the homepage and click links to navigate. If a page has a “Buy Now” button, you can click it. If not, that action isn’t available. HATEOAS brings this same discoverability to APIs: the server tells you what you can do next based on the current state.
This is the “glory of REST” (Level 3 in the Richardson Maturity Model) and the least-implemented constraint. Most APIs require clients to read documentation and hardcode URL patterns, violating HATEOAS. A truly RESTful API lets clients navigate purely by following links, making the API self-documenting and enabling servers to change URL structures without breaking clients.
Example
GitHub Pull Request Workflow: When you fetch a pull request via GET /repos/:owner/:repo/pulls/:number, GitHub returns the PR data plus hypermedia links based on state. If the PR is open and mergeable, the response includes "mergeable": true and a "merge_url" link. If it’s already merged, that link disappears, and a "commits_url" appears instead. The client doesn’t need to know the PR workflow - it follows the links provided.
PayPal Payment Approval Flow: When you create a payment via PayPal, the response includes "approval_url": "https://www.paypal.com/cgi-bin/webscr?cmd=_express-checkout&token=EC-XXXX". You redirect the user there (following the link), PayPal handles approval, then returns the user with a new link to execute the payment: "execute": "/v1/payments/payment/PAY-123/execute". Each state transition is guided by hypermedia - no hardcoded URLs needed.
Stripe Checkout Session: Stripe’s Checkout API creates a session with POST /v1/checkout/sessions. The response includes "url": "https://checkout.stripe.com/pay/cs_test_XXXX" - you redirect the user to this URL (hypermedia link). After payment, Stripe redirects back with success or cancel URLs you provided. The client follows links, the server controls the flow.
Order Lifecycle: Imagine an e-commerce API where orders transition through states: pending β paid β shipped β delivered. A pending order includes links: "pay": "/orders/123/payment", "cancel": "/orders/123". Once paid, the pay link disappears, cancel may remain, and a new "track_shipment": "/orders/123/tracking" link appears. The available actions change with state, all communicated via hypermedia.
Amazon Product Recommendation: When viewing a product, Amazon’s internal APIs likely return related product links: "frequently_bought_together": ["/products/456", "/products/789"], "customers_also_viewed": ["/products/111", "/products/222"]. The client doesn’t calculate these - it follows the server-provided links to navigate the product catalog.
Analogy
The Guided Museum Tour: You enter a museum and get an audio guide. At each exhibit, the guide tells you what you can do next: “Turn left for Renaissance Art, turn right for Modern Sculpture, or go upstairs for the Rooftop Cafe.” You don’t need a map - the guide (hypermedia) directs you based on your current location (state). If the Rooftop Cafe is closed, that option isn’t mentioned. HATEOAS works the same way - the server guides you through available actions.
The ATM Interface: When you insert your card, the ATM shows available options: Withdraw, Deposit, Check Balance. If your account is overdrawn, “Withdraw” might not appear. After selecting “Withdraw,” the ATM guides you to the next screen (enter amount), then the next (take cash), then offers new options (print receipt?). You navigate by following prompts (links), not by memorizing button sequences.
GPS Turn-by-Turn Navigation: GPS doesn’t give you the entire route upfront. It tells you the next turn, then the next, adapting if you take a wrong turn. Each instruction is based on your current state (location). HATEOAS provides the same dynamic, state-based guidance - each response includes the next valid steps.
The Adventure Video Game: In games like The Legend of Zelda, you can’t access areas until you’ve completed prerequisites. The game shows you available paths (hypermedia links) based on your progress (state). If you don’t have the hookshot, cliff edges aren’t climbable. As you gain items, new paths open. HATEOAS makes APIs behave like this - available actions emerge as state changes.
Code Example
// Example: Order Workflow with HATEOAS
// Each state provides different hypermedia links
// ===== STATE 1: Order Created (Pending Payment) =====
// GET /api/orders/789
{
"id": 789,
"user_id": 123,
"status": "pending",
"total": 59.98,
"created_at": "2026-01-09T10:30:00Z",
// HATEOAS: Available actions based on current state
"_links": {
"self": {
"href": "/api/orders/789",
"method": "GET",
"rel": "self"
},
"user": {
"href": "/api/users/123",
"method": "GET",
"rel": "related"
},
"items": {
"href": "/api/orders/789/items",
"method": "GET",
"rel": "collection"
},
// Action: Pay for the order
"pay": {
"href": "/api/orders/789/payment",
"method": "POST",
"rel": "action"
},
// Action: Update order details
"update": {
"href": "/api/orders/789",
"method": "PATCH",
"rel": "edit"
},
// Action: Cancel the order
"cancel": {
"href": "/api/orders/789",
"method": "DELETE",
"rel": "delete"
}
}
}
// ===== STATE TRANSITION: Client follows "pay" link =====
// POST /api/orders/789/payment
// { "payment_method": "card", "token": "tok_visa" }
// Server processes payment and transitions order to "paid" state
// ===== STATE 2: Order Paid =====
// GET /api/orders/789
{
"id": 789,
"user_id": 123,
"status": "paid",
"total": 59.98,
"created_at": "2026-01-09T10:30:00Z",
"paid_at": "2026-01-09T10:35:00Z",
// HATEOAS: Different links - state changed!
"_links": {
"self": {
"href": "/api/orders/789",
"method": "GET",
"rel": "self"
},
"user": {
"href": "/api/users/123",
"method": "GET",
"rel": "related"
},
"items": {
"href": "/api/orders/789/items",
"method": "GET",
"rel": "collection"
},
// "pay" link removed (already paid)
// "update" link removed (can't modify paid order)
// "cancel" link removed (can't cancel paid order)
// New links based on new state:
"invoice": {
"href": "/api/orders/789/invoice.pdf",
"method": "GET",
"rel": "related"
},
"track_shipment": {
"href": "/api/orders/789/tracking",
"method": "GET",
"rel": "related"
},
"request_refund": {
"href": "/api/orders/789/refund",
"method": "POST",
"rel": "action"
}
}
}
// ===== STATE 3: Order Shipped =====
// After warehouse ships the order
// GET /api/orders/789
{
"id": 789,
"user_id": 123,
"status": "shipped",
"total": 59.98,
"created_at": "2026-01-09T10:30:00Z",
"paid_at": "2026-01-09T10:35:00Z",
"shipped_at": "2026-01-09T14:20:00Z",
"tracking_number": "1Z999AA10123456784",
"_links": {
"self": {
"href": "/api/orders/789",
"method": "GET",
"rel": "self"
},
"user": {
"href": "/api/users/123",
"method": "GET",
"rel": "related"
},
"items": {
"href": "/api/orders/789/items",
"method": "GET",
"rel": "collection"
},
"invoice": {
"href": "/api/orders/789/invoice.pdf",
"method": "GET",
"rel": "related"
},
// Enhanced tracking now available
"tracking": {
"href": "/api/orders/789/tracking",
"method": "GET",
"rel": "related"
},
"tracking_external": {
"href": "https://www.ups.com/track?tracknum=1Z999AA10123456784",
"method": "GET",
"rel": "external"
},
// Refund still possible but more restricted
"request_refund": {
"href": "/api/orders/789/refund",
"method": "POST",
"rel": "action",
"note": "Refund available within 30 days of delivery"
}
}
}
// ===== CLIENT CODE: Navigating via HATEOAS =====
// Client doesn't hardcode URLs - it follows links
async function navigateOrder(orderId) {
// Start by fetching the order
let response = await fetch(`/api/orders/${orderId}`);
let order = await response.json();
// Client examines available links
if (order._links.pay) {
// "pay" link exists, so payment is possible
console.log("Order is pending payment");
console.log("Pay at:", order._links.pay.href);
}
if (order._links.track_shipment) {
// "track_shipment" link exists, so order is shipped or in transit
console.log("Order is shipped");
console.log("Track at:", order._links.track_shipment.href);
}
// Client follows links dynamically
if (order._links.invoice) {
// Download invoice by following link
const invoiceUrl = order._links.invoice.href;
window.open(invoiceUrl, '_blank');
}
// No hardcoded URLs - all navigation via hypermedia
}
Diagram
stateDiagram-v2
[*] --> Created: POST /api/orders
state Created {
[*] --> Links1
Links1: _links.pay
Links1: _links.update
Links1: _links.cancel
}
Created --> Paid: Follow pay link
POST /api/orders/789/payment
state Paid {
[*] --> Links2
Links2: _links.invoice
Links2: _links.track_shipment
Links2: _links.request_refund
Links2: β pay (removed)
Links2: β cancel (removed)
}
Paid --> Shipped: Warehouse ships
Server updates state
state Shipped {
[*] --> Links3
Links3: _links.tracking
Links3: _links.tracking_external
Links3: _links.invoice
Links3: _links.request_refund*
}
Shipped --> Delivered: Delivery confirmed
state Delivered {
[*] --> Links4
Links4: _links.invoice
Links4: _links.leave_review
Links4: _links.reorder
Links4: β request_refund (30 days passed)
}
Delivered --> [*]
Created --> Cancelled: Follow cancel link
DELETE /api/orders/789
Cancelled --> [*]
note right of Created
Client discovers actions
by examining _links
end note
note right of Paid
Available actions change
based on state
end note
note right of Shipped
Hypermedia guides
client through workflow
end note
Security Notes
CRITICAL: HATEOAS inadvertently exposes sensitive information through available action links.
Link Authorization:
- Filter links by permission: Only include action links the user can perform
- Don’t expose link existence: Don’t reveal links for unauthorized actions
- Server-side validation required: Validate permissions even if client discovered a link manually
- Prevent URL construction: Clients can manually construct URLs; validate all requests
Link Security:
- Signed/tokenized links: Use signed or time-limited tokens for sensitive operations
- Link expiration: Time-limit action links (e.g., payment approval links)
- CSRF protection: Include CSRF tokens in state-changing links when using cookies
- Validate link source: Ensure links haven’t been tampered with
Information Disclosure Prevention:
- Hide internal IDs: Don’t expose database IDs or internal resource identifiers
- Hide business logic: Don’t reveal implementation details through link structures
- Minimize metadata: Avoid exposing relationships and internal structure
Enumeration & Reconnaissance:
- Rate limit link discovery: Prevent attackers from enumerating available operations
- Log traversal patterns: Monitor suspicious patterns of link traversal and exploration
- Validate ownership: Ensure users can only access links for resources they own
Transport Security:
- HTTPS exclusively: Protect links in transit (links reveal structure and resource IDs)
- No unencrypted links: Never transmit sensitive links over HTTP
Best Practices
- Include links for all valid state transitions: Only show actions the resource can currently perform
- Use standard link relation types:
self,related,next,prev,edit,deletefrom RFC 5988 - Remove unavailable actions: Don’t show “cancel” link if order is already shipped
- Include method information: Specify GET, POST, PUT, DELETE for each link
- Use absolute URLs: Avoid relative paths requiring client-side construction
- Follow hypermedia standards: Use HAL, JSON:API, Siren, or Collection+JSON
- Make links self-documenting: Use descriptive
relattributes and optionaltitlefields - Implement link templates: Use RFC 6570 URI templates for parameterized links
- Provide forms for complex actions: Include schema information for POST/PUT payloads
- Start simple, evolve: Begin with basic HATEOAS and add complexity as needed
Common Mistakes
Including all possible links regardless of state: Showing “pay” link even when order is already paid violates HATEOAS principles.
Hardcoding URLs in client code: Defeats the purpose of HATEOAS and creates tight coupling.
Missing rel attributes: Clients can’t understand link purpose without relation types.
Inconsistent link formats: Mixing different hypermedia standards confuses clients.
No documentation of link relations: Custom rel values without documentation are useless.
Ignoring authorization: Showing links for actions the user isn’t allowed to perform is a security risk.
Relative URLs without base: Providing /orders/123 instead of https://api.example.com/orders/123 requires clients to know the base URL.
Static documentation instead of dynamic discovery: Writing “to pay an order, POST to /orders/:id/payment” in docs instead of providing the link dynamically.
Breaking link contracts: Changing link structures between API versions breaks clients that follow links.
Over-engineering: Implementing complex HATEOAS for simple, stable APIs where it adds little value.