Webhooks
Este conteúdo não está disponível em sua língua ainda.
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.
Subscribing to webhooks
Section titled “Subscribing to webhooks”Create a webhook subscription in Settings → Integrations & Shipping → Integrations → Webhooks:
- Click Add webhook.
- Enter your public HTTPS endpoint URL. Both live and test-labeled keys require HTTPS.
- Select the events you want to receive, then choose Create webhook.
- 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.
Event structure
Section titled “Event structure”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 }}| Field | Description |
|---|---|
object | Always "event" |
id | Unique event ID, use it to deduplicate deliveries |
type | The event name, e.g. ticket.created |
created | ISO 8601 UTC timestamp of when the event was emitted |
api_version | Always "v1" |
data | The resource DTO at the time of the event (same shape as the REST resource), or a lean transition payload for change events, see below |
Transition payloads
Section titled “Transition payloads”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" }}| Field | Description |
|---|---|
status | The ticket’s new status |
previous_status | The status it left |
changed_at | ISO 8601 UTC timestamp of the transition itself |
changed_by | Who made the change (staff email, or a system/automation actor) |
change_source | Where the change came from, e.g. queue_drag, api, an automation source |
source_kind | Always "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 lifecycle payloads
Section titled “Appointment lifecycle payloads”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.updatedaddsupdated_atand fires for every edit: a reschedule or field change, a ticket link, and a status reset (an un-cancelled appointment comes back asappointment.updatedwithstatus: "confirmed", resubscribe your calendar accordingly).appointment.canceledaddscancelled_atandcancellation_reason(capped at 500 characters). A cancellation emits onlyappointment.canceled, never a companionappointment.updated. If an appointment is un-cancelled and later cancelled again, the second cancellation is a distinct event.appointment.completedandappointment.no_showremain 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.
Event catalog
Section titled “Event catalog”| Event | Fires when | data.object type |
|---|---|---|
customer.created | A new customer record was created | customer |
customer.updated | A customer record was updated | customer |
ticket.created | A new repair ticket was created | ticket |
ticket.updated | A ticket was updated (fields other than status) | ticket |
ticket.status_changed | A ticket’s status/stage changed | ticket |
invoice.created | A new invoice was created | invoice |
invoice.sent | An invoice was sent to the customer | invoice |
invoice.paid | An invoice was fully paid | invoice |
invoice.voided | An invoice was voided | invoice |
invoice.refunded | An invoice was refunded (full or partial) | invoice |
estimate.sent | An estimate was sent to the customer | estimate |
estimate.approved | A customer approved an estimate | estimate |
estimate.declined | A customer declined an estimate | estimate |
payment.recorded | A payment was recorded against an invoice | payment |
payment.refunded | A payment was refunded (full or partial) | refund |
appointment.created | A new appointment was booked (API, dashboard, or online booking) | appointment |
appointment.updated | An appointment was rescheduled, edited, linked to a ticket, or un-cancelled | appointment |
appointment.canceled | An appointment was canceled (API or dashboard) | appointment |
appointment.completed | An appointment was marked completed | appointment |
appointment.no_show | An appointment was marked as a no-show | appointment |
lead.created | A new lead was captured | lead |
lead.updated | A lead was updated | lead |
lead.converted | A lead was converted into a repair ticket | lead |
message.received | An inbound customer message (SMS/email/portal) was received | message |
message.sent | An outbound message (SMS/email) was sent to a customer | message |
Authoritative list:
GET /api/v1/webhooks/event_typesreturns the live event catalog as a JSON array. The table above reflects the current set; new event types will be added as resources expand.
Subscriptions are scope-gated
Section titled “Subscriptions are scope-gated”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.)
Verifying signatures
Section titled “Verifying signatures”BenchKey signs every delivery with an HMAC-SHA256 signature so you can confirm the payload came from BenchKey and was not tampered with.
Signature headers
Section titled “Signature headers”| Header | Value |
|---|---|
BenchKey-Signature | t=<unix_ts>,v1=<hex_signature>; repeated v1 values during rotation |
BenchKey-Event-Id | The event ID (same as id in the body) |
BenchKey-Event-Type | The event type (same as type in the body) |
Verification algorithm
Section titled “Verification algorithm”- Extract the timestamp
tand allv1signatures fromBenchKey-Signature. - 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). - Compute
HMAC-SHA256(signingSecret, signedPayload). - Compare your digest to each
v1using a length-checked, constant-time comparison. Accept if any signature matches a currently valid signing secret. - Reject if
|now - t| > 300seconds (replay protection).
curl (illustrative request shape)
Section titled “curl (illustrative request shape)”# 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" } }'Node.js verification example
Section titled “Node.js verification example”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 exampleapp.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()(notexpress.json()) for the webhook route. The signature is computed over the raw bytes, parsing and re-serializing JSON changes whitespace and breaks verification.
Retries
Section titled “Retries”If your endpoint does not return a 2xx status within 10 seconds, BenchKey retries with exponential backoff:
| Attempt | Delay before this attempt |
|---|---|
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 hours |
| 7 | 12 hours |
| 8 | 24 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.
Failing endpoints are auto-disabled
Section titled “Failing endpoints are auto-disabled”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:
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.
Deduplication
Section titled “Deduplication”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 });});Managing subscriptions via the API
Section titled “Managing subscriptions via the API”Scope: webhooks.read for list/get/deliveries. webhooks.write for create/update/delete/ping/redeliver. webhooks.manage for rotating the signing secret.
The subscription object
Section titled “The subscription object”{ "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.
List subscriptions
Section titled “List subscriptions”GET /api/v1/webhooksScope: webhooks.read
Returns a paginated list of webhook subscriptions for the tenant.
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();Create a subscription
Section titled “Create a subscription”POST /api/v1/webhooksScope: webhooks.write
| Field | Required | Description |
|---|---|---|
url | yes | Public 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) |
events | yes | Array 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 |
description | no | Human-readable label (max 500 chars) |
enabled | no | Default true |
The key must also hold the read scope for every event family it subscribes to, see Subscriptions are scope-gated.
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 nowconsole.log(sub.signing_secret);Returns 201 with the subscription object plus signing_secret. Store the secret immediately, it cannot be retrieved later.
Retrieve a subscription
Section titled “Retrieve a subscription”GET /api/v1/webhooks/:idScope: webhooks.read
Returns the subscription object (without signing_secret).
Update a subscription
Section titled “Update a subscription”PATCH /api/v1/webhooks/:idScope: webhooks.write
Supply any combination of url, events, description, or enabled. Omitted fields are unchanged.
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 }'SSRF protection
Section titled “SSRF protection”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 a subscription
Section titled “Delete a subscription”DELETE /api/v1/webhooks/:idScope: webhooks.write
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 }.
Rotate the signing secret
Section titled “Rotate the signing secret”POST /api/v1/webhooks/:id/rotate_secretScope: 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.
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_..."Send a test ping
Section titled “Send a test ping”POST /api/v1/webhooks/:id/pingScope: 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.
curl -X POST "https://app.benchkey.com/api/v1/webhooks/whsub_abc123/ping" \ -H "Authorization: Bearer bk_live_..."List recent deliveries
Section titled “List recent deliveries”GET /api/v1/webhooks/:id/deliveriesScope: 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
| Status | Meaning |
|---|---|
pending | Queued, not yet attempted |
retrying | Prior attempt failed; scheduled for retry |
delivered | Successfully delivered (2xx from your endpoint) |
failed | Terminal failure status accepted by the delivery-management API; inspect last_error |
dead | Permanently 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.
Delivery summary
Section titled “Delivery summary”GET /api/v1/webhooks/:id/deliveries/summaryScope: 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).
Redeliver a past attempt
Section titled “Redeliver a past attempt”POST /api/v1/webhooks/:id/deliveries/:deliveryId/redeliverScope: 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
pendingorretrying(in-flight) returns422 delivery_in_flight. The delivery is already scheduled, no action is needed. Redelivering to a disabled subscription returns422 subscription_disabled; re-enable the subscription first.
curl -X POST \ "https://app.benchkey.com/api/v1/webhooks/whsub_abc123/deliveries/whd_def456/redeliver" \ -H "Authorization: Bearer bk_live_..."| HTTP status | Code | Meaning |
|---|---|---|
422 | delivery_in_flight | The delivery is already pending or retrying; wait for it to complete |
422 | subscription_disabled | The webhook subscription is disabled; re-enable it before redelivering |
404 | not_found | Webhook subscription or delivery not found |
List event types
Section titled “List event types”GET /api/v1/webhooks/event_typesReturns the live catalog of supported event types.
Scope: webhooks.read
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}Best practices
Section titled “Best practices”- Respond quickly. Return
200immediately 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-Signatureis 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
defaultcase that acknowledges the delivery without erroring.