Skip to main content

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

  1. Go to Settings → Developer → Webhooks.
  2. Click Add endpoint. Provide an HTTPS URL, a name, and the events you want.
  3. Copy the whsec_... signing secret from the show-once modal.
  4. Store the secret in your app's config and use it to verify incoming requests.
Note: HTTP URLs are rejected. So are private/loopback IPs (127.0.0.1, 10.0.0.0/8, 169.254.169.254) and non-standard ports. We enforce this at create time AND at every delivery to defend against DNS rebinding.

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:

  1. Parse t and v1 out of the header.
  2. Reject if |now - t| > 300 seconds (5-minute replay window).
  3. Compute hmac_sha256(secret, `${t}.${raw_body}`).
  4. 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.created

A 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.updated

A CRM contact's fields changed.

"data": { "contact_id": 1893, "updated_fields": ["email", "job_title"] }
company.created

A CRM company was created.

"data": { "company_id": 42, "name": "Acme B.V." }
company.updated

A CRM company's fields changed.

"data": { "company_id": 42, "updated_fields": ["website"] }
deal.created

A CRM deal was created.

"data": { "deal_id": 1024, "name": "Q4 renewal", "stage": "New", "value": 12000, "pipeline_id": 3, "crm_contact_id": 1893 }
deal.updated

A CRM deal's fields changed (any field).

"data": { "deal_id": 1024, "updated_fields": ["value"] }
deal.stage_changed

A 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.deleted

A CRM contact was soft-deleted (moved to recycle bin, 30-day purge countdown).

"data": { "contact_id": 1893 }
contact.restored

A CRM contact was restored from the recycle bin.

"data": { "contact_id": 1893 }
company.deleted

A CRM company was soft-deleted.

"data": { "company_id": 42 }
company.restored

A CRM company was restored from the recycle bin.

"data": { "company_id": 42 }
deal.deleted

A CRM deal was soft-deleted.

"data": { "deal_id": 1024 }
deal.restored

A CRM deal was restored from the recycle bin.

"data": { "deal_id": 1024 }
quote.created

A 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.updated

A draft quote was edited.

"data": { "quote_id": 551, "updated_fields": ["lines", "discount_amount"] }
quote.sent

A 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.accepted

The 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.declined

The customer declined the quote via the public URL.

"data": { "quote_id": 551, "decline_reason": "Out of budget" }
quote.cancelled

A sent quote was cancelled by the workspace.

"data": { "quote_id": 551 }
quote.deleted

A quote was soft-deleted (moved to recycle bin, 30-day purge countdown).

"data": { "quote_id": 551 }
quote.restored

A quote was restored from the recycle bin.

"data": { "quote_id": 551 }
invoice.created

A 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.updated

A draft invoice was edited.

"data": { "invoice_id": 551 }
invoice.sent

An 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.paid

An 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.voided

An unpaid invoice was voided.

"data": { "invoice_id": 551, "invoice_number": "INV-2026-0551", "reason": "Customer withdrew" }
invoice.credit_note_issued

A 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_sent

A manual reminder was sent for a sent/overdue invoice (rate-limited to 1/hour).

"data": { "invoice_id": 551, "invoice_number": "INV-2026-0551" }
invoice.deleted

A draft invoice was soft-deleted (moved to recycle bin, 30-day purge countdown).

"data": { "invoice_id": 551 }
invoice.restored

A draft invoice was restored from the recycle bin.

"data": { "invoice_id": 551 }
product.created

A product was created in the sales catalog.

"data": { "product_id": 42, "name": "AI Voice Agent — starter" }
product.updated

A product was updated.

"data": { "product_id": 42 }
product.deleted

A product was soft-deleted (recycle bin).

"data": { "product_id": 42 }
product.restored

A product was restored from the recycle bin.

"data": { "product_id": 42 }
booking.created

An Advanced Booking entry was created (initial status: requested).

"data": { "booking_id": 912, "status": "requested", "customer_id": 501, "service_id": 12 }
booking.updated

A booking's non-status fields were updated.

"data": { "booking_id": 912 }
booking.status_changed

A booking transitioned between statuses (state-machine enforced).

"data": { "booking_id": 912, "from_status": "requested", "to_status": "confirmed" }
booking.deleted

A booking was soft-deleted (recycle bin, 30-day purge countdown).

"data": { "booking_id": 912 }
booking.restored

A booking was restored from the recycle bin.

"data": { "booking_id": 912 }
booking.payment.paid

A 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.refunded

A 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.failed

A 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.created

A booking customer was created.

"data": { "customer_id": 501, "name": "Jane de Vries" }
booking_customer.updated

A booking customer was updated.

"data": { "customer_id": 501 }
booking_customer.deleted

A booking customer was soft-deleted (workspace-API recycle bin, NOT GDPR erasure).

"data": { "customer_id": 501 }
booking_customer.restored

A booking customer was restored from the recycle bin.

"data": { "customer_id": 501 }
task.created

A CRM task was created.

"data": { "task_id": 88, "title": "Call Acme back", "priority": "high" }
task.updated

A task was edited.

"data": { "task_id": 88 }
task.completed

A task was marked completed. Fires in addition to task.updated.

"data": { "task_id": 88 }
task.deleted

A task was soft-deleted.

"data": { "task_id": 88 }
task.restored

A task was restored.

"data": { "task_id": 88 }
note.created

A note was created.

"data": { "note_id": 202, "crm_contact_id": 1893, "crm_deal_id": null, "crm_company_id": null }
note.updated

A note was edited.

"data": { "note_id": 202 }
note.deleted

A note was soft-deleted.

"data": { "note_id": 202 }
note.restored

A note was restored.

"data": { "note_id": 202 }
segment.created

A segment was created.

"data": { "segment_id": 7, "name": "VIP customers 2026", "type": "static" }
segment.updated

A segment was edited OR its membership changed.

"data": { "segment_id": 7, "contacts_added": 3 }
segment.deleted

A segment was soft-deleted.

"data": { "segment_id": 7 }
segment.restored

A 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.

Webhooks - Workspace API - Staffify