Alerts and forwarding
A boat off its route, a rider gone quiet, a van stopped too long: told at once, where your team already looks.
On this page
Alerts come with fleet tracking. Set them up in the console (Fleet → Alerts) or with the alerts API.
What N-Ways watches for
| Alert | Raised when | Event |
|---|---|---|
| Leaves its area | A vehicle on a delivery goes outside the delivery’s dispatch area (a circle or a polygon), or outside your standing zones. Boats on the lagoon too. | asset.geofence_exit |
| Enters a zone | A vehicle drives into one of your zones (a depot, a market, a no-go area). | asset.geofence_enter |
| Goes silent | A vehicle has sent no position for the rule’s time (30 minutes, say). | asset.silent |
| Stops too long | A vehicle has stayed within a small radius for the rule’s time, outside your depots. | asset.idle |
| Back to normal | An alert was resolved: by someone in the console, through the API, or by itself once the vehicle is back. | asset.alert_resolved |
A rule covers all your vehicles, a group, some vehicles or a kind (boats, motorbikes…). It sends through the channels you pick (email, push, webhook), stays quiet in the hours you set, and reminds you again after the interval you choose while the alert is still open.
Forward your alerts
Start with the first two: they need no setup at all.
1. Email and push
- Email: pick who gets it in the rule (everyone with a role, chosen people, or any address, such as your operations desk).
- Push: to the N-Ways Business app, and to any browser where someone turned on notifications in the console (Fleet → Alerts → Notify me on this device). On an iPhone, web push needs the console added to the home screen first.
2. Excel: download all events
Every event, the same ones webhooks get, as a spreadsheet: the console’s Download all events button (Fleet → Alerts, or Logs → Alerts), or the API. The CSV opens in Excel and Google Sheets with names and ₦ intact; times are Lagos time.
curl -o events.csv "https://nways.ng/maps/v1/fleet/events/export?format=csv&from=2026-10-01&type=asset." \
-H "X-NWays-Api-Key: YOUR_SERVER_KEY"Columns: time_lagos, event, vehicle, vehicle_name, delivery, zone, alert, state, latitude, longitude, payload_json. Leave out type for deliveries too; format=json gives the raw events.
3. A live Google Sheet
A sheet that fills itself, row by row, as alerts happen. Google Apps Script can’t read request headers, so it can’t check the signature: protect it with a long random token in its address instead.
- Make a Google Sheet. Extensions → Apps Script, and paste this:
Code.gs
const TOKEN = 'paste-a-long-random-string-here' function doPost(e) { if (e.parameter.token !== TOKEN) return ContentService.createTextOutput('no') const ev = JSON.parse(e.postData.contents) const d = ev.data || {} const a = d.alert || {} const p = (a.detail && a.detail.position) || {} SpreadsheetApp.getActive().getSheets()[0].appendRow([ new Date(ev.createdAt), ev.type, a.vehicleName || a.vehicleId || '', a.ruleName || '', a.state || '', p.lat || '', p.lng || '', ev.id, ]) return ContentService.createTextOutput('ok') } - Deploy → New deployment → Web app; execute as Me, access Anyone. Copy the web app address.
- In the console, Settings → Webhooks → Add endpoint: the address followed by
?token=and your token, eventsasset.*. Press Send a test: apingrow appears.
4. A Telegram group
Alerts as messages in your dispatch group, through the same Apps Script (or any small server).
- In Telegram, talk to @BotFather:
/newbot, and keep the bot token. Add the bot to your group, send a message there, then openhttps://api.telegram.org/bot<token>/getUpdatesto read the group’schat.id(it starts with-). - Add this to the script above, and call
telegram(ev)indoPostbefore thereturn:Code.gsconst BOT = 'your-bot-token' const CHAT = '-1001234567890' function telegram(ev) { const a = (ev.data && ev.data.alert) || {} const text = ev.type === 'asset.alert_resolved' ? `✅ Resolved: ${a.kind}` : `⚠️ ${a.vehicleName || a.vehicleId}: ${a.ruleName || ev.type}` UrlFetchApp.fetch(`https://api.telegram.org/bot${BOT}/sendMessage`, { method: 'post', contentType: 'application/json', payload: JSON.stringify({ chat_id: CHAT, text }), }) } - Deploy a new version (Deploy → Manage deployments → Edit → New version).
WhatsApp only sends automated messages through the WhatsApp Business API, so you need your own provider account (Meta’s Cloud API or a provider such as Twilio). Point a small receiver like the ones below at it.
5. Your own server
Receive events on your server and do what you like with them. Check the X-NWays-Signature header first (t=<unix seconds>,v1=<hex>, HMAC-SHA256 of "<t>.<raw body>" with the endpoint’s secret; details), answer 2xx within 10 seconds, and do slow work afterwards.
// routes/api.php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::post('/nways/events', function (Request $request) {
$raw = $request->getContent();
parse_str(str_replace(',', '&', $request->header('X-NWays-Signature', '')), $sig);
if (abs(time() - (int) ($sig['t'] ?? 0)) > 300) {
abort(401);
}
$want = hash_hmac('sha256', ($sig['t'] ?? '') . '.' . $raw, config('services.nways.webhook_secret'));
if (! hash_equals($want, $sig['v1'] ?? '')) {
abort(401);
}
$event = json_decode($raw, true);
if ($event['type'] === 'asset.geofence_exit') {
\Log::warning('Left its area', $event['data']['alert']);
}
return response()->noContent();
});import { createHmac, timingSafeEqual } from 'node:crypto'
import { createServer } from 'node:http'
const SECRET = process.env.NWAYS_WEBHOOK_SECRET
function verified(raw, header) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false
const want = createHmac('sha256', SECRET).update(`${parts.t}.${raw}`).digest('hex')
return want.length === parts.v1?.length && timingSafeEqual(Buffer.from(want), Buffer.from(parts.v1))
}
createServer((req, res) => {
let raw = ''
req.on('data', (c) => (raw += c))
req.on('end', () => {
if (!verified(raw, req.headers['x-nways-signature'] ?? '')) return res.writeHead(401).end()
const event = JSON.parse(raw)
if (event.type.startsWith('asset.')) console.log(event.type, event.data.alert?.vehicleName)
res.writeHead(204).end()
})
}).listen(8080)import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["NWAYS_WEBHOOK_SECRET"].encode()
@app.post("/nways/events")
def events():
raw = request.get_data()
parts = dict(p.split("=", 1) for p in request.headers.get("X-NWays-Signature", "").split(",") if "=" in p)
if abs(time.time() - int(parts.get("t", 0))) > 300:
abort(401)
want = hmac.new(SECRET, parts["t"].encode() + b"." + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(want, parts.get("v1", "")):
abort(401)
event = request.get_json()
if event["type"] == "asset.silent":
print("Gone silent:", event["data"]["alert"]["vehicleName"])
return "", 204package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"math"
"net/http"
"os"
"strconv"
"strings"
"time"
)
var secret = []byte(os.Getenv("NWAYS_WEBHOOK_SECRET"))
func verified(raw []byte, header string) bool {
parts := map[string]string{}
for _, p := range strings.Split(header, ",") {
if k, v, ok := strings.Cut(p, "="); ok {
parts[k] = v
}
}
t, err := strconv.ParseInt(parts["t"], 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-t)) > 300 {
return false
}
m := hmac.New(sha256.New, secret)
m.Write([]byte(parts["t"] + "."))
m.Write(raw)
return hmac.Equal([]byte(hex.EncodeToString(m.Sum(nil))), []byte(parts["v1"]))
}
func main() {
http.HandleFunc("POST /nways/events", func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if !verified(raw, r.Header.Get("X-NWays-Signature")) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
var ev struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}
_ = json.Unmarshal(raw, &ev)
w.WriteHeader(http.StatusNoContent)
})
http.ListenAndServe(":8080", nil)
}Any tool that accepts a webhook (Zapier, Make, n8n…) works too.
What each event looks like
Every event has id, type, version (2026-10-16), createdAt and data. An alert’s data.alert is the same object the console and GET /alert-events show.
{
"id": "evt_9mT2qfX4LwP0aZk1sYb7",
"type": "asset.geofence_exit",
"version": "2026-10-16",
"createdAt": "2026-10-16T09:42:11Z",
"data": {
"alert": {
"id": "alr_5c1e2b7a90d4",
"ruleId": "rule_lagoon",
"ruleName": "Boats stay on their route",
"kind": "geofence_exit",
"vehicleId": "boat-02",
"vehicleName": "Boat 2",
"state": "active",
"detail": { "taskId": "FERRY-7", "area": "dispatch", "position": { "lat": 6.4489, "lng": 3.4542 }, "at": "2026-10-16T09:42:05Z" },
"startedAt": "2026-10-16T09:42:05Z",
"notifications": 1
}
}
}{
"id": "evt_Qe8sNw1rT6yU3iO0pLk2",
"type": "asset.geofence_enter",
"version": "2026-10-16",
"createdAt": "2026-10-16T10:05:40Z",
"data": {
"alert": {
"id": "alr_77b0f1c2e3a4",
"ruleId": "rule_market",
"ruleName": "Into Balogun market",
"kind": "geofence_enter",
"vehicleId": "bike-11",
"vehicleName": "Bike 11",
"subject": "zone:balogun",
"state": "active",
"detail": { "zoneId": "balogun", "zoneName": "Balogun market", "position": { "lat": 6.4592, "lng": 3.3898 }, "at": "2026-10-16T10:05:37Z" },
"startedAt": "2026-10-16T10:05:37Z",
"notifications": 1
}
}
}{
"id": "evt_Hb3vC7xZ0mQ9wE4rT1yU",
"type": "asset.silent",
"version": "2026-10-16",
"createdAt": "2026-10-16T11:31:02Z",
"data": {
"alert": {
"id": "alr_0a9b8c7d6e5f",
"ruleId": "rule_silent",
"ruleName": "Anyone silent for 30 min",
"kind": "silent",
"vehicleId": "bike-04",
"vehicleName": "Bike 4",
"state": "active",
"detail": { "lastSeenAt": "2026-10-16T11:00:58Z", "silentS": 1800, "position": { "lat": 6.6018, "lng": 3.3515 } },
"startedAt": "2026-10-16T11:31:00Z",
"notifications": 1
}
}
}{
"id": "evt_Lp6kJ2hG8fD4sA0zX3cV",
"type": "asset.idle",
"version": "2026-10-16",
"createdAt": "2026-10-16T13:20:15Z",
"data": {
"alert": {
"id": "alr_1f2e3d4c5b6a",
"ruleId": "rule_idle",
"ruleName": "Stopped 20 min outside the depot",
"kind": "idle",
"vehicleId": "van-01",
"vehicleName": "Van 1",
"state": "active",
"detail": { "since": "2026-10-16T13:00:09Z", "idleS": 1200, "radiusM": 50, "position": { "lat": 6.5244, "lng": 3.3792 } },
"startedAt": "2026-10-16T13:20:10Z",
"notifications": 1
}
}
}{
"id": "evt_Ys5tR8eW2qA6zX9cV1bN",
"type": "asset.alert_resolved",
"version": "2026-10-16",
"createdAt": "2026-10-16T13:41:00Z",
"data": {
"alert": { "id": "alr_1f2e3d4c5b6a", "kind": "idle", "ruleId": "rule_idle" },
"resolvedAt": "2026-10-16T13:41:00Z",
"auto": true
}
}When someone resolves it in the console or through the API, by (who) takes the place of auto.