Skip to content

Webhooks

Webhooks let BenchKey push event notifications to your server the moment something happens, a ticket is created, a payment is recorded, a customer replies. Instead of polling the API, your endpoint receives a POST request with a JSON payload.

Create a webhook subscription in Settings → Integrations & Shipping → Integrations → Webhooks:

  1. Click Add webhook.
  2. Enter your public HTTPS endpoint URL. Both live and test-labeled keys require HTTPS.
  3. Select the events you want to receive, then choose Create webhook.
  4. Copy the signing secret, it is shown only once. You will use it to verify delivery signatures.

You can also manage subscriptions programmatically, see Managing subscriptions via the API below.

Every webhook delivery is a POST request with Content-Type: application/json. The body is always:

{
"object": "event",
"id": "evt_01J8X4abc123def456",
"type": "ticket.created",
"created": "2025-06-14T10:32:00.000Z",
"api_version": "v1",
"data": {
"object": "ticket",
"id": 1042
}
}
FieldDescription
objectAlways "event"
idUnique event ID, use it to deduplicate deliveries
typeThe event name, e.g. ticket.created
createdISO 8601 UTC timestamp of when the event was emitted
api_versionAlways "v1"
dataThe resource DTO at the time of the event (same shape as the REST resource), or a lean transition payload for change events, see below

Some events describe a state transition rather than a full resource, and carry a lean payload so you always see what changed, fetch the full record with a follow-up GET when you need it. ticket.status_changed is the canonical example (it fires for every real transition: staff dragging a ticket across the queue board, the ticket’s Ticket panel, automations, and API calls alike):

{
"object": "event",
"id": "evt_obs_5f2a9c81d4e7b3a0c6f1",
"type": "ticket.status_changed",
"created": "2026-07-09T18:30:02.000Z",
"api_version": "v1",
"data": {
"object": "ticket",
"id": "1042",
"status": "Waiting for Parts",
"previous_status": "Diagnosing",
"changed_at": "2026-07-09T18:30:01.000Z",
"changed_by": "tech@yourshop.com",
"change_source": "queue_drag",
"source_kind": "observed"
}
}
FieldDescription
statusThe ticket’s new status
previous_statusThe status it left
changed_atISO 8601 UTC timestamp of the transition itself
changed_byWho made the change (staff email, or a system/automation actor)
change_sourceWhere the change came from, e.g. queue_drag, api, an automation source
source_kindAlways "observed" for transition payloads

estimate.approved and estimate.declined use the same lean transition style, and fire for customer decisions made on the portal page, not just API calls:

{
"object": "estimate",
"id": "41",
"ticket_id": "1042",
"status": "approved",
"approved_at": "2026-07-09T19:02:11.000Z",
"approved_by": "customer",
"source_kind": "observed"
}

A decline additionally carries declined_at, declined_by, and the customer’s decline_reason (capped at 500 characters). approved_by/declined_by is "customer" for portal decisions, or the staff/API actor otherwise. Signature images, IP addresses, and user agents never appear in webhook payloads, fetch the estimate for its full approval provenance.

appointment.created, appointment.updated, and appointment.canceled fire for all appointment activity, dashboard bookings, the public online-booking page, staff reschedules and edits, ticket links, un-cancels, and cancellations, not just API writes. They carry a lean schedule-position payload; customer phone and email never ride in webhook payloads (fetch the full record with GET /api/v1/appointments/:id):

{
"object": "appointment",
"id": "7",
"status": "confirmed",
"scheduled_date": "2026-07-14",
"scheduled_time": "10:00",
"duration_minutes": 45,
"tech_id": "john-doe",
"ticket_id": "1042",
"customer_name": "Jane Smith",
"created_at": "2026-07-09T18:30:01.000Z",
"source_kind": "observed"
}
  • appointment.updated adds updated_at and fires for every edit: a reschedule or field change, a ticket link, and a status reset (an un-cancelled appointment comes back as appointment.updated with status: "confirmed", resubscribe your calendar accordingly).
  • appointment.canceled adds cancelled_at and cancellation_reason (capped at 500 characters). A cancellation emits only appointment.canceled, never a companion appointment.updated. If an appointment is un-cancelled and later cancelled again, the second cancellation is a distinct event.
  • appointment.completed and appointment.no_show remain API-emitted events (dashboard completions don’t currently deliver) and carry the full appointment DTO.

