Skip to content

Webhook Integration Guide

This guide is for developers and technical integrators building DIY access-control firmware that connects to a MakerVera deployment. It assumes familiarity with HTTP, HMAC, and embedded or server-side development.

MakerVera’s webhook provider lets you wire a custom access controller — a Raspberry Pi behind a door strike, an ESP32 RFID reader on a laser cutter, a custom RFID system on a lathe — into MakerVera’s scheduling engine. When a member registers for an event or a session opens, MakerVera signs and sends an HTTP POST to your device. Your device verifies the signature and acts on it.

What MakerVera sends you (outbound webhooks): Signed HTTP POST requests for grant, revoke, sync_user, and ping actions, each sent to a URL you configure.

What you can send back (inbound webhooks): Signed HTTP POST requests carrying audit events — door opened, access denied, device health — to a URL on MakerVera that’s unique to your provider configuration.

Why signatures matter: Both sides sign their requests with an HMAC-SHA256 over the raw request body. Signatures prevent spoofed grant/revoke commands from opening doors, and prevent fake audit events from polluting the activity log. A builder who skips signature verification is building a door that opens for anyone who knows the URL.

Versioning — v1: This guide describes MakerVera’s webhook contract v1. MakerVera commits to additive-only changes to outbound bodies — future fields will be added but existing fields will never be renamed or removed within v1. Any breaking change ships under a new contract version signalled by either a new path (/access-control/webhooks/v2/{config_id}) or an X-MakerVera-Signature-Version header bump, never silently mutating v1. Builders MUST ignore unknown fields in outbound bodies — don’t fail a strict-schema parser on the first new field.


Read this before writing your handler.

Outbound bodies contain user_id (a UUID — pseudonymous but linkable) on every action. sync_user bodies additionally contain email, given_name, and family_name (PII). Common DIY patterns leak this to syslog, SD cards, or serial consoles.

Before you ship:

  • Redact request bodies in production logs. Log the action + zone + a hash of user_id, never the raw body.
  • Encrypt SD-card storage if the device persists logs.
  • Rotate logs (size-cap or time-cap) — a Pi running for a year accumulates a lot of pseudonymous IDs.
  • sync_url MUST be HTTPS in production. The whole guide assumes TLS in transit. Sending user profile data over plain HTTP is not acceptable.

  1. Settings → Integrations → Add Provider → Webhook — the WEBHOOK type is available in the provider type selector.
  2. Copy your signing secret from the Webhook config form — it displays once in a one-shot modal. If you lose it before saving it to your device, use the Regenerate option.
  3. Copy the Inbound Webhook URL shown on the saved provider’s panel — this is the full URL your device POSTs to, with provider_config_id already embedded.
  4. Add zones with your device URLs in the inline zone table on the saved provider’s panel.
  5. Test the connection using the Test Connection button (sends a ping to your configured Probe URL).

MakerVera does NOT send all four actions to a single URL. Each action is routed independently:

ActionDestination URL
grantPer-zone zone_config.webhook_url → falls back to provider-level grant_url
revokePer-zone zone_config.webhook_url → falls back to provider-level revoke_url
sync_userProvider-level sync_url only (never per-zone). If sync_url is unset, sync is skipped silently.
pingProvider-level probe_url only (used by Test Connection). Never sent to zone URLs.

Single-listener pattern: Builders running everything off one endpoint should point all four URLs at the same address and switch on the action field inside the body.

If sync_url is unset and you never receive sync_user calls, that is expected behaviour — there is no error logged on the MakerVera side.


Handling Outbound Webhooks (MakerVera → Your Device)

Section titled “Handling Outbound Webhooks (MakerVera → Your Device)”
  • Method: POST
  • Headers:
    • Content-Type: application/json
    • X-MakerVera-Signature: sha256=<hex> — HMAC-SHA256 over the raw request body bytes. The header name is configurable via WebhookConfig.signature_header (default X-MakerVera-Signature); see “Custom Signature Header” below.
  • Body: Canonical JSON — sorted keys, compact separators, UTF-8 with non-ASCII characters emitted as raw UTF-8 bytes (see “Canonical Body Contract” below).
  • Redirects (301/302/etc.): NOT followed. The URL you configure must be the final destination. MakerVera uses httpx which does not follow redirects by default; a redirect response is treated as a failure.

MakerVera’s outbound body is produced by canonical_body() with three load-bearing properties. Both sides must match these properties or signatures will not verify:

  1. Sorted keysjson.dumps(..., sort_keys=True). Keys appear in alphabetical order.
  2. Compact separatorsseparators=(",", ":"). No spaces after , or :.
  3. ensure_ascii=False — non-ASCII characters (accented names like é, ñ, CJK characters) are emitted as raw UTF-8 bytes, NOT as \uXXXX escapes. Python’s default json.dumps uses ensure_ascii=True and will produce different bytes → signatures will mismatch on any user with a non-ASCII name.

This is the most common misconfiguration causing signature failures on non-ASCII names. If your signatures match for English names but fail for members named “Sofía García” or “陈志明”, ensure_ascii=False is the fix.

{
"action": "grant",
"user_id": "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
"zone_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"timestamp": "2026-04-13T10:30:00+00:00",
"valid_until": "2026-04-13T11:30:00+00:00"
}

Field reference:

FieldPresent OnNotes
actionAllOne of grant, revoke, sync_user, ping
user_idgrant, revoke, sync_userUUID; pseudonymous but linkable
zone_idgrant, revokeUUID; matches your zone configuration
timestampAllISO-8601 UTC with timezone offset; used for replay-window checks
valid_untilgrant (sometimes)UTC ISO-8601 with timezone info; may be omitted on policy-based grants with no defined end time

sync_user body additionally contains email, given_name, family_name. Expected response: {"external_user_id": "..."}. If returned, MakerVera stores the value as the user’s external identifier for future payloads; if omitted, MakerVera falls back to the local user UUID. Only external_user_id is read from the response — any additional fields you return (device_id, rfid_uid, etc.) are silently ignored. To attach device-side metadata to a user after provisioning, send it via an inbound webhook event.

ping body contains only {"action": "ping", "timestamp": "..."}. Used by the Test Connection button (validate_config).

No signature field in the body. The signature is in the configured signature header only. Never put the signature in the body.

Do not assume grant arrives at the moment the session starts.

Event-based access (EventAccessPolicy) is buffered:

  • grant fires at session.start - buffer_minutes — often hours or days before the session starts, depending on policy configuration.
  • revoke fires at session.end + buffer_minutes.
  • Membership-based grants (future plan) fire on membership status change, not on a clock.

If your device opens the door immediately on receipt of grant, members will get early access. Two safe patterns:

  1. Honour valid_until strictly — store the grant, but only enforce it during the [current_time, valid_until] window. Compare against your NTP-synced clock. This is the recommended pattern.
  2. Use revocation as the source of truth for end — keep the grant active until revoke arrives, accepting that buffer_minutes means a few minutes of extra access.

Pick one and document it in the device’s own README so the operator understands the timing behaviour. If valid_until is absent, the grant has no defined end time — you must rely on the matching revoke to close access.

The contract builders MUST follow:

  1. Read the raw bytes of the request body — do not decode and re-serialize. Parsing JSON and re-serializing even with matching options produces different bytes if your library reorders keys or escapes non-ASCII differently.
  2. Compute HMAC-SHA256(signing_secret, raw_body_bytes) — hex-encoded digest.
  3. Constant-time compare your hex digest against the value of the signature header (after stripping the sha256= prefix).
  4. If the digests do not match, return 401.

