HTTP API
The HTTP API lets you expose your machine instances as standard HTTP endpoints. Instead of requiring clients to use the State Backed SDK or API directly, you can accept arbitrary HTTP requests, map them to machine events, and return custom HTTP responses — turning any state machine into a web service.
This is useful when you want to:
- Accept webhooks from third-party services
- Provide a custom REST API backed by durable state machines
- Integrate with systems that can only make plain HTTP requests
How it works
- You export an
httpApiMapperfrom your machine version code alongside your machine definition,allowRead, andallowWrite. - A request arrives at
/http-api/{orgId}/machines/{machineSlug}/{httpApiSlug}. - State Backed calls the
handlerfor the matching slug, passing it the raw HTTP request details (body, headers, method, and query parameters). - Your handler validates the request, performs any authentication checks, and returns the name of the machine instance to target, the event to send, an
authContext, and an optionalinitialContext. - If the machine instance doesn't exist and you provided
initialContext, State Backed auto-creates it with the providedinitialContext. Then,allowWriteis called with theauthContextyour handler returned and the event is sent to the instance. - The machine processes the event and settles.
- State Backed calls your
responseMapperwith the settled machine state, context, and result. - Your
responseMapperreturns the HTTP status code, headers, and body to send back to the caller.
Your handler is responsible for authenticating the raw HTTP request (e.g. validating a JWT, checking API keys, verifying signatures). The authContext it returns is then passed to your allowWrite function, just like in a normal State Backed API call. This means both your handler and allowWrite participate in authorization — the handler authenticates the HTTP request and constructs the appropriate authContext, and allowWrite gates event delivery based on it.
Defining an httpApiMapper
Export an httpApiMapper from your machine version. The mapper is a record keyed by slug strings. Each slug becomes a separate HTTP endpoint at /http-api/{orgId}/machines/{machineSlug}/{slug}.
import { createMachine, assign } from "xstate";
function verifyAndDecodeJwt(authHeader) {
// Verify the JWT and return the decoded payload.
// Throw if the token is missing, invalid, or expired.
}
export const allowRead = ({ authContext, context }) => {
return authContext.sub === context.userId;
};
export const allowWrite = ({ authContext, context }) => {
return authContext.sub === context.userId;
};
export default createMachine({
id: "order",
initial: "pending",
context: {
items: [],
total: 0,
orderId: null,
userId: null,
},
states: {
pending: {
on: {
place: {
target: "placed",
actions: assign({
items: (_, evt) => evt.items,
total: (_, evt) => evt.total,
}),
},
},
},
placed: {
on: {
fulfill: "fulfilled",
cancel: "cancelled",
},
},
fulfilled: {
type: "final",
data: (ctx) => ({ orderId: ctx.orderId, total: ctx.total }),
},
cancelled: {
type: "final",
},
},
});
export const httpApiMapper = {
"place-order": {
handler: (request) => {
if (request.method !== "POST") {
throw new Error("Method not allowed");
}
const jwt = verifyAndDecodeJwt(request.headers["authorization"]);
const { orderId, items, total } = request.body;
if (!orderId || !items || !total) {
throw new Error("Missing required fields");
}
return {
machineInstanceName: orderId,
event: { type: "place", items, total },
authContext: { sub: jwt.sub },
initialContext: { items: [], total: 0, orderId, userId: jwt.sub },
};
},
responseMapper: ({ state, context }) => ({
statusCode: 200,
headers: { "content-type": "application/json" },
body: {
orderId: context.orderId,
status: state,
total: context.total,
},
}),
},
};
A POST to /http-api/{orgId}/machines/order/place-order with a valid JWT will:
- Validate the method and JWT, extracting the caller's
subclaim. - Create or find the machine instance named by
orderId. - Pass
{ sub: jwt.sub }as theauthContexttoallowWrite, which checks it against the instance'suserId. - Send the
placeevent with the order data. - Return the settled state as a JSON response.
If the handler throws (e.g. bad method, invalid JWT, missing fields), the request fails before any machine instance is created or event is sent.
Example request
curl --request POST \
https://api.statebacked.dev/http-api/$ORG_ID/machines/order/place-order \
--header "Authorization: Bearer $JWT" \
--header "Content-Type: application/json" \
--data '{
"orderId": "order-123",
"items": ["item-a", "item-b"],
"total": 49.99
}'
Request flow in detail
Handler input
Your handler function receives an HttpApiRequest:
{
body: unknown, // parsed request body
headers: Record<string, string>,
method: string, // "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS"
query: Record<string, string>,
}
Handler output
Your handler returns an HttpApiHandlerResponse telling State Backed which machine instance to target:
{
machineInstanceName: string, // which instance to send the event to
event: { type: string }, // the event to send
authContext: object, // passed to allowWrite to authorize the event
initialContext: object, // optional: create the instance with this context if it doesn't exist
}
Provide initialContext when you want State Backed to auto-create the machine instance if it doesn't already exist. If you omit it and the instance doesn't exist, the request will fail.
Response mapper input
After the machine settles, your responseMapper receives:
{
state: string | object, // the current state value of the machine
context: object, // the full machine context
result: unknown | null, // the machine's output if it reached a final state, otherwise null
}
Response mapper output
Your responseMapper returns the full HTTP response:
{
statusCode: number,
headers: Record<string, string>,
body: unknown,
}
If the responseMapper throws an error, State Backed falls back to returning { ok: true } with a 200 status code. Ensure your response mapper handles all possible machine states.
Multiple endpoints
You can define multiple slugs in your httpApiMapper, each becoming a separate endpoint:
export const httpApiMapper = {
"place-order": {
handler: (request) => { /* ... */ },
responseMapper: ({ state, context }) => { /* ... */ },
},
"cancel-order": {
handler: (request) => {
const jwt = verifyAndDecodeJwt(request.headers["authorization"]);
return {
machineInstanceName: request.body.orderId,
event: { type: "cancel" },
authContext: { sub: jwt.sub },
};
},
responseMapper: ({ state }) => ({
statusCode: 200,
headers: { "content-type": "application/json" },
body: { status: state },
}),
},
};