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"]}'
The response includes secret (whsec_…) exactly once. Store it in a secret manager. 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. 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": "RO-13223", "state": "in_progress", "previous_state": "authorized" }
}
}
Payloads are thin on purpose: a reference plus a small summary. 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.
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.