Developers

CRM & HubSpot

How the Reading Rock Design Studio on readingrock.studio sends signed, real-time events — sign-ups, leads, designs, project insights, deletions — into your CRM. Written for HubSpot, with notes for Salesforce, Pipedrive and Zoho at the end. The mechanics (headers, signature, retries) are on the Webhooks page.

How it works

Everything a homeowner or professional does on readingrock.studio — creating an account, finishing a design, filling in an estimate form — becomes an event. You subscribe an HTTPS endpoint to the events you care about, and each one arrives as a JSON POST signed with a secret only you hold. Your side turns those events into contacts, notes and deals.

  1. Your visualizer — readingrock.studioThe web visualizer, share pages, project forms and in-store kiosks.
  2. The event spineEvery event is logged, signed and queued. Failed deliveries retry automatically for about 8 h 36 min.
  3. Your relay endpointVerifies the signature, de-duplicates on event_id, stores the event and answers 2xx, then maps it to your CRM.
  4. Your CRMContacts upserted by email, timeline notes, deals, lists and workflows — all in your own account.

Three ways to land events in HubSpot

OptionWhat it is
A · A small relay service Recommended A tiny HTTPS service you own (Node, PHP, Python, a serverless function, a Cloudflare Worker…). It verifies our signature, remembers each event_id and calls the HubSpot CRM API with a private-app token. Full control over property mapping, deals and GDPR deletions. Typically a day of work.
B · HubSpot workflow webhook trigger Operations Hub Professional and above can start a workflow from an incoming webhook and map JSON fields to contact properties. Quick to try, but it cannot verify our HMAC signature — keep it to non-sensitive events, or put it behind option A.
C · Zapier / Make “Catch webhook” → HubSpot “Create or update contact”. Fine for a pilot. Signature checks need a code step; de-duplication and deletion handling are awkward. Start here if you like, then graduate to A.

What you need before you start

WhereWhat
Portalreadingrock.studio/portal → Developers → Webhooks
Live examplesDevelopers → Events — real events from your visualizer, with payloads
API referencereadingrock.studio/developers — Render Engine API and Management API
EndpointsUp to 20 per account, each with its own secret and event list
Help[email protected]

Setup — ten minutes in the portal, then your relay

In the portal

  1. Open Developers → WebhooksSign in at readingrock.studio/portal as an owner or admin and press Add endpoint.
  2. Name it and paste your HTTPS URLFor example “HubSpot relay” → https://hooks.your-company.com/stoneswap. One endpoint per environment (staging, production) is a good habit.
  3. Choose the eventsTick the ones you want (our recommendation is below) or switch on All events — that endpoint then also receives new event types we add later. Press Create endpoint.
  4. Copy the signing secret — shown onceA whsec_… string. Store it as, for example, STONESWAP_WEBHOOK_SECRET. Lost it? Rotate secret mints a new one and deliveries switch immediately.
  5. Press Send test eventWe POST a webhook.test event with no business data. The message shows the HTTP code your endpoint answered; the Delivery log keeps every attempt, response code and error.
  6. Replay real events while you buildThe Events tab lists what your visualizer produced recently (up to 30 days). Replay to endpoint re-sends any of them, so you can develop against real leads and designs without waiting for new ones.

On your side (the relay)

  1. Read the raw bodyVerify against the exact bytes we sent. Do not parse and re-encode the JSON before hashing — Express users: express.raw() on this route.
  2. Verify the signature and timestampX-SS-Signature must equal hex(HMAC-SHA256(secret, timestamp + "." + rawBody)); reject an X-SS-Timestamp more than 5 minutes off. Code below.
  3. De-duplicate on event_idRetries and replays resend the same event_id. Store it (a table, or a cache with a 24-hour lifetime) and skip repeats.
  4. Answer 2xx within 10 secondsStore the event in a queue (or a table), then answer 2xx and do the HubSpot work from the queue. Once you answer 2xx we do not retry, so never answer before the event is stored. Anything else, or a timeout, counts as a failure and is retried.
  5. Map to HubSpotUpsert the contact by email, store our user_id on the contact, add notes and deals. Mapping tables below.

