HATEOAS Siren — Hypermedia Format with Actions, Fields, and Entities
In this tutorial, you will learn about HATEOAS Siren. We cover key concepts, practical examples, and best practices to help you master this topic.
Siren is a hypermedia format that describes entities with class-based typing, navigable links, and executable actions with input fields, making APIs both discoverable and self-documenting.
What You'll Learn
- Siren entity structure (class, properties, links, actions, entities)
- Defining actions with input fields and methods
- Embedded sub-entities for related resources
- Client navigation with Siren
- Comparing Siren to HAL
Why It Matters
Siren goes beyond HAL by including actions — executable operations with typed input fields. A client can discover not just where to go (links) but what it can do (actions). DodaTech's Durga Antivirus Pro uses Siren for its configuration API, letting the dashboard display forms for device quarantine, threat analysis, and config updates based on the API's actions.
Real-World Use
A client fetches a device resource and discovers a quarantine action with fields for reason (text) and duration (number). The dashboard renders a quarantine form dynamically based on the action definition. When the action changes (e.g., adding a new field), the client adapts without code changes.
flowchart TB
A["GET /devices/1"] --> B["Siren Entity"]
B --> C["class: ['device']"]
B --> D["properties: { name, os, status }"]
B --> E["links: [self, threats, scans]"]
B --> F["actions: [quarantine, analyze, update]"]
B --> G["entities: [recentThreats]"]
F --> H["quarantine: { method: POST, href: /devices/1/quarantine, fields: [reason, duration] }"]
H --> I["Client renders form from action fields"]
style A fill:#dbeafe,stroke:#2563eb
Code Examples
Example 1: Siren Entity Structure
{
"class": ["device", "endpoint"],
"properties": {
"deviceId": "1",
"name": "Office-PC",
"os": "Windows 11",
"status": "active",
"ipAddress": "192.168.1.100",
"lastSeen": "2026-06-28T10:30:00Z"
},
"links": [
{ "rel": ["self"], "href": "/devices/1" },
{ "rel": ["threats"], "href": "/devices/1/threats" },
{ "rel": ["scans"], "href": "/devices/1/scans" },
{ "rel": ["config"], "href": "/devices/1/config" }
],
"actions": [
{
"name": "quarantine",
"title": "Quarantine Device",
"method": "POST",
"href": "/devices/1/quarantine",
"type": "application/json",
"fields": [
{ "name": "reason", "type": "text", "title": "Quarantine Reason" },
{ "name": "duration", "type": "number", "title": "Duration (hours)", "value": 24 },
{ "name": "notifyUser", "type": "checkbox", "title": "Notify user", "value": true }
]
},
{
"name": "analyze",
"title": "Run Threat Analysis",
"method": "POST",
"href": "/devices/1/analyze",
"type": "application/json",
"fields": [
{ "name": "scanType", "type": "select", "title": "Scan Type", "value": "full",
"options": [
{ "title": "Quick Scan", "value": "quick" },
{ "title": "Full Scan", "value": "full" },
{ "title": "Custom", "value": "custom" }
]
}
]
}
],
"entities": [
{
"class": ["threat", "recent"],
"rel": ["item"],
"properties": {
"threatId": "101",
"name": "Ransomware-X",
"severity": "critical"
},
"links": [
{ "rel": ["self"], "href": "/threats/101" }
]
}
]
}
Example 2: Siren Server Implementation
const express = require('express');
const app = express();
function sirenEntity(classNames, properties, links, actions, entities) {
const entity = { class: classNames, properties };
if (links && links.length > 0) entity.links = links;
if (actions && actions.length > 0) entity.actions = actions;
if (entities && entities.length > 0) entity.entities = entities;
return entity;
}
function sirenLink(rel, href) {
return { rel: Array.isArray(rel) ? rel : [rel], href };
}
function sirenAction(name, title, method, href, fields) {
return {
name,
title,
method,
href,
type: 'application/json',
fields: fields.map(f => ({
name: f.name,
type: f.type,
title: f.title,
...(f.value !== undefined ? { value: f.value } : {}),
...(f.options ? { options: f.options } : {}),
})),
};
}
// Device endpoint with Siren
app.get('/devices/:id', (req, res) => {
const device = getDevice(req.params.id);
const threats = getThreatsForDevice(req.params.id);
const response = sirenEntity(
['device', device.status === 'quarantined' ? 'quarantined' : 'active'],
{ ...device },
[
sirenLink('self', `/devices/${device.id}`),
sirenLink('threats', `/devices/${device.id}/threats`),
sirenLink('scans', `/devices/${device.id}/scans`),
],
[
sirenAction(
'quarantine', 'Quarantine Device', 'POST',
`/devices/${device.id}/quarantine`,
[
{ name: 'reason', type: 'text', title: 'Reason' },
{ name: 'duration', type: 'number', title: 'Hours', value: 24 },
],
),
sirenAction(
'analyze', 'Run Analysis', 'POST',
`/devices/${device.id}/analyze`,
[
{ name: 'scanType', type: 'select', title: 'Type', value: 'full',
options: [
{ title: 'Quick', value: 'quick' },
{ title: 'Full', value: 'full' },
],
},
],
),
],
threats.map(t => sirenEntity(
['threat'],
{ ...t },
[sirenLink('self', `/threats/${t.id}`)],
)),
);
res.json(response);
});
Example 3: Siren Client
class SirenClient {
constructor(baseUrl) {
this.baseUrl = baseUrl;
}
async fetch(url) {
const res = await fetch(`${this.baseUrl}${url}`);
return res.json();
}
async executeAction(action, data) {
const options = {
method: action.method,
headers: { 'Content-Type': action.type || 'application/json' },
};
if (action.method !== 'GET') {
options.body = JSON.stringify(data);
}
const res = await fetch(`${this.baseUrl}${action.href}`, options);
return res.json();
}
renderForm(action) {
console.log(`\nAction: ${action.title}`);
const formData = {};
for (const field of action.fields) {
const value = field.value || '';
if (field.type === 'select') {
const options = field.options.map(o => o.value).join(', ');
console.log(` ${field.title} [${options}]: ${value}`);
} else {
console.log(` ${field.title} (${field.type}): ${value}`);
}
formData[field.name] = value;
}
return formData;
}
async navigate() {
// Discover and render actions dynamically
const device = await this.fetch('/devices/1');
console.log('Device:', device.properties.name);
console.log('Available actions:');
for (const action of device.actions) {
const formData = this.renderForm(action);
// Execute with form data
const result = await this.executeAction(action, formData);
console.log('Action result:', result);
}
}
}
Common Mistakes
- Not using class for entity typing — classes let clients distinguish between entity types. Always include meaningful class values.
- Actions without proper field definitions — fields must include type and title so clients can render appropriate form controls.
- Hardcoding HTTP methods in clients — the action defines the method. Clients should use the method from the action, not assume POST.
- Forgetting to include available actions — an action that isn't advertised in Siren can't be discovered. Every executable operation should have an action definition.
- Not filtering actions by state — a quarantined device shouldn't show a quarantine action. Filter actions based on the entity's current state.
Practice Questions
- How does Siren differ from HAL in representing operations?
- What is the purpose of the
classfield in a Siren entity? - How do clients execute actions discovered in Siren?
- How should actions change based on entity state?
- What field types does Siren support for action inputs?
Challenge: Design a Siren API for a threat management system where a threat entity has different available actions based on its status (new, analyzing, quarantined, resolved). Include appropriate field definitions for each action.
Mini Project
Build a Siren-based REST API for a device management system with: entities that change their available actions based on state, actions with typed input fields (text, number, select, checkbox), embedded sub-entities for recent threats, and a JavaScript Siren client that dynamically renders forms from action definitions.
FAQ
What's Next
Learn HATEOAS API design principles
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro