Developers

Webhooks

Everything that happens on permacon.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

  1. Open Developers → WebhooksSign in at permacon.studio/portal as an owner or admin (every other role can read the page, not change it).
  2. 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.
  3. 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.
  4. Copy the signing secret — it is shown onceA whsec_… string. Put it in your secrets manager (for example as STONESWAP_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.
  5. Press Send test eventWe POST a webhook.test event with no business data. The message tells you the HTTP code your endpoint answered.
  6. 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

PartValue
MethodHTTPS POST, JSON body
Content-Typeapplication/json
User-AgentStoneSwap-Studio-Webhooks/1.0
X-SS-EventThe event name, e.g. lead.created
X-SS-Event-Idevt_… — the same on every retry and replay; your de-duplication key
X-SS-TimestampUnix seconds when this attempt was signed
X-SS-Signaturehex( 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 Permacon portal only ever receives Permacon’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

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

36 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

EventWhen 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.
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; renders 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 credits adjusted Free render credits were added to (or taken from) a customer by your team, the Management API, or the free-credit project form. delta is signed; balance is the customer's free balance afterwards.
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": "free_credit_form",
    "by": "system",
    "adjusted_at": "2026-08-22T14:03:11Z"
  }
}
user.purchaseCustomer bought credits A customer paid for a render pack on your visualizer (your own Stripe account). 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"
  }
}

Renders

EventWhen it fires
render.createdNew render started A render 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": "Permacon"
      }
    ],
    "funding": "quota",
    "created_at": "2026-08-22T14:03:11Z"
  }
}
render.completedRender finished The AI image is ready. URLs are public image links (media is retained per your retention setting). products lists what was rendered — 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": "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"
  }
}
render.failedRender failed A render ended in error (the user was refunded; 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": "Permacon"
      }
    ],
    "reason": "generation_failed",
    "failed_at": "2026-08-22T14:04:02Z"
  }
}
render.analysisProject insight ready The background analysis of a finished render: title, project type, keywords, hardscape material value (USD), total installed estimate (USD), size, description — plus the products in the design (ids and names).
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": 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"
    ]
  }
}

Forms

EventWhen it fires
lead.createdPIIForm completed (lead) A homeowner estimate request, a professional's dealer request, design-gate signup form, kiosk "Email me" capture, or the free-credit project form (source free_credit — no render attached, free_credits = credits granted). The email is the stable identifier for updating your CRM.
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": "$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"
  }
}

Safety

EventWhen it fires
safety.soft_blockSoft block (render refused) A render 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

EventWhen it fires
quota.warningRender 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.exhaustedRender quota full The monthly quota is used up. Extra renders (if any) are being used now; with none left, new renders 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 renders purchased Extra renders were bought (they never expire and are used after the quota).
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 render 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 renders running low Your prepaid render balance fell below the low-balance line — threshold, in renders (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 renders cost. renders_paused true = your plan STOPS rendering at zero (overage_rate is null); false = renders continue at overage_rate per render 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 renders used up The prepaid render balance reached zero (sent the moment it happens, once per purchase cycle). renders_paused true = your plan stops rendering until a further purchase is recorded — customers see your "We'll be back" message; false = renders continue and are invoiced monthly at overage_rate per render.
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": "admin:[email protected]",
    "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

EventWhen 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). Renders 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 own (your visualizer, API and hosted kiosks), public (dealer network), contractors (the professional network) or brands (other manufacturers' visualizers). Per-colour / 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

EventWhen 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

EventWhen 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

EventWhen it fires
data.purgedRetention purge summary Your retention settings removed records today: leads, project insights, renders/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

EventWhen 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

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 Permacon are delivered by StoneSwap Inc. · Hamilton, Ontario.