Delivery contract

PartValue
MethodHTTPS POST · Content-Type: application/json
User-AgentStoneSwap-Studio-Webhooks/1.0
HeadersX-SS-Event · X-SS-Event-Id · X-SS-Timestamp · X-SS-Signature
SuccessAny 2xx within 10 s
Retries1 min · 5 min · 30 min · 2 h · 6 h after each failed try (6 attempts over about 8 h 36 min), then marked failed — retry by hand from the Delivery log
OrderNot guaranteed — use created_at from the body, not arrival time

Every event has the same envelope — four keys, nothing else:

{
  "event":      "lead.created",                  // which event — also in the X-SS-Event header
  "event_id":   "evt_5697efc51dbfcab109388b",    // unique, stable across retries — your de-dupe key
  "created_at": "2026-09-05T19:20:26+00:00",     // when it happened (UTC, ISO 8601)
  "data":       { … }                            // event-specific fields — examples below
}

Which events to capture

The portal offers 36 event types. Most are for your operations team (billing, quotas, catalog, safety). For a CRM you want the ones below — three are essential, the rest enrich the contact over time.

EventPriorityWhat it tells your CRMSuggested action
lead.createdPIIMust have A homeowner asked for an estimate, a professional (contractor or designer) asked for a dealer, someone completed the sign-up form or a kiosk “Email me”, or claimed free credits. Name, email, phone, address, budget, timeline, project details, the before/after images and the products in the attached design. Upsert the contact → lifecycle Lead; create a deal; enrol in a follow-up workflow.
user.signupPIIMust have An account became active (verified email, or Google / Facebook sign-in). Role (homeowner or contractor) and, for professionals, profession (contractor or designer); sign-in method, country and region, free designs granted. Fires once per person. Upsert the contact → lifecycle Subscriber; store user_id.
user.deletedPIIMust have The customer erased their account, or asked us to. Only the opaque user_id and a SHA-256 of the email remain. Copies you hold in your CRM must be deleted too (Quebec Law 25 / PIPEDA / GDPR). GDPR-delete the contact matched by stoneswap_user_id.
render.analysisValuable The AI’s read of a finished design: project type and size, keywords, estimated hardscape material value and total installed cost (USD ranges), city and region, plus products[]. This is the qualification data. Set project and product properties; deal amount from the cost range.
render.completedValuable A design is ready: before and after image URLs, plus products[] — the catalog id, variation id, name, colour and variation of every product in the design. Timeline note with the image link; product-interest properties; a design counter.
user.purchaseValuable The customer paid for a design pack on your visualizer (amount, currency, pack). A note; a “paying visualizer user” property.
render.createdOptional A design started — the same products[] as render.completed, before the image exists. render.failed is its error twin (the customer was refunded). Skip unless you track attempts.
user.blocked
user.credits_adjusted
Optional An account was put on hold (by the safety layer or your team); free credits were added or removed. Property flags; mostly for support.

Not for the CRM: billing.*, quota.*, catalog.*, network.*, team.changed, api.key_changed, safety.*, data.purged. These are account-operations events — route them to Slack, email or monitoring. The portal’s Email notifications tab can mail most of them with no code at all.

One endpoint, hand-picked list. A hand-picked list only ever receives what you ticked, so a HubSpot relay never has to filter out billing noise. Add a second endpoint on All events if you also want an audit copy in a data warehouse.

A lead arrives — what it looks like, where it goes

lead.created — a homeowner estimate request

