Webhooks

Webhooks push events from Stardraw Cloud into your own systems the moment they happen — a project is created, a snapshot is taken, a user registers — so you can sync a CRM, raise an ERP order or kick off a build without polling the API. Each event is a signed JSON POST to an HTTPS endpoint you register, using the open Standard Webhooks format.

How it works

An application administrator registers up to 16 HTTPS endpoints on the admin Webhooks page (/admin/webhooks, which needs the Admin Webhooks privilege; making changes needs Admin Webhooks r/w) — or programmatically through the Admin API (POST /api/v1/admin/webhooks). Each endpoint subscribes to one or more event types, or to "*" for every current and future type. When something happens, Stardraw Cloud POSTs a signed JSON envelope to every subscribed endpoint.

Delivery is at-least-once with no ordering guarantee: a delivery that fails is retried (see below), so your receiver must treat a repeated webhook-id as a duplicate and must not assume that events arrive in the order they happened.

Event catalog

Event types are dot-delimited resource.action names. The live list is also served by GET /api/v1/admin/webhooks/event-types.

Event typeGroupFires when
project.createdProjectsA project was created (POST /projects) or copied (POST /projects/{id}/copydata.copiedFromProjectId names the source). The copied products, views and items emit nothing of their own.
project.deletedProjectsA project was deleted by its owner or an administrator. Its products, views, items, cables and files go with it and emit nothing of their own.
snapshot.createdSnapshotsA snapshot (locked copy) of a project was created (POST /projects/{id}/snapshot). data.object is the snapshot project (locked: true, with a uniqueNumber); data.sourceProjectId is the project it was taken from.
snapshot.deletedSnapshotsA snapshot was deleted (admin Snapshots page).
project.product.addedProductsA product was added to a project (POST /projects/{id}/products) — one event per product, including each accessory the MCP add_products_to_project tool expands.
project.product.deletedProductsA product was removed from a project (PATCH deleted: true or DELETE). Only the product itself: the accessories and view placements removed with it emit nothing of their own.
project.product.restoredProductsA removed product was restored by undo (POST /projects/{id}/products/restore) — one event per product.
project.view.addedViewsA view was added to a project (POST /projects/{id}/views) or deep-copied (POST …/views/{id}/deepcopydata.copiedFromViewId names the source).
project.view.deletedViewsA project view was deleted. The items on it emit nothing of their own.
project.view.product.addedView itemsA product was placed on a view (a view item of type product was created). Lines, polylines, text, images and other item types never emit.
project.view.product.deletedView itemsA product placement was removed from a view directly (PATCH deleted: true or DELETE on the item). Removals caused by deleting the product, the view or the project emit nothing of their own.
project.view.product.restoredView itemsA removed product placement was restored by undo (POST /projects/{id}/viewitems/restore) — product-type items only.
project.cable.addedCablesA cable was added to a project (POST /projects/{id}/cables — from the Cables page, a drawn connection, or connect_products). data.object is the project cable with its allocated cableId; data.restored is true when an undo brought a deleted cable back.
project.cable.deletedCablesA cable was removed from a project (DELETE, PATCH deleted: true, or its last drawn segment going). Removals caused by deleting the project emit nothing of their own.
user.registeredUsersA new user account appeared in the application: data.source is registration (self sign-up — pending until the email is verified), sso (first single sign-on login) or exchange (legacy stardraw.com token). data.object is the public user record — never the password hash, passkeys or tokens.
user.deletedUsersA user account was deleted by an administrator (DELETE /admin/users/{id}).
webhook.testWebhooksSent by the admin page’s Send test event button to one endpoint. Cannot be subscribed to; a single attempt, never retried.

A few rules worth knowing:

  • Cascades emit only the top-level event. Deleting a project emits one project.deleted, not a project.product.deleted per product; copying a project emits one project.created.
  • project.view.product.* fires only for view items of type product — placing a line, a text label or an image on a view emits nothing.
  • user.registered carries data.source (registration, sso or exchange) and a public projection of the user in data.object.
  • snapshot.* objects are projects with locked: true and carry data.sourceProjectId.

The request

Every delivery is an HTTP POST with Content-Type: application/json; charset=utf-8 and User-Agent: StardrawCloud-Webhooks/1.0 (+https://stardraw.cloud/developer/webhooks), plus these headers:

HeaderMeaning
webhook-idThe event id — the same on every retry, so use it to deduplicate.
webhook-timestampUnix seconds when this attempt was sent — new on every attempt.
webhook-signaturev1,<base64> — a space-separated list; two entries while a secret roll is in its 24 h grace period.
Stardraw-Event-TypeThe event type (e.g. project.created) — a convenience for routing before you parse the body.
Stardraw-Delivery-IdThe delivery id, as shown on the admin Deliveries tab.
Stardraw-Delivery-AttemptThe attempt number, 1 to 10.
customAny headers the administrator configured on the endpoint — for example an Authorization header for an API gateway.

The body is a JSON envelope:

{
  "id": "66c6f0a1e4b0c8d9f0a1b2c3",
  "type": "project.created",
  "timestamp": "2026-08-21T10:11:12.1234567Z",
  "apiVersion": "v1",
  "applicationId": "…",
  "actor": { "userId": "…", "emailAddress": "…" },
  "data": {
    "object": { … },
    "project": { "id": "…", "name": "…" },
    "view": { "id": "…", "name": "…", "viewKey": "panel" },
    "copiedFromProjectId": "…"
  }
}
FieldMeaning
idThe event id (also sent as webhook-id).
typeThe event type from the catalog above.
timestampWhen the event happened (UTC, ISO 8601).
apiVersionThe API version the payload shapes follow — currently v1.
actorThe user whose action caused the event; null for system actions.
data.objectThe entity, exactly as the REST API’s GET returns it — a Project, ProjectProduct, ProjectView, a view item with its type discriminator, or the user projection — so the OpenAPI models apply.
data.project{ id, name } context on product, view and view-item events.
data.view{ id, name, viewKey } context on view-item events.
data.copiedFromProjectId, data.copiedFromViewId, data.sourceProjectIdPresent only when they apply — a project copy, a view deep-copy, or the project a snapshot was taken from.

Payloads are capped at 1 MB. If an entity would push the envelope over that, data.object is replaced by { "id": "…", "truncated": true } and you fetch the full entity through the API.

Verifying the signature

Signatures follow Standard Webhooks — the scheme used by OpenAI, Anthropic, Twilio, Supabase, Svix and many others — so you can verify them with an off-the-shelf library rather than rolling your own. The secret is shown once when the endpoint is created and looks like whsec_<base64>. The scheme is:

  • The signed content is "{webhook-id}.{webhook-timestamp}.{raw body}".
  • The key is the secret with the whsec_ prefix removed and the rest base64-decoded.
  • The signature is base64(HMAC-SHA256(key, signed content)), presented as v1,<signature>.

Always verify against the raw request bytes (never re-serialised JSON), compare in constant time, accept the delivery if anyv1 entry in webhook-signature matches, and reject timestamps more than 5 minutes from now to defeat replays. Every official standardwebhooks library does all of this for you:

LanguagePackage
JavaScript / TypeScriptnpm standardwebhooks
PythonPyPI standardwebhooks
C# / .NETNuGet StandardWebhooks
JavaMaven com.standardwebhooks:standardwebhooks
Gogithub.com/standard-webhooks/standard-webhooks/libraries/go
Ruby / RustRubyGems / crates.io standardwebhooks

JavaScript (Express) — mount express.raw on the route so the body reaches you as the exact bytes that were signed:

import express from 'express'
import { Webhook } from 'standardwebhooks'

const app = express()
const wh = new Webhook(process.env.STARDRAW_WEBHOOK_SECRET)   // whsec_…

app.post('/stardraw/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  let event
  try {
    event = wh.verify(req.body, req.headers)   // throws on a bad signature or stale timestamp
  } catch {
    return res.status(400).send('invalid signature')
  }
  res.sendStatus(200)                           // acknowledge first…
  handle(event)                                  // …then do the work
})

Python (FastAPI) — read await request.body() rather than a parsed model:

import os
from fastapi import FastAPI, HTTPException, Request
from standardwebhooks.webhooks import Webhook, WebhookVerificationError

