Browse the guides
Webhooks
Signed, at-least-once event deliveries with thin payloads; verify the signature, dedupe on the event id, then GET the object.
Webhooks tell your system that something changed the moment it happens. Register an HTTPS endpoint, choose event types, and Shop Commander POSTs a signed JSON envelope for every matching event.
Endpoints
Create endpoints in Settings → Developer (the shop) or with the webhooks.manage scope:
curl -X POST https://api.shopcommander.com/v1/webhook-endpoints \
-H "Authorization: Bearer $SC_API_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: webhook-endpoint-production-1" \
-d '{"url":"https://example.com/hooks/shopcommander","event_types":["appointment.created","invoice.posted"]}'
Create and rotate return the distinct WebhookEndpointWithSecret issuance
response, whose required secret starts with whsec_. Store it in a secret
manager. Ordinary list, get, and update responses use WebhookEndpoint and do
not contain a secret field at all. It cannot be retrieved later; if it is
lost or exposed, call POST /v1/webhook-endpoints/{id}/rotate-secret with an
Idempotency-Key and store the new value returned by that command. Retrying
the identical create or rotate command with the same key returns the same
issued secret. The previous secret overlaps for 24 hours, then stops signing
and is erased. An empty event_types list subscribes to no events. Use
event_types: ["*"] (alone) to subscribe to every event the integration's
scopes allow. Up to 10 endpoints per integration.
Endpoint URLs must be https:// on port 443 or 8443 to a public hostname. Private networks, link-local and cloud-metadata addresses, and redirects are refused — at creation and again at every delivery.
The envelope
{
"id": "evt_9c1e…", "object": "event",
"type": "repair_order.status_changed", "api_version": "v1",
"test": false, "created_at": "2026-09-01T14:03:27Z",
"data": {
"object_type": "repair_order", "object_id": "ro_2b7f…",
"summary": { "number": 13223, "state": "in_progress", "previous_state": "authorized" }
}
}
Payloads are thin on purpose: a reference plus a small summary. Summary values use the same primitive representation as their source field, but the summary is not a resource snapshot and fields may vary by event type. Fetch the authoritative object with a GET — it is current, complete, and redacted for your scopes, where a frozen snapshot in the event would be neither.
Every object reference in summary is a typed public ID, just like
data.object_id: repair_order_id is ro_…, reversal_of is pay_…,
credit_note_of is inv_…, and merge merged_from_id values are cus_… or
veh_…. Database UUIDs are never exposed. Summary fields remain event-specific
and additive; use them as hints and refetch the authoritative resource.
Pricing and acquisition cost are never copied into webhook summaries. Fetching the referenced object applies the integration's current scopes and redaction.
Delivery semantics
- At-least-once. Duplicates are possible; de-duplicate on
id. - Unordered. Two events for the same object may arrive out of order; the GET is what tells you the current state.
- Retries: a non-2xx response or a timeout (10 s) is retried with backoff at roughly 1 m, 5 m, 30 m, 2 h, 8 h, 24 h, 24 h — about eight attempts over three days — then marked failed.
- Auto-disable: an endpoint that fails continuously for three days is disabled and the shop is notified in-app. Fix the receiver, then re-enable it in Settings → Developer, where the shop can also inspect and resend any delivery from the last 30 days.
- Respond
2xxfast (under 10 s) and do the work asynchronously. - Every accepted event is retained; rapid edits can produce multiple
*.updatedevents. De-duplicate only by eventid, never by object id.
Verifying signatures
Every delivery carries:
ShopCommander-Event-Id: evt_9c1e…
ShopCommander-Signature: t=1756735407,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e3f0f4cee1c3ec…
v1 is HMAC-SHA256(secret, "{t}.{raw_body}") in hex. For 24 hours after a secret rotation two v1= entries are sent; accept the delivery if any matches. After that grace period only the new secret signs. Reject deliveries whose t is more than 5 minutes old.
import hashlib, hmac, time
def verify(secret: str, header: str, body: bytes, tolerance=300) -> bool:
items = [p.split("=", 1) for p in header.split(",")]
t = int(next(v for k, v in items if k == "t"))
sigs = [v for k, v in items if k == "v1"]
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in sigs)
// Node
const crypto = require('crypto')
function verify(secret, header, rawBody, tolerance = 300) {
const items = header.split(',').map(p => p.split('='))
const t = Number(items.find(([k]) => k === 't')[1])
const sigs = items.filter(([k]) => k === 'v1').map(([, v]) => v)
if (Math.abs(Date.now() / 1000 - t) > tolerance) return false
const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex')
return sigs.some(s => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)))
}
<?php
function verify(string $secret, string $header, string $body, int $tolerance = 300): bool {
$t = null; $sigs = [];
foreach (explode(',', $header) as $part) { [$k, $v] = explode('=', $part, 2); if ($k === 't') $t = (int)$v; if ($k === 'v1') $sigs[] = $v; }
if ($t === null || abs(time() - $t) > $tolerance) return false;
$expected = hash_hmac('sha256', "$t.$body", $secret);
foreach ($sigs as $s) if (hash_equals($expected, $s)) return true;
return false;
}
Verify against the raw request body bytes, before any JSON parsing or re-serialization.
Test events
POST /v1/webhook-endpoints/{id}/test (or Send test in Settings → Developer) queues a synthetic event with "test": true and a placeholder object id. Your receiver should verify it like any other delivery and then ignore it.
Event types
| Family | Events |
|---|---|
| customer | customer.created, customer.updated, customer.archived, customer.merged |
| vehicle | vehicle.created, vehicle.updated, vehicle.archived, vehicle.merged |
| appointment | appointment.created, appointment.updated, appointment.status_changed, appointment.converted_to_repair_order |
| repair_order | repair_order.created, repair_order.updated, repair_order.status_changed |
| job | job.created, job.status_changed |
| invoice | invoice.posted, invoice.voided, credit_note.issued |
| payment | payment.recorded, payment.reversed |
| task | task.created, task.updated, task.completed |
| webhook_endpoint | webhook_endpoint.disabled (meta — delivered to the other endpoints) |
Events are emitted for changes made by anyone — staff in the app, other integrations, or automation — not only for API writes. GET /v1/events lists the last 30 days of events visible to your scopes for reconciliation.