{
  "event": "lead.created",
  "event_id": "evt_5697efc51dbfcab109388b",
  "created_at": "2026-09-05T14:10:00+00:00",
  "data": {
    "lead_id": 812,
    "source": "pro_referral",
    "user_type": "homeowner",
    "profession": null,
    "user_id": "c4f1…",
    "name": "Jane Doe",
    "email": "[email protected]",
    "phone": "+1 613 555 0100",
    "business": null,
    "website": null,
    "location": {
      "address": "12 Maple St",
      "city": "Ottawa",
      "region": "ON",
      "postal": "K1A",
      "country": "CA"
    },
    "project": {
      "budget": "$20k–$40k",
      "timeline": "This season",
      "has_contractor": "no",
      "details": "Backyard patio + fire pit"
    },
    "render": {
      "generation_id": "g7e1…",
      "before_url": "https://media.stoneswap.studio/brand/inputs/a91c…jpg",
      "after_url": "https://media.stoneswap.studio/brand/outputs/g7e1…_result.jpg",
      "products": [
        {
          "variation_id": "ABCDEF123456",
          "product_id": "ABC123",
          "product": "Example Paver",
          "color": "Grey",
          "variation": "Random",
          "brand": "Reading Rock"
        }
      ]
    },
    "consent": true,
    "free_credits": null,
    "created_at": "2026-08-22T14:10:00Z"
  }
}

source is one of pro_referral (estimate request), dealer_referral (a professional wants a dealer), web_gate (sign-up form), kiosk (“Email me” in store), free_credit (project form — no design attached; free_credits = credits granted) or api. Professionals carry business and website. render is null when no design is attached; when there is one, render.products is the same list every design event carries.

Email is the stable identifier. The same person can produce user.signup today and lead.created next week — upsert by email and both land on one contact. Always write stoneswap_user_id as well: it is the only key present in user.deleted.

HubSpot contact mapping

Event fieldHubSpot property
data.emailemail — the upsert key
data.namefirstname / lastname (split on the last space)
data.phone, location.*phone; address, city, state, zip, country
data.user_type, professioncustom stoneswap_role, stoneswap_profession (contractor / designer)
data.business, websitecompany, website (professionals)
data.user_idcustom stoneswap_user_id — needed for deletions
data.sourcecustom stoneswap_lead_source; hs_lead_status = NEW
project.budget, project.timeline, project.has_contractorcustom stoneswap_budget, stoneswap_timeline, stoneswap_has_contractor
project.detailsa Note on the contact timeline
render.after_url, products[]custom stoneswap_last_design_url (+ Note), stoneswap_products
data.consentmarketing subscription / hs_legal_basis, per your policy
—lifecyclestage = lead; a Deal named from the details (amount from render.analysis)

Suggested custom properties (create once)

PropertyType
stoneswap_user_idsingle-line text (searchable)
stoneswap_role, stoneswap_profession, stoneswap_lead_source, stoneswap_signup_method, stoneswap_project_type, stoneswap_project_sizedropdown
stoneswap_budget, stoneswap_timeline, stoneswap_has_contractorsingle-line text
stoneswap_material_value, stoneswap_estimated_cost, stoneswap_render_countnumber
stoneswap_last_render_at · stoneswap_last_design_url · stoneswap_productsdate · text (URL) · multi-line text

Sign-ups, deletions and designs

user.signup — an account became active

{
  "event": "user.signup",
  "event_id": "evt_5697efc51dbfcab109388b",
  "created_at": "2026-09-05T14:10:00+00:00",
  "data": {
    "user_id": "c4f1…",
    "email": "[email protected]",
    "name": "Jane Doe",
    "role": "homeowner",
    "profession": null,
    "method": "google",
    "country": "CA",
    "region": "ON",
    "free_renders": 3,
    "created_at": "2026-08-22T14:03:11Z"
  }
}

→ Upsert the contact (email, name, stoneswap_user_id, stoneswap_role, stoneswap_profession, stoneswap_signup_method, country and region) with lifecyclestage = subscriber. method is email, google, facebook or sso. Unverified email sign-ups never fire.

user.deleted — erase your copy

