When a prospect taps the NFC chip on a boxli, scans the QR code, or the brochure's lid sensor reports an open, the goal is for the rep's Slack channel to ping inside half a second and for the HubSpot contact record to update in the same window. This post is about how that pipeline is built and why we shaped the synchronous path to stay tight.
Why latency matters
A buying signal is only useful if your AE acts on it before the recipient has put the box down. The whole architecture is designed for signal-to-Slack inside half a second at the 95th percentile, with the CRM update as a side effect of the same write path. The actual measured numbers across our pilot cohort will publish with the cohort's graduation post — designed-for is not the same as measured.
The shape of a scan
The recipient lifts the lid; the IoT sensor pulses our ingest endpoint. The NFC chip on the box spawns a redirect to /api/webhooks/qr/[id]. The video brochure starts a play session and reports back to /api/ingest/engagement via HMAC-signed POST when the session ends.
Three event sources, one signal contract:
event_source: 'boxli_qr' | 'nfc' | 'video_brochure'
Common fields:
external_event_id // idempotency key
send_id // scoped to organization
occurred_at // wall-clock at the device
duration_sec? // for video sessions
lat?, lng? // device geolocation
place_id? // Google Places resolution
play_sequence_num? // Nth play of the videoThe QR redirect path: tight synchronous, async dispatch
The QR scan is the most latency-sensitive event because the browser is hanging on the response. The handler does only this synchronously:
- Resolve send_id from the QR token (single indexed lookup).
- Insert a
qr_scan_eventsrow. - Increment
sends.scan_countvia theincrement_qr_scansRPC. - Mark
sends.status = 'engaged'if it was'delivered'. - 302 redirect to the recipient's personalized landing page.
That entire path is two SQL writes and a redirect. Then we fire-and-forget the trigger evaluation as an unawaited promise so the redirect ships immediately:
// inside the route handler, after the redirect is queued
void (async () => {
await evaluateTriggers({ send_id, scan_event_id })
})()The Slack ping, the HubSpot contact-property update, the optional outbound webhook all run in that backgrounded promise. The redirect doesn't wait for them.
The brochure ingest path: HMAC, idempotent, async
The video brochure's firmware can't hold a TCP connection open through a tap-and-walk. It POSTs at the end of each play session with whatever telemetry it captured. The endpoint:
POST /api/ingest/engagement
Headers:
content-type: application/json
x-boxli-signature: sha256=<hmac of body using org secret>
Body: { external_event_id, external_send_id, event_type, ... }Three things make this path safe:
- HMAC-SHA256 verification using a per-org
ingest_secret(64-hex, generated on org creation, rotatable). Timing-safe comparison via Node'scrypto.timingSafeEqual. - Idempotency via a partial unique index on
external_event_id. Vendor retries land on the same row, not a duplicate. - Send resolution by either
sends.id(UUID) orsends.external_ref(vendor-controlled key, indexed scoped to org). Vendors don't need to know our UUIDs.
Same shape: synchronous insert + counter, async trigger eval. The synchronous path is two SQL writes plus a redirect — by construction it stays well inside our latency budget; the long-tail risk is in dispatch, not in the write path.
Where signals become decisions
The trigger evaluator reads the touched contact_engagement_rollup row and compares it against active triggers for the org. Four ship by default:
first_scan— fires once per send on the first engagement event of any kind.re_engagement— fires when a contact engages again after a 30-day gap.pass_around— fires when a single send reports events from two or more distinct places. We have a full post on why this matters.signal_threshold— fires when the weighted signal_score crosses 15.
Each fire writes a row to trigger_fires for audit, then dispatches Slack, CRM tasks, outbound webhooks, and email in parallel. Dispatch failures are logged but don't roll back the signal — we always rather have the data than the alert.
What we would do differently
Two things on the "next time" list:
- Materialize the rollup. Today
contact_engagement_rollupis a view withsecurity_invoker = true. At our current volume the query plan is fine, but at 100× we'll convert to a materialized view with incremental refresh on the trigger evaluator's read path. - Move dispatch onto a queue. Backgrounded callbacks work; a proper job queue with retries, dead-letter, and observability would work better. On the roadmap for Q3.
The shape of this thing is "fire-and-forget at every layer that can tolerate it, idempotent on every layer that has to be retried." That gives us the latency we need without giving up the durability we need. If you're running pipelines like this and want to compare notes, come run a pilot — we'll show you the whole stack.