For your customers
Receiving webhooks
Receive your devices' messages on your own server: the request you'll get, how to verify its signature, and how to answer.
Every message your devices send over the satellite network is delivered to your server as an HTTPS POST, signed so you can check it is genuine. This page is everything your server needs.
How it works
- A device sends a message. It is stored, then POSTed to each of your enabled webhook endpoints.
- Your server checks the signature, stores the message, and answers
2xxwithin 10 seconds. - If it doesn't, the delivery is retried twice. After that the message waits 7 days in a dead letter queue, where it can be sent again. See Retries and the DLQ.
Add an endpoint
An Owner or Admin of your organization adds endpoints, in the portal under Webhooks or with the API. You can have up to 10, and each enabled endpoint receives every message.
curl -X POST https://api.houndsat.com/api/json/webhook_endpoints \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/vnd.api+json" \
-d '{"data": {"type": "webhook_endpoint", "attributes": {"url": "https://fleet.example.com/hooks"}}}'
# The response carries the signing secret, once:
# "meta": {"signing_secret": "whsec_N2Q0YjQ5ZTE4Y2Zm…"}The URL must:
- use
https://, without a username or password in it; - resolve to a public address: private, loopback and link-local addresses are refused;
- answer directly: redirects are not followed and count as a failure.
The request
Each delivery is one message:
POST /hooks HTTP/1.1
Host: fleet.example.com
Content-Type: application/json
User-Agent: HoundSat-Webhooks/1.0
X-HoundSat-Message-Id: 3d9f1a7c-5e2b-4c80-9f64-a1b8e3d0c5f2
X-HoundSat-Timestamp: 1791813803
X-HoundSat-Signature: sha256=c37c2c2f0241ba7440d85c3608ee6885f64f451721a13ea8ba6fb3f028129b2f
{"device":{"id":"5f0c2b7e-8d41-4a96-b3e2-91c7d0a4e6f8","iccid":"8999001234000020718","imsi":"901880000010001","external_id":"trailer-207"},"source":"satellite","message_id":"3d9f1a7c-5e2b-4c80-9f64-a1b8e3d0c5f2","transport":"nidd","received_at":"2026-10-12T14:03:21.000000Z","payload_base64":"dGVtcD0yMS41"}| Header | Value |
|---|---|
X-HoundSat-Message-Id | The message's id, the same as message_id in the body. |
X-HoundSat-Timestamp | When this attempt was signed, in Unix seconds. |
X-HoundSat-Signature | sha256= and the hex HMAC-SHA256 of the timestamp, a dot, and the body. |
The body, formatted for reading:
{
"message_id": "3d9f1a7c-5e2b-4c80-9f64-a1b8e3d0c5f2",
"device": {
"id": "5f0c2b7e-8d41-4a96-b3e2-91c7d0a4e6f8",
"iccid": "8999001234000020718",
"imsi": "901880000010001",
"external_id": "trailer-207"
},
"source": "satellite",
"transport": "nidd",
"received_at": "2026-10-12T14:03:21.000000Z",
"payload_base64": "dGVtcD0yMS41"
}| Field | Meaning |
|---|---|
message_id | Unique per message. The same on every retry. |
device | The sending device: its id, iccid, imsi, and the external_id your team gave it (or null). |
source | satellite. |
transport | How the message arrived from the network: nidd or udp. |
received_at | When the network received it, ISO 8601 in UTC. |
payload_base64 | The bytes the device sent, base64-encoded (at most 4 KB once decoded). |
The payload is exactly what your device sent; decode it, then parse it in your device's own format:
// Node.js
const payload = Buffer.from(message.payload_base64, "base64") // <Buffer 74 65 6d 70 …>
payload.toString("utf8") // "temp=21.5"
# Python
payload = base64.b64decode(message["payload_base64"]) # b"temp=21.5"Respond quickly
Any 2xx status counts as delivered; the response body is ignored. Anything else, no answer within 10 seconds, or a connection error counts as a failure and is retried. Store the message and answer straight away, then do the slow work (database writes, calls to other services) in a background job.
Verify the signature
Check every request before trusting it:
- Read the raw body, the exact bytes received. Don't parse it and serialize it again: the field order and spacing would change and the signature wouldn't match.
- Compute
HMAC-SHA256(secret, timestamp + "." + raw body)as lowercase hex, prefixed withsha256=, and compare it withX-HoundSat-Signaturein constant time. - Reject timestamps more than 5 minutes from your clock, so a recorded request can't be replayed later.
import crypto from "node:crypto"
import express from "express"
const SECRET = process.env.WEBHOOK_SECRET // whsec_…
const TOLERANCE_SECONDS = 300
export function isValidRequest(rawBody, timestamp, signature) {
if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false
const expected =
"sha256=" + crypto.createHmac("sha256", SECRET).update(`${timestamp}.`).update(rawBody).digest("hex")
const a = Buffer.from(expected)
const b = Buffer.from(signature ?? "")
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
const app = express()
// express.raw keeps the exact bytes: verify them before parsing.
app.post("/hooks", express.raw({ type: "application/json" }), (req, res) => {
const ok = isValidRequest(req.body, req.get("X-HoundSat-Timestamp"), req.get("X-HoundSat-Signature"))
if (!ok) return res.sendStatus(401)
const message = JSON.parse(req.body)
queue.push(message) // process later; answer now
res.sendStatus(204)
})import hashlib
import hmac
import os
import time
from flask import Flask, request
SECRET = os.environ["WEBHOOK_SECRET"].encode() # whsec_…
TOLERANCE_SECONDS = 300
def is_valid_request(raw_body: bytes, timestamp: str | None, signature: str | None) -> bool:
try:
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
except (TypeError, ValueError):
return False
digest = hmac.new(SECRET, timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest("sha256=" + digest, signature or "")
app = Flask(__name__)
@app.post("/hooks")
def receive():
raw_body = request.get_data() # the exact bytes, before any parsing
if not is_valid_request(
raw_body,
request.headers.get("X-HoundSat-Timestamp"),
request.headers.get("X-HoundSat-Signature"),
):
return "", 401
queue.put(request.get_json()) # process later; answer now
return "", 204Test your code
With these values your function must compute exactly this signature. (Skip the timestamp check for this test: it is in the past.)
Secret: whsec_docs_example_only_do_not_use_0000
Timestamp: 1791813803
Body: {"device":{"id":"5f0c2b7e-8d41-4a96-b3e2-91c7d0a4e6f8","iccid":"8999001234000020718","imsi":"901880000010001","external_id":"trailer-207"},"source":"satellite","message_id":"3d9f1a7c-5e2b-4c80-9f64-a1b8e3d0c5f2","transport":"nidd","received_at":"2026-10-12T14:03:21.000000Z","payload_base64":"dGVtcD0yMS41"}
Signature: sha256=c37c2c2f0241ba7440d85c3608ee6885f64f451721a13ea8ba6fb3f028129b2fHandle duplicates
Deliveries are at least once. If your server stored a message but its answer was lost or came after 10 seconds, the same message arrives again. Keep the message_id of every message you've processed and skip repeats. Each endpoint gets its own copy, so two endpoints mean two deliveries.
Rotate the secret
Rotate the secret if it may have leaked, from the portal or with Rotate the signing secret. The new secret applies immediately, including to retries already queued, and the old one stops working. To avoid rejecting requests during the switch, have your server accept either secret for a few minutes.