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.
Overview
Section titled “Overview”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.
Device-Side Logging Guidance
Section titled “Device-Side Logging Guidance”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_urlMUST be HTTPS in production. The whole guide assumes TLS in transit. Sending user profile data over plain HTTP is not acceptable.
Setup in MakerVera
Section titled “Setup in MakerVera”- Settings → Integrations → Add Provider → Webhook — the WEBHOOK type is available in the provider type selector.
- 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.
- Copy the Inbound Webhook URL shown on the saved provider’s panel — this is the full URL your device POSTs to, with
provider_config_idalready embedded. - Add zones with your device URLs in the inline zone table on the saved provider’s panel.
- Test the connection using the Test Connection button (sends a
pingto your configured Probe URL).
Action → URL Routing
Section titled “Action → URL Routing”MakerVera does NOT send all four actions to a single URL. Each action is routed independently:
| Action | Destination URL |
|---|---|
grant | Per-zone zone_config.webhook_url → falls back to provider-level grant_url |
revoke | Per-zone zone_config.webhook_url → falls back to provider-level revoke_url |
sync_user | Provider-level sync_url only (never per-zone). If sync_url is unset, sync is skipped silently. |
ping | Provider-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)”HTTP Request Shape
Section titled “HTTP Request Shape”- Method:
POST - Headers:
Content-Type: application/jsonX-MakerVera-Signature: sha256=<hex>— HMAC-SHA256 over the raw request body bytes. The header name is configurable viaWebhookConfig.signature_header(defaultX-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.
Canonical Body Contract
Section titled “Canonical Body Contract”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:
- Sorted keys —
json.dumps(..., sort_keys=True). Keys appear in alphabetical order. - Compact separators —
separators=(",", ":"). No spaces after,or:. ensure_ascii=False— non-ASCII characters (accented names likeé,ñ, CJK characters) are emitted as raw UTF-8 bytes, NOT as\uXXXXescapes. Python’s defaultjson.dumpsusesensure_ascii=Trueand 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.
Body Schema
Section titled “Body Schema”{ "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:
| Field | Present On | Notes |
|---|---|---|
action | All | One of grant, revoke, sync_user, ping |
user_id | grant, revoke, sync_user | UUID; pseudonymous but linkable |
zone_id | grant, revoke | UUID; matches your zone configuration |
timestamp | All | ISO-8601 UTC with timezone offset; used for replay-window checks |
valid_until | grant (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.
When MakerVera Fires grant/revoke
Section titled “When MakerVera Fires grant/revoke”Do not assume grant arrives at the moment the session starts.
Event-based access (EventAccessPolicy) is buffered:
grantfires atsession.start - buffer_minutes— often hours or days before the session starts, depending on policy configuration.revokefires atsession.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:
- Honour
valid_untilstrictly — 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. - Use revocation as the source of truth for end — keep the grant active until
revokearrives, accepting thatbuffer_minutesmeans 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.
Verifying the Outbound Signature
Section titled “Verifying the Outbound Signature”The contract builders MUST follow:
- 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.
- Compute
HMAC-SHA256(signing_secret, raw_body_bytes)— hex-encoded digest. - Constant-time compare your hex digest against the value of the signature header (after stripping the
sha256=prefix). - 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.
Expected Response Codes
Section titled “Expected Response Codes”| Response | Meaning |
|---|---|
200 / 201 / 202 / 204 | MakerVera marks the action successful |
301 / 302 | NOT followed; treated as failure |
| Anything else | MakerVera raises ProviderError; the task is marked FAILED and surfaces in the failed-tasks admin UI |
202 Acceptedwarning: MakerVera treats 2xx as success without inspecting the body. If your device returns202and 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
200only on real success.- (b) Return
202immediately, then send a follow-up inbound webhook (entry.failevent) when the async work fails, so the failure shows up in the admin failed-tasks UI.
Custom Signature Header
Section titled “Custom Signature Header”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.
LAN / Private-IP Destinations
Section titled “LAN / Private-IP Destinations”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:
- Expose the device via a public hostname (reverse proxy, ngrok, Tailscale Funnel, Cloudflare Tunnel), or
- Ask the operator to set
ACCESS_CONTROL_ALLOW_LOCAL_WEBHOOKS=truein 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
Replay Attack Prevention
Section titled “Replay Attack Prevention”MakerVera checks the timestamp field on inbound webhooks against its wall clock. The replay window is asymmetric:
| Direction | Window |
|---|---|
| Past tolerance | 5 minutes (300 seconds) — allows for clock skew and network latency |
| Future tolerance | 60 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.
Time Sync Requirements
Section titled “Time Sync Requirements”The 60-second future tolerance is tight. This section is especially important for ESP32 and other microcontroller builders.
Why This Matters
Section titled “Why This Matters”- 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.
Required Handling
Section titled “Required Handling”-
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 ontime(NULL) > 1735689600(a post-2025 threshold: 2025-01-01T00:00:00Z = 1735689600) as a sanity check that the clock has been set. -
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.
-
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.
-
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)”When to Use This
Section titled “When to Use This”- Door opened/closed events
- Access denied events
- Device health/status updates
- Any event you want visible in the admin audit trail
HTTP Request Shape
Section titled “HTTP Request Shape”- Method:
POST - URL:
https://<makervera-host>/access-control/webhooks/<provider_config_id>- The
provider_config_idand full URL are shown in the Integrations admin page next to each saved webhook provider.
- The
- Headers:
Content-Type: application/json<signature_header>: sha256=<hex>— header name defaults toX-MakerVera-Signature; uses whateverWebhookConfig.signature_headeris configured to. The same value applies to outbound.
- Body: JSON with required fields below.
Body Schema
Section titled “Body Schema”{ "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"}| Field | Required | Notes |
|---|---|---|
event_type | Yes | Free-form string (max 50 chars). Convention: dotted names like entry.unlock, entry.deny, entry.fail. Whatever you send appears in the audit trail. |
timestamp | Yes | Must 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_id | No | UUID of the member being acted on. |
zone_id | No | UUID of the zone. |
webhook_event_id | Strongly recommended | See deduplication section below. |
Field name warning: The field must be
timestamp, notevent_timestamp.event_timestampis read by the handler as a fallback when persisting the audit row, but the replay guard runs first and sees onlytimestamp. A device sending onlyevent_timestampwill receive 401 on every request and never reach the handler.
Deduplication (webhook_event_id)
Section titled “Deduplication (webhook_event_id)”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.
Signing Inbound Requests
Section titled “Signing Inbound Requests”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
timestampin 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.
Inbound Response Codes
Section titled “Inbound Response Codes”| Code | Body | Meaning |
|---|---|---|
200 | {"status": "processed", "event_id": "..."} | New event persisted |
200 | {"status": "duplicate", "webhook_event_id": "..."} | Event already seen (signatures work; dedup filtered it) |
400 | — | Invalid JSON |
401 | — | Bad signature, OR replay window failed (missing/expired/future timestamp) |
404 | — | Unknown provider_config_id |
410 | — | Provider config exists but is inactive |
Rotating the Signing Secret
Section titled “Rotating the Signing Secret”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.
Admin-Side Flow (the Operator)
Section titled “Admin-Side Flow (the Operator)”- Settings → Integrations → open the webhook provider → Regenerate Secret.
- 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.
- Copy the new secret from the one-shot display modal and ship it to the device.
Device-Side Flow (the Builder)
Section titled “Device-Side Flow (the Builder)”- 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.
- 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.
- 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.
- 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.
Versioning & Forward Compatibility
Section titled “Versioning & Forward Compatibility”MakerVera’s Commitment
Section titled “MakerVera’s Commitment”- Outbound bodies will only gain fields, never lose or rename them, within
v1. Future phases will add fields likepolicy_id,cohort_id, orcorrelation_id. These are additive. - Any breaking change ships under a new contract version — either a new path (
/access-control/webhooks/v2/{config_id}) or anX-MakerVera-Signature-Versionheader, never silently mutatingv1. - Inbound response shape is stable:
{"status": "processed", ...}and{"status": "duplicate", ...}are part of the contract.
What Builders MUST Do
Section titled “What Builders MUST Do”- 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.
Example Implementations
Section titled “Example Implementations”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 JSONhmac.compare_digestfor constant-time comparisonjson.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 hashlibimport hmacimport jsonimport osimport timefrom functools import wraps
import requestsfrom 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 minutesREPLAY_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_signaturedef 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.timingSafeEqualfor 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.
Arduino-ESP32 (BearSSL) — DIY Embedded
Section titled “Arduino-ESP32 (BearSSL) — DIY Embedded”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
WiFiClientSecureand pins a root CA certificate viasetCACert(...). Do not swap that forsetInsecure()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_CAwith your deployment’s actual root certificate (PEM-encoded, kept literal in firmware or loaded from secure storage). A non-pinned development override exists behindINSECURE_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 minutesstatic 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-----MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAwTzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2VhcmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygch77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6UA5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sWT8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyHB5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UCB5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUvKBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWnOlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTnjh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbwqHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CIrU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkqhkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZLubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KKNFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7UrTkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdCjNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVcoyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPAmRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57demyPxgcYxn/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_finishcalls over the same body bytes. Everything else — NTP gating onesp_sntp_sync_status(), dispatch onaction, HTTPS POST viaesp_http_client— is structurally identical. The mbedtls API is the same library used under the hood; only the call-site changes.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Likely Cause | Fix |
|---|---|---|
| Test Connection failed | probe_url not reachable or not publicly addressable | Check SSRF guard (LAN IPs blocked by default); confirm endpoint returns 2xx, not a 3xx redirect |
| Signature verification failing on outbound | Hashing parsed-then-re-serialized body | Hash raw received bytes; if framework auto-parses JSON, retrieve raw body before parsing |
| Signature failures only on some users | ensure_ascii=False missing | Add ensure_ascii=False to any json.dumps call; non-ASCII names produce different bytes with default settings |
| Inbound returning 401 | Missing/stale/future timestamp, wrong header name, wrong secret | Confirm 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 boot | NTP not yet synced | Gate send loop on time(NULL) > 1735689600; resync NTP on every boot |
| Inbound returning 401 after secret rotation | Device using old secret | Update device secret; if in grace window, outbound from MakerVera uses new secret — treat first outbound signature failure as rotation signal |
| Repeated events silently lost | No unique webhook_event_id | Provide a unique UUID or counter per event; the fallback hash deliberately omits timestamp |
| Access grants not arriving | Provider URL not publicly reachable from MakerVera | Check failed-task admin UI for ProviderError; check SSRF guard; try ACCESS_CONTROL_ALLOW_LOCAL_WEBHOOKS=true for LAN installs |
| Grant arrives hours before session | Expected behaviour | grant fires at session.start - buffer_minutes; honour valid_until or wait for matching revoke |
Never receiving sync_user calls | sync_url not set | Set a sync_url on the provider config; if unset, MakerVera skips sync silently with no log entry |
| After regenerating secret, all signatures fail | Device still using old secret | Update 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 MakerVera | Extra response fields silently ignored | Only external_user_id is read from the sync_user response body; send device-side metadata via inbound webhook events |
Verifying Your Integration
Section titled “Verifying Your Integration”Step 1 — Outbound Verification (MakerVera → Device)
Section titled “Step 1 — Outbound Verification (MakerVera → Device)”- Press Test Connection in the Integrations UI. MakerVera sends a
pingto yourprobe_urlwith 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.
- 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
grantthen laterrevoke. - Confirm
valid_untilis honoured if your device uses it.
- Expect
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:
# 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:
# 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 fallbackSTALE_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 fallbackFUTURE_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)Step 3 — Pre-Production Checklist
Section titled “Step 3 — Pre-Production Checklist”Before relying on this integration for door access:
- Device returns 2xx on a real
grantand access actually works. - Device returns 2xx on a real
revokeand 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 statuson Linux; logtime(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_untilhandling documented in the device README. - Secret rotation procedure documented and tested end-to-end.