The most common bug: Signing the parsed-then-re-serialized body instead of the bytes received on the wire. Web frameworks that auto-parse JSON before the handler runs (Express’s body-parser, FastAPI’s request.json()) require special handling to preserve raw bytes. See code samples below.

ResponseMeaning
200 / 201 / 202 / 204MakerVera marks the action successful
301 / 302NOT followed; treated as failure
Anything elseMakerVera raises ProviderError; the task is marked FAILED and surfaces in the failed-tasks admin UI

202 Accepted warning: MakerVera treats 2xx as success without inspecting the body. If your device returns 202 and then fails the async work, MakerVera will not retry and will not learn of the failure — the audit trail will say “granted” while the door is locked. Two safe options:

  • (a) Do the work synchronously and return 200 only on real success.
  • (b) Return 202 immediately, then send a follow-up inbound webhook (entry.fail event) when the async work fails, so the failure shows up in the admin failed-tasks UI.

WebhookConfig.signature_header applies bidirectionally — the same configured header name is used for outbound signing AND inbound verification. If an admin sets a custom header (e.g., X-Workshop-Lock-Signature), the device must also send that exact header name on its inbound webhooks.

The default — and recommended value for most builders — is X-MakerVera-Signature. Only change this if you have a specific reason (e.g., a custom integration framework that requires a different header name). If you do change it, update your device firmware to match.