app = FastAPI()
wh = Webhook(os.environ["STARDRAW_WEBHOOK_SECRET"])   # whsec_…

@app.post("/stardraw/webhook")
async def stardraw_webhook(request: Request):
    body = await request.body()                      # raw bytes, exactly as signed
    try:
        event = wh.verify(body, dict(request.headers))
    except WebhookVerificationError:
        raise HTTPException(status_code=400, detail="invalid signature")
    handle(event)
    return {"ok": True}

C# / .NET — the NuGet package works the same way; here is the verification written out by hand so you can see exactly what is checked:

using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

app.MapPost("/stardraw/webhook", async (HttpRequest request) =>
{
    using var reader = new StreamReader(request.Body);
    var body = await reader.ReadToEndAsync();                 // raw body, exactly as signed

    var id = request.Headers["webhook-id"].ToString();
    var timestamp = request.Headers["webhook-timestamp"].ToString();
    var signatures = request.Headers["webhook-signature"].ToString();

    if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(timestamp)) > 300)
    {
        return Results.BadRequest("stale timestamp");
    }

    var secret = Environment.GetEnvironmentVariable("STARDRAW_WEBHOOK_SECRET")!;   // whsec_…
    var key = Convert.FromBase64String(secret["whsec_".Length..]);
    var expected = HMACSHA256.HashData(key, Encoding.UTF8.GetBytes($"{id}.{timestamp}.{body}"));

    var valid = signatures.Split(' ', StringSplitOptions.RemoveEmptyEntries)
        .Where(s => s.StartsWith("v1,"))
        .Select(s => Convert.FromBase64String(s["v1,".Length..]))
        .Any(sig => CryptographicOperations.FixedTimeEquals(sig, expected));
    if (!valid)
    {
        return Results.BadRequest("invalid signature");
    }

    var envelope = JsonDocument.Parse(body).RootElement;
    // acknowledge, then process envelope.GetProperty("type") …
    return Results.Ok();
});

To check your implementation, this reference vector (the one the Stardraw Cloud server’s own tests pin) must verify: secret whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw, webhook-idmsg_p5jXN8AQM9LWM0D4loKWxJek, webhook-timestamp1614265330, body {"test": 2432232314}webhook-signaturev1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=.

Responding

Return any 2xx status as quickly as you can — acknowledge first, then do the real work (queue it, or process after responding). Everything else counts as a failure: a 3xx redirect, any 4xx or 5xx, a timeout (15 s for the whole request), or a connection or TLS error. A 429 or 503 carrying a Retry-After header is honored — the next attempt waits at least that long, up to 24 hours.

Retries & monitoring

A failed delivery is retried up to 10 attempts over about 3 days. After the first attempt the waits are 5 s, 5 min, 30 min, 2 h, 5 h, 10 h, 10 h, 24 h and 24 h, each with ±10 % jitter. After the tenth failure the delivery is exhausted and the endpoint’s notification email is told (at most once a day per endpoint).

An endpoint with no successful delivery for 5 days is disabled automatically and its notification email is sent. An administrator can re-enable it on the admin Webhooks page, and can resend any delivery from the Deliveries tab — the same event id and body, as a fresh attempt. Events and deliveries are kept for 30 days. Test events (webhook.test) get a single attempt and are never retried.

Security

  • Endpoint URLs must be https:// with a public host name — no private, loopback or link-local addresses (checked at registration and at send time, and the connection is pinned to the address that was checked) — and no credentials in the URL.
  • Redirects are never followed; a 3xx is a failed attempt.
  • Custom headers cannot override Host, Content-*, User-Agent, webhook-* or Stardraw-*.
  • Secrets are stored encrypted and shown once at creation. An administrator can reveal or roll a secret later; a roll keeps both secrets signing for up to 24 h, so your receiver can switch without a gap.
  • Egress IP addresses are not fixed today — verify signatures rather than allow-listing IPs.

Admin API

Everything the admin page does is available on the Admin API at https://<app>.stardraw.cloud/api/v1/admin/ with the bearer token of an administrator. All of these operations are in the Admin API document on the API reference page.

