Outbound developer webhooks

Webhooks notify your application after a ticket change commits. A durable AWS pipeline captures the event in the ticket transaction, fans it out to the subscribed destinations, signs each request and records the result. A slow or broken receiver does not make a committed ticket change fail. Use this for CRM synchronization, alerts and external automation without repeatedly polling ticket lists.

Download the runnable Node receiver. See the API reference for request and response schemas.

Authentication and permissions

Select the workspace with X-Helpdesk-Tenant-Id. Authenticate using Authorization: Bearer $ACCESS_TOKEN for a current Owner/Admin, or Authorization: ApiKey $API_KEY with full_access. Agent members, agent keys and staff_admin keys cannot manage webhooks. Signing secrets authenticate incoming webhooks to your receiver; they are not API credentials.

Ordinary operations need active workspace lifecycle and simulated entitlement. An inactive workspace permits only human Owner/Admin metadata listing, disabling and deletion for recovery. Purging/deleted workspaces allow none. These checks run again at asynchronous delivery time; restored admission never reactivates old work.

Routes

All responses use Cache-Control: no-store. Endpoint and delivery detail responses include a quoted numeric ETag, for example "3". Every mutation needs a UUID Idempotency-Key; all except create also need If-Match. Replay uses the delivery ETag; test and all endpoint changes use the endpoint ETag.

Method Path under /v1/webhooks Result
GET /event-types Subscribable event catalog
GET /endpoints Current endpoints; deleted=true selects deleted history
POST /endpoints 201 disabled endpoint plus one-time secret
GET /endpoints/{endpointId} Endpoint and decrypted URL for an authorized active caller
PATCH /endpoints/{endpointId} Edit name, url, or eventTypes
DELETE /endpoints/{endpointId} Deleted metadata; keys destroyed, queued work cancelled
POST /endpoints/{endpointId}/enable Enable after successful destination test within 24 hours
POST /endpoints/{endpointId}/disable Disable and invalidate pending work
POST /endpoints/{endpointId}/rotate-secret One-time new secret; body {"mode":"overlap"} or {"mode":"immediate"}
POST /endpoints/{endpointId}/test 202 with eventId, deliveryId, statusUrl and Location
GET /endpoints/{endpointId}/deliveries History; state, kind=test/live, limit, cursor
GET /endpoints/{endpointId}/deliveries/{deliveryId} Safe delivery metadata, immutable event, attempts, replayEligible
POST /endpoints/{endpointId}/deliveries/{deliveryId}/replay 202 for a new cycle of the same event and delivery

List limits default to 25 and cap at 100. Always follow nextCursor, including on an empty filtered page. Cursors are opaque, expire after one hour and are bound to the tenant and query. Changing filters means starting without a cursor.

On uncertain network errors retry the same idempotency key, body and If-Match. A receipt lives for 30 days and is bound to the actor and original command. Mismatched reuse returns a conflict. Successful create/rotation secrets cannot be retrieved again: a replay returns 409 secret_unavailable, the committed endpointId, and recovery: rotate_secret_immediately. Fetch that endpoint and rotate immediately with a new command/key and its current ETag. Never persist secrets in logs, URLs, screenshots, browser storage or tickets.

Quickstart: API or dashboard

The dashboard is Configuration → Webhooks. Create → save the one-time secret → configure your receiver → Send test → inspect delivery → Enable endpoint. The same validation, permissions, ETags and quotas apply through the API.

Use your deployed API URL and credentials in environment variables. The examples below intentionally contain no real credentials. jq extracts response fields. Do not enable shell tracing or copy secret response files into source control.

umask 077
CREATE_KEY=$(node -e 'console.log(crypto.randomUUID())')
curl --fail-with-body "$API_URL/v1/webhooks/endpoints" \
  -H "Authorization: ApiKey $API_KEY" \
  -H "X-Helpdesk-Tenant-Id: $TENANT_ID" \
  -H "Idempotency-Key: $CREATE_KEY" -H 'Content-Type: application/json' \
  -d '{"name":"CRM","url":"https://receiver.example.com/webhooks","eventTypes":["ticket.created","ticket.status_changed"]}' \
  -o /tmp/webhook-created.json