{
  "event": "user.deleted",
  "event_id": "evt_5697efc51dbfcab109388b",
  "created_at": "2026-09-05T14:10:00+00:00",
  "data": {
    "user_id": "c4f1…",
    "email_hash": "sha256…",
    "reason": "self_service",
    "requested_by": "customer",
    "leads_removed": 2,
    "renders_removed": 7,
    "deleted_at": "2026-08-22T14:03:11Z"
  }
}

→ Find the contact by stoneswap_user_id (or compare sha256 of the lowercase email with email_hash) and call HubSpot’s GDPR delete. Reasons: self_service · erasure_request · safety_ban · portal_blacklist · api_blacklist · api_erasure. Every erasure is also listed on the portal’s Deletion requests page, with the delivery status of this very event.

render.analysis — the qualification data

{
  "event": "render.analysis",
  "event_id": "evt_5697efc51dbfcab109388b",
  "created_at": "2026-09-05T14:10:00+00:00",
  "data": {
    "generation_id": "g7e1…",
    "user_id": "c4f1…",
    "title": "Backyard Walkout Patio",
    "project_type": "patio",
    "project_size": "medium",
    "material_low": 6500,
    "material_high": 9200,
    "cost_low": 18000,
    "cost_high": 28000,
    "currency": "USD",
    "keywords": [
      "backyard patio",
      "fire pit",
      "warm grey"
    ],
    "description": "A suburban backyard…",
    "country": "CA",
    "region": "ON",
    "city": "Ottawa",
    "products": [
      {
        "variation_id": "ABCDEF123456",
        "product_id": "ABC123",
        "product": "Example Paver",
        "color": "Grey",
        "variation": "Random",
        "brand": "Reading Rock"
      }
    ],
    "variation_ids": [
      "ABCDEF123456"
    ]
  }
}

→ stoneswap_project_type, stoneswap_project_size, stoneswap_material_value (the midpoint), stoneswap_estimated_cost; deal amount = the material midpoint (what Reading Rock sells) or the installed cost. Look the contact up by user_id — this event carries no email.

render.completed — the design and its products

{
  "event": "render.completed",
  "event_id": "evt_5697efc51dbfcab109388b",
  "created_at": "2026-09-05T14:10:00+00:00",
  "data": {
    "generation_id": "g7e1…",
    "user_id": "c4f1…",
    "role": "homeowner",
    "source": "web",
    "tool": "initial",
    "products": [
      {
        "variation_id": "ABCDEF123456",
        "product_id": "ABC123",
        "product": "Example Paver",
        "color": "Grey",
        "variation": "Random",
        "brand": "Reading Rock"
      }
    ],
    "result_url": "https://media.stoneswap.studio/brand/outputs/g7e1…_result.jpg",
    "before_url": "https://media.stoneswap.studio/brand/inputs/a91c…jpg",
    "share_url": null,
    "completed_at": "2026-08-22T14:04:02Z"
  }
}

→ A Note per design (“New design — view”), stoneswap_render_count +1, stoneswap_last_render_at, and the product names into stoneswap_products for product-interest segmentation. products[] carries catalog ids and names only — product_id matches your own product catalogue. source is web or kiosk; render.created, render.failed, render.analysis and lead.created carry the same products[] — one shape everywhere a design is mentioned. Image URLs are public links on our media host and live as long as your media-retention setting.

The code — verify, de-duplicate, upsert

Node.js (Express)

const crypto = require('crypto');

