XpectraFlow docs
Commands

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, until ack.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 "", 200

Three rules the code above encodes, worth stating on their own:

  1. Reject timestamps outside ±300 seconds. The timestamp is inside the signed string, so it cannot be rewritten — which only helps if you check it.
  2. Compare in constant time. hmac.compare_digest or timingSafeEqual, never ==.
  3. 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.

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 200 with "duplicate": true, so a retry after a network timeout is not an error.
  • A wrong, expired or already-used token returns 404, never 401 — distinguishing them would tell someone probing which half of the guess was right.

Checklist

On this page