Events observed from in-app activity (rather than API writes) may arrive up to ~30 seconds after the change. Deliveries are deduplicated per subscription either way, an API-driven change never delivers twice even though both paths see it.

EventFires whendata.object type
customer.createdA new customer record was createdcustomer
customer.updatedA customer record was updatedcustomer
ticket.createdA new repair ticket was createdticket
ticket.updatedA ticket was updated (fields other than status)ticket
ticket.status_changedA ticket’s status/stage changedticket
invoice.createdA new invoice was createdinvoice
invoice.sentAn invoice was sent to the customerinvoice
invoice.paidAn invoice was fully paidinvoice
invoice.voidedAn invoice was voidedinvoice
invoice.refundedAn invoice was refunded (full or partial)invoice
estimate.sentAn estimate was sent to the customerestimate
estimate.approvedA customer approved an estimateestimate
estimate.declinedA customer declined an estimateestimate
payment.recordedA payment was recorded against an invoicepayment
payment.refundedA payment was refunded (full or partial)refund
appointment.createdA new appointment was booked (API, dashboard, or online booking)appointment
appointment.updatedAn appointment was rescheduled, edited, linked to a ticket, or un-cancelledappointment
appointment.canceledAn appointment was canceled (API or dashboard)appointment
appointment.completedAn appointment was marked completedappointment
appointment.no_showAn appointment was marked as a no-showappointment
lead.createdA new lead was capturedlead
lead.updatedA lead was updatedlead
lead.convertedA lead was converted into a repair ticketlead
message.receivedAn inbound customer message (SMS/email/portal) was receivedmessage
message.sentAn outbound message (SMS/email) was sent to a customermessage

Authoritative list: GET /api/v1/webhooks/event_types returns the live event catalog as a JSON array. The table above reflects the current set; new event types will be added as resources expand.

Webhook payloads embed the same resource DTOs the REST endpoints serve, so a subscription is an alternate read channel. At subscribe time (create, and any change to events), the API key must hold the read scope for every data family it asks to receive, subscribing to ticket.* events needs tickets.read, invoice.* needs invoices.read, and so on for customer, estimate, payment, appointment, lead, and message events. Subscribing to ["*"] requires all of those read scopes. A key missing any of them gets 403 insufficient_scope naming the missing scope(s). (webhook.ping carries no resource data and maps to no scope.)

BenchKey signs every delivery with an HMAC-SHA256 signature so you can confirm the payload came from BenchKey and was not tampered with.

HeaderValue
BenchKey-Signaturet=<unix_ts>,v1=<hex_signature>; repeated v1 values during rotation
BenchKey-Event-IdThe event ID (same as id in the body)
BenchKey-Event-TypeThe event type (same as type in the body)
  1. Extract the timestamp t and all v1 signatures from BenchKey-Signature.
  2. Build the signed payload string: <t>.<raw_request_body> (the literal timestamp, a dot, then the raw bytes of the body, do not re-serialize JSON).
  3. Compute HMAC-SHA256(signingSecret, signedPayload).
  4. Compare your digest to each v1 using a length-checked, constant-time comparison. Accept if any signature matches a currently valid signing secret.
  5. Reject if |now - t| > 300 seconds (replay protection).
