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.