Skip to content
Looking for every option?See the full API reference →

REST API ​

The avo-api add-on exposes a JSON REST API for every Avo resource. It reuses your resources' field definitions, view visibility rules, and Pundit policies, so a resource you already built for the admin panel is available over HTTP — list, read, create, update, and delete.

This page covers installation, mounting, API tokens and authentication, reading and writing data, token entitlements, authorization, how the current user is established, and managing tokens in the panel. Every generator, hook, and option is in the API reference.

Add-on

The REST API ships as the separate avo-api gem. On avohq.io/pricing the add-on is called API; earlier pages and invoices may call it "JSON API" or "REST API". It's the same add-on, and it includes the CLI. See the add-on page →

Installation ​

Add the gem:

ruby
# Gemfile
gem "avo-api", source: "https://packager.dev/avo-hq/"
bash
bundle install

Then install:

bash
rails generate avo_api:install

This creates one controller per Avo resource under app/controllers/avo/api/resources/v1/, plus a BaseResourcesController that all of them inherit from, and the migration for the API tokens table. The base controller is where you put authentication and any global customization.

Two narrower generators exist for later: avo_api:generate regenerates the controllers alone, and avo_api:tokens adds the tokens table to an app that already has controllers — the full generator list is in the reference.

The generator is required, not optional

