N-Ways
Developer docsAlerts and forwarding

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
Note:

Alerts come with fleet tracking. Set them up in the console (Fleet → Alerts) or with the alerts API.

What N-Ways watches for

AlertRaised whenEvent
Leaves its areaA 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 zoneA vehicle drives into one of your zones (a depot, a market, a no-go area).asset.geofence_enter
Goes silentA vehicle has sent no position for the rule’s time (30 minutes, say).asset.silent
Stops too longA vehicle has stayed within a small radius for the rule’s time, outside your depots.asset.idle
Back to normalAn 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
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.

  1. 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')
    }
  2. Deploy → New deployment → Web app; execute as Me, access Anyone. Copy the web app address.
  3. In the console, Settings → Webhooks → Add endpoint: the address followed by ?token= and your token, events asset.*. Press Send a test: a ping row appears.

4. A Telegram group

Alerts as messages in your dispatch group, through the same Apps Script (or any small server).

  1. In Telegram, talk to @BotFather: /newbot, and keep the bot token. Add the bot to your group, send a message there, then open https://api.telegram.org/bot<token>/getUpdates to read the group’s chat.id (it starts with -).
  2. Add this to the script above, and call telegram(ev) in doPost before the return:
    Code.gs
    const 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 }),
      })
    }
  3. Deploy a new version (Deploy → Manage deployments → Edit → New version).
Note:

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.

PHP (Laravel)
// 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(',', '&amp;', $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();
});
Node.js
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)
Python (Flask)
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 "", 204
Go
package 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&lt;&lt;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, &amp;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.

asset.geofence_exit
{
  "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
    }
  }
}
asset.geofence_enter
{
  "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
    }
  }
}
asset.silent
{
  "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
    }
  }
}
asset.idle
{
  "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
    }
  }
}
asset.alert_resolved
{
  "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.