MakerVera’s outbound HTTP has an SSRF guard. By default, the following destinations are blocked:

  • RFC 1918 private addresses (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
  • Loopback (127.0.0.0/8, ::1)
  • IPv6 unique-local (fc00::/7)

Builders running a Pi or ESP32 on their shop LAN must either:

  1. Expose the device via a public hostname (reverse proxy, ngrok, Tailscale Funnel, Cloudflare Tunnel), or
  2. Ask the operator to set ACCESS_CONTROL_ALLOW_LOCAL_WEBHOOKS=true in the backend environment (only applicable for self-hosted deployments).

Always blocked, even with the override:

  • Link-local: 169.254.0.0/16, fe80::/10 — this includes the AWS EC2 metadata endpoint (169.254.169.254)
  • IANA Shared Address Space: 100.64.0.0/10
  • Multicast addresses
  • Cloud metadata hostnames

MakerVera checks the timestamp field on inbound webhooks against its wall clock. The replay window is asymmetric:

DirectionWindow
Past tolerance5 minutes (300 seconds) — allows for clock skew and network latency
Future tolerance60 seconds — a timestamp ahead of “now” is suspicious; the narrow window limits pre-computation attacks

Reject any request with a missing, malformed, or naive (no timezone offset) timestamp. The check runs before the handler. A timestamp without timezone info (e.g., "2026-04-13T10:30:00" without +00:00 or Z) is rejected.


The 60-second future tolerance is tight. This section is especially important for ESP32 and other microcontroller builders.

  • Cold-boot ESP32 NTP sync can take 5–30 seconds.
  • Many DIY firmwares ship without monotonic-clock fallback.
  • If the device’s clock is even a few seconds fast of MakerVera’s wall clock, every inbound webhook returns 401 with no useful error body.
  1. Block sending until NTP has synced. Do not ship grants/events with a default-epoch (1970-01-01) or boot-relative timestamp. Gate the send loop on time(NULL) > 1735689600 (a post-2025 threshold: 2025-01-01T00:00:00Z = 1735689600) as a sanity check that the clock has been set.

  2. Resync on every boot, and at least daily. ESP32 RTCs drift; a device running for a month without resync can be tens of seconds off.

  3. Retry-on-401 with NTP resync, not blind retry. A 401 immediately after boot almost always means clock skew — resync NTP, then retry once. Do not loop forever; log and surface the failure.

  4. Match the past-tolerance budget. If your event queue can hold messages for more than 5 minutes (e.g., offline cache during a WiFi outage), drop or re-stamp queued events older than 4 minutes before sending — a 5-minute-old timestamp will fail the past-tolerance check.

Pi/Linux devices typically have systemd-timesyncd already running. Verify with timedatectl status and confirm it shows NTP service: active and System clock synchronized: yes. Document the requirement in your device README.


Sending Inbound Webhooks (Your Device → MakerVera)

Section titled “Sending Inbound Webhooks (Your Device → MakerVera)”
  • Door opened/closed events
  • Access denied events
  • Device health/status updates
  • Any event you want visible in the admin audit trail
  • Method: POST
  • URL: https://<makervera-host>/access-control/webhooks/<provider_config_id>
    • The provider_config_id and full URL are shown in the Integrations admin page next to each saved webhook provider.
  • Headers:
    • Content-Type: application/json
    • <signature_header>: sha256=<hex> — header name defaults to X-MakerVera-Signature; uses whatever WebhookConfig.signature_header is configured to. The same value applies to outbound.
  • Body: JSON with required fields below.
{
"event_type": "entry.unlock",
"user_id": "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
"zone_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"timestamp": "2026-04-13T10:30:00+00:00",
"webhook_event_id": "device-uuid-or-monotonic-counter"
}
FieldRequiredNotes
event_typeYesFree-form string (max 50 chars). Convention: dotted names like entry.unlock, entry.deny, entry.fail. Whatever you send appears in the audit trail.
timestampYesMust be named timestamp (not event_timestamp). ISO-8601 with timezone offset. The replay guard reads this field by name and returns 401 if it is missing, expired, or naive.
user_idNoUUID of the member being acted on.
zone_idNoUUID of the zone.
webhook_event_idStrongly recommendedSee deduplication section below.

Field name warning: The field must be timestamp, not event_timestamp. event_timestamp is read by the handler as a fallback when persisting the audit row, but the replay guard runs first and sees only timestamp. A device sending only event_timestamp will receive 401 on every request and never reach the handler.

webhook_event_id is strongly recommended. If omitted, MakerVera generates a deterministic ID by hashing the tuple (provider_config_id, event_type, user_external_id, zone_external_id), truncated to 32 hex characters. The timestamp is intentionally excluded from this hash so that legitimate provider retries deduplicate correctly.

The trade-off: two distinct events with identical tuple values within the same retention window are treated as one, and the second is silently returned as 200 duplicate. If you emit rapid same-action events for the same user and zone, always provide a unique webhook_event_id (UUID v4, monotonic counter, or device-side millisecond timestamp) to ensure both events are preserved as separate audit-trail entries.

The fundamental rule: MakerVera hashes the exact raw bytes it receives off the wire. Compute your HMAC over the same bytes you put on the wire.

In practice:

  • Build the JSON body once as bytes, hash those bytes, send those bytes. Do not re-serialize between hashing and sending.
  • Do not let your HTTP library mutate the body (re-pretty-print, re-order keys).
  • Use the same signing secret as the outbound direction.
  • Use the configured signature header name (default X-MakerVera-Signature).
  • Include timestamp in the body with a current NTP-synced time.

Canonical form (sorted keys, compact separators, ensure_ascii=False) is a recommendation for inbound — not a hard requirement. On inbound, MakerVera hashes whatever raw bytes arrive, not a re-canonicalized form. As long as the bytes you hash and the bytes you send are identical, any valid JSON serialization works.

Contrast: outbound FROM MakerVera is always canonical-form because MakerVera generates it that way. Inbound TO MakerVera is byte-bound — we hash what we receive.

CodeBodyMeaning
200{"status": "processed", "event_id": "..."}New event persisted
200{"status": "duplicate", "webhook_event_id": "..."}Event already seen (signatures work; dedup filtered it)
400Invalid JSON
401Bad signature, OR replay window failed (missing/expired/future timestamp)
404Unknown provider_config_id
410Provider config exists but is inactive

When MakerVera regenerates a webhook provider’s signing secret, DIY devices that store the secret in EEPROM, SD card, or Preferences need a procedure to switch over. MakerVera supports a dual-secret grace window so devices can pick up the new secret without a hard cutover.

  1. Settings → Integrations → open the webhook provider → Regenerate Secret.
  2. Choose rotation mode:
    • Rotate now (default): old secret is invalidated immediately. All subsequent outbound webhooks are signed with the new secret; inbound requests signed with the old secret return 401.
    • Grace window: old secret stays valid for inbound verification until the chosen expiry. Outbound webhooks always use the new secret even during the grace window.
  3. Copy the new secret from the one-shot display modal and ship it to the device.
  1. Provide a way to update the device’s stored secret without a full reflash. At minimum, a config endpoint over HTTPS-on-LAN; ideally OTA update.
  2. During the grace window: MakerVera signs outbound with the NEW secret only. The first outbound request whose signature your device cannot verify with the OLD secret is your signal that rotation is effective. Attempt to verify the request with the new secret — if it passes, switch over.
  3. For inbound during the grace window: MakerVera verifies inbound against current OR previous secret while the grace window is active. Your device only ever signs with one secret at a time — keep using the OLD secret on inbound until you have confirmed receipt and verification of a new-secret-signed outbound request, then switch.
  4. After the grace window expires: MakerVera rejects inbound signed with the old secret.

Operator checklist for the device README: Before rotating, confirm the device is reachable and the secret-update path works. Plan rotations during low-traffic windows. If the device is offline during rotation, schedule a manual reflash window — the dual-secret grace window is for handoff, not for indefinitely tolerating an unreachable device.


  • Outbound bodies will only gain fields, never lose or rename them, within v1. Future phases will add fields like policy_id, cohort_id, or correlation_id. These are additive.
  • Any breaking change ships under a new contract version — either a new path (/access-control/webhooks/v2/{config_id}) or an X-MakerVera-Signature-Version header, never silently mutating v1.
  • Inbound response shape is stable: {"status": "processed", ...} and {"status": "duplicate", ...} are part of the contract.
  • Ignore unknown fields on outbound bodies. A strict-schema parser that rejects unknown fields will break on the first new field MakerVera adds. Log-and-continue for unknown fields.
  • Do not pin to field count. Pin to the field names you actually use (action, user_id, zone_id, timestamp, valid_until).
  • Do not depend on JSON key order for parsing. Key order matters for signature computation (you hash raw bytes), but your parser must not depend on order.

Python (Raspberry Pi) — Canonical Reference

Section titled “Python (Raspberry Pi) — Canonical Reference”

This is a complete, runnable Flask script. It demonstrates:

  • request.get_data(cache=True) to access raw bytes before Flask parses JSON
  • hmac.compare_digest for constant-time comparison
  • json.dumps(..., sort_keys=True, separators=(",", ":"), ensure_ascii=False) for canonical serialization when sending inbound webhooks back
  • NTP-synced wall-clock gating on time.time() > 1735689600
  • Explicit timeout on outbound HTTP requests
#!/usr/bin/env python3
"""
MakerVera Webhook Handler — Raspberry Pi (Flask)
Handles outbound webhooks from MakerVera and sends inbound events back.
Requirements:
pip install flask requests
"""
import hashlib
import hmac
import json
import os
import time
from functools import wraps
import requests
from flask import Flask, Response, jsonify, request
app = Flask(__name__)
# ── Configuration ─────────────────────────────────────────────────────────────
# Load from environment or a secrets file — never hardcode in source.
SIGNING_SECRET: str = os.environ["MAKERVERA_SIGNING_SECRET"]
SIGNATURE_HEADER: str = os.environ.get("MAKERVERA_SIGNATURE_HEADER", "X-MakerVera-Signature")
PROVIDER_CONFIG_ID: str = os.environ["MAKERVERA_PROVIDER_CONFIG_ID"]
MAKERVERA_HOST: str = os.environ.get("MAKERVERA_HOST", "https://api.makervera.com")
# Derived inbound URL — POST your events here.
INBOUND_URL = f"{MAKERVERA_HOST}/access-control/webhooks/{PROVIDER_CONFIG_ID}"
# Replay window — must match MakerVera's server-side values.
REPLAY_PAST_SECONDS = 300 # 5 minutes
REPLAY_FUTURE_SECONDS = 60 # 60 seconds (asymmetric — tighter future window)
# Sanity threshold: post-2025 wall clock check before sending events.
NTP_SANITY_THRESHOLD = 1_735_689_600 # 2025-01-01T00:00:00Z
# ── Signature Verification ────────────────────────────────────────────────────
def _verify_signature(raw_body: bytes, received_header: str | None) -> bool:
"""Verify HMAC-SHA256 signature over raw request bytes.
Args:
raw_body: The exact bytes received on the wire — do not re-serialize.
received_header: Value of the configured signature header, e.g.
"sha256=abcdef1234...".
Returns:
True if the signature is valid, False otherwise.
"""
if not received_header or not received_header.startswith("sha256="):
return False
expected_hex = received_header[len("sha256="):]
computed = hmac.new(
SIGNING_SECRET.encode(),
raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(computed, expected_hex)
def require_valid_signature(f):
"""Decorator: reject requests with invalid or missing signatures."""
@wraps(f)
def wrapper(*args, **kwargs):
# cache=True keeps the body available for request.get_json() later.
raw_body = request.get_data(cache=True)
sig_header = request.headers.get(SIGNATURE_HEADER)
if not _verify_signature(raw_body, sig_header):
return Response("Invalid signature", status=401)
return f(*args, **kwargs)
return wrapper
# ── Inbound Event Sender ──────────────────────────────────────────────────────
def _make_canonical_body(data: dict) -> bytes:
"""Produce canonical JSON bytes: sorted keys, compact separators, UTF-8.
IMPORTANT: ensure_ascii=False emits non-ASCII chars as raw UTF-8 bytes,
matching MakerVera's canonical_body(). Python's default json.dumps uses
ensure_ascii=True and will produce different bytes for names with accents.
"""
return json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
def send_inbound_event(event_type: str, user_id: str | None = None,
zone_id: str | None = None,
webhook_event_id: str | None = None) -> None:
"""Send an inbound audit event to MakerVera.
Blocks if the device clock is not NTP-synced (time < NTP_SANITY_THRESHOLD).
"""
now = time.time()
if now < NTP_SANITY_THRESHOLD:
# Clock is not NTP-synced — sending would produce a 401.
app.logger.error(
"Clock not NTP-synced (time=%s < threshold=%s). "
"Not sending inbound event.",
now, NTP_SANITY_THRESHOLD,
)
return
from datetime import datetime, timezone
ts = datetime.now(timezone.utc).isoformat()
payload: dict = {"event_type": event_type, "timestamp": ts}
if user_id:
payload["user_id"] = user_id
if zone_id:
payload["zone_id"] = zone_id
if webhook_event_id:
payload["webhook_event_id"] = webhook_event_id
body_bytes = _make_canonical_body(payload)
# Sign the bytes we are about to send — same bytes, no re-serialization.
sig = hmac.new(SIGNING_SECRET.encode(), body_bytes, hashlib.sha256).hexdigest()
try:
resp = requests.post(
INBOUND_URL,
data=body_bytes,
headers={
"Content-Type": "application/json",
SIGNATURE_HEADER: f"sha256={sig}",
},
timeout=5,
)
if resp.status_code == 401:
# On 401 right after boot, NTP skew is the most likely cause.
# Resync NTP and retry once — don't loop.
app.logger.warning(
"Inbound 401 sending %s — possible NTP skew. "
"Resync NTP and retry.",
event_type,
)
elif not resp.ok:
app.logger.error("Inbound event %s failed: %s", event_type, resp.status_code)
else:
app.logger.info(
"Inbound event %s: %s %s",
event_type, resp.status_code, resp.text,
)
except requests.exceptions.RequestException as exc:
app.logger.error("Inbound event %s network error: %s", event_type, exc)
# ── Outbound Webhook Handler ──────────────────────────────────────────────────
@app.post("/webhook")
@require_valid_signature
def handle_webhook():
"""Receive and dispatch outbound webhooks from MakerVera."""
data = request.get_json()
action = data.get("action")
if action == "grant":
_handle_grant(data)
elif action == "revoke":
_handle_revoke(data)
elif action == "sync_user":
_handle_sync_user(data)
elif action == "ping":
pass # Health check — no action needed, just return 200.
else:
# Unknown action — forward-compat: ignore unknown actions.
app.logger.warning("Unknown action: %s", action)
return jsonify({"status": "ok"}), 200
def _handle_grant(data: dict) -> None:
"""Open access for the given user/zone."""
user_id = data.get("user_id")
zone_id = data.get("zone_id")
valid_until = data.get("valid_until") # May be None for policy-based grants.
# Do NOT log the full body — it contains user_id (PII-adjacent).
# Log action + a truncated zone ID for ops tracing.
app.logger.info("GRANT zone=%.8s valid_until=%s", zone_id, valid_until)
# TODO: Call your hardware control layer here.
# Honour valid_until if set — do not keep access open past this time.
def _handle_revoke(data: dict) -> None:
"""Revoke access for the given user/zone."""
zone_id = data.get("zone_id")
app.logger.info("REVOKE zone=%.8s", zone_id)
# TODO: Call your hardware control layer here.
def _handle_sync_user(data: dict) -> None:
"""Provision a MakerVera user in the device's access list."""
# sync_user payloads include PII — redact from logs.
app.logger.info("SYNC_USER (body redacted)")
# Optional: return an external ID for this user.
# Only `external_user_id` is read from the response — all other fields ignored.
# return jsonify({"external_user_id": "your-device-uid-here"})
# ── Entry Point ───────────────────────────────────────────────────────────────
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080, debug=False)

Node.js (Express) — Alternative for JavaScript Shops

Section titled “Node.js (Express) — Alternative for JavaScript Shops”

Key differences from the Python example:

  • bodyParser.raw({ type: "application/json" }) preserves raw bytes before Express parses JSON.
  • crypto.createHmac("sha256", secret).update(rawBuffer).digest("hex") computes the HMAC.
  • crypto.timingSafeEqual for constant-time comparison.
const express = require("express");
const crypto = require("crypto");
const app = express();
const SIGNING_SECRET = process.env.MAKERVERA_SIGNING_SECRET;
const SIG_HEADER = process.env.MAKERVERA_SIGNATURE_HEADER || "X-MakerVera-Signature";
// CRITICAL: Use bodyParser.raw to preserve bytes for HMAC verification.
// If you use bodyParser.json(), the raw bytes are gone and you cannot
// verify the signature against the original payload.
app.use(
"/webhook",
express.raw({ type: "application/json" }),
);
function verifySignature(rawBuffer, receivedHeader) {
if (!receivedHeader || !receivedHeader.startsWith("sha256=")) return false;
const receivedHex = receivedHeader.slice("sha256=".length);
const computed = crypto
.createHmac("sha256", SIGNING_SECRET)
.update(rawBuffer)
.digest("hex");
// timingSafeEqual requires equal-length buffers.
const a = Buffer.from(computed, "hex");
const b = Buffer.from(receivedHex, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post("/webhook", (req, res) => {
const rawBody = req.body; // Buffer, because of express.raw()
if (!verifySignature(rawBody, req.headers[SIG_HEADER.toLowerCase()])) {
return res.status(401).send("Invalid signature");
}
const data = JSON.parse(rawBody.toString("utf8"));
const { action, user_id, zone_id } = data;
switch (action) {
case "grant":
// Honour valid_until if present — see Python example for full pattern.
break;
case "revoke":
break;
case "sync_user":
// Return external_user_id if you want to link MakerVera users to device UIDs.
return res.json({ external_user_id: "your-device-uid" });
case "ping":
break;
default:
// Unknown action — ignore for forward compatibility.
break;
}
res.json({ status: "ok" });
});
app.listen(8080);

For everything else (inbound event sending, canonical body, NTP gating, rotation playbook), refer to the Python example above — the patterns are identical.


A complete, compilable Arduino-ESP32 sketch. This runs on a standard $5 ESP32-DevKitC with WiFi.h and HTTPClient.h from the ESP32 Arduino core, and BearSSL::HashSHA256 from the same SDK.

⚠️ Security — TLS verification is not optional.

The sketch below uses WiFiClientSecure and pins a root CA certificate via setCACert(...). Do not swap that for setInsecure() in production. setInsecure() disables certificate verification entirely: any device on the same network can MITM the inbound channel and harvest signing secrets, replay events, or impersonate your device. Because this integration controls physical access (door strikes, equipment interlocks), a successful MITM is a physical-security incident.

The sketch ships with the ISRG Root X1 Let’s Encrypt root, which covers MakerVera’s hosted production deployment. For a self-hosted MakerVera or a different CA, replace MAKERVERA_ROOT_CA with your deployment’s actual root certificate (PEM-encoded, kept literal in firmware or loaded from secure storage). A non-pinned development override exists behind INSECURE_DEV_TLS — see the comment in the sketch — and #errors out unless you explicitly opt in. Do not flip that flag for a production build.

/**
* MakerVera Webhook Handler — ESP32 (Arduino / BearSSL)
*
* Board: ESP32 Dev Module (any ESP32 Arduino core >= 2.0)
* Built-in libraries (ESP32 Arduino core): WiFi.h, HTTPClient.h,
* WiFiClientSecure.h, mbedtls (HMAC-SHA256)
* External library (install via Library Manager):
* ArduinoJson by Benoit Blanchon — version 6.x or later
*
* IMPORTANT: Fill in SSID, PASSWORD, SIGNING_SECRET, PROVIDER_CONFIG_ID,
* and MAKERVERA_HOST before compiling.
*/
#include <Arduino.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include <mbedtls/md.h> // HMAC-SHA256 via mbedtls (included in ESP32 Arduino core)
#include <time.h>
#include <stdarg.h> // va_list/va_start/va_end for the bounds-checked
// bufAppend() helper used to assemble the JSON body
#include <strings.h> // strncasecmp() for RFC 7230 case-insensitive header
// matching in findHeader()
#include <ArduinoJson.h> // Robust JSON parsing for incoming outbound webhook
// bodies. Substring-based extraction of the `action`
// key is fragile against any future field whose
// value contains the literal `"action":"..."`.
// Install via Library Manager: ArduinoJson by
// Benoit Blanchon (v6.x or later).
// ── Configuration ─────────────────────────────────────────────────────────────
static const char* WIFI_SSID = "YourSSID";
static const char* WIFI_PASSWORD = "YourPassword";
// Store this in NVS/Preferences in production — never compile secrets into
// production firmware intended for public distribution.
static const char* SIGNING_SECRET = "your-signing-secret-here";
static const char* PROVIDER_CONFIG_ID = "your-provider-config-id-here";
static const char* MAKERVERA_HOST = "https://api.makervera.com";
static const char* SIGNATURE_HEADER = "X-MakerVera-Signature";
// Replay window constants (must match MakerVera backend).
static const long REPLAY_PAST_SEC = 300; // 5 minutes
static const long REPLAY_FUTURE_SEC = 60; // 60 seconds (asymmetric)
// ── TLS Trust Anchor ──────────────────────────────────────────────────────────
//
// MakerVera's hosted production endpoint uses a Let's Encrypt issued
// certificate; the chain terminates at ISRG Root X1. For a self-hosted
// MakerVera deployment behind a different CA (private PKI, AWS ACM, etc.),
// replace this PEM with that deployment's root certificate. Get the right
// PEM by running:
//
// openssl s_client -connect <your-host>:443 -showcerts < /dev/null \
// | awk '/BEGIN CERT/,/END CERT/' | tail -n +1
//
// and copying the LAST certificate block (the root).
static const char* MAKERVERA_ROOT_CA = R"PEM(
-----BEGIN CERTIFICATE-----
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
-----END CERTIFICATE-----
)PEM";
// Set INSECURE_DEV_TLS=1 in your build flags ONLY for local development
// against a self-signed cert. This MUST NOT be enabled for production
// firmware — the #error below catches accidental release builds. See the
// security warning above the sketch for the full rationale.
// #define INSECURE_DEV_TLS 1
#if defined(INSECURE_DEV_TLS) && defined(NDEBUG)
#error "INSECURE_DEV_TLS must not be enabled in release (NDEBUG) builds."
#endif
// Post-2025 sanity threshold for NTP gate (2025-01-01T00:00:00Z).
static const time_t NTP_SANITY_THRESHOLD = 1735689600L;
// ── NTP Setup ─────────────────────────────────────────────────────────────────
void setupTime() {
// Use multiple NTP servers for reliability.
configTime(0, 0, "pool.ntp.org", "time.nist.gov");
Serial.print("Waiting for NTP sync");
time_t now = 0;
int attempts = 0;
while (now < NTP_SANITY_THRESHOLD && attempts < 30) {
delay(1000);
now = time(NULL);
Serial.print(".");
attempts++;
}
if (now < NTP_SANITY_THRESHOLD) {
Serial.println("\nNTP sync FAILED — inbound events will be blocked.");
} else {
Serial.printf("\nNTP synced: %lu\n", (unsigned long)now);
}
}
// ── HMAC-SHA256 ───────────────────────────────────────────────────────────────
/**
* Compute HMAC-SHA256 over `data` using `key`.
* Writes 32 bytes into `out`.
*/
void hmacSha256(const uint8_t* key, size_t keyLen,
const uint8_t* data, size_t dataLen,
uint8_t* out) {
mbedtls_md_context_t ctx;
const mbedtls_md_info_t* info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
mbedtls_md_init(&ctx);
mbedtls_md_setup(&ctx, info, 1 /* HMAC */);
mbedtls_md_hmac_starts(&ctx, key, keyLen);
mbedtls_md_hmac_update(&ctx, data, dataLen);
mbedtls_md_hmac_finish(&ctx, out);
mbedtls_md_free(&ctx);
}
/** Convert 32 raw HMAC bytes to a lowercase hex string (64 chars + null). */
void bytesToHex(const uint8_t* bytes, size_t len, char* out) {
for (size_t i = 0; i < len; i++) {
sprintf(out + (i * 2), "%02x", bytes[i]);
}
out[len * 2] = '\0';
}
// ── Signature Verification ────────────────────────────────────────────────────
/**
* Constant-time comparison of two hex strings.
* Returns true if equal. Works correctly even when lengths differ.
*/
bool constTimeHexEqual(const char* a, const char* b) {
size_t lenA = strlen(a);
size_t lenB = strlen(b);
if (lenA != lenB) return false;
uint8_t diff = 0;
for (size_t i = 0; i < lenA; i++) {
diff |= (uint8_t)(a[i] ^ b[i]);
}
return diff == 0;
}
/**
* Verify the HMAC-SHA256 signature on an incoming POST body.
*
* @param rawBody Raw request body bytes (not re-parsed).
* @param bodyLen Length of rawBody.
* @param sigHeader Value of the signature header (e.g. "sha256=abcd...").
* @return true if signature is valid.
*/
bool verifyOutboundSignature(const uint8_t* rawBody, size_t bodyLen,
const char* sigHeader) {
if (!sigHeader || strncmp(sigHeader, "sha256=", 7) != 0) return false;
const char* receivedHex = sigHeader + 7; // skip "sha256="
uint8_t hmacBytes[32];
hmacSha256(
(const uint8_t*)SIGNING_SECRET, strlen(SIGNING_SECRET),
rawBody, bodyLen,
hmacBytes
);
char computedHex[65];
bytesToHex(hmacBytes, 32, computedHex);
return constTimeHexEqual(computedHex, receivedHex);
}
// ── Inbound Event Sender ──────────────────────────────────────────────────────
/**
* Bounds-checked snprintf wrapper. `snprintf` returns the would-have-been
* length, NOT the bytes written, so naively chaining `offset += snprintf(...)`
* lets `offset` exceed the buffer on truncation — and the next call's
* `bufCap - offset` then underflows to a huge size_t, scribbling past the end
* of the buffer. This helper stops appending the moment any write is
* truncated and signals the caller via false.
*
* @return true if the formatted bytes fit; false on truncation or error.
*/
static bool bufAppend(char* buf, size_t bufCap, size_t* offset,
const char* fmt, ...) {
if (*offset >= bufCap) return false;
va_list ap;
va_start(ap, fmt);
int written = vsnprintf(buf + *offset, bufCap - *offset, fmt, ap);
va_end(ap);
if (written < 0) return false;
if ((size_t)written >= bufCap - *offset) return false; // truncated
*offset += (size_t)written;
return true;
}
/**
* Inner worker for sendInboundEvent. `allowRetry` caps recursion at one
* retry — a 401 after the retry is a real failure, not another clock-skew
* glitch, so we surface it instead of looping.
*/
static void sendInboundEventImpl(const char* eventType,
const char* userId,
const char* zoneId,
const char* eventId,
bool allowRetry) {
time_t now = time(NULL);
if (now < NTP_SANITY_THRESHOLD) {
Serial.println("[inbound] NTP not synced — skipping event");
return;
}
// Build ISO-8601 timestamp with UTC offset.
struct tm* utc = gmtime(&now);
char ts[32];
strftime(ts, sizeof(ts), "%Y-%m-%dT%H:%M:%S+00:00", utc);
// Build canonical JSON body. Keep it simple: sorted keys, no spaces.
// Fields in alphabetical order: event_type, timestamp, user_id, zone_id,
// webhook_event_id. bufAppend() short-
// circuits on truncation — drop the event
// rather than hash a partial body.
char body[512];
size_t offset = 0;
bool ok = true;
ok = ok && bufAppend(body, sizeof(body), &offset,
"{\"event_type\":\"%s\"", eventType);
if (ts[0]) {
ok = ok && bufAppend(body, sizeof(body), &offset,
",\"timestamp\":\"%s\"", ts);
}
if (userId) {
ok = ok && bufAppend(body, sizeof(body), &offset,
",\"user_id\":\"%s\"", userId);
}
if (eventId) {
ok = ok && bufAppend(body, sizeof(body), &offset,
",\"webhook_event_id\":\"%s\"", eventId);
}
if (zoneId) {
ok = ok && bufAppend(body, sizeof(body), &offset,
",\"zone_id\":\"%s\"", zoneId);
}
ok = ok && bufAppend(body, sizeof(body), &offset, "}");
if (!ok) {
// The buffer was too small for this event. Increase BODY_BUF or
// shorten the field values; never sign and send a truncated body.
Serial.println("[inbound] body buffer overflow — event dropped");
return;
}
size_t bodyLen = offset;
// HMAC-SHA256 over the body bytes.
uint8_t hmacBytes[32];
hmacSha256(
(const uint8_t*)SIGNING_SECRET, strlen(SIGNING_SECRET),
(const uint8_t*)body, bodyLen,
hmacBytes
);
char sigHex[65];
bytesToHex(hmacBytes, 32, sigHex);
char sigValue[80];
snprintf(sigValue, sizeof(sigValue), "sha256=%s", sigHex);
// Build inbound URL.
char url[256];
snprintf(url, sizeof(url), "%s/access-control/webhooks/%s",
MAKERVERA_HOST, PROVIDER_CONFIG_ID);
WiFiClientSecure client;
#ifdef INSECURE_DEV_TLS
// DEVELOPMENT ONLY — disables certificate verification entirely.
// This branch is gated behind a build flag and #error's out in release
// builds (see configuration block at top of file). Do not ship this.
client.setInsecure();
#else
// Production: pin against MakerVera's root CA. Replace MAKERVERA_ROOT_CA
// for self-hosted deployments behind a different CA.
client.setCACert(MAKERVERA_ROOT_CA);
#endif
HTTPClient http;
http.begin(client, url);
http.addHeader("Content-Type", "application/json");
http.addHeader(SIGNATURE_HEADER, sigValue);
int code = http.POST((uint8_t*)body, bodyLen);
http.end();
if (code == 401 && allowRetry) {
// 401 immediately after boot is almost always clock skew. Resync
// NTP, then rebuild the body+signature with the new timestamp —
// re-POSTing the original (stale-timestamp) bytes would just fail
// again because the signature is bound to the timestamp inside the
// body. allowRetry=false on the recursive call caps us at one retry.
Serial.println("[inbound] 401 — possible NTP skew; resync and retry once");
setupTime();
sendInboundEventImpl(eventType, userId, zoneId, eventId, false);
return;
}
Serial.printf("[inbound] %s -> %d\n", eventType, code);
}
/**
* Send a signed audit event inbound to MakerVera.
* Blocks until the NTP clock is past NTP_SANITY_THRESHOLD.
*
* @param eventType e.g. "entry.unlock", "entry.deny"
* @param userId UUID string, or NULL to omit.
* @param zoneId UUID string, or NULL to omit.
* @param eventId Unique ID for this event (UUID or counter), or NULL.
*/
void sendInboundEvent(const char* eventType,
const char* userId,
const char* zoneId,
const char* eventId) {
sendInboundEventImpl(eventType, userId, zoneId, eventId, /*allowRetry=*/true);
}
// ── Outbound Webhook Receiver ─────────────────────────────────────────────────
//
// This sketch acts as a simple HTTP server to receive MakerVera outbound
// webhooks. The single-threaded WiFiServer API is used for clarity, but
// readRequest() below is byte-by-byte and bounded by a per-byte stall
// timeout so a stalling peer can't pin the device. For deployments that
// need concurrent connections (multiple readers POSTing simultaneously),
// consider ESPAsyncWebServer.
#include <WiFiServer.h>
WiFiServer server(8080);
/** Minimal HTTP/1.1 response writer. */
const char* httpReason(int status) {
switch (status) {
case 200: return "OK";
case 400: return "Bad Request";
case 401: return "Unauthorized";
case 404: return "Not Found";
case 500: return "Internal Server Error";
default: return "Status";
}
}
void httpRespond(WiFiClient& client, int status, const char* body) {
client.printf(
"HTTP/1.1 %d %s\r\n"
"Content-Type: application/json\r\n"
"Content-Length: %d\r\n"
"Connection: close\r\n\r\n%s",
status, httpReason(status), (int)strlen(body), body
);
}
/**
* Find an HTTP header value in a raw header buffer, RFC 7230-compliant.
*
* Operates in place on `headers` — does NOT allocate a copy of the request
* or lowercase the buffer. Two reasons that matters on an ESP32:
*
* 1. Memory. An incoming request can be several KB; allocating a second
* copy plus a String wrapper doubles the heap footprint and is the
* kind of allocation that fragments under load.
* 2. Scope. This helper only sees the header section (caller passes the
* bytes up to but not including the body), so a header-shaped string
* inside the JSON payload (`"notes":"X-MakerVera-Signature: ..."`)
* can never be mistaken for a real header.
*
* Properties:
* - Case-insensitive name matching via `strncasecmp` (RFC 7230 §3.2:
* header names are case-insensitive; proxies routinely lowercase them).
* - Anchors to header-line starts: position 0 OR immediately after CRLF.
* - Tolerates arbitrary OWS (space / tab) after the colon, per RFC 7230.
* - Trims trailing OWS before CRLF (also per spec).
*
* @return true if the header was found; copies its value into `out`.
*/
bool findHeader(const char* headers, size_t headersLen,
const char* name, char* out, size_t outLen) {
if (outLen == 0) return false;
const size_t nameLen = strlen(name);
for (size_t pos = 0; pos + nameLen < headersLen; pos++) {
const bool atLineStart =
(pos == 0) ||
(pos >= 2 && headers[pos - 2] == '\r' && headers[pos - 1] == '\n');
if (!atLineStart) continue;
if (strncasecmp(headers + pos, name, nameLen) != 0) continue;
if (headers[pos + nameLen] != ':') continue;
// Skip the colon and any OWS after it.
size_t valStart = pos + nameLen + 1;
while (valStart < headersLen
&& (headers[valStart] == ' ' || headers[valStart] == '\t')) {
valStart++;
}
// Read until CRLF (or end of buffer).
size_t valEnd = valStart;
while (valEnd < headersLen
&& headers[valEnd] != '\r' && headers[valEnd] != '\n') {
valEnd++;
}
// Trim trailing OWS.
while (valEnd > valStart
&& (headers[valEnd - 1] == ' ' || headers[valEnd - 1] == '\t')) {
valEnd--;
}
size_t valLen = valEnd - valStart;
if (valLen >= outLen) valLen = outLen - 1;
memcpy(out, headers + valStart, valLen);
out[valLen] = '\0';
return true;
}
return false;
}
/**
* Block briefly waiting for `client` to deliver more data. Returns true if
* a byte is available within `timeoutMs`, false on timeout / disconnect.
* Used to bound `readRequest` without taking the default 1-second
* `Stream::readString()` hit on every empty read.
*/
static bool waitForByte(WiFiClient& client, unsigned long timeoutMs) {
const unsigned long start = millis();
while (!client.available()) {
if (!client.connected()) return false;
if (millis() - start >= timeoutMs) return false;
delay(1);
}
return true;
}
/**
* Read an incoming HTTP request from `client` and split it into:
* - `headersOut` — raw header section (not including the trailing CRLFCRLF)
* - `bodyOut` — exactly Content-Length body bytes
* - `sigHeaderOut` — the signature header value (looked up in the headers
* section only, so a header-shaped JSON string in the
* body cannot impersonate a real header)
*
* Reads byte-by-byte and stops at the header delimiter so a malicious or
* malformed peer can't make the device sit on `readString()`'s 1-second
* timeout for every connection. Bounded by buffer capacity AND a short
* per-byte stall timeout.
*
* @return body length in bytes on success, -1 on error.
*/
int readRequest(WiFiClient& client,
char* headersOut, size_t headersCap,
char* bodyOut, size_t bodyCap,
char* sigHeaderOut, size_t sigCap) {
// 1. Read header section until "\r\n\r\n" or buffer is full.
size_t hLen = 0;
int delimMatch = 0; // matched-prefix length of "\r\n\r\n"
static const char DELIM[4] = {'\r', '\n', '\r', '\n'};
while (hLen + 1 < headersCap) {
if (!waitForByte(client, 1000)) return -1;
int c = client.read();
if (c < 0) return -1;
headersOut[hLen++] = (char)c;
delimMatch = (c == DELIM[delimMatch]) ? delimMatch + 1
: (c == DELIM[0] ) ? 1
: 0;
if (delimMatch == 4) {
// Trim the trailing "\r\n\r\n" so findHeader doesn't see an
// empty line at the end of the buffer.
hLen -= 4;
break;
}
}
if (delimMatch != 4) return -1; // header overflow or peer hung up
headersOut[hLen] = '\0';
// 2. Look up the configured signature header — restricted to the
// headers section so a body field named X-MakerVera-Signature
// cannot impersonate a real header.
if (!findHeader(headersOut, hLen, SIGNATURE_HEADER, sigHeaderOut, sigCap)) {
sigHeaderOut[0] = '\0';
}
// 3. Read exactly Content-Length bytes for the body. We treat
// Content-Length as authoritative; a request without it is rejected
// rather than read until EOF (which would invite slowloris-style
// holds).
char clBuf[16] = {};
if (!findHeader(headersOut, hLen, "Content-Length", clBuf, sizeof(clBuf))) {
return -1;
}
long bodyLen = atol(clBuf);
if (bodyLen < 0 || (size_t)bodyLen + 1 > bodyCap) return -1;
size_t bRead = 0;
while (bRead < (size_t)bodyLen) {
if (!waitForByte(client, 1000)) return -1;
int c = client.read();
if (c < 0) return -1;
bodyOut[bRead++] = (char)c;
}
bodyOut[bRead] = '\0';
return (int)bRead;
}
void handleIncomingRequest(WiFiClient& client) {
char rawHeaders[1024] = {};
char rawBody[1024] = {};
char sigHeader[128] = {};
int bodyLen = readRequest(client,
rawHeaders, sizeof(rawHeaders),
rawBody, sizeof(rawBody),
sigHeader, sizeof(sigHeader));
if (bodyLen < 0) {
httpRespond(client, 400, "{\"error\":\"bad request\"}");
return;
}
if (!verifyOutboundSignature((const uint8_t*)rawBody, (size_t)bodyLen,
sigHeader)) {
httpRespond(client, 401, "{\"error\":\"invalid signature\"}");
return;
}
// Parse the body with ArduinoJson rather than a substring search.
// A naive `body.indexOf("\"action\":\"")` is fragile: any future
// string-valued field whose value happens to contain the literal
// `"action":"..."` would be picked up before the real top-level
// `action` key. ArduinoJson reads the structure, not the bytes.
StaticJsonDocument<512> doc;
DeserializationError jsonErr = deserializeJson(doc, rawBody, (size_t)bodyLen);
if (jsonErr) {
httpRespond(client, 400, "{\"error\":\"invalid json\"}");
return;
}
const char* action = doc["action"] | ""; // empty string if missing
if (strcmp(action, "grant") == 0) {
Serial.println("[grant] Access granted");
// TODO: Activate relay / unlock mechanism.
// Honour valid_until if present:
// const char* validUntil = doc["valid_until"] | nullptr;
// parse + compare against time(NULL) before opening.
} else if (strcmp(action, "revoke") == 0) {
Serial.println("[revoke] Access revoked");
// TODO: Deactivate relay / lock mechanism.
} else if (strcmp(action, "sync_user") == 0) {
Serial.println("[sync_user] User sync received");
// Optionally return an external_user_id:
// httpRespond(client, 200, "{\"external_user_id\":\"esp32-uid-001\"}");
// return;
} else if (strcmp(action, "ping") == 0) {
Serial.println("[ping] Probe");
} else {
Serial.printf("[unknown action] %s — ignoring\n", action);
}
httpRespond(client, 200, "{\"status\":\"ok\"}");
}
// ── Arduino Setup / Loop ──────────────────────────────────────────────────────
void setup() {
Serial.begin(115200);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
Serial.print("Connecting to WiFi");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.printf("\nConnected: %s\n", WiFi.localIP().toString().c_str());
setupTime(); // Block until NTP syncs.
server.begin();
Serial.println("Webhook server started on port 8080");
}
void loop() {
WiFiClient client = server.available();
if (client) {
handleIncomingRequest(client);
client.stop();
}
// Example: send a periodic health event every 5 minutes.
static unsigned long lastHealth = 0;
if (millis() - lastHealth > 300000UL) {
lastHealth = millis();
sendInboundEvent("device.health", NULL, NULL, NULL);
}
}

ESP-IDF users: The same flow applies — replace the BearSSL/mbedtls wrapper above with direct mbedtls_md_hmac_starts / mbedtls_md_hmac_update / mbedtls_md_hmac_finish calls over the same body bytes. Everything else — NTP gating on esp_sntp_sync_status(), dispatch on action, HTTPS POST via esp_http_client — is structurally identical. The mbedtls API is the same library used under the hood; only the call-site changes.


SymptomLikely CauseFix
Test Connection failedprobe_url not reachable or not publicly addressableCheck SSRF guard (LAN IPs blocked by default); confirm endpoint returns 2xx, not a 3xx redirect
Signature verification failing on outboundHashing parsed-then-re-serialized bodyHash raw received bytes; if framework auto-parses JSON, retrieve raw body before parsing
Signature failures only on some usersensure_ascii=False missingAdd ensure_ascii=False to any json.dumps call; non-ASCII names produce different bytes with default settings
Inbound returning 401Missing/stale/future timestamp, wrong header name, wrong secretConfirm body has timestamp (not event_timestamp) within the replay window; confirm device uses the exact header name in WebhookConfig.signature_header
Inbound 401 only on first bootNTP not yet syncedGate send loop on time(NULL) > 1735689600; resync NTP on every boot
Inbound returning 401 after secret rotationDevice using old secretUpdate device secret; if in grace window, outbound from MakerVera uses new secret — treat first outbound signature failure as rotation signal
Repeated events silently lostNo unique webhook_event_idProvide a unique UUID or counter per event; the fallback hash deliberately omits timestamp
Access grants not arrivingProvider URL not publicly reachable from MakerVeraCheck failed-task admin UI for ProviderError; check SSRF guard; try ACCESS_CONTROL_ALLOW_LOCAL_WEBHOOKS=true for LAN installs
Grant arrives hours before sessionExpected behaviourgrant fires at session.start - buffer_minutes; honour valid_until or wait for matching revoke
Never receiving sync_user callssync_url not setSet a sync_url on the provider config; if unset, MakerVera skips sync silently with no log entry
After regenerating secret, all signatures failDevice still using old secretUpdate the device; if you used a grace window, MakerVera signs outbound with the new secret immediately — old-secret-signed inbound is still accepted until the grace expires
sync_user response field not appearing in MakerVeraExtra response fields silently ignoredOnly external_user_id is read from the sync_user response body; send device-side metadata via inbound webhook events

Step 1 — Outbound Verification (MakerVera → Device)

Section titled “Step 1 — Outbound Verification (MakerVera → Device)”
  1. Press Test Connection in the Integrations UI. MakerVera sends a ping to your probe_url with a real HMAC signature.
    • Expect a 2xx from the device.
    • If you receive a 401, your HMAC verification code is wrong — debug using the canonical bytes section above.
  2. Configure a zone with the device URL, then trigger a real grant (register a test user for an event session with an EventAccessPolicy pointing to the zone).
    • Expect grant then later revoke.
    • Confirm valid_until is honoured if your device uses it.

Step 2 — Inbound Verification (Device → MakerVera)

Section titled “Step 2 — Inbound Verification (Device → MakerVera)”

Use openssl and curl to test inbound from the command line before implementing it in firmware:

Terminal window
# Fill in these values:
SECRET='your-signing-secret'
CONFIG_ID='your-provider-config-id'
# Replace HOST with the URL shown in MakerVera Settings → Integrations
# (Step 3 of the Setup section above) — strip the trailing path so HOST
# is just the scheme + hostname.
HOST='https://api.makervera.example'
# Use the current wall clock — the replay window only accepts timestamps
# within 5 minutes past / 60 seconds future. A stale hardcoded value will
# always be rejected.
NOW=$(date -u +%Y-%m-%dT%H:%M:%S+00:00)
BODY="{\"event_type\":\"entry.test_unlock\",\"timestamp\":\"$NOW\",\"webhook_event_id\":\"manual-test-001\",\"user_id\":\"3f2504e0-4f89-11d3-9a0c-0305e82c3301\",\"zone_id\":\"a1b2c3d4-e5f6-7890-abcd-ef1234567890\"}"
# Compute HMAC-SHA256 over the body.
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
# First send — should return 200 processed.
curl -i -X POST \
-H "Content-Type: application/json" \
-H "X-MakerVera-Signature: sha256=$SIG" \
--data "$BODY" \
"$HOST/access-control/webhooks/$CONFIG_ID"
# Expected: 200 {"status":"processed","event_id":"..."}

Now test the negative cases:

Terminal window
# Same command again — should deduplicate.
# Expected: 200 {"status":"duplicate","webhook_event_id":"manual-test-001"}
# Mutate the body without regenerating the signature — should reject.
# Reuse $NOW so the timestamp is still inside the replay window; the
# rejection then unambiguously demonstrates HMAC mismatch, not replay.
MUTATED="{\"event_type\":\"entry.test_unlock_MUTATED\",\"timestamp\":\"$NOW\",\"webhook_event_id\":\"manual-test-001\",\"user_id\":\"3f2504e0-4f89-11d3-9a0c-0305e82c3301\",\"zone_id\":\"a1b2c3d4-e5f6-7890-abcd-ef1234567890\"}"
curl -i -X POST \
-H "Content-Type: application/json" \
-H "X-MakerVera-Signature: sha256=$SIG" \
--data "$MUTATED" \
"$HOST/access-control/webhooks/$CONFIG_ID"
# Expected: 401
# Use a timestamp from 10 minutes ago — outside the 5-minute past-tolerance window.
OLD_TS=$(date -u -d '10 minutes ago' '+%Y-%m-%dT%H:%M:%S+00:00' 2>/dev/null \
|| date -u -v-10M '+%Y-%m-%dT%H:%M:%S+00:00') # macOS fallback
STALE_BODY="{\"event_type\":\"entry.test_unlock\",\"timestamp\":\"$OLD_TS\",\"webhook_event_id\":\"stale-test\",\"user_id\":\"3f2504e0-4f89-11d3-9a0c-0305e82c3301\",\"zone_id\":\"a1b2c3d4-e5f6-7890-abcd-ef1234567890\"}"
STALE_SIG=$(printf '%s' "$STALE_BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -i -X POST \
-H "Content-Type: application/json" \
-H "X-MakerVera-Signature: sha256=$STALE_SIG" \
--data "$STALE_BODY" \
"$HOST/access-control/webhooks/$CONFIG_ID"
# Expected: 401 (past-tolerance exceeded)
# Use a timestamp 5 minutes in the future — outside the 60-second future-tolerance window.
FUTURE_TS=$(date -u -d '5 minutes' '+%Y-%m-%dT%H:%M:%S+00:00' 2>/dev/null \
|| date -u -v+5M '+%Y-%m-%dT%H:%M:%S+00:00') # macOS fallback
FUTURE_BODY="{\"event_type\":\"entry.test_unlock\",\"timestamp\":\"$FUTURE_TS\",\"webhook_event_id\":\"future-test\",\"user_id\":\"3f2504e0-4f89-11d3-9a0c-0305e82c3301\",\"zone_id\":\"a1b2c3d4-e5f6-7890-abcd-ef1234567890\"}"
FUTURE_SIG=$(printf '%s' "$FUTURE_BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -i -X POST \
-H "Content-Type: application/json" \
-H "X-MakerVera-Signature: sha256=$FUTURE_SIG" \
--data "$FUTURE_BODY" \
"$HOST/access-control/webhooks/$CONFIG_ID"
# Expected: 401 (future-tolerance exceeded — 60s window, not 5 minutes)

Before relying on this integration for door access:

  • Device returns 2xx on a real grant and access actually works.
  • Device returns 2xx on a real revoke and access actually closes.
  • Test Connection button is green from the admin UI.
  • Manual curl with a valid signature returns 200 processed.
  • Manual curl with a mutated body returns 401.
  • Manual curl with a stale timestamp returns 401.
  • NTP sync verified on the device (timedatectl status on Linux; log time(NULL) on boot for ESP32).
  • Signing secret stored encrypted or protected on the device — not in cleartext in plaintext logs or source repositories.
  • Device’s own logs redact request bodies in production mode.
  • valid_until handling documented in the device README.
  • Secret rotation procedure documented and tested end-to-end.