Terminal window
# This placeholder signature and expired timestamp should be rejected.
# Use the subscription ping endpoint for a real signed test delivery.
curl -X POST https://your-server.example.com/webhooks/benchkey \
-H "Content-Type: application/json" \
-H "BenchKey-Signature: t=1718358720,v1=abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" \
-H "BenchKey-Event-Id: evt_01J8X4abc123def456" \
-H "BenchKey-Event-Type: ticket.created" \
-d '{
"object": "event",
"id": "evt_01J8X4abc123def456",
"type": "ticket.created",
"created": "2025-06-14T10:32:00.000Z",
"api_version": "v1",
"data": { "object": "ticket" }
}'
import crypto from "node:crypto";
const SIGNING_SECRET = process.env.BENCHKEY_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
function verifyBenchKeyWebhook(rawBody, signatureHeader) {
// Keep the exact bytes; do not parse JSON before verification.
let timestamp;
const signatures = [];
for (const part of String(signatureHeader ?? "").split(",")) {
const separator = part.indexOf("=");
const name = part.slice(0, separator).trim();
const value = part.slice(separator + 1).trim();
if (separator < 0) continue;
if (name === "t") timestamp = value;
if (name === "v1" && /^[0-9a-f]{64}$/i.test(value)) {
signatures.push(Buffer.from(value, "hex"));
}
}
if (!/^\d+$/.test(timestamp ?? "") || !signatures.length) {
throw new Error("Invalid BenchKey-Signature header");
}
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) {
throw new Error("Webhook timestamp is outside the allowed window");
}
const expected = crypto
.createHmac("sha256", SIGNING_SECRET)
.update(`${timestamp}.`)
.update(rawBody)
.digest();
const valid = signatures.some((signature) =>
signature.length === expected.length && crypto.timingSafeEqual(signature, expected)
);
if (!valid) throw new Error("Webhook signature mismatch");
}
// Express handler example
app.post("/webhooks/benchkey", express.raw({ type: "application/json" }), (req, res) => {
try {
verifyBenchKeyWebhook(req.body, req.headers["benchkey-signature"]);
} catch (err) {
return res.status(400).send(err.message);
}
const event = JSON.parse(req.body);
switch (event.type) {
case "ticket.created":
console.log("New ticket:", event.data.id);
break;
case "invoice.paid":
console.log("Invoice paid:", event.data.id);
break;
default:
// Ignore unrecognised events, forward compatibility
}
res.status(200).json({ received: true });
});

Important: Use express.raw() (not express.json()) for the webhook route. The signature is computed over the raw bytes, parsing and re-serializing JSON changes whitespace and breaks verification.

If your endpoint does not return a 2xx status within 10 seconds, BenchKey retries with exponential backoff:

AttemptDelay before this attempt
1Immediate
21 minute
35 minutes
430 minutes
52 hours
66 hours
712 hours
824 hours

After 8 failures the delivery is marked dead (permanently abandoned). To inspect deliveries, open Settings → Integrations & Shipping → Integrations → Webhooks and choose Deliveries on the subscription. Manually re-queue a failed delivery via POST /api/v1/webhooks/:id/deliveries/:deliveryId/redeliver.

If every delivery to an endpoint over the last 7 days has failed permanently, the subscription is automatically disabled: enabled flips to false, disabled_at is stamped, and last_error records the auto-disable notice. No further deliveries are attempted (and pending ones are dead-lettered) until you fix the endpoint and re-enable the subscription.

The auto-disable is fully reversible:

Terminal window
curl -X PATCH "https://app.benchkey.com/api/v1/webhooks/whsub_abc123" \
-H "Authorization: Bearer bk_live_..." \
-H "Content-Type: application/json" \
-d '{ "enabled": true }'

Events emitted while a subscription is disabled are not queued retroactively, re-enable promptly, and use the redeliver endpoint to replay any terminal deliveries you missed.

Network issues can cause the same event to be delivered more than once. Use the id field to deduplicate:

const seen = new Set(); // replace with a persistent store in production
app.post("/webhooks/benchkey", express.raw({ type: "application/json" }), (req, res) => {
// ... verify signature ...
const event = JSON.parse(req.body);
if (seen.has(event.id)) {
return res.status(200).json({ received: true, duplicate: true });
}
seen.add(event.id);
// process event ...
res.status(200).json({ received: true });
});

Scope: webhooks.read for list/get/deliveries. webhooks.write for create/update/delete/ping/redeliver. webhooks.manage for rotating the signing secret.

{
"object": "webhook_subscription",
"id": "whsub_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"url": "https://your-server.example.com/webhooks/benchkey",
"description": "Production endpoint",
"enabled": true,
"events": ["ticket.created", "invoice.paid"],
"api_version": "2026-06-13",
"last_error": null,
"disabled_at": null,
"created_at": "2025-11-01T09:00:00.000Z",
"updated_at": "2025-11-01T09:00:00.000Z"
}

The signing_secret is returned only at creation or after rotate_secret. It cannot be retrieved later.


GET /api/v1/webhooks

Scope: webhooks.read

Returns a paginated list of webhook subscriptions for the tenant.

Terminal window
curl "https://app.benchkey.com/api/v1/webhooks" \
-H "Authorization: Bearer bk_live_..."
const res = await fetch("https://app.benchkey.com/api/v1/webhooks", {
headers: { Authorization: "Bearer bk_live_..." },
});
const { data } = await res.json();

POST /api/v1/webhooks

