Webhooks
Webhooks are HTTP POST requests HandyBook sends to your URL whenever something happens (invoice paid, job created, customer added, etc.). Useful for real-time integrations without polling.
How they work
- You set up a webhook endpoint — a URL on your server that accepts POSTs
- You register the URL with HandyBook and pick events to subscribe to
- When subscribed events fire, HandyBook POSTs a JSON payload to your URL
- Your server processes it and responds with HTTP 200 OK
Setting up a webhook
- Settings → Developer → Webhooks → + New Webhook
- Enter your endpoint URL (must be HTTPS, must respond within 5 seconds)
- Pick events to subscribe to:
customer.createdcustomer.updatedjob.createdjob.status_changedquote.sentquote.signedinvoice.sentinvoice.paidinvoice.overduepayment.createdpayment.refundedform.submittedexpense.created- And ~20 more — see docs
- Save — HandyBook sends a test event to verify the URL responds
Payload format
Standard envelope with event metadata + the resource data:
{
"id": "evt_abc123",
"type": "invoice.paid",
"created_at": "2026-05-03T14:23:11Z",
"data": {
"id": "inv_xyz789",
"number": "INV-1234",
"customer_id": "cust_456",
"amount": 28500,
"currency": "USD",
"paid_at": "2026-05-03T14:23:09Z",
...
}
}
Amounts are in cents/smallest currency unit.
Signature verification
Each webhook request includes a signature header:
HandyBook-Signature: t=1683123456,v1=abc123def...
Verify by:
- Computing HMAC-SHA256 of
{timestamp}.{payload}with your webhook secret - Comparing to
v1value - Rejecting if mismatch (likely forged)
- Rejecting if
tis more than 5 minutes old (replay attack)
Sample code in JavaScript / Python in the docs.
Retries
If your endpoint returns non-200 or doesn't respond within 5 seconds, HandyBook retries:
- After 30 seconds
- Then 1 minute
- Then 5 minutes
- Then 30 minutes
- Then 1 hour
- Then every 6 hours, up to 3 days
- Then gives up
View delivery attempts in Settings → Developer → Webhooks → [endpoint] → Logs.
Idempotency
Because of retries, your endpoint may receive the same event multiple times. Make your processing idempotent — store event.id and skip if already processed.
Common patterns
Update an external CRM
- Listen for
customer.updated - Push relevant fields to your other CRM
Trigger a Slack notification
- Listen for
invoice.paidwith amount > $5,000 - Post to Slack via Incoming Webhook
Sync to a data warehouse
- Listen for any event
- Append to a BigQuery / Snowflake table
- Build custom analytics
Trigger a custom workflow
- Listen for
job.status_changedtocomplete - Trigger your custom post-job process (insurance updates, certifications, etc.)
Testing locally
For local development:
- Use a tunneling tool like ngrok or Cloudflare Tunnel to expose your local endpoint
- Or use a tool like Webhook.site to inspect payloads first
Pausing / disabling
Toggle a webhook Paused while debugging. Events queue up and resume when you un-pause (within 24 hours).
Best practices
- Process asynchronously. Receive the webhook → enqueue → respond 200 immediately. Process in a background worker.
- Verify signatures. Always.
- Handle retries gracefully. Idempotent processing.
- Log everything. Webhooks fail silently otherwise.
- Use ngrok or similar during development.
FAQ
Are webhooks free? Yes — included on all tiers. Volume-related limits are very high; not something most users hit.
Can I send the webhook to multiple URLs? Yes — register multiple endpoints, each with its own event subscription.
What if my server is down — do I lose events? Events are retried over 3 days. As long as your server is back within that window, you'll receive them.
Is the payload encrypted? TLS (HTTPS) protects the connection. The payload itself isn't separately encrypted — but the signature ensures authenticity. Don't put sensitive secrets in your URL path; everything's logged.
Can I get historical events?
Yes — via API: GET /v1/events?since=.... Up to 30 days of event history.