Shop Commander · Developers

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

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.