Endpoints (Routes & JSON APIs)
What this page gives you: by the end you'll have a public URL of your own that returns JSON, and an admin-only panel URL that validates its request body — the two endpoints Shoutbox needs so a theme can read shouts and an admin can post them.
An endpoint is one web address your addon answers. You write an @Endpoint-annotated class extending one of Pano's base API classes; Pano registers the route the moment your addon loads — there is no registration call anywhere.
Every backend edit needs a rebuild-and-restart before it takes effect — see the Backend overview for that step.
A public API endpoint
Expose the shouts to the theme. A public JSON endpoint extends Api (file routes/GetShoutsAPI.kt):
package com.panomc.plugins.shoutbox.routes
import com.panomc.platform.annotation.Endpoint
import com.panomc.platform.model.*
import com.panomc.plugins.shoutbox.db.dao.ShoutDao
import io.vertx.ext.web.RoutingContext
@Endpoint
class GetShoutsAPI(private val shoutDao: ShoutDao) : Api() {
override val paths = listOf(Path("/shouts", RouteType.GET))
override suspend fun handle(context: RoutingContext): Result {
val sqlClient = getSqlClient()
return Successful(mapOf("shouts" to shoutDao.getAll(sqlClient)))
}
}What is happening:
@Endpointmakes the route register itself the moment the addon loads.ShoutDaois injected straight into the constructor, because it lives in your box alongside this endpoint (constructor injection — see the Backend overview). The DAO itself is built on the Database & Migrations page.pathslists the relative path and HTTP method. You never write/api,/panelor the plugin id: Pano mounts this endpoint at/api/plugins/pano-plugin-shoutbox/shouts. Start the path with a resource name (/shouts,/items/:id), never with a parameter or_. Choose a base class by who is allowed in:Api(public),LoggedInApi(any signed-in user),PanelApi(admins),SetupApi(only during setup).getSqlClient()is a convenience onApithat hands you the shared SQL client.getValidationHandleris optional. An endpoint with nothing to validate declares nothing. The panel endpoint below overrides it to validate a request body.- Success is
Successful(map), which serializes to exactly your map (noresultkey; a success body may not have a top-levelerrororresultkey). To fail, you throw a platformErrorsubclass (NotFound,BadRequest,NoPermission, …) or your own. Every failure is{"error":{"code":"…"}}; see Error codes.
Checkpoint: hit your first endpoint
This is the payoff — a URL of yours that returns your JSON. Rebuild, restart, then open your endpoint in a browser (or curl it):
http://localhost:8088/api/plugins/pano-plugin-shoutbox/shoutsPort 8088 is Pano's address when you started it with --dev; on a default install Pano listens on port 80, so use http://localhost/api/plugins/pano-plugin-shoutbox/shouts instead. Either way you should see:
{"shouts":[]}An empty shouts list — because nothing has posted a shout yet. You'll post one at the end of this page.
Optional: put maxShouts to work. If you added the maxShouts config key on the Configuration page, this endpoint is where it earns its keep. Using the request-time config-read pattern from that page, you can cap the list to the configured number. Everything else you have already seen; the only additions are injecting plugin (your plugin class is injectable) and Kotlin's standard take(n):
// Optional variant of handle(): respect maxShouts.
// For this to compile, also add `private val plugin: ShoutboxPlugin` to the constructor,
// alongside `shoutDao`, so you can reach pluginBeanContext.
val sqlClient = getSqlClient()
val configManager = plugin.pluginBeanContext.getBean(PluginConfigManager::class.java)
val limit = (configManager.config as ShoutboxConfig).maxShouts
return Successful(mapOf("shouts" to shoutDao.getAll(sqlClient).take(limit)))A panel endpoint
Posting a shout is an admin action, so this endpoint does three things the public one didn't: it validates the request body, checks a permission, and writes an activity-log entry. It's the biggest code block here — as you read it, look for those three jobs in order.
Panel endpoints extend PanelApi and declare no /panel
A PanelApi is mounted under the panel scope by Pano. You declare only the part after it:
| You declare in Kotlin | Pano mounts it at |
|---|---|
class PanelAddShoutAPI : PanelApi() with Path("/shouts", RouteType.POST) | POST /api/plugins/pano-plugin-shoutbox/panel/shouts |
Panel UI code never writes that URL either: it calls api.panel.post({ path: '/shouts' }) (see Panel UI).
Heads up: this file won't compile on its own yet
PanelAddShoutAPI refers to two classes covered on Permissions & Activity Logs — ManageShoutboxPermission and CreatedShoutLog. Write all three, then build once. If you build right after this section, expect "unresolved reference" errors; that's the two missing classes, not a mistake in this file.
File routes/panel/PanelAddShoutAPI.kt:
package com.panomc.plugins.shoutbox.routes.panel
import com.panomc.platform.annotation.Endpoint
import com.panomc.platform.auth.AuthProvider
import com.panomc.platform.db.DatabaseManager
import com.panomc.platform.error.BadRequest
import com.panomc.platform.model.*
import com.panomc.plugins.shoutbox.ShoutboxPlugin
import com.panomc.plugins.shoutbox.db.dao.ShoutDao
import com.panomc.plugins.shoutbox.db.model.Shout
import com.panomc.plugins.shoutbox.log.CreatedShoutLog
import com.panomc.plugins.shoutbox.permission.ManageShoutboxPermission
import io.vertx.ext.web.RoutingContext
import com.panomc.platform.schema.dsl.Bodies
import com.panomc.platform.schema.dsl.ValidationHandlerBuilder
import io.vertx.ext.web.validation.RequestPredicate
import io.vertx.ext.web.validation.ValidationHandler
import io.vertx.json.schema.SchemaRepository
import io.vertx.json.schema.common.dsl.Schemas.*
@Endpoint
class PanelAddShoutAPI(
private val plugin: ShoutboxPlugin,
private val shoutDao: ShoutDao
) : PanelApi() {
override val paths = listOf(Path("/shouts", RouteType.POST))
private val authProvider by lazy { plugin.applicationContext.getBean(AuthProvider::class.java) }
private val databaseManager by lazy { plugin.applicationContext.getBean(DatabaseManager::class.java) }
override fun getValidationHandler(schemaRepository: SchemaRepository): ValidationHandler =
ValidationHandlerBuilder.create(schemaRepository)
.body(
Bodies.json(
objectSchema()
.requiredProperty("message", stringSchema())
)
)
.predicate(RequestPredicate.BODY_REQUIRED)
.build()
override suspend fun handle(context: RoutingContext): Result {
authProvider.requirePermission(ManageShoutboxPermission(), context)
val data = getParameters(context).body().jsonObject
val message = data.getString("message")
if (message.isNullOrBlank()) {
throw BadRequest()
}
val sqlClient = getSqlClient()
val userId = authProvider.getUserIdFromRoutingContext(context)
val username = databaseManager.userDao.getUsernameFromUserId(userId, sqlClient)!!
shoutDao.add(Shout(message = message, username = username, date = System.currentTimeMillis()), sqlClient)
databaseManager.panelActivityLogDao.add(CreatedShoutLog(userId, username, plugin.pluginId, message), sqlClient)
return Successful()
}
}Walking through the three new jobs:
- Validation uses the
SchemasDSL (objectSchema(),requiredProperty,stringSchema()) plusRequestPredicate.BODY_REQUIRED. A request with a missing or malformed body is rejected before yourhandleever runs. - Permission check:
authProvider.requirePermission(ManageShoutboxPermission(), context)is the very first line ofhandle. If the logged-in admin lacks the permission, it throws and the request is denied. (AuthProviderandDatabaseManagerare Pano's own services, so you fetch them from Pano's box withgetBean.) The permission class is defined on Permissions & Activity Logs. - Activity log:
databaseManager.panelActivityLogDao.add(CreatedShoutLog(...), sqlClient)records who posted what, so the admin panel's activity feed can show it. TheCreatedShoutLogclass is defined on that same Permissions & Activity Logs page. - One bit of Kotlin syntax:
getUsernameFromUserId(userId, sqlClient)!!ends with!!, which asserts "this value is not null — crash if it somehow is." It's safe here because a logged-in admin always has a username.
Try it end to end
Here is the full loop the backend promised — a database table, a public JSON API, a guarded admin endpoint, and an activity-log entry, all working together. You've already seen the empty list; now create a shout and watch it appear.
- Before: open
http://localhost:8088/api/plugins/pano-plugin-shoutbox/shouts(or the port80form on a default install). You should still see{"shouts":[]}. - Post a shout: send
POST /api/plugins/pano-plugin-shoutbox/panel/shoutswith the JSON body{"message":"Hello Pano!"}as a logged-in admin. The easiest way is from the panel UI you'll build in Frontend Development; to do it right now,curlthat URL through your browser's authenticated session (the endpoint needs your admin session cookie, which is why the panel UI is the simpler route). - After: refresh
http://localhost:8088/api/plugins/pano-plugin-shoutbox/shouts— your shout is now in the JSON:
{"shouts":[{"id":1,"message":"Hello Pano!","username":"<you>","date":1700000000000}]}- Activity feed: open Panel → Logs (the Activity Logs page) — you'll see your
CREATED_SHOUTentry (shown as the raw key until you add the locale string in Localization).
If all four steps line up, the backend half of Shoutbox is done.
If it doesn't work
The failures the backend pages warn about, in one place — symptom, cause, fix:
| Symptom | Likely cause | Fix |
|---|---|---|
| Addon not listed in Panel → Addons | the jar isn't in plugins/, or Pano wasn't restarted | rebuild (the build copies the jar into plugins/) and restart Pano |
| Your event listener never fires (setup gate never runs) | you imported Spring's @EventListener instead of Pano's | use com.panomc.platform.api.annotation.EventListener (see Events) |
Crash: NoSuchBeanDefinitionException | you took PluginConfigManager (or another bean registered in onStart) as a constructor parameter | fetch it at request time with plugin.pluginBeanContext.getBean(...) instead (see Configuration) |
Request rejected with NO_PERMISSION | the (non-admin) role calling the panel endpoint hasn't been granted the permission | grant it in Panel → Permissions, or test as an admin (admins bypass the check — see Permissions) |
| A Kotlin edit seems ignored | you disabled/enabled the addon instead of restarting | Kotlin isn't hot — rebuild and restart Pano |
Where to next
- Database & Migrations — the
ShoutDaothis page injects, plus the model and SQL behind it. - Permissions & Activity Logs — define
ManageShoutboxPermissionandCreatedShoutLogso the panel endpoint compiles. - Configuration — add the
maxShoutskey the optional variant reads. - Backend API Reference — every route primitive, base class, result, and error by name.