{
  "$type": "site.standard.document",
  "bskyPostRef": {
    "cid": "bafyreifksjn6hjoqpjhrgd6hifeslxxfa6d62eotdpmhs3qafzwblhjvmq",
    "uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3mp2hyvkmfkn2"
  },
  "coverImage": {
    "$type": "blob",
    "ref": {
      "$link": "bafkreia3lc5omcuw4q76eko6i37t26ilplao6fyvy4emuxbdvvid2o2pqq"
    },
    "mimeType": "image/webp",
    "size": 89492
  },
  "path": "/priyom_sarkar/how-we-connected-off-page-content-to-actual-revenue-with-a-small-calendly-to-hubspot-webhook-jej",
  "publishedAt": "2026-06-24T17:42:56.000Z",
  "site": "https://dev.to",
  "tags": [
    "seo",
    "devops",
    "automation",
    "opensource",
    "@app.post"
  ],
  "textContent": "For a long time our demo bookings showed up in the CRM with a source of `(direct)`. Someone read a Reddit answer, clicked through, booked a call, and as far as our data was concerned, fell out of the sky. We knew the off-page content was working. We could not prove which piece.\n\nThis is the writeup of the small thing we built to fix that. It is not clever. That is the point. Attribution does not need to be clever; it needs to actually fire on every booking.\n\n##  The problem in one sentence\n\nCalendly bookings landed in HubSpot with no campaign data, because the UTM parameters on the landing URL were not being carried through into the contact record. So every off-page channel collapsed into `(direct)`, and we could not tell a Reddit-sourced demo from a Medium-sourced one from a podcast-sourced one.\n\n##  The shape of the fix\n\nThree pieces.\n\n  1. A strict UTM convention so every off-domain link is tagged consistently before it ever ships.\n  2. A Calendly webhook that fires on `invitee.created`.\n  3. A small handler that reads the UTMs off the booking and writes them onto the HubSpot contact's `self_reported_source`standard UTM properties.\n\n\n\n##  1. The UTM convention\n\nThe unglamorous part that makes the rest work. Every link we publish off-domain follows the same pattern:\n\n\n\n    https://asanify.com/<path>/?utm_source=<platform>&utm_medium=<type>&utm_campaign=<topic>_<yyyymm>&utm_content=<placement>\n\n\n`utm_source` is the platform token, lowercase, single word: `reddit`, `quora`, `medium`, `devto`, `linkedin`. Crucially it is never `website` or our own brand name, because those collapse straight back to direct. We enforce this with a regex check in CI before any draft is allowed to ship:\n\n\n\n    import re\n\n    def verify_utms(text: str) -> list[str]:\n        fails = []\n        urls = re.findall(\n            r'https?://(?:[a-z0-9-]+\\.)*(?:asanify\\.com|calendly\\.com)[^\\s\\)\\]\"]*',\n            text,\n        )\n        for url in urls:\n            for p in (\"utm_source=\", \"utm_medium=\", \"utm_campaign=\"):\n                if p not in url:\n                    fails.append(f\"missing {p}: {url}\")\n            if re.search(r\"utm_source=(website|na|none|test)\\b\", url, re.I):\n                fails.append(f\"bad source value: {url}\")\n        return fails\n\n\nIf `verify_utms` returns anything, the draft does not publish. A missing UTM is treated as a defect, not a nice-to-have. We standardized the whole thing on a single conventions doc that every content producer reads, which is the boring governance move that actually held the data clean. The corridor pages these links point at live on our EOR hub.\n\n##  2. The Calendly webhook\n\nCalendly fires a webhook on `invitee.created`. The payload includes `tracking`, which carries through the UTM parameters present on the scheduling link. That is the whole trick: Calendly already captures them; you just have to read them and forward them.\n\n\n\n    from flask import Flask, request, jsonify\n\n    app = Flask(__name__)\n\n    @app.post(\"/marketing/calendly\")\n    def calendly_hook():\n        event = request.get_json(force=True)\n        if event.get(\"event\") != \"invitee.created\":\n            return jsonify(ok=True), 200\n\n        payload = event[\"payload\"]\n        email = payload[\"email\"]\n        tracking = payload.get(\"tracking\", {}) or {}\n\n        utms = {\n            \"utm_source\":   tracking.get(\"utm_source\"),\n            \"utm_medium\":   tracking.get(\"utm_medium\"),\n            \"utm_campaign\": tracking.get(\"utm_campaign\"),\n            \"utm_content\":  tracking.get(\"utm_content\"),\n        }\n        upsert_hubspot_contact(email, utms)\n        return jsonify(ok=True), 200\n\n\n##  3. Writing it onto the HubSpot contact\n\nThe handler upserts the contact and stamps the UTMs, plus a human-readable `self_reported_source` the sales team can filter on without learning UTM syntax.\n\n\n\n    import requests, os\n\n    HS = \"https://api.hubapi.com/crm/v3/objects/contacts\"\n    HEADERS = {\"Authorization\": f\"Bearer {os.environ['HUBSPOT_TOKEN']}\"}\n\n    def upsert_hubspot_contact(email: str, utms: dict):\n        source = utms.get(\"utm_source\") or \"direct\"\n        props = {\n            \"email\": email,\n            \"self_reported_source\": source,\n            \"utm_source\": source,\n            \"utm_medium\": utms.get(\"utm_medium\") or \"\",\n            \"utm_campaign\": utms.get(\"utm_campaign\") or \"\",\n        }\n        # idempotent upsert by email\n        requests.post(\n            f\"{HS}?idempotencyKey={email}\",\n            json={\"properties\": props},\n            headers=HEADERS,\n            timeout=10,\n        )\n\n\nA couple of things we learned the hard way. Make the webhook idempotent, because Calendly will retry on a slow response and you do not want duplicate writes. Return `200` fast and do the slow CRM work behind a queue if your handler is heavy, otherwise the retries pile up. And log the raw `tracking` blob for a while, because the first week we found a chunk of links in the wild that predated the convention and arrived with no UTMs at all, which told us exactly which old content to go back and re-tag.\n\n##  What it changed\n\nOnce this was live, the source breakdown on demo bookings stopped being a mystery. We could finally see which off-page channel was actually producing booked calls instead of just traffic, and we reallocated effort accordingly. The number that surprised us most was how much of our demand was coming from AI assistants citing our content, which only became visible because the source value was specific instead of a generic bucket.\n\nWe write more about how that AI-citation channel works for us on our EOR hub.\nNothing here is hard engineering.\n\n_The leverage is entirely in two things_ : a UTM convention you actually enforce, and a webhook that fires on every booking with no exceptions. Get those two right and your \"where do demos come from\" question has a real answer.",
  "title": "How we connected off-page content to actual revenue with a small Calendly to HubSpot webhook"
}