OperationPurpose
GET webhooks/event-typesThe subscribable event catalog.
GET webhooks / POST webhooksList endpoints / create one. The create response is { "endpoint": {…}, "secret": "whsec_…" } — the only time the secret is returned unasked.
GET webhooks/{id} / PATCH webhooks/{id}Read / update an endpoint. PATCH accepts url, description, eventTypes, headers, enabled, notificationEmail and deleted; version is required (see Conventions).
GET webhooks/{id}/secretReveal the current secret.
POST webhooks/{id}/secret/rollRoll the secret: { "gracePeriodHours": 24 } keeps the old one signing for that long (0–24).
POST webhooks/{id}/testSend a webhook.test event to the endpoint.
POST webhooks/deliveries/gridPage through deliveries (grid request; add ?endpointId= to filter to one endpoint).
GET webhooks/deliveries/{id}One delivery with its attempts and the event payload.
POST webhooks/deliveries/{id}/resendResend a delivery as a fresh attempt.

Creating an endpoint:

curl -X POST https://stardraw.cloud/api/v1/admin/webhooks \
  -H "Authorization: Bearer ADMIN_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/stardraw/webhook",
    "description": "ERP order sync",
    "eventTypes": ["snapshot.created", "user.registered"],
    "headers": { "Authorization": "Bearer MY_GATEWAY_TOKEN" },
    "enabled": true,
    "notificationEmail": "ops@example.com"
  }'

# 201 Created
{
  "endpoint": { "id": "66c6f0a1e4b0c8d9f0a1b2c3", "url": "https://example.com/stardraw/webhook", "eventTypes": ["snapshot.created", "user.registered"], "enabled": true, "secretHint": "aLSw", "version": 0, … },
  "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
}

Store the secret now — it is not in any later GET (use GET webhooks/{id}/secret to reveal it again).

Example flows

CRM: tag dealers when they sign up.

Subscribe to user.registered. On each event, look data.object.emailAddress up in your CRM. If they are a dealer, GET /api/v1/admin/users/{id} (the id from data.object), set settings["Dealer"].value on the returned user and PUT /api/v1/admin/users/{id} it back. The application must define a user setting of that name; from then on roles, reports and pricing can key off it.

ERP: raise an order when a snapshot is taken.

Subscribe to snapshot.created. The snapshot is a locked copy of the project at that moment, so it is a stable bill of materials: read data.object.uniqueNumber for your order reference, fetch the snapshot’s products with GET /api/v1/projects/{id}/products (the snapshot’s id, not the source project’s), and create the order in your ERP.

Testing locally

Endpoints must be public HTTPS, so to develop against a receiver on your own machine expose it with a tunnel such as ngrok or smee, or point an endpoint at webhook.site to inspect raw deliveries before you write any code. Register the tunnel URL on the admin Webhooks page, press Send Test Event, and check the Deliveries tab for the response your receiver returned. Because a webhook.test gets a single attempt, a failure shows up immediately rather than after the retry schedule.

FAQ

My endpoint returns a redirect and every delivery fails.

Redirects are never followed (following one would let a receiver bounce signed traffic anywhere), so a 301/302 — for example an http:// → https:// or a trailing-slash redirect — is a failed attempt. Register the final URL.

Why does the raw body matter?

The signature covers the exact bytes Stardraw Cloud sent. If your framework parses the JSON and you re-serialise it to verify, key order, whitespace and number formatting can change and the signature no longer matches. Read the body as bytes or text before any JSON middleware touches it.

I received the same event twice / out of order.

Delivery is at-least-once and unordered by design: a retry after a timeout can duplicate an event your server actually processed, and two events emitted a second apart can arrive in either order. Deduplicate on webhook-id (keep the ids you have processed for at least 3 days, the retry window) and, where order matters, compare timestamp or re-read the entity through the API rather than trusting arrival order.

Where do deliveries come from? Can I allow-list an IP?

Deliveries are sent from Stardraw Cloud’s hosting platform and the egress addresses are not fixed today, so IP allow-listing is not reliable. The signature is your authentication: verify it on every request, and if your gateway needs a shared token, add it as a custom header on the endpoint.