JSON API
- Introduction
- Returning JSON Responses
- Adding ToJSON Instances
- Reading JSON Request Bodies
- Building a REST API
- API Routing
- Setting Response Headers
- Error Responses
- Authentication for APIs
Introduction
IHP can serve JSON responses alongside regular HTML views. This is useful when you need to build API endpoints for mobile apps, single-page applications (SPAs), or third-party integrations.
This guide shows you how to return JSON from controllers, read JSON request bodies, build a full REST API, configure routing and CORS, return proper error responses, and handle authentication for API endpoints.
Returning JSON Responses
The simplest way to return JSON from a controller action is renderJson. It takes any value that has a ToJSON instance and sends it as an application/json response:
module Web.Controller.Posts where
import Web.Controller.Prelude
instance Controller PostsController where
action PostsAction = do
renderJson (object ["status" .= ("ok" :: Text), "message" .= ("Hello from IHP!" :: Text)])
The Data.Aeson module (which provides object, .=, ToJSON, FromJSON, etc.) is already re-exported by Web.Controller.Prelude, so you do not need any extra imports for basic JSON work.
You can return any value that implements ToJSON – strings, numbers, booleans, lists, maps, or your own types:
action PostsAction = do
renderJson ("Hello" :: Text)
-- Response: "Hello"
action PostsAction = do
renderJson (42 :: Int)
-- Response: 42
action PostsAction = do
renderJson (object ["users" .= (["alice", "bob"] :: [Text])])
-- Response: {"users":["alice","bob"]}
Adding ToJSON Instances
IHP database records do not have ToJSON instances by default. You need to define them yourself, which gives you full control over exactly what fields appear in your JSON output.
Manual ToJSON Instance
Suppose you have a posts table with columns id, title, body, and created_at. You can write a ToJSON instance like this:
instance ToJSON Post where
toJSON post = object
[ "id" .= post.id
, "title" .= post.title
, "body" .= post.body
, "createdAt" .= post.createdAt
]
Place this instance in the controller file or view file where you need it. If multiple controllers need the same instance, you can put it in a shared module (e.g. Web/Controller/Prelude.hs or a dedicated Web/JsonInstances.hs).
Now you can return posts as JSON:
action PostsAction = do
posts <- query @Post |> fetch
renderJson posts
This will produce a response like:
[
{
"id": "5a8a4c5e-1b5e-4b2a-9c0a-3e5e4b8e1b5e",
"title": "My First Post",
"body": "Hello, world!",
"createdAt": "2025-01-15T10:30:00Z"
}
]
Controlling JSON Field Names
You have full control over the JSON keys. For example, if your API consumers expect snake_case keys:
instance ToJSON Post where
toJSON post = object
[ "id" .= post.id
, "title" .= post.title
, "body" .= post.body
, "created_at" .= post.createdAt
]
Nested Objects
You can nest objects and include related data:
instance ToJSON Post where
toJSON post = object
[ "id" .= post.id
, "title" .= post.title
, "body" .= post.body
, "author" .= object
[ "id" .= post.authorId
]
]
FromJSON Instances
If you need to parse incoming JSON into a custom type (not an IHP record), define a FromJSON instance:
data CreatePostPayload = CreatePostPayload
{ title :: Text
, body :: Text
}
instance FromJSON CreatePostPayload where
parseJSON = withObject "CreatePostPayload" \o -> do
title <- o .: "title"
body <- o .: "body"
pure CreatePostPayload { title, body }
Reading JSON Request Bodies
Using param with JSON Bodies
When a client sends a request with Content-Type: application/json, IHP automatically parses the JSON body. The param function works with both form-encoded and JSON request bodies. For a JSON body like:
{
"title": "My Post",
"body": "Hello, world!"
}
You can read individual fields the same way you would with form parameters:
action CreatePostAction = do
let title = param @Text "title"
let body = param @Text "body"
...
This means fill also works with JSON request bodies:
action CreatePostAction = do
let post = newRecord @Post
post
|> fill @'["title", "body"]
|> ifValid \case
Left post -> renderJson (object ["error" .= ("Validation failed" :: Text)])
Right post -> do
post <- post |> createRecord
renderJson post
Using requestBodyJSON
For more control, use requestBodyJSON to get the entire parsed JSON body as an Aeson Value. This is useful when the JSON structure does not map directly to form-style key-value pairs, or when you want to decode it into a custom type with FromJSON:
action CreatePostAction = do
payload <- requestBodyJSON
-- payload is an Aeson.Value that you can pattern match or decode
...
If the request body is not valid JSON (or the Content-Type is not application/json), requestBodyJSON will automatically respond with a 400 error – you do not need to handle that case yourself.
Using FromJSON for Structured Parsing
For complex payloads, parse the JSON body into a custom type using Aeson’s fromJSON:
import qualified Data.Aeson as Aeson
data CreatePostPayload = CreatePostPayload
{ title :: Text
, body :: Text
, tags :: [Text]
}
instance FromJSON CreatePostPayload where
parseJSON = withObject "CreatePostPayload" \o -> do
title <- o .: "title"
body <- o .: "body"
tags <- o .:? "tags" .!= []
pure CreatePostPayload { title, body, tags }
instance Controller PostsController where
action CreatePostAction = do
jsonBody <- requestBodyJSON
case Aeson.fromJSON jsonBody of
Aeson.Success CreatePostPayload { title, body, tags } -> do
post <- newRecord @Post
|> set #title title
|> set #body body
|> createRecord
renderJson post
Aeson.Error err -> do
renderJsonWithStatusCode status400 (object ["error" .= err])
Building a REST API
Here is a complete example of a CRUD API for a posts resource. We will assume a posts table with id, title, body, and created_at columns.
Types
Define the controller actions in Web/Types.hs:
data PostsController
= PostsAction
| ShowPostAction { postId :: !(Id Post) }
| CreatePostAction
| UpdatePostAction { postId :: !(Id Post) }
| DeletePostAction { postId :: !(Id Post) }
deriving (Eq, Show, Data)
Controller
Implement the controller in Web/Controller/Posts.hs:
module Web.Controller.Posts where
import Web.Controller.Prelude
import Network.HTTP.Types (status422)
instance Controller PostsController where
-- GET /Posts
action PostsAction = do
posts <- query @Post |> fetch
renderJson posts
-- GET /ShowPost?postId=...
action ShowPostAction { postId } = do
post <- fetch postId
renderJson post
-- POST /CreatePost
action CreatePostAction = do
let post = newRecord @Post
post
|> fill @'["title", "body"]
|> validateField #title nonEmpty
|> validateField #body nonEmpty
|> ifValid \case
Left post -> do
renderJsonWithStatusCode status422 (validationErrorsToJson post)
Right post -> do
post <- post |> createRecord
renderJson post
-- POST /UpdatePost?postId=... or PATCH /UpdatePost?postId=...
action UpdatePostAction { postId } = do
post <- fetch postId
post
|> fill @'["title", "body"]
|> validateField #title nonEmpty
|> validateField #body nonEmpty
|> ifValid \case
Left post -> do
renderJsonWithStatusCode status422 (validationErrorsToJson post)
Right post -> do
post <- post |> updateRecord
renderJson post
-- DELETE /DeletePost?postId=...
action DeletePostAction { postId } = do
post <- fetch postId
deleteRecord post
renderJson (object ["success" .= True])
instance ToJSON Post where
toJSON post = object
[ "id" .= post.id
, "title" .= post.title
, "body" .= post.body
, "createdAt" .= post.createdAt
]
-- | Convert validation errors on a record to a JSON object
validationErrorsToJson :: (HasField "meta" record MetaBag) => record -> Value
validationErrorsToJson record =
let MetaBag { annotations } = record.meta
in object
[ "errors" .= object (map (\(field, violation) -> (fromString (cs field)) .= violation.message) annotations)
]
Testing the API
You can test the API endpoints with curl:
# List all posts
curl http://localhost:8000/Posts
# Show a single post
curl http://localhost:8000/ShowPost?postId=5a8a4c5e-1b5e-4b2a-9c0a-3e5e4b8e1b5e
# Create a post (form-encoded)
curl -X POST http://localhost:8000/CreatePost \
-d "title=My Post" \
-d "body=Hello, world!"
# Create a post (JSON)
curl -X POST http://localhost:8000/CreatePost \
-H "Content-Type: application/json" \
-d '{"title": "My Post", "body": "Hello, world!"}'
# Update a post
curl -X POST http://localhost:8000/UpdatePost?postId=5a8a4c5e-... \
-H "Content-Type: application/json" \
-d '{"title": "Updated Title", "body": "Updated body"}'
# Delete a post
curl -X DELETE http://localhost:8000/DeletePost?postId=5a8a4c5e-...
API Routing
Using AutoRoute
The simplest approach is to use AutoRoute, the same as with HTML controllers. Add to Web/Routes.hs:
instance AutoRoute PostsController
And register the controller in Web/FrontController.hs:
instance FrontController WebApplication where
controllers =
[ startPage WelcomeAction
, parseRoute @PostsController
]
This gives you URLs like /Posts, /ShowPost?postId=..., /CreatePost, etc.
API Controllers Alongside HTML Controllers
You can have API controllers coexist with HTML controllers in the same application. Just give them different names:
-- Web/Types.hs
-- HTML controller for browser views
data PostsController
= PostsAction
| NewPostAction
| ShowPostAction { postId :: !(Id Post) }
| CreatePostAction
| EditPostAction { postId :: !(Id Post) }
| UpdatePostAction { postId :: !(Id Post) }
| DeletePostAction { postId :: !(Id Post) }
deriving (Eq, Show, Data)
-- JSON API controller
data ApiPostsController
= ApiPostsAction
| ApiShowPostAction { postId :: !(Id Post) }
| ApiCreatePostAction
| ApiUpdatePostAction { postId :: !(Id Post) }
| ApiDeletePostAction { postId :: !(Id Post) }
deriving (Eq, Show, Data)
Register both in the FrontController:
instance FrontController WebApplication where
controllers =
[ parseRoute @PostsController
, parseRoute @ApiPostsController
]
Serving Both HTML and JSON from the Same Action
If you want one action to serve HTML to browsers and JSON to API clients, use renderHtmlOrJson with a view that implements both View (for HTML) and JsonView (for JSON):
-- In the controller
action ShowPostAction { postId } = do
post <- fetch postId
renderHtmlOrJson ShowView { post }
-- In the view
instance View ShowView where
html ShowView { post } = [hsx|
<h1>{post.title}</h1>
<p>{post.body}</p>
|]
instance JsonView ShowView where
json ShowView { post } = toJSON post
When a browser requests the page (sending Accept: text/html), it gets the HTML response. When an API client requests it with Accept: application/json, it gets JSON. You can test this:
# Get HTML (default)
curl http://localhost:8000/ShowPost?postId=...
# Get JSON
curl -H "Accept: application/json" http://localhost:8000/ShowPost?postId=...
Custom API Routes
For RESTful URLs like /api/posts and /api/posts/:id, define custom routing with CanRoute and HasPath:
-- Web/Types.hs
data ApiPostsController
= ApiListPostsAction
| ApiGetPostAction { postId :: !(Id Post) }
| ApiCreatePostAction
| ApiUpdatePostAction { postId :: !(Id Post) }
| ApiDeletePostAction { postId :: !(Id Post) }
deriving (Eq, Show, Data)
-- Web/Routes.hs
instance CanRoute ApiPostsController where
parseRoute' = do
string "/api/posts"
let
listAction = do
endOfInput
onlyAllowMethods [GET, HEAD]
pure ApiListPostsAction
getAction = do
string "/"
postId <- parseId
endOfInput
onlyAllowMethods [GET, HEAD]
pure ApiGetPostAction { postId }
createAction = do
endOfInput
onlyAllowMethods [POST]
pure ApiCreatePostAction
updateAction = do
string "/"
postId <- parseId
endOfInput
onlyAllowMethods [PATCH]
pure ApiUpdatePostAction { postId }
deleteAction = do
string "/"
postId <- parseId
endOfInput
onlyAllowMethods [DELETE]
pure ApiDeletePostAction { postId }
createAction <|> updateAction <|> deleteAction <|> getAction <|> listAction
instance HasPath ApiPostsController where
pathTo ApiListPostsAction = "/api/posts"
pathTo ApiGetPostAction { postId } = "/api/posts/" <> tshow postId
pathTo ApiCreatePostAction = "/api/posts"
pathTo ApiUpdatePostAction { postId } = "/api/posts/" <> tshow postId
pathTo ApiDeletePostAction { postId } = "/api/posts/" <> tshow postId
Note that createAction is tried before getAction in the alternation. This is because both /api/posts (without a trailing id) match the same prefix. Since createAction checks for POST and getAction checks for GET/HEAD, the parser needs to try the non-id routes first when there is no trailing /.
Then register the controller in Web/FrontController.hs:
instance FrontController WebApplication where
controllers =
[ parseRoute @ApiPostsController
-- ...
]
Now you have RESTful URLs:
GET /api/posts => ApiListPostsAction
GET /api/posts/:id => ApiGetPostAction
POST /api/posts => ApiCreatePostAction
PATCH /api/posts/:id => ApiUpdatePostAction
DELETE /api/posts/:id => ApiDeletePostAction
Separate API Application
For larger projects, you can create a separate IHP application for your API (similar to how you might add an Admin application). Create an Api/ directory with its own Types.hs, Routes.hs, FrontController.hs, and controllers. Then mount it in your main Web/FrontController.hs:
instance FrontController WebApplication where
controllers =
[ mountFrontController ApiApplication
, parseRoute @PostsController
]
The API controllers will automatically be prefixed with /api/ (based on the module name Api).
Setting Response Headers
Using setHeader
Use setHeader to add custom headers to the response:
action PostsAction = do
setHeader ("X-Request-Id", "abc-123")
posts <- query @Post |> fetch
renderJson posts
The Content-Type header is automatically set to application/json by renderJson, so you do not need to set it manually.
CORS Headers
If your API is called from a different domain (e.g., a JavaScript frontend on a different host), you need to configure CORS. IHP has built-in CORS support via the wai-cors library.
Configure CORS in Config/Config.hs:
module Config where
import IHP.Prelude
import IHP.Environment
import IHP.FrameworkConfig
import qualified Network.Wai.Middleware.Cors as Cors
config :: ConfigBuilder
config = do
option $ Just Cors.simpleCorsResourcePolicy
{ Cors.corsOrigins = Nothing -- Allow all origins. Use Just (["https://example.com"], True) to restrict.
, Cors.corsMethods = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
, Cors.corsRequestHeaders = ["Content-Type", "Authorization", "Accept"]
}
simpleCorsResourcePolicy provides sensible defaults. You customize it by overriding individual fields.
To restrict CORS to specific origins:
option $ Just Cors.simpleCorsResourcePolicy
{ Cors.corsOrigins = Just (["https://myapp.example.com", "http://localhost:3000"], True)
, Cors.corsMethods = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
, Cors.corsRequestHeaders = ["Content-Type", "Authorization", "Accept"]
}
The second element in the tuple (True) enables the Access-Control-Allow-Credentials header.
Error Responses
When building an API, you want error responses in JSON rather than HTML. Here are patterns for common error cases.
Validation Errors (422)
When record validation fails, return a 422 status with the validation errors:
import Network.HTTP.Types (status422)
action CreatePostAction = do
let post = newRecord @Post
post
|> fill @'["title", "body"]
|> validateField #title nonEmpty
|> ifValid \case
Left post -> do
let MetaBag { annotations } = post.meta
let errors = object (map (\(field, violation) -> (fromString (cs field)) .= violation.message) annotations)
renderJsonWithStatusCode status422 (object ["errors" .= errors])
Right post -> do
post <- post |> createRecord
renderJson post
Not Found (404)
Return a JSON 404 response using renderJsonWithStatusCode:
import Network.HTTP.Types (status404)
renderNotFoundJson :: (?request :: Request) => IO ()
renderNotFoundJson =
renderJsonWithStatusCode status404 (object ["error" .= ("Not found" :: Text)])
Use it in a controller:
action ShowPostAction { postId } = do
postMaybe <- query @Post |> filterWhere (#id, postId) |> fetchOneOrNothing
case postMaybe of
Just post -> renderJson post
Nothing -> renderNotFoundJson
Bad Request (400)
import Network.HTTP.Types (status400)
action CreatePostAction = do
renderJsonWithStatusCode status400 (object ["error" .= ("Invalid request" :: Text)])
Custom Status Codes
Use renderJsonWithStatusCode with any HTTP status code from Network.HTTP.Types. You will need to import the status codes you use:
import Network.HTTP.Types (status201, status403, status404, status422, status500)
-- 201 Created
action CreatePostAction = do
post <- newRecord @Post
|> fill @'["title", "body"]
|> createRecord
renderJsonWithStatusCode status201 post
-- 403 Forbidden
action ShowSecretAction = do
renderJsonWithStatusCode status403 (object ["error" .= ("Access denied" :: Text)])
beforeAction for API-Wide Error Handling
Use beforeAction to run logic before every action in a controller. This is a good place to verify authentication and return JSON errors:
import Network.HTTP.Types (status401)
instance Controller ApiPostsController where
beforeAction = do
let apiKey = getHeader "X-API-Key"
when (apiKey /= Just "secret-key") do
renderJsonWithStatusCode status401 (object ["error" .= ("Invalid API key" :: Text)])
action ApiListPostsAction = do
posts <- query @Post |> fetch
renderJson posts
...
When renderJsonWithStatusCode (or any render function) is called in beforeAction, it short-circuits the request – the main action is never executed.
Authentication for APIs
API Key Authentication
A simple approach for service-to-service communication. Check for an API key in a request header:
import Network.HTTP.Types (status401)
instance Controller ApiPostsController where
beforeAction = requireApiKey
action ApiListPostsAction = do
posts <- query @Post |> fetch
renderJson posts
requireApiKey :: (?request :: Request) => IO ()
requireApiKey = do
let apiKey = getHeader "X-API-Key"
when (apiKey /= Just expectedApiKey) do
renderJsonWithStatusCode status401 (object ["error" .= ("Invalid or missing API key" :: Text)])
where
expectedApiKey = "your-secret-api-key" -- In practice, load from environment variable
For production, load the API key from an environment variable using IHP’s config system:
-- Config/Config.hs
newtype ApiKey = ApiKey Text
config :: ConfigBuilder
config = do
apiKey <- ApiKey <$> env @Text "API_KEY"
option apiKey
Then use it in the controller:
requireApiKey :: (?context :: ControllerContext, ?request :: Request) => IO ()
requireApiKey = do
let (ApiKey expectedKey) = getAppConfig @Config.ApiKey
let providedKey = getHeader "X-API-Key"
when (providedKey /= Just (cs expectedKey)) do
renderJsonWithStatusCode status401 (object ["error" .= ("Invalid or missing API key" :: Text)])
Bearer Token Authentication
For user-facing APIs, use bearer tokens in the Authorization header:
requireBearerToken :: (?request :: Request) => IO ()
requireBearerToken = do
let authHeader = getHeader "Authorization"
case authHeader of
Just header | "Bearer " `isPrefixOf` header -> do
let token = ByteString.drop 7 header
-- Validate the token (e.g., look it up in the database)
tokenValid <- validateToken (cs token)
unless tokenValid do
renderJsonWithStatusCode status401 (object ["error" .= ("Invalid token" :: Text)])
_ -> do
renderJsonWithStatusCode status401 (object ["error" .= ("Missing Authorization header" :: Text)])
Session-Based Authentication
If your API is consumed by a browser-based SPA on the same domain, you can reuse IHP’s built-in session authentication. Since IHP sets an HTTP-only session cookie, AJAX requests from the same origin will automatically include it. See the Authentication guide for how to set up session-based authentication.
HTTP Basic Authentication
For simple cases (e.g., protecting a staging API), use IHP’s built-in basicAuth:
instance Controller ApiPostsController where
beforeAction = basicAuth "admin" "secret-password" "api"
See the Authentication guide for more details.