Build a receiver
Verify the signature, respond fast, acknowledge honestly. Working code in Python and Node.
Your receiver is the last mile. Everything on this page is about making it a trustworthy one.
What arrives
POST /your-webhook HTTP/1.1
Content-Type: application/json
User-Agent: XpectraFlow-Webhook/1
X-Xpectra-Event: command.dispatched
X-Xpectra-Delivery: 8f2c…-1
X-Xpectra-Timestamp: 1785499200
X-Xpectra-Signature: v1=6c1f…9ab2{
"eventType": "command.dispatched",
"sentAt": "2026-07-31T12:00:00.000Z",
"command": {
"id": "7e1b90c4-3a55-4d21-8c9f-2b3e4a1d6f80",
"datasetId": "3d81f0a2-55c7-4f9e-9a1b-77c2e0d43a10",
"experimentId": "9f2c1b44-7e30-4a11-b8d2-1f4a6c0e8b91",
"organizationId": "5a1c0e77-9b32-4d18-a6f2-0c8b17d4e903",
"commandText": "MAV_CMD_DO_SET_SERVO",
"description": "Vent valve to 90%",
"args": { "servo": 5, "pwm": 1900 },
"templateKey": "drone.mav.do_set_servo",
"category": "drone",
"destructive": false,
"relTime": 412.7,
"issuedAt": "2026-07-31T11:59:58.100Z",
"issuedBy": { "name": "Ada Okafor", "email": "ada@example.com" },
"clientToken": "0d6a5b1c-2e94-4a7f-9c31-8b02d5e6f7a1"
},
"ack": {
"url": "https://app.xpectraflow.com/api/commands/ack",
"token": "cak_7f3a…",
"deadline": "2026-07-31T12:05:00.000Z"
},
"arming": { "expiresAt": "2026-07-31T12:30:00.000Z" }
}Two fields deserve your attention before you write any code:
command.destructive— set from the command template. It is the same flag the console uses to decide whether to demand a typed confirmation. Your receiver should apply at least as much caution.ack.token— a one-time bearer token, valid for this command only, untilack.deadline. It is how you report back. It appears nowhere else and we do not store it.
Verify the signature
The signed string is exactly:
v1:<X-Xpectra-Timestamp>:<raw request body>ASCII colons, no whitespace, no trailing newline. HMAC-SHA256, keyed with your
secret's UTF-8 bytes including the whsec_ prefix, output lowercase hex.
Sign the raw bytes of the body, before any JSON parsing. If your framework hands you a parsed object and you re-serialise it, key order and number formatting will differ and the signature will never match. Almost every integration problem with this scheme is this mistake.
import hmac, hashlib, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRETS = [os.environ["XPECTRA_WEBHOOK_SECRET"]] # a list, for rotation
TOLERANCE = 300 # seconds
def verify(raw_body: bytes, timestamp: str, header: str) -> bool:
try:
ts = int(timestamp)
except (TypeError, ValueError):
return False
# Reject anything too old or too far ahead. Without this, a captured
# request stays replayable forever.
if abs(time.time() - ts) > TOLERANCE:
return False
signed = b"v1:" + timestamp.encode() + b":" + raw_body
presented = [p.strip()[3:] for p in header.split(",") if p.strip().startswith("v1=")]
for secret in SECRETS:
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
# compare_digest, never ==. A plain comparison leaks the answer
# one byte at a time through its timing.
if any(hmac.compare_digest(p, expected) for p in presented):
return True
return False
@app.post("/xpectra-hook")
def hook():
raw = request.get_data() # bytes, unparsed
if not verify(raw, request.headers.get("X-Xpectra-Timestamp", ""),
request.headers.get("X-Xpectra-Signature", "")):
abort(401)
command = request.get_json()["command"]
# Respond immediately; uplink on your own time. See below.
enqueue_for_uplink(command, request.get_json()["ack"])
return "", 200Three rules the code above encodes, worth stating on their own:
- Reject timestamps outside ±300 seconds. The timestamp is inside the signed string, so it cannot be rewritten — which only helps if you check it.
- Compare in constant time.
hmac.compare_digestortimingSafeEqual, never==. - Accept if any
v1=value matches. During a secret rotation two are sent. A receiver that checks only the first breaks halfway through every rotation.
Respond fast, uplink slowly
Return 2xx as soon as you have verified and durably queued the command. Do not hold the connection open while you talk to a radio.
Your handler has ten seconds before we time out and count the attempt as failed. A lossy link can easily take longer than that, and a receiver that blocks on it turns one slow uplink into a retry storm.
verify → persist → 200 (fast, synchronous)
→ uplink → ack (slow, asynchronous)Deduplicate
Delivery is at-least-once. If your endpoint is slow, or answers after we have already given up on that attempt, you can see the same command twice.
Key on command.id, or on command.clientToken if you are reconciling against
records you created. Both are stable across every redelivery.
This is where at-most-once has to be enforced, because we are not on the wire to
your vehicle. Re-sending MAV_CMD_COMPONENT_ARM_DISARM because a notification
arrived twice is exactly the failure the whole design is trying to avoid, and
only your receiver is positioned to prevent it.
Acknowledge
curl -s -X POST https://app.xpectraflow.com/api/commands/ack \
-H "Authorization: Bearer cak_7f3a…" \
-H "Content-Type: application/json" \
-d '{
"commandId": "7e1b90c4-3a55-4d21-8c9f-2b3e4a1d6f80",
"state": "acked",
"vehicleTime": "2026-07-31T12:00:04.180Z",
"detail": "MAV_RESULT_ACCEPTED"
}'Prop
Type
Send a nacked when the vehicle refuses. A nack that arrives is far more
useful than a silence that becomes timed_out — one says the command was
rejected, the other says nobody knows what happened.
Notes on the token:
- It authenticates one command and expires at
ack.deadline. It is not an API key and grants nothing else. - It is single-use. A second call with the same token gets a
404. - A repeat of the same state you already reported returns
200with"duplicate": true, so a retry after a network timeout is not an error. - A wrong, expired or already-used token returns
404, never401— distinguishing them would tell someone probing which half of the guess was right.