All API endpoints are available through the processor (default port 3030). The processor handles all endpoints directly.
- Authentication
- Response Format
- Health & Monitoring
- Tracking CRUD
- Type-Specific POST Fields
- Human Management
- Profile Management
- Summary Schedules
- Geofence Data & Tiles
- State Management
- Statistics
- Weather
- Configuration
- DTS Editor — includes
/api/dts/reload - Config Editor
- Autocreate — bulk sync trigger + channel-template editor
- Game Data
- Geocoding
- Confirmation Messages
- Test
- Validation Hook — outbound POST sent by the processor to an operator-supplied URL
| Endpoint | What it reloads |
|---|---|
/api/reload |
Tracking rules from the database. Keeps current geofences and DTS templates in place. |
/api/geofence/reload |
Geofence files on disk and re-fetches Koji geofences, then reloads tracking rules. Use after editing fences or rotating a Koji bearer token. |
/api/dts/reload |
DTS templates and partials from config/dts.json, config/dts/, and config/partials.json. Does not refresh Koji or tracking rules. |
All /api/* endpoints require the X-Poracle-Secret header matching the configured [processor] api_secret value (with [alerter] api_secret as a backward-compatible fallback). Health and metrics endpoints do not require authentication.
curl -H "X-Poracle-Secret: your-secret" http://localhost:3030/api/tracking/pokemon/123456789All endpoints return JSON with a status field:
{"status": "ok", ...}
{"status": "error", "message": "description of the problem"}
{"status": "authError", "reason": "incorrect or missing api secret"}Health check.
curl http://localhost:3030/health{"status": "healthy"}Prometheus metrics endpoint.
All tracking types follow the same 4-endpoint pattern. The {id} parameter is the user's Discord/Telegram ID.
| Parameter | Description |
|---|---|
profile_no |
Override the user's current profile number |
silent |
Suppress confirmation message to user |
suppressMessage |
Alias for silent |
List all tracking rules for a user. Returns rules with a human-readable description field.
Types: pokemon, raid, egg, quest, invasion, lure, nest, gym, fort, maxbattle
curl -H "X-Poracle-Secret: secret" http://localhost:3030/api/tracking/pokemon/123456789{
"status": "ok",
"pokemon": [
{
"uid": 42,
"pokemon_id": 1,
"form": 0,
"min_iv": 90,
"max_iv": 100,
"distance": 500,
"template": "1",
"description": "**Bulbasaur** | iv: 90%-100% | cp: 0-9000 | ..."
}
]
}Create or update tracking rules. Accepts a single object or array. Returns counts of new/updated/unchanged rules and sends a confirmation message to the user (unless silent).
The endpoint uses smart diff logic: if an incoming rule matches an existing one on its key fields and only display settings (distance, template, clean) differ, it updates in place rather than creating a duplicate.
curl -X POST -H "X-Poracle-Secret: secret" -H "Content-Type: application/json" \
http://localhost:3030/api/tracking/pokemon/123456789 \
-d '[{"pokemon_id": 1, "min_iv": 90, "max_iv": 100, "distance": 500}]'{
"status": "ok",
"message": "New: **Bulbasaur** | iv: 90%-100% | ...",
"newUids": [42],
"alreadyPresent": 0,
"updates": 0,
"insert": 1
}Per-rule location and area overrides (all tracking types):
| Field | Type | Description |
|---|---|---|
override_location_label |
string or null | Label of a saved location to use as the distance anchor instead of the user's default. Requires distance > 0. |
override_areas |
[]string or null | Area names to restrict this rule to, overriding the user's default areas. Mutually exclusive with distance-based tracking. |
Server-side validation rules:
override_location_label+distance == 0→ 400 "override_location_label requires distance > 0"override_areas+distance > 0→ 400 "override_areas and distance are mutually exclusive"override_location_label+override_areas→ 400 "override_location_label and override_areas are mutually exclusive"override_location_labelreferences unknown saved location → 400 "unknown location label"override_areascontains area outside user's permitted set → 400 "area not permitted"
Example with override location:
[{"pokemon_id": 1, "min_iv": 90, "distance": 1000, "override_location_label": "Work"}]Example with override areas:
[{"pokemon_id": 1, "min_iv": 90, "override_areas": ["city_centre", "park"]}]Delete a single tracking rule by its unique ID.
curl -X DELETE -H "X-Poracle-Secret: secret" \
http://localhost:3030/api/tracking/pokemon/123456789/byUid/42{"status": "ok"}Bulk delete tracking rules. Body is a JSON array of UIDs.
curl -X POST -H "X-Poracle-Secret: secret" -H "Content-Type: application/json" \
http://localhost:3030/api/tracking/pokemon/123456789/delete \
-d '[42, 43, 44]'{"status": "ok"}Get all tracking rules across all types for the user's current profile.
{
"status": "ok",
"human": {"id": "123", "name": "User", ...},
"pokemon": [...],
"raid": [...],
"egg": [...],
"quest": [...],
"invasion": [...],
"lure": [...],
"nest": [...],
"gym": [...],
"maxbattle": [...],
"fort": [...]
}Force a state reload (same as POST /api/reload).
| Field | Type | Default | Description |
|---|---|---|---|
pokemon_id |
int | 0 | Pokemon ID (0 = everything) |
form |
int | 0 | Form ID |
min_iv |
int | -1 | Minimum IV% (-1 = unencountered) |
max_iv |
int | 100 | Maximum IV% |
min_cp |
int | 0 | Minimum CP |
max_cp |
int | 9000 | Maximum CP |
min_level |
int | 0 | Minimum level |
max_level |
int | 40 | Maximum level |
atk / def / sta |
int | 0 | Minimum IVs |
max_atk / max_def / max_sta |
int | 15 | Maximum IVs |
gender |
int | 0 | Gender filter (0=any, 1=male, 2=female) |
size / max_size |
int | 0/5 | Size range (1=XXS to 5=XXL) |
rarity / max_rarity |
int | -1/6 | Rarity range (1=Common to 6=Unseen) |
pvp_ranking_league |
int | 0 | PVP league CP cap (500/1500/2500) |
pvp_ranking_best / pvp_ranking_worst |
int | PVP rank range | |
pvp_ranking_min_cp |
int | 0 | Minimum CP for PVP |
pvp_ranking_cap |
int | 0 | Level cap for PVP |
pvp_ranking_evolution |
int | 0 | Mega/temporary-evolution discriminator: 0=base only (or base+mega when [pvp] include_mega_evolution=true), 1=any mega, 2=Mega X, 3=Mega Y. Set via the mega / mega:x / mega:y keywords on !track. |
distance |
int | 0 | Distance in metres (0 = use area) |
template |
string | config default | DTS template name |
clean |
bool | false | Auto-delete message after TTH |
When a tracked pokemon's data changes after the initial sighting (form, species, gender, or weather-boost shift), users with a prior message for that encounter receive a monsterChanged template render threaded as a reply to the original. Templates have access to the prior sighting via {{original.X}}:
| Field | Type | Notes |
|---|---|---|
original.pokemonId |
int | |
original.formId |
int | |
original.gender |
int | |
original.cp, atk, def, sta |
int | 0 when not encountered |
original.iv |
float | 0 when not encountered |
original.weatherId |
int | Pre-shift in-game weather |
original.encountered |
bool | true once CP > 0 |
original.name, formName, weatherName, fullName |
string | translated to the recipient's language |
The encounter event itself (non-IV → IV) deliberately re-fires the regular monster template threaded as a reply — it's the natural fulfilment of the non-IV alert, not a "change". Reply threading is implicit (every pokemon render carries a reply key keyed on the encounter ID) and applies to both Discord (message_reference) and Telegram (reply_to_message_id). Edit mode (clean bit 2) takes priority when set: the prior message is updated in place rather than replied to. Users matched at the change event with no prior message receive a fresh monster render (no reply target, no monsterChanged — they never saw the original).
Ships a default monsterChanged template per platform in fallbacks/dts.json; admins can override via config/dts.json or config/dts/ like any other type.
Raid tracking supports three input modes. Each request object uses one of these:
Mode 1: Track by level — set level (pokemon_id defaults to 9000):
{"level": 5, "team": 4, "distance": 500}level can be a single int or an array — each level becomes a separate tracking rule:
{"level": [1, 3, 5], "team": 4}Use level: 90 for all levels.
Mode 2: Track specific pokemon — set pokemon_id (level is ignored):
{"pokemon_id": 150, "form": 0, "distance": 500}Mode 3: Track multiple pokemon — use pokemon_form array. Each entry becomes a separate tracking rule with level=9000. Do NOT use pokemon_id/form/level with this mode:
{"pokemon_form": [{"pokemon_id": 150, "form": 0}, {"pokemon_id": 151, "form": 0}], "distance": 500}Common fields (used with all modes):
| Field | Type | Default | Description |
|---|---|---|---|
team |
int | 4 | Gym team (0-3 specific, 4=any) |
exclusive |
bool | false | EX raid only |
move |
int | 9000 | Move ID filter (9000=any) |
evolution |
int | 9000 | Evolution filter |
gym_id |
string | null | Specific gym ID |
rsvp_changes |
int | 0 | RSVP mode (0=without, 1=including, 2=only) |
distance |
int | 0 | Distance in metres |
template |
string | config default | DTS template |
clean |
bool | false | Auto-delete |
level is required. It can be a single int or an array — each level becomes a separate tracking rule. Use 90 for all levels.
{"level": 5, "team": 4}
{"level": [1, 3, 5]}
{"level": 90}| Field | Type | Default | Description |
|---|---|---|---|
level |
int/int[] | required | Egg level(s) (90=all) |
team |
int | 4 | Gym team (0-3 specific, 4=any) |
exclusive |
bool | false | EX gym only |
gym_id |
string | null | Specific gym ID |
rsvp_changes |
int | 0 | RSVP mode (0=without, 1=including, 2=only) |
distance |
int | 0 | Distance |
template |
string | config default | DTS template |
clean |
bool | false | Auto-delete |
| Field | Type | Default | Description |
|---|---|---|---|
reward_type |
int | required | Reward type (2=item, 3=stardust, 4=candy, 7=pokemon, 12=mega energy) |
reward |
int | 0 | Reward ID (pokemon ID, item ID, or stardust amount) |
form |
int | 0 | Form ID (for pokemon rewards) |
shiny |
bool | false | Shiny only |
amount |
int | 0 | Minimum reward amount |
distance |
int | 0 | Distance |
template |
string | config default | DTS template |
clean |
bool | false | Auto-delete |
| Field | Type | Default | Description |
|---|---|---|---|
grunt_type |
string | "" | Grunt type name (empty=any) |
gender |
int | 0 | Gender (0=any, 1=male, 2=female) |
distance |
int | 0 | Distance |
template |
string | config default | DTS template |
clean |
bool | false | Auto-delete |
| Field | Type | Default | Description |
|---|---|---|---|
lure_id |
int | 0 | Lure type (0=any, 501-506 specific) |
distance |
int | 0 | Distance |
template |
string | config default | DTS template |
clean |
bool | false | Auto-delete |
| Field | Type | Default | Description |
|---|---|---|---|
pokemon_id |
int | 0 | Pokemon ID (0=everything) |
form |
int | 0 | Form ID |
min_spawn_avg |
int | 0 | Minimum spawns per hour |
distance |
int | 0 | Distance |
template |
string | config default | DTS template |
clean |
bool | false | Auto-delete |
| Field | Type | Default | Description |
|---|---|---|---|
team |
int | required | Team (0-3 specific, 4=any) |
slot_changes |
bool | false | Track slot changes |
battle_changes |
bool | false | Track battle changes |
gym_id |
string | null | Specific gym ID |
distance |
int | 0 | Distance |
template |
string | config default | DTS template |
clean |
bool | false | Auto-delete |
{"fort_type": "pokestop", "change_types": ["name", "location"], "distance": 500}
{"fort_type": "everything", "include_empty": true}| Field | Type | Default | Description |
|---|---|---|---|
fort_type |
string | "everything" | Type: "pokestop", "gym", or "everything" |
include_empty |
bool | false | Include changes with no name/description |
change_types |
string[] | [] | Change types to track: "location", "name", "image_url", "removal", "new" |
distance |
int | 0 | Distance |
template |
string | config default | DTS template |
| Field | Type | Default | Description |
|---|---|---|---|
pokemon_id |
int | 9000 | Pokemon ID (9000=any) |
level |
int | required if pokemon_id=9000 | Battle level |
form |
int | 0 | Form ID |
move |
int | 9000 | Move filter (9000=any) |
gmax |
int | 0 | Gigantamax filter |
evolution |
int | 9000 | Evolution filter |
station_id |
string | null | Specific station ID |
distance |
int | 0 | Distance |
template |
string | config default | DTS template |
clean |
bool | false | Auto-delete |
Get available geofence areas for a user. Areas are filtered by community membership if area security is enabled.
{
"status": "ok",
"areas": [
{"name": "Canterbury", "group": "Kent", "description": "", "userSelectable": true},
{"name": "Dover", "group": "Kent", "description": "Port area", "userSelectable": true}
]
}Get full human record.
{
"status": "ok",
"human": {
"id": "123456789",
"name": "UserName",
"type": "discord:user",
"enabled": true,
"area": "[\"canterbury\"]",
"latitude": 51.28,
"longitude": 1.08,
"language": "en",
"current_profile_no": 1
}
}Create a new user.
curl -X POST -H "X-Poracle-Secret: secret" -H "Content-Type: application/json" \
http://localhost:3030/api/humans \
-d '{"id": "123456789", "name": "NewUser", "type": "discord:user"}'Optional fields: enabled, area, latitude, longitude, language, admin_disable, community, profile_name, notes.
Enable a user.
Disable a user.
Toggle admin disable flag.
{"state": true}Set a user's alert and bot-response language. If [general.available_languages] is configured, language must be one of those configured language codes; otherwise any non-empty language code is accepted.
curl -X POST -H "X-Poracle-Secret: secret" -H "Content-Type: application/json" \
http://localhost:3030/api/humans/123456789/language \
-d '{"language": "de"}'{"status": "ok", "language": "de"}Update user location. Validates against area restrictions if area security is enabled.
Check if a location is within the user's allowed areas.
{"status": "ok", "locationOk": true}Set user's selected geofence areas. Body is a JSON array of area names. Areas are validated against the user's community membership.
curl -X POST -H "X-Poracle-Secret: secret" -H "Content-Type: application/json" \
http://localhost:3030/api/humans/123456789/setAreas \
-d '["canterbury", "dover"]'{"status": "ok", "setAreas": ["canterbury", "dover"]}List the user's saved locations plus the default.
{"status": "ok", "locations": {"default": {"latitude": 51.28, "longitude": 1.08}, "named": [{"label": "Home", "latitude": 51.28, "longitude": 1.08}]}}Show one saved location by case-insensitive label. Returns 404 if the label is not found.
{"status": "ok", "location": {"label": "Home", "latitude": 51.28, "longitude": 1.08}}Create one or more saved locations. Body is a single object or array of {label, latitude, longitude}. The place field is accepted but server-side geocoding is deferred — clients should resolve coordinates before calling this endpoint.
[{"label": "Home", "latitude": 51.28, "longitude": 1.08}]Returns per-row results:
{"status": "ok", "results": [{"label": "Home", "error": null}]}Delete a saved location by label. Returns 409 if any tracking rule currently references it, with a list of referencing rules:
{"status": "conflict", "referencing_rules": [{"type": "pokemon", "uid": 42}]}Switch the user's active profile.
List Discord roles across all configured guilds.
Add a Discord role to a user.
Remove a Discord role from a user.
Get the user's delegated administration permissions (channels, webhooks, user tracking).
List all profiles for a user.
{
"status": "ok",
"profile": [
{"id": "123", "profile_no": 1, "name": "Home", "area": "[\"canterbury\"]", "latitude": 51.28, "longitude": 1.08, "active_hours": ""}
]
}Create new profile(s). Body is a single object or array.
{"name": "Work", "active_hours": "[{\"day\":1,\"hours\":9,\"mins\":0}]"}Update active_hours on profiles.
[{"profile_no": 2, "active_hours": "[{\"day\":1,\"hours\":\"17\",\"mins\":\"00\"}]"}]Copy all tracking rules from one profile to another.
Delete a profile and all its tracking rules.
Summary schedules buffer matched events (currently quest) until a per-user, per-alert-type schedule fires. Each row in summary_schedules holds an active_hours JSON array shaped exactly like a profile's active_hours ([{day, hours, mins}, …]). Tracking rules opt into buffered delivery by setting bit 4 of the rule's clean bitmask (the summary keyword in !quest).
When [tracking] quest_summary_enabled = false all five endpoints return 503 Service Unavailable with a status payload — clients can distinguish "feature off" from "endpoint missing" without parsing strings.
List every summary schedule for the user across all alert types.
{
"status": "ok",
"schedules": [
{"id": "123", "alert_type": "quest", "active_hours": [{"day": 1, "hours": 7, "mins": 30}]}
]
}Return one schedule. alertType is quest today (the only renderer wired). Missing schedules return 404 with {"status":"error","message":"schedule not found"}.
{
"status": "ok",
"schedule": {"id": "123", "alert_type": "quest", "active_hours": [{"day": 1, "hours": 7, "mins": 30}]}
}Create or replace a schedule. Body must contain active_hours either as a JSON array or a stringified JSON value:
{"active_hours": [{"day": 1, "hours": 7, "mins": 30}]}Either form is canonicalised before storage. The processor triggers a debounced state reload after a successful set, so an in-flight scheduler tick picks up the change without waiting for the periodic reload.
Remove a schedule. Deleting a missing schedule is a no-op (200 ok) so idempotent clean-up scripts don't have to special-case 404.
Force-flush the buffered events for (id, alertType) immediately. Equivalent to !summary <alertType> now. Returns 200 ok regardless of whether the buffer was empty. The handler invokes the dispatch callback synchronously, so the response only returns after the dispatcher has enqueued (not delivered) the rendered messages.
All geofences with metadata.
MD5 hash of each geofence path (for cache validation).
All geofences as a GeoJSON FeatureCollection.
Generate a static map tile showing a geofence area polygon.
{"status": "ok", "url": "http://tileserver:9000/staticmap/pregenerated/abc123.png"}Generate a static map tile showing a distance circle.
Generate a static map tile showing a location pin.
Generate a static map tile showing multiple geofence areas with rainbow colours.
{"areas": ["canterbury", "dover", "folkestone"]}Generate a static map tile showing the weather S2 cell at a location. Optional query param weather for the weather condition ID.
Trigger a full geofence reload (re-fetches Koji geofences and reloads state).
Reload tracking rules from the database (preserves geofences). Called automatically after tracking mutations.
Rarity group statistics from the rolling window.
Shiny encounter statistics.
Shiny-possible spawn data.
Get weather data for a specific S2 cell.
Server configuration for the web UI (locale, prefix, PVP settings, admin lists, etc.).
Available DTS templates by platform, type, and language (metadata only, no template content).
All DTS editor endpoints require the X-Poracle-Secret header. CORS is enabled globally.
Returns DTS template entries with full template content. Filterable by query parameters. Each entry includes a readonly flag — entries from fallbacks/dts.json are readonly.
| Parameter | Description |
|---|---|
type |
Filter by DTS type (monster, raid, egg, etc.) |
platform |
Filter by platform (discord, telegram) |
language |
Filter by language code (en, de, etc.) or empty for language-independent |
id |
Filter by template ID |
When a user has any non-readonly entry for a given (type, platform), fallback entries for that combo are suppressed — the user has taken ownership in the editor.
For entries using templateFile (external Handlebars files), the response includes templateFileContent with the resolved raw file content. Entries with inline template objects do not have this field.
{
"status": "ok",
"templates": [
{
"id": "1",
"type": "monster",
"platform": "discord",
"language": "",
"default": true,
"readonly": true,
"template": {"embed": {"title": "{{round iv}}% {{fullName}} ..."}},
"name": "Default Monster",
"description": "Standard pokemon alert"
},
{
"id": "1",
"type": "fort-update",
"platform": "discord",
"language": "",
"default": true,
"templateFile": "dts/fort_update.txt",
"templateFileContent": "{{#eq fortType 'pokestop'}}..raw handlebars..{{/eq}}"
}
]
}Editor note: Entries with templateFile use raw Handlebars text (not JSON). The editor should display these differently from inline template entries and use PUT /api/dts/templates/file to save changes to the file content.
Save DTS template entries. Accepts a JSON array of entries. Each entry requires at minimum type, platform, and template.
Save behaviour:
- Each entry is saved to its own file in
config/dts/(e.g.,monster-1-discord.json) - If the entry previously existed in another file (
config/dts.jsonor anotherconfig/dts/*.json), it is removed from the old file (other entries in that file are preserved) - Saving a readonly entry (from fallbacks) creates an override copy in
config/dts/— the fallback is not modified, and the override takes precedence via the loading order - The
idfield defaults to empty if not provided (matches the "default" template)
Required fields per entry: type, platform
curl -X POST -H "X-Poracle-Secret: secret" -H "Content-Type: application/json" \
http://localhost:3030/api/dts/templates \
-d '[{
"id": "1",
"type": "monster",
"platform": "discord",
"language": "en",
"default": false,
"template": {"embed": {"title": "{{round iv}}% {{fullName}}"}}
}]'Success:
{"status": "ok", "saved": 1}Errors:
{"status": "error", "message": "no templates provided"}
{"status": "error", "message": "entry 0 missing required fields (type=\"\", platform=\"\", id=\"\")"}Delete a DTS template entry. Removes from memory and from the source file on disk. Readonly entries (from fallbacks) cannot be deleted.
| Parameter | Description |
|---|---|
type |
DTS type (required) |
platform |
Platform (required) |
id |
Template ID (required) |
language |
Language (empty string matches language-independent entries) |
Errors:
{"status": "error", "message": "template not found"}
{"status": "error", "message": "template monster/discord/1/ is readonly"}Update the raw content of a templateFile entry. The file path is resolved from the template's key fields — no client-supplied paths are accepted, preventing path traversal. Readonly entries are rejected.
| Parameter | Description |
|---|---|
type |
DTS type (required) |
platform |
Platform (required) |
id |
Template ID (required) |
language |
Language code |
Request body:
{"content": "{{#eq fortType 'pokestop'}}...raw handlebars text...{{/eq}}"}Response:
{"status": "ok", "templateFile": "dts/fort_update.txt"}Errors:
{"status": "error", "message": "template not found"}
{"status": "error", "message": "template uses inline JSON, not a templateFile"}
{"status": "error", "message": "template is readonly (bundled default)"}Returns the emoji lookup map for template editing. Emojis come from util.json (defaults) overlaid with emoji.json (per-platform custom overrides). Used for resolving {{getEmoji 'key'}} in the editor and presenting an emoji picklist.
Per-platform merged map (what the renderer uses):
GET /api/dts/emoji?platform=discord
{
"status": "ok",
"platform": "discord",
"emoji": {"team_0": "<:team_unknown:123>", "weather_1": "☀️"}
}Full structure (for UIs showing customised vs default):
GET /api/dts/emoji
{
"status": "ok",
"defaults": {"team_0": "❓", "weather_1": "☀️"},
"platforms": {
"discord": {"team_0": "<:team_unknown:123>"},
"telegram": {}
}
}Run a raw webhook through the enrichment pipeline and return the enriched variable map — the same data that Handlebars templates see during rendering. Includes all layers: base enrichment, translated fields, PVP display, aliases, resolved emoji, and computed fields (tthh/tthm, areas, weatherChange, etc.).
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
type |
string | yes | Webhook type: pokemon, raid, egg, quest, invasion, lure, nest, gym, fort_update, max_battle | |
webhook |
object | yes | Raw webhook payload (same format as Golbat sends) | |
language |
string | no | "en" | Language code for translations |
platform |
string | no | "discord" | Platform for emoji resolution (discord or telegram) |
curl -X POST -H "X-Poracle-Secret: secret" -H "Content-Type: application/json" \
http://localhost:3030/api/dts/enrich \
-d '{"type":"pokemon","webhook":{"pokemon_id":129,"latitude":51.28,"longitude":1.08,"disappear_time":9999999999,"individual_attack":15,"individual_defense":15,"individual_stamina":15},"language":"en","platform":"discord"}'{
"status": "ok",
"variables": {
"name": "Magikarp",
"fullName": "Magikarp",
"pokemonId": 129,
"iv": 100,
"cp": 212,
"level": 27,
"quickMoveName": "Splash",
"chargeMoveName": "Struggle",
"pvpGreat": [...],
"pvpUltra": [...]
}
}Returns available template fields, block scopes, and insertable snippets for a DTS type.
Field properties: name, type, description, category, preferred (recommended for new templates), deprecated (use preferredAlternative instead), rawWebhook (direct from scanner, prefer enriched equivalent).
Block scopes: describe what fields are available inside block helpers like {{#each pvpGreat}}. Each scope lists iterableFields (which arrays it applies to) and fields (what's available on each item). Scopes are per-iterable — pvpGreat items have different fields from weaknessList items.
Snippets: pre-made Handlebars expressions for quick insertion. Each has label, insert (the text to insert), description, category, and optional platform ("discord", "telegram", or omitted for all). Uses single quotes (Poracle convention).
{
"status": "ok",
"type": "monster",
"fields": [
{"name": "fullName", "type": "string", "description": "Name + form combined", "category": "identity", "preferred": true},
{"name": "pokemon_id", "type": "int", "description": "Pokemon ID (webhook)", "category": "identity", "rawWebhook": true, "preferredAlternative": "pokemonId"},
{"name": "despawnTimestamp", "type": "int", "description": "Unix despawn timestamp (for Discord <t:N:R>)", "category": "time"}
],
"blockScopes": [
{"helper": "each", "args": ["pvpGreat"], "iterableFields": ["pvpGreat","pvpUltra","pvpLittle"], "description": "Iterate over a PVP league display list", "fields": [...]},
{"helper": "each", "args": ["weaknessList"], "iterableFields": ["weaknessList"], "description": "Iterate over weakness categories", "fields": [...]},
{"helper": "each", "args": ["evolutions"], "iterableFields": ["evolutions"], "description": "Iterate over evolution chain entries", "fields": [...]},
{"helper": "pokemon", "args": ["id","form"], "description": "Pokemon data block helper", "fields": [...]},
{"helper": "getPowerUpCost", "args": ["levelStart","levelEnd"], "description": "Power-up cost between two levels", "fields": [...]}
],
"snippets": [
{"label": "Round IV", "insert": "{{round iv}}", "description": "IV rounded to integer", "category": "pokemon"},
{"label": "IV or 💯", "insert": "{{#isnt iv 100}}{{round iv}}%{{else}}💯{{/isnt}}", "description": "Show IV% or 💯 for hundos", "category": "pokemon"},
{"label": "Countdown", "insert": "<t:{{despawnTimestamp}}:R>", "description": "Discord relative countdown", "category": "pokemon", "platform": "discord"},
{"label": "getEmoji", "insert": "{{getEmoji 'key'}}", "description": "Look up emoji by key", "category": "emoji"}
]
}Types: monster, monsterNoIv, raid, egg, quest, invasion, lure, nest, gym, fort-update, maxbattle, weatherchange, greeting.
Returns the list of all available DTS type names as a string array.
Returns Handlebars partials for client-side template rendering. Register these with the Handlebars engine before rendering templates that use {{> partialName}}.
{"status": "ok", "partials": {"remainingTime": "{{#if tthh}}{{tthh}}h{{/if}}{{tthm}}m{{tths}}s"}}Compile a template with provided variables and deliver the rendered message to a Discord/Telegram user. Used by the editor to preview exactly what Discord/Telegram will show.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
template |
object | yes | DTS template object (same format as in dts.json) | |
variables |
object | yes | Enriched variable map (from /api/dts/enrich) | |
target.id |
string | yes | Discord user/channel ID or Telegram chat ID | |
target.type |
string | no | "discord:user" | Destination type (discord:user, discord:channel, telegram:user, telegram:group) |
platform |
string | no | "discord" | Platform for rendering |
language |
string | no | "en" | Language for rendering |
{"status": "ok", "message": "sent"}Returns test webhook scenarios from testdata.json. The editor can use these as sample payloads for the enrich endpoint. Config dir entries override fallback entries by type+test key.
| Parameter | Description |
|---|---|
type |
Filter by webhook type (pokemon, raid, pokestop, gym, max_battle, quest, fort_update) |
{
"status": "ok",
"testdata": [
{"type": "pokemon", "test": "hundo", "location": "current", "webhook": {...}},
{"type": "pokemon", "test": "great-rank1", "location": "current", "webhook": {...}},
{"type": "pokemon", "test": "shiny", "location": "current", "webhook": {...}}
]
}Available test scenarios: boring, hundo, great-rank1, great-rank9, ultra1, unencountered, boosted, shiny (pokemon); egg1, level1, egg5, level5, egg6, level3 (raid); invasion, lure, giovanni, kecleon, goldstop, goldlure, showcase, pokemoncontest (pokestop); teamchange (gym); level1, level3 (max_battle); quest-item, quest-stardust, quest-pokemon, quest-energy (quest); edit, new, remove, etc. (fort_update).
Reload DTS templates and partials from disk (config/dts.json, config/dts/*.json, config/partials.json, plus the shipped fallbacks). Use after editing files directly or after saving via the API if you want to pick up changes from other sources.
Scope is DTS only — this does not re-fetch Koji geofences or reload tracking rules. Use /api/geofence/reload for a Koji refresh and /api/reload for the DB tracking state.
{"status": "ok"}All config editor endpoints require the X-Poracle-Secret header. Sensitive settings (database, tokens, bind addresses) are excluded — they remain TOML-only.
Returns the config schema with field metadata for the editor. Each field includes:
| Property | Description |
|---|---|
name |
TOML field name |
type |
string, int, float, bool, string[], int[], color[], select, map |
default |
Default value |
description |
Help text |
hotReload |
true if changes take effect immediately, false if restart needed |
sensitive |
true for fields masked in values response |
deprecated |
true if the field/option is no longer recommended — editor should warn or hide unless already set |
advanced |
true if the field should be hidden behind a "show advanced" toggle |
hideDefault |
true if the editor should NOT pre-fill the default value (e.g. fallback URLs that the user shouldn't normally see) |
minLength, maxLength |
For array types: minimum/maximum number of entries (e.g., iv_colors requires exactly 6) |
resolve |
ID resolution hint: discord:user, discord:role, discord:channel, discord:guild, discord:target, discord:user|role, telegram:chat, geofence:area, destination |
options |
For select type: [{value, label, description, deprecated?}] |
dependsOn |
Visibility dependency: {field, value} — hide when parent field doesn't match |
Type notes:
color[]— array of CSS hex colour strings (e.g.,iv_colors); editor should render colour pickersint[]— array of integers (e.g.,pvp.level_caps)map—map[string]any; the field'sresolvehint applies to the values where appropriate (e.g.,command_securityvalues are user/role IDs)
Deprecated handling: Field-level deprecated: true means the entire field is deprecated. Option-level deprecated: true (inside options) means a specific select value is deprecated but the field itself is fine. Editor behaviour suggestion: hide deprecated items unless they're already set, in which case show them with a warning badge.
Response is grouped by sections, each with fields and optional tables (array-of-tables like delegated_admins, communities, role_subscriptions).
Returns current merged config values (TOML + overrides) plus a list of fields that are currently overridden by config/overrides.json. The editor uses overridden to display badges showing which fields come from the web editor vs the user's config.toml. Only web-editable fields. Sensitive fields are masked.
| Parameter | Description |
|---|---|
section |
(optional) Return only this section |
{
"status": "ok",
"values": {
"general": {"locale": "en", "max_pokemon": 0},
"discord": {"admins": ["344179542874914817"], "check_role": true}
},
"overridden": ["discord.admins", "alert_limits.dm_limit"]
}The processor also logs a prominent banner at startup listing every field overridden by overrides.json — so users editing config.toml directly can see at a glance which of their values are being shadowed.
Save config changes. Accepts partial updates — only changed fields. Writes to config/overrides.json (never modifies config.toml). Hot-reloadable settings are applied immediately.
Sensitive field handling: Fields marked sensitive in the schema are returned as "****" by GET /values. When you POST a value of "****" for a sensitive field, the processor silently strips it before saving — preserving the existing secret. This lets the editor resubmit a whole form without wiping secrets the user didn't change. To actually update a secret, send a real value.
{
"discord": {"admins": ["344179542874914817", "999888777"]},
"alert_limits": {"dm_limit": 30}
}Response:
{
"status": "ok",
"saved": 2,
"restart_required": false
}If any changed field requires restart:
{
"status": "ok",
"saved": 3,
"restart_required": true,
"restart_fields": ["discord.check_role"]
}Dry-run validation. Same request body as POST /api/config/values but only checks for problems without writing. Useful for live previews — the editor can call this whenever a value changes and show validation issues immediately.
The save endpoint runs the same validators internally; any field with an error-severity issue rejects the save with HTTP 400. warning-severity issues are advisory and don't block saves.
Request: same as POST /api/config/values
Response:
{
"status": "ok",
"issues": [
{
"field": "discord.iv_colors[2]",
"severity": "error",
"message": "not a valid hex colour (expected #RGB or #RRGGBB): \"red\""
},
{
"field": "discord.iv_colors",
"severity": "error",
"message": "requires at least 6 entries (got 5)"
},
{
"field": "geofence.paths[1]",
"severity": "warning",
"message": "file does not exist (yet) at /path/to/config/geofences/foo.json"
},
{
"field": "geofence.paths[2]",
"severity": "error",
"message": "absolute paths not allowed; use a path relative to the config directory"
}
]
}Empty issues array means everything is valid. Each issue is one of:
| Severity | Meaning |
|---|---|
error |
Save will be rejected. The field value is invalid and the user must fix it. |
warning |
Save proceeds. The value is suspicious but technically allowed (e.g., a geofence path that doesn't exist on disk yet — the user might be configuring a fence they haven't created). |
Validators currently applied:
color[]fields: each entry must be a valid hex colour (#RGBor#RRGGBB)MinLength/MaxLength: array length must fall within boundsgeofence.paths: each entry must be either an http(s):// URL or a relative path under the config directory; absolute paths and..escapes are rejected; non-existent files trigger a warning
Slim config.toml by moving every web-editable non-default value into config/overrides.json. Useful after a user has been using the web editor for a while — it cleans up config.toml so it contains only TOML-only fields (database, tokens, processor host/port).
Process:
- Backs up the current
config.tomltoconfig.toml.bak.YYYY-MM-DD_HHMMSS - For every web-editable field with a non-default value, copies it to
overrides.json(without overwriting existing overrides — existing overrides win) - Rewrites
config.tomlcontaining only fields NOT in the editor schema (database, tokens, etc.) with a header comment pointing to the backup
Idempotent: running it twice produces the same result. Safe to retry on errors.
Reversible: delete overrides.json and restore the backup file to undo.
curl -X POST -H "X-Poracle-Secret: secret" http://localhost:3030/api/config/migrateResponse:
{
"status": "ok",
"backup": "config.toml.bak.2026-04-08_153022",
"fields_moved": [
"alert_limits.dm_limit",
"discord.admins",
"general.locale"
],
"fields_kept": [
"alerter.api_secret",
"database.host",
"database.password",
"discord.token",
"processor.api_secret",
"processor.port",
"telegram.token"
]
}Batch resolve Discord/Telegram IDs to human-readable names. Results cached for 10 minutes. IDs that cannot be resolved are omitted (not an error). Discord/Telegram sections are omitted when the respective bot is not configured.
Request:
{
"discord": {
"users": ["344179542874914817"],
"roles": ["987654321"],
"channels": ["111222333"],
"guilds": ["444555666"]
},
"telegram": {
"chats": ["789012345", "-100123456"]
},
"destinations": ["111222333", "raid-feed", "999000111"]
}The destinations array is for IDs of unknown type — used when a schema field has resolve: "destination" (e.g., alert_limits.overrides.target which can be a Discord channel/user/webhook/Telegram chat). The processor tries the humans table first, then Discord (channel → user → role → guild), then Telegram, returning whatever matches first.
Response:
{
"status": "ok",
"discord": {
"users": {"344179542874914817": {"name": "JamesBerry", "globalName": "James Berry"}},
"roles": {"987654321": {"name": "Moderator", "guild": "My Server", "guildId": "444555666"}},
"channels": {"111222333": {"name": "raid-alerts", "type": "text", "guild": "My Server", "guildId": "444555666", "categoryName": "Pokemon"}},
"guilds": {"444555666": {"name": "My Server"}}
},
"telegram": {
"chats": {
"789012345": {"name": "James Berry", "type": "private"},
"-100123456": {"name": "Pokemon Group", "type": "supergroup"}
}
},
"destinations": {
"111222333": {
"kind": "discord:channel",
"name": "raid-alerts",
"enabled": true,
"notes": "EU South RAID alerts",
"areas": ["london"],
"type": "text",
"guild": "My Server",
"guildId": "444555666"
},
"raid-feed": {
"kind": "webhook",
"name": "raid-feed",
"enabled": true,
"notes": "Discord raid feed for #raids"
},
"999000111": {
"kind": "discord:channel",
"name": "old-channel",
"enabled": true,
"notes": "channel deleted after server cleanup",
"stale": true
}
}
}Stale flag: when a destination matches an entry in PoracleNG's humans table but the platform API can't find the corresponding entity (e.g., a channel that was deleted, a user who left the server), the result includes "stale": true. The editor should warn the user before letting them keep stale targets in their config — these IDs are registered but no longer reachable.
The kind field tells the editor what type was matched: webhook, discord:channel, discord:user, discord:role, discord:guild, telegram:user, telegram:channel, telegram:group, etc.
For geofence:area resolve hints, the editor uses the existing GET /api/geofence/all endpoint to populate autocomplete.
Endpoints for the bulk-autocreate feature. Two surfaces:
- Sync trigger (
POST /api/autocreate/run) — applies[[autocreate.rules]]fromconfig.tomlagainst the live geofence list. Mirrors the!autocreate syncDiscord command. - Template editor (
/api/autocreate/templates*) — read, write, validate, deleteconfig/channelTemplate.jsonentries. Backs up the previous version of the file underconfig/backups/on every write.
See AUTOCREATE.md for the channel-template format itself.
Trigger a bulk-autocreate sync. Empty rule runs every configured [[autocreate.rules]] entry; a named rule runs just that one.
Request:
{
"rule": "uk-areas",
"dry_run": false,
"removals": false,
"force": false,
"reset": false
}| Field | Type | Default | What it does |
|---|---|---|---|
rule |
string | "" |
Rule name to run, or empty for all rules. |
dry_run |
bool | false |
Preview only — no Discord or DB writes. |
removals |
bool | false |
Permit orphan removal (rule must also have remove_missing = true). |
force |
bool | false |
Bypass removal_safety_max_percent threshold. |
reset |
bool | false |
Re-run the template's commands on reused channels (default behaviour preserves admin-tweaked tracking). |
Response:
{
"status": "ok",
"rules": [
{
"Rule": "uk-areas",
"Status": "ok",
"DryRun": false,
"Note": "",
"Created": ["Aalst", "Antwerp"],
"Reused": ["Brussels"],
"Orphans": [],
"Removed": [],
"Skipped": [],
"Errors": []
}
]
}Status values:
"ok"— runner went through its normal path. May still have per-fence errors inErrors[]— check those separately."busy"— another sync of the same rule was already running (TryLock failed). Retry later."safety_blocked"— the removal phase was aborted because the orphan ratio exceededautocreate.removal_safety_max_percent. Creates and reuses still ran. Retry with"force": trueto override.
404 returned when "rule" names a rule that doesn't exist. 503 returned when the Discord bot isn't running.
curl -X POST -H "X-Poracle-Secret: secret" -H "Content-Type: application/json" \
-d '{"rule":"uk-areas","dry_run":true}' \
http://localhost:3030/api/autocreate/runRead the live config/channelTemplate.json array. Returns an empty array when the file doesn't exist (first-time install).
curl -H "X-Poracle-Secret: secret" http://localhost:3030/api/autocreate/templatesResponse:
{
"status": "ok",
"templates": [
{
"name": "area",
"definition": {
"category": { "categoryName": "{0}", "roles": [...] },
"channels": [...]
}
}
]
}Replace the full template array (validate + write). The previous version of the file is snapshotted into config/backups/channelTemplate.json.bak.<timestamp> before the write so the operator can roll back.
Request:
{
"templates": [
{ "name": "area", "definition": { ... } },
{ "name": "raid-feed", "definition": { ... } }
]
}The full array replaces what's on disk — to delete a template, omit it from the POST. Or use DELETE /api/autocreate/templates/:name if you only want to remove one entry without re-sending the rest.
Success response:
{
"status": "ok",
"backup": "backups/channelTemplate.json.bak.2026-05-07_113402",
"warnings": []
}backup is the path of the snapshot relative to config/. Empty when there was no existing file. warnings[] carries non-blocking validation issues (e.g. voice channel with text-only fields, a threadPicker set on a channel with no threads).
Validation failure (400):
{
"status": "error",
"errors": [
{ "path": "templates[0].definition.channels", "severity": "error", "message": "at least one channel is required" },
{ "path": "templates[1].name", "severity": "error", "message": "duplicate name \"area\" (must be unique within the array)" }
]
}The bulk runner's loadChannelTemplates() reads the file on every SyncOneRule, so changes pick up immediately — no reload trigger needed.
Same body as POST /api/autocreate/templates but never writes. Useful for "lint as you type" in an editor.
{ "templates": [ ... ] }Response: { "status": "ok", "warnings": [...] } on success, or 400 with errors[] on validation failure (same shape as POST).
Remove one template by name. Snapshots the previous file to config/backups/ before writing.
curl -X DELETE -H "X-Poracle-Secret: secret" \
http://localhost:3030/api/autocreate/templates/areaResponse:
{ "status": "ok", "backup": "backups/channelTemplate.json.bak.2026-05-07_113402" }404 returned when :name doesn't match any template in the array.
Static metadata for editors to render dropdowns, permission-flag pickers, and placeholder hints.
curl -H "X-Poracle-Secret: secret" http://localhost:3030/api/autocreate/templates/schemaResponse:
{
"status": "ok",
"schema": {
"channelTypes": ["text", "voice"],
"controlTypes": ["", "bot", "webhook"],
"buttonStyles": ["primary", "secondary", "success", "danger"],
"permissionFlags": [
{ "name": "view", "label": "View Channel", "group": "general" },
{ "name": "viewHistory", "label": "Read Message History", "group": "general" },
{ "name": "send", "label": "Send Messages", "group": "general" },
{ "name": "connect", "label": "Connect", "group": "voice" },
{ "name": "channels", "label": "Manage Channels", "group": "admin" }
],
"placeholderHelp": {
"interactive": "{N} indexes args[N+1] from !autocreate <template> <args...> — note the off-by-one (args[0] is the template name itself).",
"bulk": "[[autocreate.rules]] params[] elements render per-fence then become positional args in order. Whitespace inside a rendered element splits it into multiple args; \"quoted segments\" stay as one."
},
"backupNamePrefix": "channelTemplate.json.bak."
}
}group is one of "general" / "voice" / "admin" / "other", sorted in that order. The full permission-flag list is derived from the bot's authoritative bit-mask map — every flag the bot understands appears here.
All pokemon with names, forms, and types.
All invasion grunt types.
Reverse geocoding for alert templates is locale-aware. The processor performs a base reverse-geocode lookup that populates the default-locale address fields, then performs per-language lookups for matched user languages that shadow those fields in the per-language DTS layer. Existing DTS fields such as {{addr}}, {{city}}, {{streetName}}, and {{formattedAddress}} automatically resolve to the user's localized geocoder result when the provider supports it.
Supported reverse-geocode language parameters:
| Provider | Parameter |
|---|---|
| Nominatim | accept-language |
| Photon | lang |
language |
Reverse geocode cache keys include the language code for localized lookups, so cached German and English address results do not overwrite each other.
Forward geocode a location name to coordinates.
[{"latitude": 51.28, "longitude": 1.08, "city": "Canterbury", "country": "United Kingdom"}]Send a confirmation message to a user via Discord or Telegram. Used internally by the processor for API operation confirmations (e.g. tracking added/removed). This is the canonical endpoint; POST /api/postMessage is a legacy alias that behaves identically.
[{
"target": "123456789",
"type": "discord:user",
"name": "UserName",
"message": {"content": "Hello from Poracle"},
"tth": {"hours": 1, "minutes": 0, "seconds": 0},
"clean": false,
"language": "en"
}]Simulate a webhook for testing DTS templates. Used by the !poracle-test command.
{
"type": "pokemon",
"webhook": {"pokemon_id": 1, "latitude": 51.28, "longitude": 1.08, ...},
"target": {"id": "123", "name": "User", "type": "discord:user", "language": "en", "template": "1"}
}PoracleNG can call an operator-supplied URL once per matched user, after rate-limit pre-filtering and before enrichment. This is an outbound webhook from the processor — the operator implements the receiving endpoint. It exists so external services (subscription/licensing servers, A/B testing controllers, custom business logic) can approve or deny each user without touching PoracleNG's source.
The hook is disabled by default. Configure it under [validation] (see Configuration → validation) — leave url empty to skip the call entirely (zero overhead).
| Setting | Default | Description |
|---|---|---|
[validation] url |
"" |
Endpoint POSTed once per matched user. Empty disables the hook. |
[validation] fail_mode |
"open" |
"open" allows on timeout/error/non-2xx (safe default — your validator going down doesn't take alerts down with it). "closed" denies on the same conditions (use for hard licensing where un-validated alerts are unacceptable). |
[tuning] validation_timeout_ms |
1500 |
Per-call HTTP timeout in milliseconds. |
[tuning] validation_max_concurrent |
16 |
Maximum parallel validator calls per webhook event. The processor fans out matched users under a semaphore at this size. |
POST <url>
Content-Type: application/json
{
"human": {
"id": "123456789012345678",
"type": "discord:user",
"name": "Alice"
},
"areas": ["SanFrancisco/Downtown", "Oakland/Lake"],
"webhook_type": "raid",
"webhook": { "gym_id": "...", "level": 5, ... }
}| Field | Type | Description |
|---|---|---|
human.id |
string | The matched destination's primary key (Discord user/channel/webhook id, Telegram user/chat id). |
human.type |
string | discord:user, discord:channel, discord:webhook, telegram:user, telegram:channel, telegram:group. |
human.name |
string | Friendly name from the humans row. |
areas |
string[] | Geofence area names containing the event's coordinates. Same set the matcher used for area-overlap checks (case as configured in your geofences, not lower-cased). |
webhook_type |
string | One of pokemon, raid, egg, quest, pokestop, invasion, nest, gym, fort_update, max_battle. |
webhook |
object | The original Golbat message verbatim. Useful when the validator's decision depends on event details (raid level, pokemon IV, etc.). |
200 OK with JSON. Anything else is treated according to fail_mode.
| Body | Effect |
|---|---|
{"success": true} |
Allow — the user proceeds to enrichment / render / delivery. |
{"success": false} |
Silent deny — the user is dropped from the alert. No notification is sent. |
{"success": false, "failure_message": "Subscription expired"} |
Deny + notify — the user is dropped, AND the failure_message is delivered to them as a single bypass message (skips the rate-limit budget so the user always sees the explanation). |
The failure_message text is delivered verbatim. PoracleNG does not translate or template it — the validator owns the wording (and any localisation, if needed).
| Outcome | fail_mode = "open" (default) |
fail_mode = "closed" |
|---|---|---|
HTTP timeout (exceeded validation_timeout_ms) |
Allow | Deny |
| Network error | Allow | Deny |
| Non-2xx response | Allow | Deny |
Malformed JSON / missing success field |
Allow | Deny |
A denied user produced by fail_mode = "closed" does not receive a failure_message (there's nothing to deliver — the validator wasn't reachable).
Tracking system with a paid tier. Free users get raid alerts only; paid users get everything. The validator endpoint is https://license.example.com/poracle/validate and configured with fail_mode = "open" so a license-server outage doesn't take the bot down.
# Pseudocode for the validator endpoint
def handle(req):
user = lookup_subscription(req["human"]["id"])
if user.tier == "paid":
return {"success": True}
if req["webhook_type"] == "raid":
return {"success": True} # free tier gets raids
return { # everything else denied
"success": False,
"failure_message": (
"Pokemon and quest alerts are paid-tier only. "
"Upgrade at example.com/poracle to unlock all alert types."
),
}Per matched user, in this order:
- Match (against tracking rules in memory)
- Rate-limit pre-filter (skip users whose destination is over its limit)
- Type-specific filter (raid: RSVP preference; etc.)
- Validation hook ← this endpoint
- Enrichment (game data, translations, maps, geocoding)
- DTS render
- Delivery
The validator runs after the in-process filters so denied users don't burn validator load when they would have been dropped anyway.
| Prometheus metric | Description |
|---|---|
poracle_processor_validation_total{result="allow"} |
Count of allowed users (200 + success: true). |
poracle_processor_validation_total{result="deny"} |
Count of denied users (200 + success: false, silent + with-message combined). |
poracle_processor_validation_total{result="error_<reason>"} |
Count of failures by reason: http_error (network / timeout), status_<code> (non-2xx), decode_error (malformed body), marshal_error, build_error. The error counter is incremented regardless of fail_mode; whether the user was allowed or denied as a result is determined by fail_mode and is not separately counted under allow / deny. |
poracle_processor_validation_seconds |
Latency histogram per call. |