// Keep the RAW body — JSON parsers change the bytes.
app.post('/webhooks/stoneswap', express.raw({ type: 'application/json' }), async (req, res) => {
  const secret = process.env.STONESWAP_WEBHOOK_SECRET;       // whsec_… from the portal, shown once
  const ts  = req.get('X-SS-Timestamp') || '';
  const sig = req.get('X-SS-Signature') || '';
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300)       // ±5 minutes
    return res.sendStatus(401);
  const expected = crypto.createHmac('sha256', secret).update(ts + '.' + req.body).digest('hex');
  const a = Buffer.from(expected), b = Buffer.from(sig);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b))   // bad signature
    return res.sendStatus(401);

  const evt = JSON.parse(req.body.toString('utf8'));
  if (await seenBefore(evt.event_id))                        // a retry or a replay
    return res.sendStatus(200);
  try {
    await queue.add('stoneswap', evt);                       // store the job first…
    await markSeen(evt.event_id);                            // …then remember its id
  } catch (err) {
    return res.sendStatus(503);                              // not stored — we retry
  }
  res.sendStatus(200);                                       // ack within 10 s; a worker does the CRM work
});

PHP

$secret = getenv('STONESWAP_WEBHOOK_SECRET');
$raw    = file_get_contents('php://input');                  // exact bytes
$ts     = $_SERVER['HTTP_X_SS_TIMESTAMP'] ?? '';
$sig    = $_SERVER['HTTP_X_SS_SIGNATURE'] ?? '';

if (abs(time() - (int)$ts) > 300) {                          // ±5 minutes
    http_response_code(401); exit;
}
$expected = hash_hmac('sha256', $ts . '.' . $raw, $secret);
if (!hash_equals($expected, $sig)) {                         // bad signature
    http_response_code(401); exit;
}
$evt = json_decode($raw, true);                              // event, event_id, created_at, data
// Seen $evt['event_id'] before? Answer 200 and stop. Otherwise store $evt in
// a job queue (or a table) and remember the event_id — answer 503 if that
// fails, so we retry. A worker does the CRM work afterwards.
http_response_code(200);                                     // ack once the job is stored

Both are the receivers the portal shows on Developers → Events. Python, Go, C# and Java have the same primitives: HMAC-SHA256 over timestamp + "." + rawBody, compared in constant time.

HubSpot — upsert the contact by email

POST https://api.hubapi.com/crm/v3/objects/contacts/batch/upsert
Authorization: Bearer <private-app access token>
Content-Type: application/json

{ "inputs": [ {
    "idProperty": "email", "id": "[email protected]",
    "properties": {
      "firstname": "Jane", "lastname": "Doe",
      "phone": "+1 613 555 0100",
      "address": "12 Maple St", "city": "Ottawa",
      "state": "ON", "zip": "K1A", "country": "CA",
      "lifecyclestage": "lead", "hs_lead_status": "NEW",
      "stoneswap_user_id": "c4f1…",
      "stoneswap_role": "homeowner",
      "stoneswap_lead_source": "pro_referral",
      "stoneswap_budget": "$20k–$40k",
      "stoneswap_timeline": "This season",
      "stoneswap_last_design_url": "https://media.stoneswap.studio/…"
} } ] }

HubSpot — a note on the timeline

