Home Features Pricing App AI Advisor Docs Compare About Training Contact Log In Get an API key
Developersv1.0.0-rc.6
Browse the guides
Developers/Events

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 2xx fast (under 10 s) and do the work asynchronously.
  • Every accepted event is retained; rapid edits can produce multiple *.updated events. De-duplicate only by event id, 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.