The API's routes are drawn from the controllers in your app's app/controllers/avo/api/resources/* directory. If you never run the generator, that directory doesn't exist and no API routes are drawn at all — every request 404s. There is no catch-all controller that serves resources you haven't generated.

The same applies to resources you add later: a resource with no controller gets no route, so generate one for each new resource or it won't be reachable over the API. It also won't be offered in a token's Entitlements panel, since there is nothing there to grant.

Pass --version to namespace under something other than v1:

bash
rails generate avo_api:install --version v2

Install the API tokens table ​

avo_api:install above already wrote the migrations for the avo_api_tokens table, so a first install needs only:

bash
rails db:migrate

Already running the API from an earlier version? Your app has controllers but no tokens table. Add it without touching them:

bash
rails generate avo_api:tokens
rails db:migrate

There is no initializer setting, environment variable, or credential to add — the feature needs none. Running it twice is a no-op rather than an error.

Skipping tokens entirely

Only if your app brings its own credential scheme. Then replace the authentication hook too, so nothing goes looking for a token that cannot exist.

Mount the API ​

Add mount_avo_api to your routes. It mounts the API engine at /api by default.

ruby
# config/routes.rb
Rails.application.routes.draw do
  mount_avo_api

  authenticate :user do
    mount_avo
  end
end

Mount the API outside your authentication block

If mount_avo lives inside an authenticate :user do … end block, mount_avo_api must be mounted outside and before it. Mounting the API inside the block forces every API request through your web session authentication, which breaks access for external clients.

This does not mean the API is unauthenticated — it authenticates itself (see Authentication). It just must not inherit the web interface's session guard.

ruby
# ❌ Don't — the API inherits web session auth and external access breaks
Rails.application.routes.draw do
  authenticate :user do
    mount_avo_api
    mount_avo
  end
end

Mount options ​

mount_avo_api accepts a mount path and forwards any option Rails' mount accepts — constraints:, defaults:, or a block for custom routes inside the engine.

ruby
# config/routes.rb

# Mount under Avo's root path (e.g. /admin/api)
mount_avo_api at: "#{Avo.configuration.root_path}/api"

Endpoints ​

Each generated resource controller gets standard RESTful endpoints under resources/v1. For a teams resource mounted at the default /api:

GET    /api/resources/v1/teams        # List teams
POST   /api/resources/v1/teams        # Create a team
GET    /api/resources/v1/teams/:id    # Show a team
PATCH  /api/resources/v1/teams/:id    # Update a team
PUT    /api/resources/v1/teams/:id    # Update a team
DELETE /api/resources/v1/teams/:id    # Delete a team

The path segment is the resource's route_key (e.g. blog_posts, product_categories).

API tokens are not served over the API

The token resource is skipped when routes are drawn, so no version namespace gets a tokens endpoint. Tokens are managed in the panel only — a credential can neither mint nor revoke credentials, and so cannot outlive being revoked.

Discovery ​

Three read-only GET endpoints tell a client which resources exist, which key a write nests under, and which fields each request takes, so none of that has to be hardcoded.

GET    /api/resources/v1/_schema                    # every resource the caller may reach
GET    /api/resources/v1/teams/_schema?view=create  # one resource's fields on one view
GET    /api/resources/v1/_openapi                   # the same, as one OpenAPI document

The resource listing ​

GET /api/resources/v1/_schema runs under the same authentication as the rest of the API and lists only the resources the caller may list and the token may reach.

json
{
  "api_version": "v1",
  "resources": [
    {
      "route_key": "teams",
      "param_key": "team",
      "name": "Team",
      "entitlements": ["index", "show", "create", "update", "destroy"]
    }
  ]
}
KeyMeaning
api_versionThe version namespace the request came through.
route_keyThe URL segment every other request is built from.
param_keyThe key a POST or PATCH body nests its fields under. Use it rather than singularizing route_key.
nameThe resource's singular title, for display.
entitlementsThe API actions the token may call on this resource. All five for a token that was never restricted.

One resource's fields ​

GET /api/resources/v1/teams/_schema?view=create lists one resource's fields on one view. view defaults to create.

viewLists
createWhat a POST body may send, with each field's field_options. The default.
updateWhat a PATCH or PUT body may send, with each field's field_options.
showWhat a record comes back with from GET /teams/:id.
indexWhat a row of GET /teams carries.
json
{
  "param_key": "team",
  "view": "create",
  "fields": [
    { "field_id": "name", "field_type": "text", "field_options": { "required": true } },
    { "field_id": "admin_id", "field_type": "belongs_to", "field_options": {} },
    { "field_id": "plan", "field_type": "select", "field_options": { "options": ["free", "pro"] } },
    { "field_id": "tags", "field_type": "select", "field_options": { "options": ["ops", "eu", "us"], "multiple": true } },
    { "field_id": "coordinates", "field_type": "location", "field_options": {} }
  ]
}
KeyMeaning
viewThe view the fields belong to: the one the request named, or create when it named none.
field_idThe key the field reads back under, or, on create and update, the key the body sets it through (admin_id).
field_typeThe Avo field type.
field_optionsForm views only: one object holding what a write needs to know about the field, the keys below. Read views carry none.
field_options.requiredtrue when a blank value is refused. Absent otherwise, so a missing key means not required.
field_options.optionsOn choice fields: the values the field accepts, never the labels.
field_options.multipletrue on a field that takes a list (select with multiple, checkbox_list, boolean_group); absent otherwise.

Each field's field_type says what to put under its field_id in a POST or PATCH body: see Field value formats.

Refusals

  • 403 with reason: "token_entitlement" when the token lacks the action the view is named after.
  • 403 with reason: "policy" when the token's owner cannot index the resource.
  • 400 with { "error": "Unknown view", "views": ["index", "show", "create", "update"] } for a view it doesn't know.
  • 404 when this version has no controller for the resource. No route exists then, so this one is Rails' own page, not the JSON body above.

OpenAPI document ​

GET /api/resources/v1/_openapi returns the whole API as one OpenAPI 3.1 file: every resource, every request, every field. Use it to plug the API into a tool that speaks OpenAPI. For your own requests, _schema above is smaller and enough.

Nothing is stored on the server. The document is built from your resources and the token's entitlements on every request, so there is no file to find in the app and nothing to regenerate after a deploy: request it again and it is current.

1. Download the document ​

You need two things: your app's URL and an API token you created in the panel. The token travels in a header, so send it with curl and save the response:

bash
curl https://YOUR-APP.com/api/resources/v1/_openapi \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -o openapi.json

To save it somewhere else, give the full path:

bash
curl https://YOUR-APP.com/api/resources/v1/_openapi \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -o ~/Downloads/openapi.json

Open openapi.json before importing it. It should start with {"openapi":"3.1.0"; if it holds {"error":"Unauthorized"} the token is wrong, and if it holds HTML the host or the mount path is.

2. Import it into your tool ​

Import openapi.json into any tool that reads OpenAPI 3.1, such as Swagger UI, Postman, or an API client generator.

Each token gets its own document

The file describes what the requesting token may do, nothing more. A read-only token gets no create, update or delete operations; a token granted two resources gets only those two. So export with the token the tool will use, and export again after you change its entitlements.

What is inside ​

PartContents
servers[0].urlThe version's base URL, so every path in the file is the path you request
SecurityBearer token, applied to every operation. Left out when no API token authenticated the request, as in an app that authenticates the API its own way, since the file cannot name your scheme
Paths/_schema, /{resource}/_schema, and one operation per action the token may call (get, post, patch/put, delete)
Record schemas<Name>Index and <Name>Show for what you read back; <Name>Create and <Name>Update for what you send, nested under param_key
Shared schemasError, ValidationErrors, Pagination
Error responses400, 401, 403, 404, 422

Every field property also carries x-avo-field-type (for example "belongs_to"), so a generator that cannot tell what a JSON type stands for can still see the Avo field behind it.

Authentication ​

Every request runs through setup_authentication, a hook on BaseResourcesController. The default implementation accepts a valid API token and rejects everything else with 401 Unauthorized:

json
{ "error": "Unauthorized" }

Raise Avo::Api::AuthenticationError anywhere in the hook to reject a request with that response.

Create a token ​

Once the tokens table is installed, an API tokens entry appears in the Avo sidebar. Create a token there with a name and, optionally, an expiry date.

The token belongs to whoever created it, the user config.current_user_method returns; without one, creating a token fails with "Owner must exist". The form has no owner field, and a token cannot be moved to another owner afterwards, so a crafted request cannot mint a credential belonging to somebody else.

The secret is shown once, on the new token's page immediately after creation, with a copy button. Only a digest of it is stored, so nothing can show it again — not a console, not a support tool, not a database dump. Miss it and there is no recovery beyond minting a replacement and revoking the one you lost.

What the list shows afterwards is a short leading slice of the secret (avo_a1B2c3D4...), enough to tell two tokens apart and far too little to guess one.

Send the token ​

Present the secret as a bearer credential in the Authorization header, over HTTPS:

bash
curl https://example.com/api/resources/v1/teams \
  -H "Authorization: Bearer avo_a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8s9T0u1V"

Use TLS, always

A token is a replayable, full-privilege bearer credential: anyone holding it acts as its owner, with all of that owner's access, until the token expires or is revoked. There is nothing bound to a device, an IP, or a request body to stop a copy of it working. Send it only to https:// endpoints.

Only that header, and only that scheme, is read. A credential in a query string is not read at all, so the request is rejected exactly like one carrying no credential.

A token sent in a URL is already compromised

Rejecting it doesn't undo the disclosure — by the time the request reached Avo it was in your web server's access log, and likely in a proxy log and a browser history too. Treat any token that has appeared in a URL as leaked: revoke it and mint a replacement.

Token lifecycle ​

A token's status is derived from two timestamps, never stored, so there is no lifecycle field to drift out of step:

StatusWhenAuthenticates
ActiveNot revoked, and either no expiry or an expiry still in the futureYes
ExpiredThe expiry instant has passedNo
RevokedThe Revoke action was run on itNo

Expiry is optional — leave it blank and the token never expires. Revocation is permanent: the Revoke action on the token resource marks the token and keeps the record, and nothing can un-revoke it. A token that is both revoked and past its expiry reports Revoked.

Each authenticated request stamps the token's Last used column, a refused one included, which is the fastest way to tell a token that was never wired up from one that stopped working.

Deleting a token's owner also stops the token: a token whose owner can no longer be resolved is rejected, so a request never proceeds with nobody attached to it.

Every rejection looks the same

Absent, malformed, unknown, expired, revoked, and orphaned credentials all produce the identical 401 and { "error": "Unauthorized" } body — the response tells a caller nothing about which one it was.

So debug a 401 from the panel, not from the response. Check the token's Status first (expired and revoked are the common answers), then whether its owner still exists, then Last used to see whether the request reached this token at all.

A token acts as its owner ​

An authenticated request runs as the token's owner, and every policy that owner is subject to applies to it unchanged. That is the ceiling: a token is never more powerful than the person who created it, so an administrator's token reaches everything an administrator reaches.

A token can be less powerful, though. Entitlements narrow it to a chosen set of resources and actions — always a subset of what the owner may already do, never an addition to it. A token nobody restricted reaches everything its owner does.

Your app's own authentication still runs ​

Token traffic is not exempt from whatever you put in config.authenticate_with. Avo can't tell an authentication check there from an IP allowlist, a tenant assignment, or a maintenance gate, so the block is satisfied rather than skipped: where Devise is present and the token's owner is a Devise-mapped model, the owner is signed in for that one request (store: false, so no session is written) and a standard session check passes on its own terms. Everything else in the block still runs, and a token request your rules reject stays rejected.

If your block can't be satisfied that way — another authentication library, or a check against a scope the owner isn't in — guard it yourself:

ruby
# config/initializers/avo.rb
Avo.configure do |config|
  config.authenticate_with do
    next if Avo::Api.token_request?

    authenticate_user!
  end
end

Avo::Api.token_request? is supported public API and returns true while a valid API token is authenticating the request in flight. Guard on it rather than on anything inside the gem's controllers.

Keep the credential safe ​

Three rules, two of them above: send it only over TLS, and treat a token that has appeared in a URL as leaked. The third is about the one moment the plaintext exists at all.

The one-time reveal transits your session store

The plaintext secret is never written to the tokens table, but it does ride the flash from the create request to the page that displays it. That means it passes through whatever session store the app is configured with. Rails' default cookie store keeps it in the visitor's own encrypted cookie; a server-side store writes it to disk (or to Redis) for that one hop. The page itself is sent Cache-Control: no-store and opts out of Turbo's snapshot cache, but neither reaches your session store.

How the secret is stored ​

The token itself is never stored. The table keeps a SHA-256 digest of it and a short leading slice for display — nothing a database dump, a backup, a replica, or a leaked SQL log can be turned back into a working credential. Authentication hashes what arrives and looks the digest up, so the plaintext exists only in transit and in whatever the caller saved it to.

A few consequences worth knowing:

  • A leaked database yields no usable tokens. Reversing SHA-256 is not feasible, and neither is guessing: a secret is 43 random alphanumeric characters, about 256 bits of entropy, so there is no dictionary or rainbow table to try. Rotation after a leak is prudent, not urgent.
  • No pepper or key to manage. Peppering defends short, low-entropy secrets like passwords. These are neither, so the digest is unkeyed — nothing to configure, nothing to rotate, nothing that breaks every token if it is lost.
  • The digest column is uniquely indexed, so the lookup is a single indexed read and two tokens cannot collide.

Who may mint and revoke tokens is your app's authorization decision, and the default is permissive — see Who may manage tokens.

Reading data ​

Index ​

GET /api/resources/v1/teams returns records visible on the :index view, plus pagination:

json
{
  "records": [
    { "id": 1, "name": "Development Team", "url": "https://dev.company.com" },
    { "id": 2, "name": "Marketing Team", "url": "https://marketing.company.com" }
  ],
  "pagination": {
    "page": 1,
    "per_page": 25,
    "total_pages": 1,
    "total_count": 2,
    "has_next_page": false,
    "has_prev_page": false
  }
}

Pagination: page (default 1) and per_page (default config.per_page). Each must be an integer of 1 or more; anything else is refused with a 400 naming the parameter at fault:

json
{ "error": "Invalid pagination", "parameter": "per_page", "expected": "an integer of 1 or more" }

Sorting: sort_by (field) and sort_direction (asc / desc).

bash
GET /api/resources/v1/teams?page=2&per_page=10&sort_by=name&sort_direction=asc

Filters are not supported yet

The index endpoint does not apply the resource's filters, and a filters parameter is ignored. To select records by value, page through the index and filter on the client.

per_page is remembered in a cookie

Avo stores per_page in a cookie so the admin panel remembers the reader's choice. A client that keeps cookies between requests (a browser, or a scripted session with a cookie jar) will keep the last per_page it sent even when it omits the parameter. Send per_page explicitly on every request if you need a fixed page size.

Filtering is not supported

The index has no supported way to select records by value. To find records matching a condition, page through the index and filter on the client.

Show ​

GET /api/resources/v1/teams/1 returns a single record with fields visible on the :show view:

json
{
  "record": {
    "id": 1,
    "name": "Development Team",
    "url": "https://dev.company.com",
    "admin": { "id": "5", "label": "John Doe" },
    "team_members": { "count": 12 }
  }
}

Field serialization ​

Fields are respected according to their view visibility, and typed values are serialized per field type:

Field typeShape
Text, number, boolean, date/datetimeThe raw value
belongs_to{ "id": "5", "label": "John Doe" }. The id is the related record's to_param, always a string: the slug or prefixed id where the model has one
has_many, has_and_belongs_to_many{ "count": 12 }, counted through the related resource's Scope
has_one{ "id": "5" }, the related record's to_param, or null when the related resource's Scope hides it
file{ "filename": "…", "content_type": "…", "byte_size": 1234, "url": "…" }
filesa list of those objects, one per attachment

Field visibility follows your resource's view settings, so you can shape the API per view:

ruby
# app/avo/resources/team.rb
class Avo::Resources::Team < Avo::BaseResource
  def fields
    field :id, as: :id
    field :name, as: :text
    field :url, as: :text

    field :internal_notes, as: :text, hide_on: :index   # hidden from the index response
    field :logo, as: :external_image, only_on: :show     # only in the show response
  end
end

A policy can also withhold a field from a user outright, with whitelisted_fields / blacklisted_fields on the resource's policy. A withheld field is absent from every response and refused on write, so a client cannot set it by naming it in a payload. Avo::Current.interface is :api for every request this add-on serves, so a policy can withhold more from a token than from the panel. See Field authorization.

File URLs are permanent

file and files fields serialize the standard attachment URL. These are not signed or expiring — anyone who obtains the URL can fetch the file. Keep that in mind before exposing attachments to clients you don't control.

Writing data ​

Send field data as JSON, nested under the resource's param_key. It is usually the singular name (team), but not always: an HTTP resource uses avo/http_resource/planet. Read it from the schema rather than guess it.

Create ​

bash
curl -X POST https://example.com/api/resources/v1/teams \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{ "team": { "name": "Mobile Team", "url": "https://mobile.company.com", "admin_id": "5" } }'

On success the response is 201 Created with the record serialized on its :show view:

json
{
  "record": { "id": 3, "name": "Mobile Team", "url": "https://mobile.company.com", "admin": { "id": "5", "label": "John Doe" } }
}

On validation failure it's 422 Unprocessable Entity:

json
{
  "errors": { "name": ["can't be blank"], "url": ["is not a valid URL"] },
  "message": "Failed to create Team"
}

Update ​

PATCH for a partial update, PUT for a full one. Both return the updated record on its :show view (200 OK).

bash
curl -X PATCH https://example.com/api/resources/v1/teams/1 \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{ "team": { "name": "Updated Mobile Team" } }'

Delete ​

bash
curl -X DELETE https://example.com/api/resources/v1/teams/1 \
  -H "Authorization: Bearer <token>"

Success (200 OK):

json
{ "message": "Team successfully deleted" }

Failure (422 Unprocessable Entity):

json
{
  "errors": { "base": ["Cannot delete a team with active projects"] },
  "message": "Failed to delete Team"
}

Field value formats ​

Different field types accept the formats you'd expect:

FieldExample value
Text / string"Team Name"
Number42, 19.99
Booleantrue, false
Date / datetime"2024-01-15", "2024-01-15T10:30:00Z"
belongs_tothe foreign key, as the id a read returns: "admin_id": "5"
Polymorphic belongs_toboth parts: "reactable_type": "Post", "reactable_id": "7"
select with multiple, checkbox_list, boolean_groupa list: "tags": ["ops", "eu"]
location on two columnsan object keyed by its stored_as columns: "coordinates": { "latitude": 44.43, "longitude": 26.10 }
tags, location on one columnone string the field parses: "skills": "ruby,rails", "home": "44.43,26.10"
key_valuean object, or the same JSON as a string: "settings": { "theme": "dark" }, "settings": "{\"theme\":\"dark\"}". Older Avo versions wrote {} for an object, so send the string to stay compatible
codeone string, stored as it is unless the field is declared with pretty_generated: true, which parses it as JSON

null clears any field, a list and a location on two columns included. The one exception is a location on one column, which splits the string it is given: clear it with "", since null is answered with a 500. A has_many or has_one key is not a field a body can set; the API ignores it, null included.

CSRF and JSON clients

API controllers use Rails' :null_session CSRF strategy, so a stateless client that carries no CSRF token is not rejected — no InvalidAuthenticityToken is raised. self.setup_csrf_protection is the hook if you need a different strategy.

Entitle a token ​

A token starts out able to do everything its owner can. Entitlements narrow it: an allowlist, held on the token itself, of the resources it may reach and what it may do on each. Set them in the Entitlements panel — on the token's page, and on the create and edit forms — there's no initializer setting and nothing to configure globally, because entitling is per token.

Each granted resource is held at one of two levels:

LevelAPI actions it permits
Readindex, show
Read & Writeindex, show, create, update, destroy

Granting one resource restricts every other one

A token with no grants at all is unrestricted — it reaches everything its owner's policies allow. That's every token minted without anyone opening the panel.

Grant anything, and that list becomes the whole of what the token may reach. Every resource you didn't grant is refused — including resources your app ships later. A deploy never widens a token; widening one is always a deliberate act in the panel.

Grant a resource ​

Every resource the token's owner can reach is a row, set to None, Read, or Read & Write. None means not granted — there's no separate list of grants and nothing to remove.

Above the rows, a search box narrows the list as you type, and Set all shown applies one level to whatever the search is currently showing: "read-only across the board" is one click, "read-only on everything matching order" is a search away.

Clicking a level changes nothing on its own. The moment the grid differs from what's stored, Apply changes and Undo appear — Apply writes the whole grid at once, Undo snaps back. On the create and edit forms it's the same grid, and your Save is what commits it, so a token can be entitled as it's minted rather than sitting unrestricted until you come back to it.

An unrestricted token shows one line instead of the grid. Fine-tune opens it with every row already at Read & Write — which is what unrestricted means for this owner — so you narrow from there instead of building the list up from nothing. Nothing is stored until you apply or save.

Two things never get a row:

  • Resources the token's owner can't reach. The rows are resolved through the owner's own policies, so an administrator restricting somebody else's token can't grant past what that person already sees.
  • The API tokens resource itself. It has no API endpoint at all (why), so granting it would promise something no route can keep.

Read a token's access at a glance ​

The tokens index sums each token's entitlements up in an Access column, so the token that can write stands out without opening every one. Hover the badge for how many resources it reaches.

AccessMeans
UnrestrictedNo grants at all: everything the owner's policies allow, including resources added later.
Read onlyEvery granted resource is at Read.
Read & writeEvery granted resource is at Read & Write.
CustomGranted resources sit at different levels, or one holds an action set the grid can't name.
No accessEvery resource is at None: the token is refused everywhere.

Take a token back to unrestricted ​

Setting every row to None does not land where you started. A token with nothing granted is entitled to nothing — it refuses every request. That's a legitimate thing to want, and it is not the same as never having entitled the token at all.

Make unrestricted is the way back: it drops every grant and returns the token to reaching everything its owner can. The panel says which of the two states a token is in rather than leaving you to infer it — a collapsed Unrestricted line, or a footer counting what's granted against what could be.

Entitlements sit in front of your policies, never instead of them ​

The check runs on the way in — before any record is loaded and before your authorization runs — and it can only ever subtract. A granted request then goes through your policies and policy scopes exactly as the same request would without a token.

So a token granted Read & Write on Orders still cannot destroy an order its owner's policy protects: the grant opens the door, the policy still decides. There is no grant that lets a token exceed its owner.

Two consequences worth knowing:

  • A refusal can't be used to probe for records. Because the gate runs before anything loads, an ungranted request gets the identical response whether the id exists or not.
  • Only gem-issued tokens are gated. If your setup_authentication replaces the built-in one without calling super, no token is in flight and no entitlement check runs — your scheme is in sole charge.

Tell the three refusals apart ​

A refused request answers one of three ways, and they're deliberately distinguishable:

ResponseWhat happenedWhere you fix it
401 { "error": "Unauthorized" }The credential didn't authenticate — absent, malformed, unknown, expired, revoked, or orphaned (all identical on purpose)Mint or rotate the token
403 { "error": "Forbidden", "reason": "token_entitlement" }The credential authenticated fine; the token's grants don't cover this resource or this actionThe token's Entitlements panel
403 { "error": "Forbidden", "reason": "policy" }The grants allow it; the owner's policy denied itYour policy classes, or the token's owner

reason is the whole point of the split: it tells an operator holding the response which lever to pull — widen the token, or fix the policy — without having to reproduce the request from the panel. The full status-code table is in the reference.

Who may change entitlements ​

Editing a token's entitlements is gated by its own policy method, edit_entitlements?, exactly like revoke? gates the Revoke action:

ruby
# app/policies/avo/api/token_policy.rb
class Avo::Api::TokenPolicy < ApplicationPolicy
  def edit_entitlements? = user.admin?
end

Denied, the panel renders the grid read-only rather than disappearing — so someone who can see a token can always see what it reaches, and is told plainly that they can't change it.

Without an authorization client, everyone may entitle every token

edit_entitlements? is asked through the resource's authorization service, and with no client configured every such question answers yes — the same permissive default that applies to minting and revoking. Nothing in the gem restricts entitlement editing on its own.

What entitlements don't cover ​

Entitlements constrain resources and actions. They do not constrain rows or attributes:

  • Which records come back is still your policy scopes' job, unchanged. A token granted Read on Orders sees exactly the orders its owner sees — no more, and no fewer.
  • Which fields are serialized is still the resource's per-view visibility (only_on: / hide_on:).
  • Custom controller actions. A grant holds the five REST actions; an action you route onto a generated controller yourself is refused for every restricted token — only an unrestricted token reaches it.

Renaming a resource class orphans its grants

Grants are stored under the Avo resource's class name (Avo::Resources::Order). Rename that class and the grant no longer matches anything, so the token is refused on the renamed resource — the safe direction, but a silent one.

The grid doesn't show it, and doesn't lose it. It lists only resources the token's owner can currently reach, so a grant on a class that no longer exists — or on one the owner's policies stopped letting them index — is held without a row and left untouched by every apply. Grant the new name like any other resource; Make unrestricted is what clears the leftovers.

Bring your own authentication ​

Override setup_authentication on BaseResourcesController to use a different scheme. Your override wins by ordinary inheritance, and omitting super opts out of tokens entirely.

super changed meaning

In an override of setup_authentication, super used to reject the request. It now accepts a valid API token. Check every existing override before upgrading — see the upgrade note.

Set the current user ​

Authenticating the request is only half the job. Avo's authorization — Pundit policies and policy scopes — runs against Avo::Current.user, and a hand-rolled setup_authentication does not set it. If you verify a credential but never establish a user, Avo::Current.user stays nil and every policy scope receives nil. (The built-in token path does this for you: a token resolves to its owner, and a token whose owner can't be resolved is rejected rather than let through as nobody.)

Avo::Current.user is assigned from whatever config.current_user_method resolves to, evaluated in the controller instance:

ruby
# config/initializers/avo.rb
Avo.configure do |config|
  config.current_user_method = :current_user
end

So with that setting, the API controller's current_user method is what the policies see. Make that method return the authenticated user:

ruby
# app/controllers/avo/api/resources/v1/base_resources_controller.rb
module Avo::Api::Resources::V1
  class BaseResourcesController < ResourcesController
    def setup_authentication
      token = request.headers["Authorization"].to_s.delete_prefix("Bearer ")
      @api_user = User.find_by(api_token: token) if token.present?

      raise Avo::Api::AuthenticationError if @api_user.nil?
    end

    # This is what `config.current_user_method` resolves to,
    # and therefore what Pundit receives.
    def current_user
      @api_user
    end
  end
end

Assigning Avo::Current.user directly does not work

setup_authentication runs before Avo's init_app callback, and init_app then overwrites Avo::Current.user with whatever config.current_user_method returns. Setting Avo::Current.user = user inside the hook is silently discarded. Always go through current_user (or whichever method you configured).

A global config.current_user_method do … end block is the alternative, but it's evaluated for the admin UI too, so you'd have to branch on the request. Overriding current_user in the API controller keeps the two paths separate.

Authentication examples ​

Your own bearer token, resolving to a user — see the snippet above. Use this when the credential lives in your app rather than in Avo's tokens table.

HTTP Basic with Devise. sign_in(user, store: false) is what makes current_user return the user for the rest of the request, so no current_user override is needed here:

ruby
# app/controllers/avo/api/resources/v1/base_resources_controller.rb
module Avo::Api::Resources::V1
  class BaseResourcesController < ResourcesController
    def setup_authentication
      raise Avo::Api::AuthenticationError unless authenticate_with_http_basic do |email, password|
        user = User.find_by(email: email)
        user&.valid_password?(password) ? sign_in(user, store: false) : false
      end
    end
  end
end

Shared API key for a trusted server-to-server integration. A single shared key identifies no particular user, so pick the account the request should act as — otherwise your policies get nil:

ruby
# app/controllers/avo/api/resources/v1/base_resources_controller.rb
module Avo::Api::Resources::V1
  class BaseResourcesController < ResourcesController
    def setup_authentication
      provided = request.headers["Authorization"].to_s.delete_prefix("ApiKey ")

      unless ActiveSupport::SecurityUtils.secure_compare(provided, ENV.fetch("API_KEY"))
        raise Avo::Api::AuthenticationError
      end
    end

    def current_user
      @current_user ||= User.find_by!(email: "integration@example.com")
    end
  end
end

Disable authentication for one resource — public, unauthenticated reads:

ruby
# app/controllers/avo/api/resources/v1/users_controller.rb
module Avo::Api::Resources::V1
  class UsersController < BaseResourcesController
    skip_before_action :setup_authentication
  end
end

A public endpoint still has a nil user

Skipping authentication leaves Avo::Current.user as nil. Any policy scope that calls a method on user (user.admin?) raises NoMethodError and the request 500s. Guard for nil in your scopes before exposing an endpoint this way.

Authorization ​

Your existing Pundit policies apply to the API automatically — the same Scope classes and policy methods that protect the admin panel filter the API response. Nothing API-specific to configure.

  • Index resolves through the resource's Scope, so GET /api/resources/v1/comments returns only the records that user may see.
  • Show, update, and destroy look the record up through the same scope, so a record outside it is 404 Not Found, not a 403 — the API doesn't confirm that an out-of-scope record exists.
  • Association fields appear only when the record's policy allows view_<field>?. Under explicit_authorization they are also hidden when the policy doesn't define the method, as on the panel.
ruby
# app/policies/comment_policy.rb
class CommentPolicy < ApplicationPolicy
  class Scope < ApplicationPolicy::Scope
    def resolve
      user.admin? ? scope.all : scope.where(user:)
    end
  end
end

With that policy, an admin's GET /api/resources/v1/comments returns every comment; a regular user's returns only their own.

Policy scoping requires all three of the following. If any is missing, the query is handed back untouched — it is not scoped, every record is returned, and nothing raises:

RequirementWhere
The avo-authorization add-on installedGemfile
The avo-authorization feature enabled on your licenseYour Avo plan
An authorization client configuredconfig.authorization_client = :pundit

One exception: when your initializer sets a client and your license lacks the add-on, the API returns no records at all, and every policy method answers 403 with reason: "policy". See When the license lapses.

A nil user fails differently

Those three are configuration — miss one and the query is silently unscoped. A missing user is the opposite failure: with Pundit configured, scoping still runs, so nil reaches your Scope#resolve and the usual user.admin? raises NoMethodError. You get a 500, not a leak — the same trap described under Authentication examples.

Both have one cure: make sure config.current_user_method resolves to a real user on every API request.

A denied policy method answers 403, and says so

Policy methods (index?, update?, …) returning false raise Avo::NotAuthorizedError, which the API renders as JSON:

json
{ "error": "Forbidden", "reason": "policy" }

The reason is what separates this from an entitlement refusal, which is also a 403. Policy scopes are unaffected — they keep filtering the index silently, with no error at all.

This used to be a 302 redirect to your root URL, behavior meant for the HTML admin panel; see the upgrade note. A rescue_from Avo::NotAuthorizedError you added to your own BaseResourcesController to work around that still wins, so your response shape is unchanged.

Custom controllers ​

The generated controllers are yours to edit. Put behavior for one resource in its controller, and behavior for every resource in BaseResourcesController — all of them inherit from it. The overridable methods cover the actions, the success and failure responses, and serialization; per-controller generators exist for resources you add later.

ruby
# app/controllers/avo/api/resources/v1/users_controller.rb
module Avo::Api::Resources::V1
  class UsersController < BaseResourcesController
    def create_success_action
      render json: {
        record: serialize_record(@resource, :show),
        message: "Welcome! Your account has been created."
      }, status: :created
    end
  end
end

A guard on one controller does not cover the OpenAPI document

BaseResourcesController serves the OpenAPI document, so a before_action you add to one resource's controller never runs for it, and that resource's fields are still listed. To keep a resource out of the document, use the token's entitlements or the resource's index? policy.

Your admin panel controllers are not inherited ​

Each resource has two controllers, one for the admin panel and one for the API. The API controller does not inherit from the admin panel one:

ServesFile
Admin panelapp/controllers/avo/users_controller.rb
APIapp/controllers/avo/api/resources/v1/users_controller.rb

A method you override in the admin panel controller has no effect on the API. To change how the API behaves, override the method in the API controller.

Array resources ​

An array resource is served like any other resource. Generate its controller, and index, show and _schema work with no code in it.

Accept writes ​

An array record is saved by the two methods the array resource page describes, save_record and destroy_record. They live on the resource, so the API uses the same ones as the panel and its controller needs no code.

Without them, a create, update or delete answers 422 and names the method the record lacks:

json
{
  "errors": { "base": ["undefined method 'save!' for an instance of Avo::Movie"] },
  "message": "Failed to create Movie"
}

What decides access ​

  • self.writable is for the panel only. It shows or hides the Create, Edit and Delete controls. Over the API, the token's entitlements and the resource's policy decide who may write.
  • Name the policy on the resource. Avo builds the record's class at runtime, so set self.authorization_policy = BookmarkPolicy rather than rely on a lookup by class name.
  • A policy scope does not narrow the list. The rows are what records returns. The scope's resolve is handed the record's class, not a relation, so return it untouched.

Sorting ​

An array resource is not sorted. sort_by is ignored and the request still answers 200, with the rows in the order records returns them.

HTTP resources ​

An HTTP resource needs no code in its API controller. Sorting and paging reach the remote API as they do in the panel. What differs is how a failing remote API answers.

A failed read answers 502 with { "error": "Bad Gateway", "message": "<the error>" }, and so does an update or delete, since both read the record first. A failed write answers 422: a timeout or other exception lands in errors.base, and a non-success status from the remote API leaves errors empty.

Manage tokens in the panel ​

Tokens are minted, entitled, and revoked in Avo — never over the API. The resource ships with the gem, so there is nothing to generate. Two things decide whether it works for your team: whether people can reach it, and what they may do once they are there.

Put it in the menu ​

Out of the box, nothing to do. Avo builds the sidebar from your registered resources, so an API tokens entry appears on its own — already filtered by the index? policy, so a user who may not list tokens never sees it.

A custom config.main_menu replaces that generated sidebar with exactly what you list, and the entry disappears until you add it back:

ruby
# config/initializers/avo.rb
Avo.configure do |config|
  config.main_menu = -> {
    # your other sections and resources

    section "API", icon: "tabler/outline/plug" do
      resource :"avo_api/token"
    end
  }
end

The name is the resource's own path — avo_api/token, written as a quoted symbol because of the slash. :token and :avo_api_token don't resolve.

A name that doesn't resolve is dropped in silence

The menu builder looks the resource up and skips the item when it finds nothing — no error, no log line. The entry is simply absent from the sidebar, which looks exactly like a permissions problem. If it doesn't appear, check the name before you go auditing policies.

A hand-listed item is not policy-filtered. The generated sidebar hides a resource whose index? is false; an item you list yourself renders for everyone, and the refusal only arrives after the click. Put the rule back with visible: — the authorize helper reuses the policy you already wrote instead of restating it:

ruby
resource :"avo_api/token", visible: -> {
  authorize current_user, Avo::Api::Token, "index?", raise_exception: false
}

Who may manage tokens ​

avo-api adds no setting for this. The token resource obeys your app's existing authorization exactly like any other resource, and the gem ships no policy and generates none — policy method names are configurable and the client need not be Pundit, so a supplied file would be wrong for those apps.

The model is Avo::Api::Token, so the policy is Avo::Api::TokenPolicy at app/policies/avo/api/token_policy.rb — the ordinary derivation, with nothing registered on the resource to redirect it.

Without an authorization client, every panel user can mint a token

And a token carries its owner's full privileges over every record. If avo-authorization isn't installed, config.authorization_client is nil, or the add-on isn't on your license and your initializer sets no client, nothing stands between a user who can sign in to Avo and a credential that reaches your whole API. That's your app's configuration rather than anything this feature decides, but decide it deliberately.

The opposite is just as true. With config.explicit_authorization at its default of true, a resource whose policy class is missing is denied, so the token resource is unreachable until you write one — the same rule that applies to every other resource in that app.

What each policy method controls ​

Each one gates both whether the control renders and whether it can be run, so a hidden Revoke button can't be reached by a direct request either.

MethodWhat it gates
index?The token list — and, in the generated sidebar, whether the API tokens entry appears at all
show?A single token's page
create? · new?Minting a token. The owner is assigned, never chosen, so this is "may you have a token", not "whose"
update? · edit?The edit form
destroy?Deleting the record outright — a different question from revoking it, which keeps the row
act_on?The Actions menu as a whole
revoke?The Revoke action. Offered only on an active token whatever this returns — an expired or revoked one has nothing left to withdraw
edit_entitlements?The Entitlements grid. Denying it renders the grid read-only rather than hiding it, and the form's write path strips what it refuses, so what is shown and what is accepted can't drift apart
view_audit_trail?The Audit trail table on a token's page, and the request that fills it. Avo's own view_<field>? for a has_many: under explicit_authorization (the default) the table is hidden until the policy answers it

The last four aren't Avo's standard CRUD set, but they're asked exactly the same way — through the resource's authorization service, so a client other than Pundit answers them in its own idiom, and names you remapped through config.authorization_methods are honored.

Everyone manages their own tokens ​

The common shape: each person mints and revokes their own credentials, administrators oversee all of them. Copy this and adjust the admin test to whatever your app uses.

ruby
# app/policies/avo/api/token_policy.rb
class Avo::Api::TokenPolicy < ApplicationPolicy
  # Open, because the Scope below is what makes "list" mean "mine". Minting is
  # open for the same reason — a new token belongs to whoever created it.
  def index?  = true
  def create? = true
  def new?    = create?

  # Everything that touches a token that already exists follows ownership.
  def show?    = mine?
  def update?  = mine?
  def edit?    = update?
  def destroy? = mine?

  # The Actions menu as a whole. Revoke is the only action here and gates
  # itself, so there's nothing to gain by closing the menu around it.
  def act_on? = true

  # Revoking is permanent, and changing what a token reaches is just as
  # consequential — both follow ownership rather than the looser browse rules.
  def revoke?            = mine?
  def edit_entitlements? = mine?

  class Scope < ApplicationPolicy::Scope
    def resolve
      return scope.all if user.admin?

      # `owner` is polymorphic, so this matches owner_id *and* owner_type —
      # it works whatever your user model is called.
      scope.where(owner: user)
    end
  end

  private

  def mine?
    return true if user.admin?

    # Avo asks this against the model class, not a row, when it decides whether
    # to offer an action on an index view. There is nothing to own yet, so the
    # honest answer is "yes, in principle" — the per-record check that runs
    # before the action touches anything is what actually decides.
    return true unless record.is_a?(ActiveRecord::Base)

    record.owner == user
  end
end

Administrators get every token; everyone else sees, edits, and revokes the ones they created and nothing more.

Freezing a revoked token ​

Revocation is permanent, and a revoked token can never authenticate again whatever its expiry says — so editing one changes only what a dead credential would have been allowed to do. Some teams would rather it stopped being editable at all, which is a policy question rather than a gem rule: your app decides whether a finished token is a record to close or one to keep annotating.

Add it to the same policy — record.try(:revoked_at) rather than record.revoked_at?, for the reason mine? guards against above:

ruby
class Avo::Api::TokenPolicy < ApplicationPolicy
  # …everything above, plus:

  # `try`, because Avo asks these against the model *class* on an index view,
  # and a class has no `revoked_at`. On the class this is nil, so the rule
  # falls through to ownership and the per-record check decides.
  def edit?              = update?
  def update?            = mine? && !revoked?
  def destroy?           = mine? && !revoked?
  def edit_entitlements? = mine? && !revoked?

  private

  def revoked? = record.try(:revoked_at).present?
end

With that in place the Edit button disappears from a revoked token, its entitlements grid renders read-only, and a crafted request to either is refused — the panel and the tool ask the same policy, so there is one answer and no way around it. Leave show? alone: reading what a revoked token used to reach is exactly what someone investigating it needs.

Expiry is not revocation

Resist adding expires_at to that rule. An expiry can be extended, so a lapsed token can be brought back — and it needs its name, expiry and entitlements editable in order to come back usefully. Revocation has no such path: the model refuses to un-revoke.

This is one client's shape

The example is Pundit's. Other authorization clients express the same rules their own way — the gem asks through the resource's authorization service, not through any particular library.

Who sees the owner ​

The Owner column is part of the resource the gem ships, and it renders for anyone who can see a token. There is no per-field policy in Avo to turn it off for some users: field-level authorization exists only for file fields (upload, delete, download) and association frames, and a plain text field like this one has no such hook.

In practice you don't need one. The Scope above already decides this: a non-admin only ever sees tokens they own, so the column tells them nothing they didn't know, and the only people reading somebody else's owner are the administrators who should. Scoping the list is the answer to "who sees the owner", not a field option.

If you genuinely must change the field list, a file at app/avo/resources/avo_api/token.rb in your own app takes precedence over the gem's copy. Weigh it first: it replaces the resource rather than extending it, so the one-time reveal, the entitlements grid, the lifecycle strip, and every field become yours to maintain against future versions of the gem. Scoping is almost always the better trade.

See what a token changed ​

With Audit Logging installed, every request a token authenticates is already recorded — the API's controllers are Avo's own, so the panel's audit recorder runs for them, attributed to the token's owner. What's new is that the entry says it came through the API, and through which token:

ColumnValue
authorThe token's owner
origin"api_token" — or "api" for a request your own scheme let in without a token
origin_recordThe token

That shows up in three places:

  • The record's own timeline continues the author's name with it: Ada Lovelace through API token.
  • The activity's page has an Origin field — API token — CI deploy key — the whole of which links to the token.
  • The token's page has an Audit trail table: Audit Logging's own activity table, listing what this token changed.

The table is gated by view_audit_trail? on your token policy — Avo's own convention for a has_many, asked once for the table and again by the endpoint that fills it. Under explicit_authorization (Avo's default) the table stays hidden until the policy defines it, exactly as any other association would:

ruby
# app/policies/avo/api/token_policy.rb
def view_audit_trail? = user.admin? || record.owner == user

Tokens can never reach the audit log itself: Avo::AuditLogging::Activity is excluded from the API's routes and from the entitlements grid the same way the token resource is, so a credential cannot delete the record of what it just did.

INFO

The api origin needs a current user to attribute to. A hand-rolled setup_authentication that verifies a credential without setting the current user records nothing — the same pitfall that leaves every policy scope with nil.

Works better with ​

Nothing on this page needs another add-on. Two of them change what the feature can do.

  • avo-authorization — the layer every question here defers to: which tokens a user sees, who may revoke or re-entitle one, and the policies that entitlements subtract from. Without it every such question answers yes — Authorization and Who may manage tokens have the exact failure modes.
  • avo-custom_controls — promotes Revoke out of the Actions dropdown. On a token's page it becomes a button beside Edit; on the index it becomes an icon at the end of every row, so working down a list of tokens costs one click each instead of a round trip. It appears only where the Actions menu would have offered it anyway — the control is filtered by the same revoke? policy and the same active-token check, so an expired or already-revoked token shows nothing.

Localization ​

Everything the add-on renders in the panel is a translation: the API tokens resource name, the one-time reveal banner, the status chips, the entitlements grid, and the Revoke action. All of it lives under avo.api.token.* and ships translated in every locale Avo supports, so a panel already running in another language shows tokens in that language with no work from you.

Change any of those strings by defining the same key in your app — yours wins, and you only write the keys you are changing:

yaml
# config/locales/avo.sv.yml
sv:
  avo:
    api:
      token:
        resource:
          one: API-token
          other: API-tokens
        reveal:
          warning: Kopiera din token nu — den visas inte igen.
        actions:
          revoke:
            name: Återkalla

The whole key tree is in the gem's config/locales/en.yml. See Localization for how Avo resolves keys and what else can go under avo..

Relative times need rails-i18n

The lifecycle chips say "3 minutes ago" through Rails' own datetime.distance_in_words strings, which Rails ships in English only. Add the rails-i18n gem for translated relative times — without it, chips in other languages show the exact timestamp instead.

Command line client ​

avo-cli is the official command line client for this API. It reads the discovery endpoints first, so it knows the resource names and the key a write nests under, and it turns every refusal into one line and an exit code.

bash
gem install avo-cli

To get the OpenAPI document for the token the CLI is logged in with:

bash
avo schema --format openapi > openapi.json