Webhooks
Get every alert your account makes as it happens (ships and ports you follow, your zones, your saved searches) as signed JSON sent to your own https endpoint. Webhooks come with Pro (5 endpoints) and with the paid API plans (Developer 5, Growth 100, Scale 1,000 endpoints); with both, the higher limit applies. What alerts is set by your personal plan's limits for follows, zones and saved searches.
Add endpoints, copy the signing secret, send a test event and read the delivery log in My Voydar → Webhooks
The request
One POST per alert and endpoint. Seadar-Event-Id stays the same across retries and endpoints, so use it to ignore duplicates. Answer with any 2xx status within 10 seconds; do the work afterwards.
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Seadar-Webhooks/1 (+https://www.seadar.io/developers/webhooks)
Seadar-Event-Id: evt_48213
Seadar-Delivery: 5b0f7c2e-8a41-4a0e-9f3c-2d6f1b9e0a77
Seadar-Event-Type: alert.zone_enter
Seadar-Signature: t=1790000000,v1=5f2c…(hex HMAC-SHA256)Verify the signature
Seadar-Signature is "t" (unix seconds), then "v1" (the signature). The signature is the hex HMAC-SHA256 of the unix time, a period, and the raw body, keyed with your endpoint secret. Compute it over the raw body before parsing JSON, compare in constant time, and reject timestamps more than 5 minutes old.
import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody: the request body exactly as received (a string or Buffer, before JSON.parse).
export function verifyVoydar(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(',').map((kv) => kv.trim().split('=')));
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
const got = Buffer.from(parts.v1 ?? '', 'hex');
return got.length === expected.length && timingSafeEqual(got, expected);
}import hashlib, hmac, time
def verify_voydar(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(kv.strip().split("=", 1) for kv in header.split(","))
t = int(parts.get("t", "0"))
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))Retries and turning off
A delivery that does not get a 2xx is retried after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h (8 attempts in all), then marked as given up in your delivery log. An endpoint that fails 15 times in a row over at least 24 hours is turned off; turn it back on in My Voydar once it is fixed.
Where we send
https only, on ports 443, 8443. We resolve the host and send only to public addresses (never private, loopback, link-local or metadata addresses), connect to the address we checked, never follow redirects, wait at most 10 seconds, and send at most 64 KB.
What a payload contains
Identifiers (IMO, MMSI, name, UN/LOCODE), the alert type and time, your own zone or search names, and `links` (the Voydar page of the vessel or port, plus your zone or saved search) always go, so you can open what a payload withholds. A sourced fact (a sanctions listing, an owner, a destination) goes only when every source behind it permits redistribution through an API (and commercial use, on commercial plans); otherwise fact and text are null and `withheld` names the sources. Zone events and saved-search matches are Voydar's own evaluation (`derived: true`); the raw positions behind them are left out when the position source is not licensed for API use. Positions, speeds and arrival ports stay withheld until their sources' API rights are cleared.
Event types
alert.arrivedalert.departedalert.no_signalalert.signal_restoredalert.identity_changedalert.sanctionsalert.ownership_changedalert.congestionalert.destination_changedalert.eta_changedalert.arrival_predictedalert.detentionalert.incidentalert.news_mentionalert.went_darkalert.sts_candidatealert.position_anomalyalert.zone_enteralert.zone_leavealert.zone_loiteralert.zone_darkalert.zone_behaviouralert.zone_summaryalert.search_matchalert.search_unmatchtest.ping
Example payloads
{
"id": "evt_48213",
"type": "alert.zone_enter",
"created": "2026-09-24T10:02:11.000Z",
"api_version": "2026-09-24",
"data": {
"alert": {
"id": 48213,
"type": "zone_enter",
"occurred_at": "2026-09-24T09:58:40.000Z",
"derived": true,
"subject": { "kind": "vessel", "ref": "imo:9811000", "imo": 9811000, "mmsi": 353136000, "unlocode": null,
"name": "EVER GIVEN", "url": "https://www.voydar.com/vessel/9811000-ever-given" },
"links": { "entity": "https://www.voydar.com/vessel/9811000-ever-given",
"zone": "https://www.voydar.com/account/zones#zone-3f0e…", "saved_search": null },
"follow_id": null,
"zone": { "id": "3f0e…", "name": "Maasvlakte anchorage" },
"saved_search": null,
"text": "EVER GIVEN entered your zone Maasvlakte anchorage.",
"fact": { "t": "zone_enter", "zoneId": "3f0e…", "zone": "Maasvlakte anchorage" },
"sources": [],
"withheld": [ { "fields": ["lat", "lon", "sog"], "sources": ["aisstream"], "reason": "source_rights" } ]
}
}
}{
"id": "evt_48377",
"type": "alert.sanctions",
"created": "2026-09-24T12:00:05.000Z",
"api_version": "2026-09-24",
"data": {
"alert": {
"id": 48377,
"type": "sanctions",
"occurred_at": "2026-09-24T11:40:00.000Z",
"derived": false,
"subject": { "kind": "vessel", "ref": "imo:9321483", "imo": 9321483, "mmsi": null, "unlocode": null,
"name": "EXAMPLE TRADER", "url": "https://www.voydar.com/vessel/9321483-example-trader" },
"follow_id": "a41c…",
"zone": null,
"saved_search": null,
"text": "EXAMPLE TRADER was added to the OFAC SDN list.",
"fact": { "t": "sanctions", "action": "listed", "list": "ofac_sdn", "program": "IRAN", "sourceId": "ofac_sdn" },
"sources": [ { "id": "ofac_sdn", "name": "OFAC SDN list", "attribution": "U.S. Department of the Treasury" } ],
"withheld": []
}
}
}