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
- Five nondeleted endpoints per workspace, including disabled endpoints. HTTPS, public DNS and port 443 only. No literal IPs, user info, query, fragment, local or private/special-use addresses, or redirects. Every DNS answer is checked and the validated address is pinned for TLS. Request timeout is ten seconds.
-
Tests: one per endpoint per minute and ten per workspace per hour.
Tests work on disabled/enabled endpoints. Replay: ten requests per
workspace per hour and up to five extra cycles per delivery. Read
Retry-Afterafter a quota response. -
Delivery states:
pending,sending,retrying,delivered,failed,cancelled. Each cycle permits nine HTTP attempts within 24 hours. Backoff is approximately 1m, 5m, 15m, 1h, 2h, 4h, 8h, 8h, with ±20% jitter; the deadline may stop it sooner. 429/503 Retry-After can delay a retry, capped at one hour and never earlier than backoff. -
2xx succeeds. 408/425/429/5xx, timeouts and transient network failures
retry. Redirects, other 4xx, unsafe destinations and TLS failures stop
the cycle. A crashed sender can record
unknown_outcome; the receiver may already have accepted it. This is why receiver deduplication is required. - URL, event filter and enable/disable changes invalidate queued work. URL edits also disable the endpoint and clear test proof. Name edits and secret rotation preserve delivery eligibility. Normal rotation accepts/signs old and new keys for 24 hours; another overlapping rotation is refused. Immediate rotation revokes the old key, including any existing overlap.
- Replay only applies to eligible delivered/failed live deliveries with the same destination generation and current tenant admission. Tests, cancelled records, parked infrastructure failures, expired data and exhausted replay budgets are ineligible. Same event/body/delivery ID; fresh cycle and signature. Rotation alone does not prevent replay. Original 30-day retention is never extended.
- One in-flight request per endpoint, two per workspace, and at least 200ms between endpoint starts. These are protective limits, not an ordering or throughput SLA. SQS tenant fairness and a bounded recovery scheduler prevent a noisy tenant from claiming every recovery slot.
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.