ENDPOINT_ID=$(jq -r '.endpoint.endpointId' /tmp/webhook-created.json)
# Transfer .secret directly to your receiver's secret manager, then remove the file.
VERSION=$(jq -r '.endpoint.version' /tmp/webhook-created.json)
TEST_KEY=$(node -e 'console.log(crypto.randomUUID())')
curl --fail-with-body "$API_URL/v1/webhooks/endpoints/$ENDPOINT_ID/test" \
  -H "Authorization: ApiKey $API_KEY" -H "X-Helpdesk-Tenant-Id: $TENANT_ID" \
  -H "Idempotency-Key: $TEST_KEY" -H "If-Match: \"$VERSION\"" -X POST

Poll the returned relative statusUrl against $API_URL until delivered or a terminal failure. Fetch the endpoint again: successful testing advances its version. Enable with the fresh ETag and a new UUID idempotency key. Then create or change a test ticket using the ordinary ticket API/UI, inspect its live delivery, and verify the receiver processed it. HTTP 2xx proves receiver acceptance, not ownership of that URL, signature checking, or successful downstream processing.

Events and payloads

Event When it is emitted
ticket.created Initial ticket creation, including email intake; no separate initial-message event
ticket.status_changed A real transition, including a reply/customer message that reopens the ticket
ticket.assignment_changed Routing team or assignee changes
ticket.message_created A public staff reply or accepted customer message; never an internal note
webhook.test Explicit synthetic destination test; cannot be subscribed to

Every envelope has schemaVersion: 1, UUIDv7 eventId, type, occurredAt, tenantId, homeRegion, lifecycleEpoch and data. Ticket events additionally have ticketId, ticketVersion, changeId, eventIndex. A mutation may produce status and message events at the same ticket version; they have distinct event IDs, one changeId and ordered eventIndex values. Deduplicate by eventId, not version. No ordering is guaranteed across deliveries or endpoints. Fetch current ticket state with separately scoped API credentials when your workflow needs it.

Payloads contain thin identifiers and safe state metadata. They exclude ticket subjects, bodies, private notes, personal names/emails, raw mail, files, signed URLs and credentials. Future additive fields are permitted: ignore unknown fields. The OpenAPI top-level webhooks section describes requests sent to your URL; it is not a set of additional routes to call on CaseKit.

Receiver verification

Read exact raw bytes before JSON parsing. Verify Standard Webhooks HMAC-SHA256:

signed bytes = UTF8(webhook-id + "." + webhook-timestamp + ".") + rawBody
key = base64Decode(secret without "whsec_")
webhook-signature = "v1," + base64(HMAC-SHA256(key, signed bytes))

Accept a matching constant-time compared signature for any currently trusted key; rotation sends space-separated v1,... signatures. Enforce a five-minute timestamp tolerance with a synchronized clock. Verify body eventId matches webhook-id, then atomically deduplicate and enqueue durable work before returning 2xx. Retries/replays retain the event ID and exact body but have fresh timestamps and signatures. Retain deduplication records at least 30 days. Keep business processing after acceptance short or asynchronous; an already accepted duplicate should return 2xx. A replay is not an instruction to apply a business effect twice.

The downloadable receiver uses Node built-ins and a persistent single-host POSIX inbox to demonstrate durable acceptance; it is not a distributed queue. Keep processed markers and use a database transaction with a unique eventId for a replicated production inbox. It listens on localhost:8080 behind your public TLS proxy; CaseKit cannot deliver to localhost. Set WEBHOOK_SECRETS (comma-separated keys during overlap) and WEBHOOK_INBOX, then run node webhook-receiver.mjs. Never expose its HTTP listener directly to the internet.

Limits, changes and retries

Errors and troubleshooting

400 invalid_request: fix the command/URL/header. 401: authenticate again. 404 not_found: generic unavailable or unauthorized resource; never infer another tenant's resources. 428 precondition_required: supply If-Match. 412 precondition_failed: refresh and reconcile changes. 409: incompatible state, idempotency conflict, required test, rotation overlap or lost one-time secret. 429 rate_limited: wait Retry-After. 503 temporarily_unavailable: retry the same command. Other infrastructure errors use the existing generic API error policy.

Inspect safe attempt reasons in the dashboard/API. No receiver response bodies are stored. For tls_error, repair the certificate and replay if eligible; for unsafe_destination, use a valid public endpoint and test it. Changing the URL requires new events because old-generation deliveries are cancelled. For needsOperatorRecovery, contact your operator; ordinary replay cannot reset the infrastructure failure budget. Disabled/inactive tenants cannot resume previously captured work by simply re-enabling access.