POST https://api.hubapi.com/crm/v3/objects/notes            // same token
{ "properties": {
    "hs_timestamp": "2026-09-05T14:10:00Z",
    "hs_note_body": "Estimate request — backyard patio + fire pit. Budget $20k–$40k, this season, no contractor. Design: https://media.stoneswap.studio/…" },
  "associations": [ { "to": { "id": "<contact id>" },
    "types": [ { "associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 202 } ] } ] }

HubSpot — honour user.deleted

// 1. find the contact by our id
POST https://api.hubapi.com/crm/v3/objects/contacts/search
{ "filterGroups": [ { "filters": [ {
    "propertyName": "stoneswap_user_id", "operator": "EQ", "value": "c4f1…" } ] } ] }

// 2. permanently delete it (GDPR delete)
POST https://api.hubapi.com/crm/v3/objects/contacts/gdpr-delete
{ "objectId": "<contact id>" }

These are HubSpot’s CRM v3 endpoints as of 2026 — check HubSpot’s current reference for scopes (crm.objects.contacts.write and so on) and rate limits. Deals: POST /crm/v3/objects/deals, associated to the contact.

Salesforce, Pipedrive and Zoho

The relay is the same for every CRM — verify, de-duplicate, store and acknowledge, then call the CRM’s own upsert. Only the last step changes. Check each vendor’s current API reference for versions, scopes and limits.

Salesforce. Create a custom text field StoneSwap_User_Id__c marked External ID on Lead (and Contact, if you convert). Authenticate the relay as a Connected App (OAuth 2.0 client-credentials flow) and upsert with PATCH /services/data/v{api-version}/sobjects/Lead/StoneSwap_User_Id__c/{user_id} — Salesforce creates the record or updates the one with that id. Kiosk captures have no user_id: look them up by email with a SOQL query first (SELECT Id FROM Lead WHERE Email = '…'). For user.deleted, delete the record found by StoneSwap_User_Id__c.

Pipedrive. Add a Person custom field for stoneswap_user_id. The relay searches for the person by email (GET /api/v2/persons/search?term=…&fields=email&exact_match=true), then updates it or creates one (PATCH / POST /api/v2/persons), and files the enquiry as a Lead (POST /v1/leads with the person_id) and a Note. Authenticate with an API token or an OAuth app. For user.deleted, delete the person found by the custom field.

Zoho CRM. Add a custom field StoneSwap_User_Id to Leads. Upsert with POST https://www.zohoapis.com/crm/v8/Leads/upsert and "duplicate_check_fields": ["Email"], authenticated with Authorization: Zoho-oauthtoken … from a self-client or server-based OAuth app — use your data centre’s domain (zohoapis.ca, zohoapis.eu, …). For user.deleted, search by StoneSwap_User_Id and delete the record.

Go-live checklist

If something goes wrong

You seeWhat it means
Delivery log says HTTP 401Your verification rejected us — usually the body was re-encoded before hashing, or the secret belongs to another endpoint.
“Unreachable” / HTTP 0DNS, TLS or a firewall. We resolve your host and require a public address; timeouts are 5 s to connect, 10 s in total.
“destination not allowed”The URL is not a public https host — private addresses and http:// are refused.
Duplicates in the CRMA retry after a late 2xx. De-duplicate on event_id, and upsert by email rather than create.
Missed events while you were downRetries cover about 8 h 36 min. For longer outages, replay from the Events tab (up to 30 days) or pull GET /v1/manage/leads?since=… and /v1/manage/events?since=… with a Management API key.

Privacy — what arrives, what you owe

Events tagged PII carry a real person’s name, email, phone and address. We store those payloads encrypted at rest and only transmit them over HTTPS to endpoints your team configured. Once they are in your CRM they are your records, under your privacy policy.

When a customer erases their account we send user.deleted, ring the portal bell, email your owner address by default, and keep a durable entry on the portal’s Deletion requests page. Deleting the CRM copy is Reading Rock’s obligation under Quebec Law 25, PIPEDA and GDPR — automate it from the event, and use the Deletion requests page (or GET /v1/manage/deletions) as the audit trail that you did.

Consent: lead.created includes the customer’s consent flag from your form. Respect it when enrolling contacts in marketing email.

Prefer polling? The Management API

If a pull model suits your stack better, a portal owner can mint a Management API key (Developers → API; scope manage). Signed requests, 60 calls per minute, 5,000 per day:

GET /v1/manage/leads?since=2026-09-01&limit=200     // your leads, decrypted (cursor-paged)
GET /v1/manage/[email protected]           // one customer, by email or user_id
GET /v1/manage/deletions?since=2026-09-01             // the durable deletion log
GET /v1/manage/events?since=2026-09-01                // the recent event stream (up to 30 days)

Full reference, signing recipe and the customer object: readingrock.studio/developers.

Questions, or a pairing session?

We are happy to get on a call with your developers, watch the first deliveries land, and add an event or a payload field if your CRM needs something we do not send yet.

StoneSwap Inc. · Hamilton, Ontario · [email protected]