POST to an HTTPS endpoint you register, using the open Standard Webhooks format. 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 types are dot-delimited resource.action names. The live list is also served by GET /api/v1/admin/webhooks/event-types.
| Event type | Group | Fires when |
|---|---|---|
project.created | Projects | A project was created (POST /projects) or copied (POST /projects/{id}/copy — data.copiedFromProjectId names the source). The copied products, views and items emit nothing of their own. |
project.deleted | Projects | A 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.created | Snapshots | A 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.deleted | Snapshots | A snapshot was deleted (admin Snapshots page). |
project.product.added | Products | A 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.deleted | Products | A 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.restored | Products | A removed product was restored by undo (POST /projects/{id}/products/restore) — one event per product. |
project.view.added | Views | A view was added to a project (POST /projects/{id}/views) or deep-copied (POST …/views/{id}/deepcopy — data.copiedFromViewId names the source). |
project.view.deleted | Views | A project view was deleted. The items on it emit nothing of their own. |
project.view.product.added | View items | A 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.deleted | View items | A 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.restored | View items | A removed product placement was restored by undo (POST /projects/{id}/viewitems/restore) — product-type items only. |
project.cable.added | Cables | A 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.deleted | Cables | A 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.registered | Users | A 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.deleted | Users | A user account was deleted by an administrator (DELETE /admin/users/{id}). |
webhook.test | Webhooks | Sent 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:
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. 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:
| Header | Meaning |
|---|---|
webhook-id | The event id — the same on every retry, so use it to deduplicate. |
webhook-timestamp | Unix seconds when this attempt was sent — new on every attempt. |
webhook-signature | v1,<base64> — a space-separated list; two entries while a secret roll is in its 24 h grace period. |
Stardraw-Event-Type | The event type (e.g. project.created) — a convenience for routing before you parse the body. |
Stardraw-Delivery-Id | The delivery id, as shown on the admin Deliveries tab. |
Stardraw-Delivery-Attempt | The attempt number, 1 to 10. |
| custom | Any 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": "…"
}
}| Field | Meaning |
|---|---|
id | The event id (also sent as webhook-id). |
type | The event type from the catalog above. |
timestamp | When the event happened (UTC, ISO 8601). |
apiVersion | The API version the payload shapes follow — currently v1. |
actor | The user whose action caused the event; null for system actions. |
data.object | The 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.sourceProjectId | Present 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.
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:
"{webhook-id}.{webhook-timestamp}.{raw body}".whsec_ prefix removed and the rest base64-decoded.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:
| Language | Package |
|---|---|
| JavaScript / TypeScript | npm standardwebhooks |
| Python | PyPI standardwebhooks |
| C# / .NET | NuGet StandardWebhooks |
| Java | Maven com.standardwebhooks:standardwebhooks |
| Go | github.com/standard-webhooks/standard-webhooks/libraries/go |
| Ruby / Rust | RubyGems / 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=.
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.
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.
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. 3xx is a failed attempt.Host, Content-*, User-Agent, webhook-* or Stardraw-*. 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.
| Operation | Purpose |
|---|---|
GET webhooks/event-types | The subscribable event catalog. |
GET webhooks / POST webhooks | List 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}/secret | Reveal the current secret. |
POST webhooks/{id}/secret/roll | Roll the secret: { "gracePeriodHours": 24 } keeps the old one signing for that long (0–24). |
POST webhooks/{id}/test | Send a webhook.test event to the endpoint. |
POST webhooks/deliveries/grid | Page 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}/resend | Resend 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).
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.
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.
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.