Blog · Engineering

How a QR scan lands a signal in HubSpot in under 500 milliseconds

A walkthrough of the architecture behind boxli's QR redirect: fire-and-forget webhook, idempotent ingest, HMAC verification, and how the synchronous path is shaped to stay tight.

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 video

The 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:

  1. Resolve send_id from the QR token (single indexed lookup).
  2. Insert a qr_scan_events row.
  3. Increment sends.scan_count via the increment_qr_scans RPC.
  4. Mark sends.status = 'engaged' if it was 'delivered'.
  5. 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.

Why fire-and-forget
Doing dispatch synchronously meant Slack and HubSpot tail-latency showed up in the redirect path — Slack alone could push a 250ms tail on a bad day. The recipient's phone is hanging on a redirect; that latency lives in the user's perception of the product. Backgrounding it was non-negotiable. The architecture is designed for sub-500ms end-to-end at the 95th percentile under normal load; we're instrumenting the full latency distribution as part of pilot graduation, and we'll publish the actual numbers when the cohort completes.

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:

  1. HMAC-SHA256 verification using a per-org ingest_secret (64-hex, generated on org creation, rotatable). Timing-safe comparison via Node's crypto.timingSafeEqual.
  2. Idempotency via a partial unique index on external_event_id. Vendor retries land on the same row, not a duplicate.
  3. Send resolution by either sends.id (UUID) or sends.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:

  1. Materialize the rollup. Today contact_engagement_rollup is a view with security_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.
  2. 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.

See it on your own pipeline.

boxli is in private pilot with B2B revenue teams closing $25K+ ACV deals. 30 days, minimum 25 sends, sign or refund.