Skip to content
Docs

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

  1. A device sends a message. It is stored, then POSTed to each of your enabled webhook endpoints.
  2. Your server checks the signature, stores the message, and answers 2xx within 10 seconds.
  3. 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.

Add an endpoint with the API
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:

Delivery
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"}
HeaderValue
X-HoundSat-Message-IdThe message's id, the same as message_id in the body.
X-HoundSat-TimestampWhen this attempt was signed, in Unix seconds.
X-HoundSat-Signaturesha256= and the hex HMAC-SHA256 of the timestamp, a dot, and the body.

The body, formatted for reading:

Body
{
  "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"
}
FieldMeaning
message_idUnique per message. The same on every retry.
deviceThe sending device: its id, iccid, imsi, and the external_id your team gave it (or null).
sourcesatellite.
transportHow the message arrived from the network: nidd or udp.
received_atWhen the network received it, ISO 8601 in UTC.
payload_base64The 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:

Decode the payload
// 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:

  1. 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.
  2. Compute HMAC-SHA256(secret, timestamp + "." + raw body) as lowercase hex, prefixed with sha256=, and compare it with X-HoundSat-Signature in constant time.
  3. Reject timestamps more than 5 minutes from your clock, so a recorded request can't be replayed later.
Verify and receive · Node.js
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)
})
Verify and receive · Python
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 "", 204

Test your code

With these values your function must compute exactly this signature. (Skip the timestamp check for this test: it is in the past.)

Test vector
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=c37c2c2f0241ba7440d85c3608ee6885f64f451721a13ea8ba6fb3f028129b2f

Handle 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.