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:
# Gemfile
gem "avo-api", source: "https://packager.dev/avo-hq/"bundle installThen install:
rails generate avo_api:installThis 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:
rails generate avo_api:install --version v2Install the API tokens table
avo_api:install above already wrote the migrations for the avo_api_tokens table, so a first install needs only:
rails db:migrateAlready running the API from an earlier version? Your app has controllers but no tokens table. Add it without touching them:
rails generate avo_api:tokens
rails db:migrateThere 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.
# config/routes.rb
Rails.application.routes.draw do
mount_avo_api
authenticate :user do
mount_avo
end
endMount 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.
# ❌ 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
endMount 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.
# 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 teamThe 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 documentThe 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.
{
"api_version": "v1",
"resources": [
{
"route_key": "teams",
"param_key": "team",
"name": "Team",
"entitlements": ["index", "show", "create", "update", "destroy"]
}
]
}| Key | Meaning |
|---|---|
api_version | The version namespace the request came through. |
route_key | The URL segment every other request is built from. |
param_key | The key a POST or PATCH body nests its fields under. Use it rather than singularizing route_key. |
name | The resource's singular title, for display. |
entitlements | The 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.
view | Lists |
|---|---|
create | What a POST body may send, with each field's field_options. The default. |
update | What a PATCH or PUT body may send, with each field's field_options. |
show | What a record comes back with from GET /teams/:id. |
index | What a row of GET /teams carries. |
{
"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": {} }
]
}| Key | Meaning |
|---|---|
view | The view the fields belong to: the one the request named, or create when it named none. |
field_id | The key the field reads back under, or, on create and update, the key the body sets it through (admin_id). |
field_type | The Avo field type. |
field_options | Form views only: one object holding what a write needs to know about the field, the keys below. Read views carry none. |
field_options.required | true when a blank value is refused. Absent otherwise, so a missing key means not required. |
field_options.options | On choice fields: the values the field accepts, never the labels. |
field_options.multiple | true 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
403withreason: "token_entitlement"when the token lacks the action the view is named after.403withreason: "policy"when the token's owner cannot index the resource.400with{ "error": "Unknown view", "views": ["index", "show", "create", "update"] }for a view it doesn't know.404when 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:
curl https://YOUR-APP.com/api/resources/v1/_openapi \
-H "Authorization: Bearer YOUR_TOKEN" \
-o openapi.jsonTo save it somewhere else, give the full path:
curl https://YOUR-APP.com/api/resources/v1/_openapi \
-H "Authorization: Bearer YOUR_TOKEN" \
-o ~/Downloads/openapi.jsonOpen 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
| Part | Contents |
|---|---|
servers[0].url | The version's base URL, so every path in the file is the path you request |
| Security | Bearer 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 schemas | Error, ValidationErrors, Pagination |
| Error responses | 400, 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:
{ "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:
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:
| Status | When | Authenticates |
|---|---|---|
| Active | Not revoked, and either no expiry or an expiry still in the future | Yes |
| Expired | The expiry instant has passed | No |
| Revoked | The Revoke action was run on it | No |
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:
# config/initializers/avo.rb
Avo.configure do |config|
config.authenticate_with do
next if Avo::Api.token_request?
authenticate_user!
end
endAvo::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:
{
"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:
{ "error": "Invalid pagination", "parameter": "per_page", "expected": "an integer of 1 or more" }Sorting: sort_by (field) and sort_direction (asc / desc).
GET /api/resources/v1/teams?page=2&per_page=10&sort_by=name&sort_direction=ascFilters 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:
{
"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 type | Shape |
|---|---|
| Text, number, boolean, date/datetime | The 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": "…" } |
files | a list of those objects, one per attachment |
Field visibility follows your resource's view settings, so you can shape the API per view:
# 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
endA 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
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:
{
"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:
{
"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).
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
curl -X DELETE https://example.com/api/resources/v1/teams/1 \
-H "Authorization: Bearer <token>"Success (200 OK):
{ "message": "Team successfully deleted" }Failure (422 Unprocessable Entity):
{
"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:
| Field | Example value |
|---|---|
| Text / string | "Team Name" |
| Number | 42, 19.99 |
| Boolean | true, false |
| Date / datetime | "2024-01-15", "2024-01-15T10:30:00Z" |
belongs_to | the foreign key, as the id a read returns: "admin_id": "5" |
Polymorphic belongs_to | both parts: "reactable_type": "Post", "reactable_id": "7" |
select with multiple, checkbox_list, boolean_group | a list: "tags": ["ops", "eu"] |
location on two columns | an object keyed by its stored_as columns: "coordinates": { "latitude": 44.43, "longitude": 26.10 } |
tags, location on one column | one string the field parses: "skills": "ruby,rails", "home": "44.43,26.10" |
key_value | an 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 |
code | one 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:
| Level | API actions it permits |
|---|---|
| Read | index, show |
| Read & Write | index, 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.
| Access | Means |
|---|---|
| Unrestricted | No grants at all: everything the owner's policies allow, including resources added later. |
| Read only | Every granted resource is at Read. |
| Read & write | Every granted resource is at Read & Write. |
| Custom | Granted resources sit at different levels, or one holds an action set the grid can't name. |
| No access | Every 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_authenticationreplaces the built-in one without callingsuper, 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:
| Response | What happened | Where 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 action | The token's Entitlements panel |
403 { "error": "Forbidden", "reason": "policy" } | The grants allow it; the owner's policy denied it | Your 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:
# app/policies/avo/api/token_policy.rb
class Avo::Api::TokenPolicy < ApplicationPolicy
def edit_entitlements? = user.admin?
endDenied, 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:
# config/initializers/avo.rb
Avo.configure do |config|
config.current_user_method = :current_user
endSo with that setting, the API controller's current_user method is what the policies see. Make that method return the authenticated user:
# 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
endAssigning 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:
# 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
endShared 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:
# 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
endDisable authentication for one resource — public, unauthenticated reads:
# app/controllers/avo/api/resources/v1/users_controller.rb
module Avo::Api::Resources::V1
class UsersController < BaseResourcesController
skip_before_action :setup_authentication
end
endA 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, soGET /api/resources/v1/commentsreturns 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 a403— the API doesn't confirm that an out-of-scope record exists. - Association fields appear only when the record's policy allows
view_<field>?. Underexplicit_authorizationthey are also hidden when the policy doesn't define the method, as on the panel.
# app/policies/comment_policy.rb
class CommentPolicy < ApplicationPolicy
class Scope < ApplicationPolicy::Scope
def resolve
user.admin? ? scope.all : scope.where(user:)
end
end
endWith 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:
| Requirement | Where |
|---|---|
The avo-authorization add-on installed | Gemfile |
The avo-authorization feature enabled on your license | Your Avo plan |
| An authorization client configured | config.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:
{ "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.
# 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
endA 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:
| Serves | File |
|---|---|
| Admin panel | app/controllers/avo/users_controller.rb |
| API | app/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:
{
"errors": { "base": ["undefined method 'save!' for an instance of Avo::Movie"] },
"message": "Failed to create Movie"
}What decides access
self.writableis 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 = BookmarkPolicyrather than rely on a lookup by class name. - A policy scope does not narrow the list. The rows are what
recordsreturns. The scope'sresolveis 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:
# 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
}
endThe 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:
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.
| Method | What 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.
# 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
endAdministrators 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:
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?
endWith 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:
| Column | Value |
|---|---|
author | The token's owner |
origin | "api_token" — or "api" for a request your own scheme let in without a token |
origin_record | The 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:
# app/policies/avo/api/token_policy.rb
def view_audit_trail? = user.admin? || record.owner == userTokens 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 samerevoke?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:
# 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: ÅterkallaThe 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.
gem install avo-cliTo get the OpenAPI document for the token the CLI is logged in with:
avo schema --format openapi > openapi.json