Webhooks
Everything that happens on rcpblock.studio — a homeowner or professional creating an account, sending a project form, finishing a design — and every change to your account (billing, catalog, team) becomes an event. Subscribe an HTTPS endpoint to the events you care about and we POST each one to it as signed JSON, usually within a minute. If your side is down we keep retrying for about 8 h 36 min.
Connecting a CRM? Read this page for the mechanics, then CRM & HubSpot for which events to use and how to map them.
Set it up in six steps
- Open Developers → WebhooksSign in at
rcpblock.studio/portalas an owner or admin (every other role can read the page, not change it). - Press Add endpointGive it a name (for example “CRM sync”) and paste your HTTPS URL. One endpoint per environment — staging, production — is a good habit. You can have up to 20.
- Pick the eventsTick the ones you want, or switch on All events — that endpoint then also receives new event types we add later. A hand-picked list only ever gets what you ticked. Press Create endpoint.
- Copy the signing secret — it is shown onceA
whsec_…string. Put it in your secrets manager (for example asSTONESWAP_WEBHOOK_SECRET), then press I’ve saved it. Lost it? Rotate secret on the endpoint card mints a new one, and deliveries switch to it immediately. - Press Send test eventWe POST a
webhook.testevent with no business data. The message tells you the HTTP code your endpoint answered. - Open the Delivery logEvery attempt with its response code, error and payload. Retry now re-sends a delivery that has not succeeded. The Events tab lists what your visualizer produced recently, and Replay to endpoint re-sends any of them — build against real leads and designs without waiting for new ones.
What every delivery looks like
| Part | Value |
|---|---|
| Method | HTTPS POST, JSON body |
Content-Type | application/json |
User-Agent | StoneSwap-Studio-Webhooks/1.0 |
X-SS-Event | The event name, e.g. lead.created |
X-SS-Event-Id | evt_… — the same on every retry and replay; your de-duplication key |
X-SS-Timestamp | Unix seconds when this attempt was signed |
X-SS-Signature | hex( HMAC-SHA256( secret, timestamp + "." + rawBody ) ) |
The body is always the same four-key envelope:
{
"event": "lead.created", // which event — also in X-SS-Event
"event_id": "evt_5697efc51dbfcab109388b", // unique, stable across retries — your de-dupe key
"created_at": "2026-09-05T14:10:00+00:00", // when it happened (UTC, ISO 8601)
"data": { … } // the event's fields — see the table below
}
There is no account or tenant key: an endpoint set up in the RCP Block & Brick portal only ever receives RCP Block & Brick’s own events.
Verify the signature
Hash the raw bytes we sent — parsing the JSON and encoding it again changes them. Reject a
timestamp more than five minutes off, compare in constant time, and answer 401 when the check
fails. These are the same receivers the portal shows on Developers → Events.
PHP
$secret = getenv('STONESWAP_WEBHOOK_SECRET'); // whsec_… (from the portal, shown once)
$raw = file_get_contents('php://input'); // the EXACT body — do not re-encode
$ts = $_SERVER['HTTP_X_SS_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_SS_SIGNATURE'] ?? '';
if (abs(time() - (int)$ts) > 300) { http_response_code(401); exit; } // ±5 minutes
$expected = hash_hmac('sha256', $ts . '.' . $raw, $secret);
if (!hash_equals($expected, $sig)) { http_response_code(401); exit; } // bad signature
$event = json_decode($raw, true); // $event['event'], ['event_id'], ['created_at'], ['data']
// Retries resend the same event_id — store it and skip duplicates, then do your work.
http_response_code(200); // answer 2xx within 10 seconds (queue slow work)
Node.js (Express)
const crypto = require('crypto');
// Express: keep the RAW body for this route (JSON parsers change the bytes).
app.post('/webhooks/stoneswap', express.raw({ type: 'application/json' }), (req, res) => {
const secret = process.env.STONESWAP_WEBHOOK_SECRET; // whsec_…
const ts = req.get('X-SS-Timestamp') || '';
const sig = req.get('X-SS-Signature') || '';
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(401); // ±5 minutes
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)) return res.sendStatus(401);
const event = JSON.parse(req.body.toString('utf8')); // event.event, event.event_id, event.data
// Retries resend the same event_id — store it and skip duplicates, then do your work.
res.sendStatus(200); // answer 2xx within 10 seconds
});
Python, Go, C# and Java have the same primitives: HMAC-SHA256 over timestamp + "." + rawBody
with your whsec_… secret as the key, hex-encoded, compared in constant time.
Answer fast — we retry the rest
- Success is any 2xx within 10 seconds (5 seconds to connect). Store the event (a queue or a table), answer, and do slow work afterwards. Once you answer 2xx we do not retry that delivery, so answer 2xx only after the event is safely stored.
- Everything else is a failure — a 3xx (we do not follow redirects), a 4xx, a 5xx, a timeout or an unreachable host.
- Retries: the first try is immediate; after a failure we try again 1 min, 5 min, 30 min, 2 h and 6 h later — 6 attempts, the last one about 8 h 36 min after the first. Then the delivery is marked failed; Retry now in the Delivery log re-sends it for as long as it is listed there (up to 30 days, less if your data retention setting is shorter).
- Order is not guaranteed. Use
created_atfrom the body, not arrival time. - Destinations must be public HTTPS hosts. Private or internal addresses are refused (the log says “destination not allowed”). Tunnels such as ngrok are fine while you test.
- A disabled endpoint receives nothing; deliveries already queued for it fail as “endpoint disabled”.
Handle duplicates
A retry after a slow 2xx, a Retry now and a Replay to endpoint all resend the same
event_id. Store every event_id you have processed (a table, or a cache with a 24-hour
lifetime) and answer 200 to repeats without doing the work again. Update records by a stable key
(the customer’s email, or user_id) rather than creating new ones.
The events
37 event types, grouped as in the portal. Events marked PII carry a person’s name, email or phone; we store those payloads encrypted and send them only over HTTPS to endpoints your team configured. Open an example to see the full body.
Users
| Event | When it fires |
|---|---|
user.signupPIINew user signup |
A homeowner or professional account became ACTIVE (email verified, or signed in with Google/Facebook/SSO). Unverified email signups never fire. method is email, google, facebook or sso (chatgpt once Sign in with ChatGPT is switched on for your visualizer). free_renders is the number of free designs the account received. Example{
"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"
}
} |
user.deletedPIIAccount deleted / erased |
A customer account was erased — by the customer (self_service), on their written request (erasure_request), by the safety layer (safety_ban), or by your team / API (portal_blacklist, api_blacklist, api_erasure). Only the opaque user id and a SHA-256 of the email remain. IF YOU KEEP COPIES OF THIS CUSTOMER'S DATA OUTSIDE STONESWAP (CRM, email tool, spreadsheets) YOU MUST DELETE THEM NOW — Quebec Law 25 / PIPEDA / GDPR. Every erasure is also listed on the Deletion requests page. Example{
"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"
}
} |
user.blockedAccount blocked (quarantine) |
A customer account was put on hold — by the safety layer (quarantine, no erasure), by your team on the Users page, or through the Management API. The account keeps its data; new designs and sign-ins stop until it is unblocked. Example{
"event": "user.blocked",
"event_id": "evt_5697efc51dbfcab109388b",
"created_at": "2026-09-05T14:10:00+00:00",
"data": {
"user_id": "c4f1…",
"reason": "safety_quarantine",
"stage": "prompt",
"source": "web",
"by": "safety",
"blocked_at": "2026-08-22T14:03:11Z"
}
} |
user.credits_adjustedCustomer designs adjusted |
Free designs were added to (or taken from) a customer by your team or the Management API. delta is signed; balance is the customer's free designs afterwards. When a customer who had no designs left receives some, they also get a "new designs are waiting" e-mail (unless they turned e-mails off). Example{
"event": "user.credits_adjusted",
"event_id": "evt_5697efc51dbfcab109388b",
"created_at": "2026-09-05T14:10:00+00:00",
"data": {
"user_id": "c4f1…",
"delta": 2,
"balance": 5,
"kind": "free",
"reason": "Goodwill credit",
"by": "portal:[email protected]",
"adjusted_at": "2026-08-22T14:03:11Z"
}
} |
user.purchaseCustomer bought designs |
A customer paid for a pack of designs on your visualizer (your own Stripe account). renders is the number of designs in the pack. Amounts are what the customer paid; no card details. Example{
"event": "user.purchase",
"event_id": "evt_5697efc51dbfcab109388b",
"created_at": "2026-09-05T14:10:00+00:00",
"data": {
"user_id": "c4f1…",
"pack": "pack_10",
"renders": 10,
"amount": 19,
"currency": "USD",
"purchased_at": "2026-08-22T14:03:11Z"
}
} |
Designs
| Event | When it fires |
|---|---|
render.createdNew design started |
A design was submitted and paid for (quota/extra/dealer wallet). Fires before the image exists. Example{
"event": "render.created",
"event_id": "evt_5697efc51dbfcab109388b",
"created_at": "2026-09-05T14:10:00+00:00",
"data": {
"generation_id": "g7e1…",
"user_id": "c4f1…",
"role": "contractor",
"source": "web",
"tool": "initial",
"dealer_id": null,
"kiosk_id": null,
"products": [
{
"variation_id": "ABCDEF123456",
"product_id": "ABC123",
"product": "Example Paver",
"color": "Grey",
"variation": "Random",
"brand": "RCP Block & Brick"
}
],
"funding": "quota",
"created_at": "2026-08-22T14:03:11Z"
}
} |
render.completedDesign finished |
The AI image is ready. URLs are public image links (media is retained per your retention setting). products lists what is in the design — catalog ids and names only. share_url is null until the user shares the design. Example{
"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": "RCP Block & Brick"
}
],
"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"
}
} |
render.failedDesign failed |
A design ended in error (the user got the design back; your quota was refunded too). Example{
"event": "render.failed",
"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": "RCP Block & Brick"
}
],
"reason": "generation_failed",
"failed_at": "2026-08-22T14:04:02Z"
}
} |
render.analysisProject insight ready |
The background analysis of a finished design: title, project type, project size (small / medium / large), keywords, description — plus the products in the design (ids and names). DEPRECATED: material_low, material_high, cost_low, cost_high and currency are always null now (dollar estimates are no longer made); the keys stay in the payload for one release so existing CRM mappings do not break. Example{
"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": null,
"material_high": null,
"cost_low": null,
"cost_high": null,
"currency": null,
"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": "RCP Block & Brick"
}
],
"variation_ids": [
"ABCDEF123456"
]
}
} |
render.favoritedDesign favourited |
A customer hearted (favorited true) or un-hearted (favorited false) one of their own designs. Fires once per real change; favorited_at is null when the heart was removed. products lists the catalog ids and names in the design. Example{
"event": "render.favorited",
"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": "RCP Block & Brick"
}
],
"favorited": true,
"favorited_at": "2026-10-06T14:04:02+00:00",
"country": "CA",
"region": "ON",
"city": "Ottawa"
}
} |
Leads
| Event | When it fires |
|---|---|
lead.createdPIINew lead |
A homeowner estimate request, a professional's dealer request, a kiosk "Email me" capture or an API lead; for webhooks also a design-gate sign-up (source web_gate). free_credits is always null now (it was only set by the retired free-credit project form); project.budget is null on new leads (the budget question is retired); project.size is small | medium | large — the customer's own size answer when they gave one, else the size analysed from the attached design — or null. The email is the stable identifier for updating your CRM. Lead e-mails: the addresses you list for this event get one full lead e-mail per estimate, dealer, showroom-device or API lead (name, contact details, project size, products and a brief link); design-gate signups are shown in the bell only, never e-mailed. Example{
"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": null,
"size": "medium",
"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": "RCP Block & Brick"
}
]
},
"consent": true,
"free_credits": null,
"created_at": "2026-08-22T14:10:00Z"
}
} |
Safety
| Event | When it fires |
|---|---|
safety.soft_blockSoft block (design refused) |
A design was refused as junk/off-topic/unsafe. The user keeps their account. Example{
"event": "safety.soft_block",
"event_id": "evt_5697efc51dbfcab109388b",
"created_at": "2026-09-05T14:10:00+00:00",
"data": {
"user_id": "c4f1…",
"stage": "first_render",
"source": "web",
"category": "off_topic",
"at": "2026-08-22T14:03:11Z"
}
} |
safety.hard_blockHard block (user banned) |
A user was banned and erased for malicious use; their email/device are blacklisted. Example{
"event": "safety.hard_block",
"event_id": "evt_5697efc51dbfcab109388b",
"created_at": "2026-09-05T14:10:00+00:00",
"data": {
"user_id": "c4f1…",
"stage": "prompt",
"source": "web",
"category": "malicious_content",
"at": "2026-08-22T14:03:11Z"
}
} |
Billing
| Event | When it fires |
|---|---|
quota.warningDesign quota above threshold |
Your monthly quota passed 80% / 90% (one event per threshold per period). Example{
"event": "quota.warning",
"event_id": "evt_5697efc51dbfcab109388b",
"created_at": "2026-09-05T14:10:00+00:00",
"data": {
"threshold": 90,
"used": 452,
"limit": 500,
"extra_balance": 40,
"resets_at": "2026-09-01"
}
} |
quota.exhaustedDesign quota full |
The monthly quota is used up. Extra designs (if any) are being used now; with none left, new designs pause until you buy extras, upgrade, or the quota resets. Example{
"event": "quota.exhausted",
"event_id": "evt_5697efc51dbfcab109388b",
"created_at": "2026-09-05T14:10:00+00:00",
"data": {
"used": 500,
"limit": 500,
"extra_balance": 0,
"renders_paused": true,
"resets_at": "2026-09-01"
}
} |
quota.resetQuota reset (new period) |
A successful renewal started a new billing period and reset the quota. Example{
"event": "quota.reset",
"event_id": "evt_5697efc51dbfcab109388b",
"created_at": "2026-09-05T14:10:00+00:00",
"data": {
"plan": "keystone",
"limit": 500,
"period_start": "2026-09-01",
"period_end": "2026-09-30"
}
} |
billing.renewedSubscription renewed |
The monthly plan fee was charged successfully. Example{
"event": "billing.renewed",
"event_id": "evt_5697efc51dbfcab109388b",
"created_at": "2026-09-05T14:10:00+00:00",
"data": {
"plan": "keystone",
"amount": 449,
"currency": "USD",
"period_start": "2026-09-01",
"period_end": "2026-09-30"
}
} |
billing.payment_failedPayment failed |
A renewal charge failed. Update the card in Billing; we retry daily during the grace period. Example{
"event": "billing.payment_failed",
"event_id": "evt_5697efc51dbfcab109388b",
"created_at": "2026-09-05T14:10:00+00:00",
"data": {
"plan": "keystone",
"amount": 449,
"currency": "USD",
"error": "card_declined",
"grace_until": "2026-09-04T00:00:00Z"
}
} |
billing.plan_changedPlan changed |
An upgrade took effect immediately, or a downgrade/cancellation was scheduled or applied. Example{
"event": "billing.plan_changed",
"event_id": "evt_5697efc51dbfcab109388b",
"created_at": "2026-09-05T14:10:00+00:00",
"data": {
"from": "keystone",
"to": "capstone",
"change": "upgrade",
"effective": "now",
"amount_paid": 350,
"quota_limit": 1000
}
} |
billing.extra_purchasedExtra designs purchased |
Extra designs were bought (they are used after the quota; on older plans they never expire, on plans started from October 2026 they expire 6 months after purchase). Example{
"event": "billing.extra_purchased",
"event_id": "evt_5697efc51dbfcab109388b",
"created_at": "2026-09-05T14:10:00+00:00",
"data": {
"qty": 100,
"amount": 100,
"currency": "USD",
"extra_balance": 140
}
} |
billing.invoice_issuedInvoice issued |
An invoice is ready — a monthly usage invoice, a prepaid design commitment, or a setup / support invoice. kind says which; due_at is the payment date (net-30 for commitment and setup invoices, 72 hours for usage). Payable in the portal by card when eligible, otherwise by bank transfer. Example{
"event": "billing.invoice_issued",
"event_id": "evt_5697efc51dbfcab109388b",
"created_at": "2026-09-05T14:10:00+00:00",
"data": {
"invoice_id": 41,
"number": "SS-1-260901-41",
"kind": "usage",
"period_start": "2026-08-01",
"period_end": "2026-08-31",
"total": 8999,
"currency": "USD",
"due_at": "2026-10-01T23:59:59Z"
}
} |
billing.invoice_voidedInvoice voided |
StoneSwap cancelled an invoice (it is no longer payable; a prepaid draw it carried is restored). Example{
"event": "billing.invoice_voided",
"event_id": "evt_5697efc51dbfcab109388b",
"created_at": "2026-09-05T14:10:00+00:00",
"data": {
"invoice_id": 41,
"number": "SS-1-260901-41",
"kind": "usage",
"total": 0,
"currency": "USD",
"reason": "pre-launch test period",
"voided_at": "2026-09-02T10:00:00Z"
}
} |
billing.commit_lowPrepaid designs running low |
Your prepaid design balance fell below the low-balance line — threshold, in designs (1,000 unless StoneSwap set another number for you); one event per purchase cycle, sent the moment it happens. remaining is the live balance, rate is what the prepaid designs cost. renders_paused true = your plan STOPS making designs at zero (overage_rate is null); false = designs continue at overage_rate per design on the monthly invoice. Example{
"event": "billing.commit_low",
"event_id": "evt_5697efc51dbfcab109388b",
"created_at": "2026-09-05T14:10:00+00:00",
"data": {
"remaining": 940,
"total": 150000,
"threshold": 1000,
"rate": 0.330000000000000015543122344752191565930843353271484375,
"overage_rate": null,
"renders_paused": true,
"currency": "USD"
}
} |
billing.commit_exhaustedPrepaid designs used up |
The prepaid design balance reached zero (sent the moment it happens, once per purchase cycle). renders_paused true = your plan stops making designs until a further purchase is recorded — customers see your "We'll be back" message; false = designs continue and are invoiced monthly at overage_rate per design. Example{
"event": "billing.commit_exhausted",
"event_id": "evt_5697efc51dbfcab109388b",
"created_at": "2026-09-05T14:10:00+00:00",
"data": {
"remaining": 0,
"total": 150000,
"threshold": 1000,
"rate": 0.330000000000000015543122344752191565930843353271484375,
"overage_rate": null,
"renders_paused": true,
"currency": "USD"
}
} |
billing.invoice_overdueInvoice overdue |
An issued invoice passed its due date unpaid. Example{
"event": "billing.invoice_overdue",
"event_id": "evt_5697efc51dbfcab109388b",
"created_at": "2026-09-05T14:10:00+00:00",
"data": {
"invoice_id": 41,
"number": "SS-1-202608",
"total": 8999,
"currency": "USD",
"due_at": "2026-09-04T00:00:00Z"
}
} |
billing.invoice_paidInvoice paid |
An invoice was paid. Example{
"event": "billing.invoice_paid",
"event_id": "evt_5697efc51dbfcab109388b",
"created_at": "2026-09-05T14:10:00+00:00",
"data": {
"invoice_id": 41,
"number": "SS-1-202608",
"total": 8999,
"currency": "USD",
"method": "wire",
"paid_by": "StoneSwap",
"paid_at": "2026-09-02T10:00:00Z"
}
} |
billing.integration_purchasedProduct integration paid |
The one-time integration fee for a hosted-product request was paid. Example{
"event": "billing.integration_purchased",
"event_id": "evt_5697efc51dbfcab109388b",
"created_at": "2026-09-05T14:10:00+00:00",
"data": {
"request_id": 7,
"variations": 10,
"amount": 100,
"currency": "USD",
"paid_by": "[email protected]"
}
} |
Catalog
| Event | When it fires |
|---|---|
catalog.requestPIIHosting request sent |
Your team asked StoneSwap to host a new product (Catalog → Host a Product). One request = one product with its colours and variations; the setup fee is invoiced at once (fee, currency) and the request moves to "in review" when it is paid. Example{
"event": "catalog.request",
"event_id": "evt_5697efc51dbfcab109388b",
"created_at": "2026-09-05T14:10:00+00:00",
"data": {
"request_id": 3,
"product": "Example Paver",
"category": "Paver",
"variations": 6,
"fee": 60,
"currency": "USD",
"requested_by": "[email protected]"
}
} |
catalog.request_statusHosting request updated |
StoneSwap moved one of your product hosting requests: in_progress (integration started), done (live — live_product_id is the new catalog id), declined, or cancelled. Example{
"event": "catalog.request_status",
"event_id": "evt_5697efc51dbfcab109388b",
"created_at": "2026-09-05T14:10:00+00:00",
"data": {
"request_id": 7,
"product": "Example Paver",
"status": "done",
"note": "Live — 6 variations",
"live_product_id": "ABC123"
}
} |
catalog.removedProduct removed from your visualizer |
Your team removed one of your own products from the catalog (every surface: your visualizer, the dealer and contractor networks, other brands' visualizers). Designs that used it are untouched. Example{
"event": "catalog.removed",
"event_id": "evt_5697efc51dbfcab109388b",
"created_at": "2026-09-05T14:10:00+00:00",
"data": {
"product_id": "ABC123",
"product": "Example Paver",
"variations": 6,
"removed_by": "[email protected]"
}
} |
catalog.visibilityProduct visibility changed |
A product-level visibility switch was flipped. scope is own (your visualizer, API and showroom devices), public (the network: other brands' and dealers' visualizers and their showroom devices), stoneswap (StoneSwap.ai), brands (other manufacturers' visualizers, opt-in) or one audience token (own_home, own_pro, own_showroom, net_home, net_pro, net_showroom); surface (own | network | stoneswap | brands) and audience (home | pro | showroom | empty) say which. Per-color / per-variation edits are not emitted individually — level says which. Example{
"event": "catalog.visibility",
"event_id": "evt_5697efc51dbfcab109388b",
"created_at": "2026-09-05T14:10:00+00:00",
"data": {
"product_id": "ABC123",
"product": "Example Paver",
"scope": "public",
"enabled": false,
"level": "product",
"items": 1,
"changed_by": "[email protected]"
}
} |
Team
| Event | When it fires |
|---|---|
team.changedPIITeam member added / changed / removed |
A portal login was invited, changed role, disabled or removed. Example{
"event": "team.changed",
"event_id": "evt_5697efc51dbfcab109388b",
"created_at": "2026-09-05T14:10:00+00:00",
"data": {
"action": "invited",
"email": "[email protected]",
"role": "billing",
"by": "[email protected]"
}
} |
Network
| Event | When it fires |
|---|---|
network.access_requestedAccess request from a web property |
A dealer, contractor or brand asked to show your products in their visualizer (your Network page is set to "Ask me first"). Approve or decline it on the Network page. Example{
"event": "network.access_requested",
"event_id": "evt_5697efc51dbfcab109388b",
"created_at": "2026-09-05T14:10:00+00:00",
"data": {
"request_id": 12,
"tenant_name": "Example Dealer Supply",
"tenant_type": "dealer",
"domain": "example-dealer.studio",
"message": "We stock your products in our showroom.",
"requested_at": "2026-09-02T14:10:00Z"
}
} |
network.access_changedBrand access changed |
A brand approved, declined, blocked or removed YOUR access to its products (delivered to the web property, not the brand). status: approved | denied | blocked | removed. Example{
"event": "network.access_changed",
"event_id": "evt_5697efc51dbfcab109388b",
"created_at": "2026-09-05T14:10:00+00:00",
"data": {
"brand": "Example Brand",
"brand_slug": "example-brand",
"status": "blocked",
"note": "",
"changed_at": "2026-09-02T14:10:00Z"
}
} |
Compliance
| Event | When it fires |
|---|---|
data.purgedRetention purge summary |
Your retention settings removed records today: leads, project insights, designs/photos, idle customer profiles. Counts only — the records are gone. Copies you keep elsewhere are yours to delete. Example{
"event": "data.purged",
"event_id": "evt_5697efc51dbfcab109388b",
"created_at": "2026-09-05T14:10:00+00:00",
"data": {
"day": "2026-09-02",
"leads_removed": 3,
"renders_removed": 12,
"media_removed": 40,
"profiles_stripped": 1,
"retention_days": {
"data": 90,
"media": 30
}
}
} |
Developers
| Event | When it fires |
|---|---|
api.key_changedAPI key created / rotated / revoked |
A Management or Engine API key was created, rotated or revoked (by your team in the portal, or by StoneSwap). The secret itself is never in the payload. Example{
"event": "api.key_changed",
"event_id": "evt_5697efc51dbfcab109388b",
"created_at": "2026-09-05T14:10:00+00:00",
"data": {
"key_id": "key_7f3k2q",
"action": "created",
"scope": "manage",
"by": "[email protected]",
"changed_at": "2026-09-02T14:10:00Z"
}
} |
webhook.testWebhook test ping |
Sent when your team presses "Test" on an endpoint. Carries no business data — answer 2xx to prove the endpoint and its signature check work. Example{
"event": "webhook.test",
"event_id": "evt_5697efc51dbfcab109388b",
"created_at": "2026-09-05T14:10:00+00:00",
"data": {
"webhook_id": 3,
"endpoint": "HubSpot relay",
"sent_by": "[email protected]",
"sent_at": "2026-09-02T14:10:00Z"
}
} |
What never arrives
Payloads carry catalog ids and names, public image links and closed reason codes. They never carry product descriptions, reference imagery, matching keywords, model names, compute cost, the render engine’s raw error text or the safety model’s verdict. Dealer kiosk applications and visualizer access applications are notifications only (bell and email) — they are never sent to an endpoint.
Testing tips
- Send test event posts
webhook.testto one endpoint only:{"webhook_id": 3, "endpoint": "HubSpot relay", "sent_by": "[email protected]", "sent_at": "…"}. It proves reachability and your signature check; it carries no business data and never appears in the Events tab. - Tamper on purpose: change one byte of the body in your test and make sure your endpoint
answers
401. - Replay real events from the Events tab while you build, and check that a second replay does not create a second record.
- Watch the Delivery log for the first week. Three failures in a row show a warning on the endpoint card.
Prefer pulling?
The Management API gives the same data on request:
GET /v1/manage/events?since=… is the recent event stream (up to 30 days),
GET /v1/manage/leads your leads, GET /v1/manage/deletions the durable deletion log.
A portal owner mints the key on Developers → API. Use it to catch up after an outage longer than the
retry window.
Two webhook systems, one signing scheme. This page covers the portal webhooks — activity
on your visualizer. The Render Engine API also posts
render.completed for server-to-server render jobs, with its own retry schedule; the
verification code is identical.
Questions, or a pairing session with your developers: [email protected]. Webhooks for RCP Block & Brick are delivered by StoneSwap Inc. · Hamilton, Ontario.