Scope: webhooks.write

FieldRequiredDescription
urlyesPublic HTTPS endpoint URL, for both live and test-labeled keys. Private, loopback, and cloud-metadata addresses are rejected. The delivery connection also verifies the resolved destination (see SSRF protection)
eventsyesArray of event type strings, or ["*"] for all events. Every array element must be a plain string, nested arrays or non-string entries (e.g. [["customer.created"]], [123]) return 400 invalid_field
descriptionnoHuman-readable label (max 500 chars)
enablednoDefault true

The key must also hold the read scope for every event family it subscribes to, see Subscriptions are scope-gated.

Terminal window
curl -X POST "https://app.benchkey.com/api/v1/webhooks" \
-H "Authorization: Bearer bk_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-server.example.com/webhooks/benchkey",
"events": ["ticket.created", "invoice.paid"],
"description": "Production endpoint"
}'
const res = await fetch("https://app.benchkey.com/api/v1/webhooks", {
method: "POST",
headers: {
Authorization: "Bearer bk_live_...",
"Content-Type": "application/json",
},
body: JSON.stringify({
url: "https://your-server.example.com/webhooks/benchkey",
events: ["ticket.created", "invoice.paid"],
description: "Production endpoint",
}),
});
const sub = await res.json();
// sub.signing_secret is shown ONCE, store it securely now
console.log(sub.signing_secret);

Returns 201 with the subscription object plus signing_secret. Store the secret immediately, it cannot be retrieved later.


GET /api/v1/webhooks/:id

Scope: webhooks.read

Returns the subscription object (without signing_secret).


PATCH /api/v1/webhooks/:id

Scope: webhooks.write

Supply any combination of url, events, description, or enabled. Omitted fields are unchanged.

Terminal window
curl -X PATCH "https://app.benchkey.com/api/v1/webhooks/whsub_abc123" \
-H "Authorization: Bearer bk_live_..." \
-H "Content-Type: application/json" \
-d '{ "events": ["*"], "enabled": true }'

BenchKey applies two layers of server-side request forgery (SSRF) defense to webhook endpoint URLs:

At create/update time (string check): The host is validated against a blocklist of private, loopback, and cloud-metadata address ranges (e.g. 10.0.0.0/8, 192.168.0.0/16, 169.254.169.254, ::1). Any URL resolving to a blocked literal address is rejected immediately with 400 invalid_field.

At delivery time (DNS check): Right before each HTTP POST, BenchKey resolves the hostname and dead-letters the delivery if any returned A/AAAA record maps to a private or reserved IP address. This closes the DNS-rebinding class of attack where a public-looking hostname (looks-legit.example.com) is pointed at cloud metadata or an internal host. If the delivery is dead-lettered this way, last_error will be set to "blocked target: hostname resolves to a private or reserved IP address" and the delivery status will be dead.

DNS errors such as NXDOMAIN, SERVFAIL, or a timeout are retryable network failures. The delivery connection uses the vetted DNS result; a failed lookup does not bypass destination checks.


DELETE /api/v1/webhooks/:id

Scope: webhooks.write

Terminal window
curl -X DELETE "https://app.benchkey.com/api/v1/webhooks/whsub_abc123" \
-H "Authorization: Bearer bk_live_..."

Returns { "object": "webhook_subscription", "id": "whsub_abc123", "deleted": true }.


POST /api/v1/webhooks/:id/rotate_secret

Scope: webhooks.manage

An Idempotency-Key of 8 to 255 characters is required. Retain it before sending; an identical retry by the same principal recovers the command’s result after an ambiguous response.

Generates a new signing secret and returns it once alongside the updated subscription object. The response also includes previous_signing_secret_expires_at and signing_secret_generation.

The previous secret remains valid for 24 hours. During that window, deliveries include v1 signatures for both secrets. Install the new secret promptly and keep the previous secret available only until the returned expiry time. Your verifier must accept any matching v1, rather than keeping just the last value. After expiry, accept only the new secret.

Retry a lost rotation response with the same idempotency key to recover the committed result. Another rotation is blocked while the previous secret’s grace period is active.

Terminal window
curl -X POST "https://app.benchkey.com/api/v1/webhooks/whsub_abc123/rotate_secret" \
-H "Idempotency-Key: rotate-whsub-abc123-001" \
-H "Authorization: Bearer bk_live_..."

