Webhooks
Instead of polling, register an HTTPS endpoint and we'll POST event payloads to it in real-time. Every request is HMAC-SHA256 signed so you can verify it came from Staffify.
Registering an endpoint
- Go to Settings → Developer → Webhooks.
- Click Add endpoint. Provide an HTTPS URL, a name, and the events you want.
- Copy the
whsec_...signing secret from the show-once modal. - Store the secret in your app's config and use it to verify incoming requests.
Request shape
POST https://your-server.com/hooks
Content-Type: application/json
User-Agent: Staffify-Webhooks/1.0 (endpoint=Zapier receiver)
Staffify-Signature: t=1748908260,v1=1b8f...4b7c
Staffify-Event-Id: b28f10a3-4f9a-4b34-9c12-e0d7ec5c40b8
{
"id": "evt_b28f10a3-4f9a-4b34-9c12-e0d7ec5c40b8",
"type": "contact.created",
"created": 1748908260,
"data": {
"event_type": "contact.created",
"contact_id": 1893,
"email": "[email protected]",
"first_name": "Jan",
"last_name": "de Vries"
}
}Verifying the signature
The Staffify-Signature header has two fields: t (unix timestamp) and v1 (hex HMAC). To verify:
- Parse t and v1 out of the header.
- Reject if |now - t| > 300 seconds (5-minute replay window).
- Compute
hmac_sha256(secret, `${t}.${raw_body}`). - Constant-time compare with v1. Reject on mismatch.
# Verifying with curl is not typical. see Node/Python examples.
Retries + delivery guarantees
- Timeout: 10 seconds hard. Return quickly, do work async.
- Success = 2xx: any 2xx status is treated as delivered.
- Retries: up to 5 retries at 30s, 2min, 10min, 1h, 6h. Total 6 attempts.
- Dead-letter: after 6 attempts the delivery is marked dead-letter. Replay from Settings → Developer → Webhooks → Deliveries.
- Circuit breaker: after 5 consecutive failures on an endpoint, deliveries pause for 30 minutes. Fix your endpoint or click Reset circuit in the UI.
- Redirects: we do NOT follow redirects. 3xx counts as failure. Point us at the final URL directly.
Idempotency on your side
Each event has a stable id field starting with evt_. Store the last seen id per source and dedupe. retries and manual replays can re-deliver the same event id.
Event catalog
contact.createdA CRM contact was created via API, UI or automation.
"data": { "contact_id": 1893, "email": "[email protected]", "phone": "+31...", "first_name": "Jan", "last_name": "de Vries" }contact.updatedA CRM contact's fields changed.
"data": { "contact_id": 1893, "updated_fields": ["email", "job_title"] }company.createdA CRM company was created.
"data": { "company_id": 42, "name": "Acme B.V." }company.updatedA CRM company's fields changed.
"data": { "company_id": 42, "updated_fields": ["website"] }deal.createdA CRM deal was created.
"data": { "deal_id": 1024, "name": "Q4 renewal", "stage": "New", "value": 12000, "pipeline_id": 3, "crm_contact_id": 1893 }deal.updatedA CRM deal's fields changed (any field).
"data": { "deal_id": 1024, "updated_fields": ["value"] }deal.stage_changedA CRM deal moved between pipeline stages. Fires in addition to deal.updated.
"data": { "deal_id": 1024, "from_stage": "New", "to_stage": "Qualified", "pipeline_id": 3, "is_won": false, "is_lost": false }contact.deletedA CRM contact was soft-deleted (moved to recycle bin, 30-day purge countdown).
"data": { "contact_id": 1893 }contact.restoredA CRM contact was restored from the recycle bin.
"data": { "contact_id": 1893 }company.deletedA CRM company was soft-deleted.
"data": { "company_id": 42 }company.restoredA CRM company was restored from the recycle bin.
"data": { "company_id": 42 }deal.deletedA CRM deal was soft-deleted.
"data": { "deal_id": 1024 }deal.restoredA CRM deal was restored from the recycle bin.
"data": { "deal_id": 1024 }quote.createdA sales quote was created (status: draft).
"data": { "quote_id": 551, "quote_number": "Q-2026-0551", "total": 798.00, "currency": "EUR", "status": "draft", "crm_contact_id": 1893, "crm_deal_id": 1024 }quote.updatedA draft quote was edited.
"data": { "quote_id": 551, "updated_fields": ["lines", "discount_amount"] }quote.sentA quote was sent to the customer (status: sent) — public accept-URL generated.
"data": { "quote_id": 551, "quote_number": "Q-2026-0551", "total": 798.00, "currency": "EUR" }quote.acceptedThe customer accepted the quote via the public URL.
"data": { "quote_id": 551, "quote_number": "Q-2026-0551", "total": 798.00, "currency": "EUR", "accepted_by_name": "Jane Doe" }quote.declinedThe customer declined the quote via the public URL.
"data": { "quote_id": 551, "decline_reason": "Out of budget" }quote.cancelledA sent quote was cancelled by the workspace.
"data": { "quote_id": 551 }quote.deletedA quote was soft-deleted (moved to recycle bin, 30-day purge countdown).
"data": { "quote_id": 551 }quote.restoredA quote was restored from the recycle bin.
"data": { "quote_id": 551 }invoice.createdA sales invoice was created (status: draft).
"data": { "invoice_id": 551, "invoice_number": "INV-2026-0551", "status": "draft", "total": 798.00, "currency": "EUR", "crm_contact_id": 1893, "crm_deal_id": 1024 }invoice.updatedA draft invoice was edited.
"data": { "invoice_id": 551 }invoice.sentAn invoice was sent to the customer (status: sent). Public URL / payment link generated.
"data": { "invoice_id": 551, "invoice_number": "INV-2026-0551", "total": 798.00, "currency": "EUR", "public_url": "https://app.staffifyai.com/hub/acme/invoice/abc123" }invoice.paidAn invoice was fully paid (either via mark-paid or Stripe webhook). Not fired for partial payments.
"data": { "invoice_id": 551, "invoice_number": "INV-2026-0551", "total": 798.00, "amount_paid": 798.00, "currency": "EUR", "method": "bank_transfer" }invoice.voidedAn unpaid invoice was voided.
"data": { "invoice_id": 551, "invoice_number": "INV-2026-0551", "reason": "Customer withdrew" }invoice.credit_note_issuedA credit note was issued for a paid/sent invoice. credit_note_id is the NEW invoice row.
"data": { "invoice_id": 551, "credit_note_id": 552, "invoice_number": "INV-2026-0551", "reason": "Partial refund" }invoice.reminder_sentA manual reminder was sent for a sent/overdue invoice (rate-limited to 1/hour).
"data": { "invoice_id": 551, "invoice_number": "INV-2026-0551" }invoice.deletedA draft invoice was soft-deleted (moved to recycle bin, 30-day purge countdown).
"data": { "invoice_id": 551 }invoice.restoredA draft invoice was restored from the recycle bin.
"data": { "invoice_id": 551 }product.createdA product was created in the sales catalog.
"data": { "product_id": 42, "name": "AI Voice Agent — starter" }product.updatedA product was updated.
"data": { "product_id": 42 }product.deletedA product was soft-deleted (recycle bin).
"data": { "product_id": 42 }product.restoredA product was restored from the recycle bin.
"data": { "product_id": 42 }booking.createdAn Advanced Booking entry was created (initial status: requested).
"data": { "booking_id": 912, "status": "requested", "customer_id": 501, "service_id": 12 }booking.updatedA booking's non-status fields were updated.
"data": { "booking_id": 912 }booking.status_changedA booking transitioned between statuses (state-machine enforced).
"data": { "booking_id": 912, "from_status": "requested", "to_status": "confirmed" }booking.deletedA booking was soft-deleted (recycle bin, 30-day purge countdown).
"data": { "booking_id": 912 }booking.restoredA booking was restored from the recycle bin.
"data": { "booking_id": 912 }booking.payment.paidA booking payment was successfully processed via Stripe Checkout. An invoice was auto-generated (deposit or full amount). Booking-payments feature.
"data": { "booking_type": "hub", "booking_id": 912, "invoice_id": 55, "payment_intent_id": "pi_1Nx...", "amount": 25.00, "currency": "EUR", "mode": "full" }booking.payment.refundedA booking payment was refunded. A credit note was auto-generated and the booking payment_status was set to refunded.
"data": { "booking_type": "hub", "booking_id": 912, "invoice_id": 55, "credit_note_id": 56, "refund_amount": 25.00, "reason": "requested_by_customer" }booking.payment.failedA Stripe Checkout Session for a booking expired or was declined. Booking was not created (or remained pending_payment).
"data": { "booking_type": "simple", "payment_intent_id": null, "session_id": "cs_test_...", "reason": "checkout_expired" }booking_customer.createdA booking customer was created.
"data": { "customer_id": 501, "name": "Jane de Vries" }booking_customer.updatedA booking customer was updated.
"data": { "customer_id": 501 }booking_customer.deletedA booking customer was soft-deleted (workspace-API recycle bin, NOT GDPR erasure).
"data": { "customer_id": 501 }booking_customer.restoredA booking customer was restored from the recycle bin.
"data": { "customer_id": 501 }task.createdA CRM task was created.
"data": { "task_id": 88, "title": "Call Acme back", "priority": "high" }task.updatedA task was edited.
"data": { "task_id": 88 }task.completedA task was marked completed. Fires in addition to task.updated.
"data": { "task_id": 88 }task.deletedA task was soft-deleted.
"data": { "task_id": 88 }task.restoredA task was restored.
"data": { "task_id": 88 }note.createdA note was created.
"data": { "note_id": 202, "crm_contact_id": 1893, "crm_deal_id": null, "crm_company_id": null }note.updatedA note was edited.
"data": { "note_id": 202 }note.deletedA note was soft-deleted.
"data": { "note_id": 202 }note.restoredA note was restored.
"data": { "note_id": 202 }segment.createdA segment was created.
"data": { "segment_id": 7, "name": "VIP customers 2026", "type": "static" }segment.updatedA segment was edited OR its membership changed.
"data": { "segment_id": 7, "contacts_added": 3 }segment.deletedA segment was soft-deleted.
"data": { "segment_id": 7 }segment.restoredA segment was restored.
"data": { "segment_id": 7 }Rotating the signing secret
Rotation replaces the secret immediately. there is no grace period. Update your integration first, then rotate. Both new deliveries and manual replays use the new secret.
Testing & delivery logs
Four developer-tool endpoints let you simulate events, inspect delivery history and resend failed attempts without creating real resources.
POST /webhooks/:id/test-event— fires a synthetic event to one endpoint. Body:{ "event_type": "contact.created" }. Payload carries__test__: true+ nep IDs (test_contact_...) so your handler can filter test traffic. Scope:webhooks.write.GET /webhooks/:id/deliveries— list recent deliveries. Filters:status=pending|succeeded|failed, cursor pagination viastarting_after, limit 1-100. Scope:webhooks.read.GET /webhooks/:id/deliveries/:deliveryId— full detail including the original event payload, response status, response body excerpt, duration and error message. Scope:webhooks.read.POST /webhooks/:id/deliveries/:deliveryId/replay— re-dispatch the stored event. Creates a new delivery row. Rate-limited to 10 replays per hour per endpoint. HMAC is re-computed with the current signing secret — if you rotated after the original delivery, replay won't match the old signature. Scope:webhooks.write.