CRM & HubSpot
How the Permacon Design Studio on permacon.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 permacon.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.
- Your visualizer — permacon.studioThe web visualizer, share pages, project forms and in-store kiosks.
- The event spineEvery event is logged, signed and queued. Failed deliveries retry automatically for about 8 h 36 min.
- Your relay endpointVerifies the signature, de-duplicates on
event_id, stores the event and answers 2xx, then maps it to your CRM. - Your CRMContacts upserted by email, timeline notes, deals, lists and workflows — all in your own account.
Three ways to land events in HubSpot
| Option | What 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
- A portal login with the owner or admin role —
permacon.studio/portal. - A public HTTPS endpoint with a valid certificate and no credentials in the URL. Tunnels such as ngrok are fine while testing.
- A HubSpot private-app access token with contact (and optionally deal and note) read-write scopes.
- A secrets manager for the signing secret (
whsec_…) — it is shown once.
| Where | What |
|---|---|
| Portal | permacon.studio/portal → Developers → Webhooks |
| Live examples | Developers → Events — real events from your visualizer, with payloads |
| API reference | permacon.studio/developers — Render Engine API and Management API |
| Endpoints | Up 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
- Open Developers → WebhooksSign in at
permacon.studio/portalas an owner or admin and press Add endpoint. - 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. - 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.
- 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. - Press Send test eventWe POST a
webhook.testevent with no business data. The message shows the HTTP code your endpoint answered; the Delivery log keeps every attempt, response code and error. - 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)
- 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. - Verify the signature and timestamp
X-SS-Signaturemust equalhex(HMAC-SHA256(secret, timestamp + "." + rawBody)); reject anX-SS-Timestampmore than 5 minutes off. Code below. - 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. - 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.
- Map to HubSpotUpsert the contact by
email, store ouruser_idon the contact, add notes and deals. Mapping tables below.
Delivery contract
| Part | Value |
|---|---|
| Method | HTTPS POST · Content-Type: application/json |
| User-Agent | StoneSwap-Studio-Webhooks/1.0 |
| Headers | X-SS-Event · X-SS-Event-Id · X-SS-Timestamp · X-SS-Signature |
| Success | Any 2xx within 10 s |
| Retries | 1 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 |
| Order | Not 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.
| Event | Priority | What it tells your CRM | Suggested action |
|---|---|---|---|
lead.createdPII | Must 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.signupPII | Must 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.deletedPII | Must 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.analysis | Valuable | 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.completed | Valuable | 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.purchase | Valuable | The customer paid for a design pack on your visualizer (amount, currency, pack). | A note; a “paying visualizer user” property. |
render.created | Optional | 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.blockeduser.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": "Permacon"
}
]
},
"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 field | HubSpot property |
|---|---|
data.email | email — the upsert key |
data.name | firstname / lastname (split on the last space) |
data.phone, location.* | phone; address, city, state, zip, country |
data.user_type, profession | custom stoneswap_role, stoneswap_profession (contractor / designer) |
data.business, website | company, website (professionals) |
data.user_id | custom stoneswap_user_id — needed for deletions |
data.source | custom stoneswap_lead_source; hs_lead_status = NEW |
project.budget, project.timeline, project.has_contractor | custom stoneswap_budget, stoneswap_timeline, stoneswap_has_contractor |
project.details | a Note on the contact timeline |
render.after_url, products[] | custom stoneswap_last_design_url (+ Note), stoneswap_products |
data.consent | marketing subscription / hs_legal_basis, per your policy |
| — | lifecyclestage = lead; a Deal named from the details (amount from render.analysis) |
Suggested custom properties (create once)
| Property | Type |
|---|---|
stoneswap_user_id | single-line text (searchable) |
stoneswap_role, stoneswap_profession, stoneswap_lead_source, stoneswap_signup_method, stoneswap_project_type, stoneswap_project_size | dropdown |
stoneswap_budget, stoneswap_timeline, stoneswap_has_contractor | single-line text |
stoneswap_material_value, stoneswap_estimated_cost, stoneswap_render_count | number |
stoneswap_last_render_at · stoneswap_last_design_url · stoneswap_products | date · 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": "Permacon"
}
],
"variation_ids": [
"ABCDEF123456"
]
}
}
→ stoneswap_project_type, stoneswap_project_size, stoneswap_material_value
(the midpoint), stoneswap_estimated_cost; deal amount = the material midpoint (what
Permacon 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": "Permacon"
}
],
"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
- The endpoint is public HTTPS with a valid certificate; no credentials in the URL.
- The signature and the 5-minute timestamp window are verified on the raw body; a tampered test returns 401.
event_idde-duplication is in place — a Replay to endpoint from the portal does not create a second contact or note.- The endpoint stores the event, then answers 2xx in under 10 s even when the CRM is slow (queue the CRM call); it answers 5xx when the event could not be stored.
- Send test event shows HTTP 200 in the Delivery log.
- You replayed a real
lead.created,user.signup,render.analysisanduser.deletedfrom the Events tab and checked the CRM record each time. stoneswap_user_idis written on every contact, so deletions can be matched.- The signing secret lives in a secrets manager, not in code, and you know where Rotate secret is.
- Someone watches the Delivery log for the first week. Failed deliveries can be retried by hand while they are listed (up to 30 days, less if your data retention setting is shorter).
- Staging and production use separate endpoints and separate CRM portals or test accounts.
If something goes wrong
| You see | What it means |
|---|---|
| Delivery log says HTTP 401 | Your verification rejected us — usually the body was re-encoded before hashing, or the secret belongs to another endpoint. |
| “Unreachable” / HTTP 0 | DNS, 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 CRM | A retry after a late 2xx. De-duplicate on event_id, and upsert by email rather than create. |
| Missed events while you were down | Retries 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 Permacon’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: permacon.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]