POST /api/v1/webhooks/:id/ping

Scope: webhooks.write

Enqueues a synthetic webhook.ping event to confirm your endpoint is reachable and that signature verification works. The subscription must be enabled. Returns 202 with a delivery object in pending status.

Terminal window
curl -X POST "https://app.benchkey.com/api/v1/webhooks/whsub_abc123/ping" \
-H "Authorization: Bearer bk_live_..."

GET /api/v1/webhooks/:id/deliveries

Scope: webhooks.read

Returns a paginated list of recent delivery attempts for this subscription (newest first). Each delivery object includes status, attempts, last_status_code, last_error, response_ms, and timestamps.

Delivery statuses: pending · retrying · delivered · failed · dead

StatusMeaning
pendingQueued, not yet attempted
retryingPrior attempt failed; scheduled for retry
deliveredSuccessfully delivered (2xx from your endpoint)
failedTerminal failure status accepted by the delivery-management API; inspect last_error
deadPermanently abandoned, including exhausted retries, a disabled/deleted subscription, or a blocked destination

last_error values: null (none yet), a network error string (e.g. "ECONNREFUSED"), "timeout" when the per-attempt deadline elapsed, or "blocked target: …" when the send-time SSRF screen rejected the URL. last_status_code is null for network errors, timeouts, and blocked deliveries.


GET /api/v1/webhooks/:id/deliveries/summary

Scope: webhooks.read

Returns counts grouped by status for quick monitoring:

{
"object": "webhook_delivery_summary",
"subscription_id": "whsub_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
"total": 45,
"counts": {
"delivered": 42,
"retrying": 1,
"pending": 1,
"failed": 0,
"dead": 1
},
"pending": 2,
"last_delivered_at": "2025-11-01T12:34:00.000Z",
"last_attempt_at": "2025-11-01T12:35:00.000Z"
}

pending in the top-level is a convenience field equal to counts.pending + counts.retrying (deliveries that still owe an attempt).


POST /api/v1/webhooks/:id/deliveries/:deliveryId/redeliver

Scope: webhooks.write

Re-queues a terminal delivery (delivered, dead, or failed) on the next worker tick with the original event payload and BenchKey-Event-Id. The attempt counter is reset; idempotent subscribers can safely deduplicate using the event ID.

Note: Redelivering a delivery that is already pending or retrying (in-flight) returns 422 delivery_in_flight. The delivery is already scheduled, no action is needed. Redelivering to a disabled subscription returns 422 subscription_disabled; re-enable the subscription first.

Terminal window
curl -X POST \
"https://app.benchkey.com/api/v1/webhooks/whsub_abc123/deliveries/whd_def456/redeliver" \
-H "Authorization: Bearer bk_live_..."
HTTP statusCodeMeaning
422delivery_in_flightThe delivery is already pending or retrying; wait for it to complete
422subscription_disabledThe webhook subscription is disabled; re-enable it before redelivering
404not_foundWebhook subscription or delivery not found

GET /api/v1/webhooks/event_types

Returns the live catalog of supported event types.

Scope: webhooks.read

Terminal window
curl "https://app.benchkey.com/api/v1/webhooks/event_types" \
-H "Authorization: Bearer bk_live_..."
{
"object": "list",
"data": [
{ "object": "webhook_event_type", "type": "customer.created", "description": "A new customer record was created.", "data_object": "customer" },
{ "object": "webhook_event_type", "type": "ticket.created", "description": "A new repair ticket was created.", "data_object": "ticket" }
],
"has_more": false,
"next_cursor": null
}

  • Respond quickly. Return 200 immediately and process the event asynchronously (e.g., push to a queue). If your handler does heavy work synchronously, you risk timing out and triggering unnecessary retries.
  • Verify every signature. Never skip verification, even for test-mode events.
  • Protect against replays. Always check that the timestamp in BenchKey-Signature is within ±5 minutes of now.
  • Persist event IDs. Use a database, not an in-process Set, to track seen event IDs across restarts.
  • Use a development workspace. Create a subscription against fictional data and a public HTTPS endpoint. A bk_test_ key does not isolate data or relax URL restrictions.
  • Log raw payloads. Store the raw JSON for at least 7 days so you can replay or debug missed events.
  • Handle unknown event types gracefully. BenchKey may add new event types; always include a default case that acknowledges the delivery without erroring.
System status