# Phoenix JSON API

> Handles Phoenix-specific JSON API construction end-to-end. Use when building or modifying Phoenix API controllers, router pipelines, FallbackController error handling, paginated list endpoints, URL-versioned API routes (/api/v1/), or Bearer token authentication plugs in an Elixir/Phoenix application. Covers the full workflow from route definition to structured JSON error responses. Trigger words: Phoenix JSON API, API pipeline, FallbackController, paginated API, Bearer token plug, API versioning, Elixir API controller, action_fallback.

- Skill: `igmarin/phoenix-json-api` (Agent Skill)
- Install (CLI): `npx skillmds@latest add igmarin/phoenix-json-api`
- Raw SKILL.md: https://api.skillmd.com/api/skills/igmarin/phoenix-json-api/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- License: MIT
- Author: igmarin (https://skillmd.com/u/igmarin)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/igmarin/phoenix-json-api

---


# Phoenix JSON API


Canonical FP bar: [`docs/fcis-engineering-rules.md`](../../docs/fcis-engineering-rules.md) — **Functional Core, Imperative Shell**: pure domain modules; side effects at edges. Keep LiveView/controller callbacks thin; delegate business rules to contexts/pure modules.

## RULES — Follow these with no exceptions

**1.** **Use the `:api` pipeline** — don't mix HTML and JSON pipelines; API routes skip CSRF and sessions
**2.** **Render errors as structured JSON** — `{:error, changeset}` must become `{"errors": {...}}`
**3.** **Version APIs via URL prefix (`/api/v1/`)** — not headers; URL versioning is visible and cacheable
**4.** **Use `FallbackController` for consistent error handling** — every action returns `{:ok, result}` or `{:error, reason}`


## Build Workflow

Follow these steps in order when constructing a new API endpoint:

1. **Define the route** in the `:api` (or `:api_auth`) pipeline scope with a versioned URL prefix
2. **Create the controller** with `action_fallback MyAppWeb.FallbackController` and return `{:ok, _}` / `{:error, _}` from every action
3. **Verify error responses** — confirm that invalid input and missing resources return structured JSON (e.g., `{"errors": {...}}`) before proceeding
4. **Add the auth plug** (`ApiAuth`) to protected scopes; confirm that missing/invalid tokens yield `401` with a JSON body
5. **Paginate list endpoints** — ensure `index` accepts `page`/`per_page` params and never returns an unbounded collection


## API Pipeline Setup

❌ **Bad — one mixed pipeline (drags CSRF/session into JSON) and header-based versioning:**
```elixir
scope "/api", MyAppWeb do
  pipe_through :browser  # CSRF + session cookies on a JSON API

  # version selected from a header — invisible and hard to cache
  resources "/posts", PostController
end
```

✅ **Good — a dedicated `:api` pipeline with a versioned URL prefix:**
```elixir
defmodule MyAppWeb.Router do
  use MyAppWeb, :router

  pipeline :api do
    plug :accepts, ["json"]
  end

  pipeline :api_auth do
    plug MyAppWeb.Plugs.ApiAuth
  end

  # Public endpoints
  scope "/api/v1", MyAppWeb.API.V1, as: :api_v1 do
    pipe_through :api

    post "/auth/login", AuthController, :login
    post "/auth/register", AuthController, :register
  end

  # Protected endpoints
  scope "/api/v1", MyAppWeb.API.V1, as: :api_v1 do
    pipe_through [:api, :api_auth]

    resources "/posts", PostController, except: [:new, :edit]
  end
end
```


## Controller Pattern

❌ **Bad — no `action_fallback`, error handling duplicated inline, unstructured error body:**
```elixir
def create(conn, %{"post" => post_params}) do
  case Blog.create_post(post_params) do
    {:ok, post} ->
      json(conn, %{data: post})

    {:error, _changeset} ->
      # plain string, no status control, inconsistent shape
      json(conn, %{error: "could not create"})
  end
end
```

✅ **Good — `action_fallback` handles every `{:error, _}`; actions stay focused on the happy path:**
```elixir
defmodule MyAppWeb.API.V1.PostController do
  use MyAppWeb, :controller

  alias MyApp.Blog
  alias MyApp.Blog.Post

  action_fallback MyAppWeb.FallbackController

  def index(conn, params) do
    page = parse_int(params["page"], 1)
    per_page = parse_int(params["per_page"], 20) |> min(100)

    {posts, total} = Blog.list_posts(page: page, per_page: per_page)

    conn
    |> put_resp_header("x-total-count", to_string(total))
    |> json(%{
      data: Enum.map(posts, &post_json/1),
      meta: %{page: page, per_page: per_page, total: total}
    })
  end

  def create(conn, %{"post" => post_params}) do
    with {:ok, %Post{} = post} <- Blog.create_post(post_params) do
      conn
      |> put_status(:created)
      |> put_resp_header("location", ~p"/api/v1/posts/#{post}")
      |> json(%{data: post_json(post)})
    end
  end

  defp post_json(post) do
    %{
      id: post.id,
      title: post.title,
      body: post.body,
      inserted_at: post.inserted_at
    }
  end

  defp parse_int(nil, default), do: default
  defp parse_int(value, default) when is_binary(value) do
    case value |> String.trim() |> Integer.parse() do
      {n, ""} -> max(n, 1)
      _ -> default
    end
  end
  defp parse_int(value, default) when is_integer(value), do: max(value, 1)
  defp parse_int(_, default), do: default
end
```


## FallbackController

```elixir
defmodule MyAppWeb.FallbackController do
  use MyAppWeb, :controller

  def call(conn, {:error, :not_found}) do
    conn
    |> put_status(:not_found)
    |> json(%{errors: %{detail: "Resource not found"}})
  end

  def call(conn, {:error, :unauthorized}) do
    conn
    |> put_status(:unauthorized)
    |> json(%{errors: %{detail: "Not authorized"}})
  end

  def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
    conn
    |> put_status(:unprocessable_entity)
    |> json(%{errors: format_changeset_errors(changeset)})
  end

  defp format_changeset_errors(changeset) do
    Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
      Enum.reduce(opts, msg, fn {key, value}, acc ->
        String.replace(acc, "%{#{key}}", to_string(value))
      end)
    end)
  end
end
```


## Bearer Token Authentication

```elixir
defmodule MyAppWeb.Plugs.ApiAuth do
  import Plug.Conn
  import Phoenix.Controller

  def init(opts), do: opts

  def call(conn, _opts) do
    with ["Bearer " <> token] <- get_req_header(conn, "authorization"),
         {:ok, user} <- MyApp.Accounts.get_user_by_api_token(token) do
      assign(conn, :current_user, user)
    else
      _ ->
        conn
        |> put_status(:unauthorized)
        |> json(%{errors: %{detail: "Invalid or missing token"}})
        |> halt()
    end
  end
end
```


## Common Pitfalls

| ❌ Don't | ✅ Do |
|----------|-------|
| Route JSON through the `:browser` pipeline | Use a dedicated `:api` pipeline (`plug :accepts, ["json"]`) |
| Version via a custom header | Version via URL prefix (`/api/v1/`) — visible and cacheable |
| Handle `{:error, _}` inline in every action | Set `action_fallback` and return `{:ok, _}`/`{:error, _}` |
| Return `changeset` or a bare string on error | Render structured JSON (`%{errors: %{...}}`) with a proper status |
| Return an unbounded `index` collection | Paginate with `page`/`per_page`, cap `per_page` |
| Trust or echo a missing/invalid token | Reject in the auth plug with `401` + JSON body and `halt()` |

---

## Integration

| Predecessor | This Skill | Successor |
|-------------|------------|-----------|
| elixir-essentials | phoenix-json-api | testing-essentials |
| security-essentials | phoenix-json-api | req-http-client |

