Webhooks
Everything Hustl sends, how to verify it, and ready-made recipes for n8n and Zapier.
Overview
A webhook is an HTTP POST that Hustl sends to a URL you control whenever a subscribed event happens in your business. Instead of polling or checking the dashboard, your systems find out within seconds.
Webhooks are available on the Max plan. You manage endpoints from the dashboard at app.hustl.it → Webhooks: add up to 10 endpoint URLs, pick which events each one receives, send test events, rotate signing secrets, and inspect the delivery log.
Getting started
- Create a receiver. Any HTTPS URL that accepts a POST works — an n8n Webhook trigger, a Zapier "Catch Hook", a Make webhook, or a route on your own server.
- Add the endpoint in Hustl. In the dashboard, open Webhooks → Add endpoint, paste the URL, and choose the events you want (or "All events").
- Send a test event. Use the send-test button on the endpoint — Hustl POSTs a
pingevent and shows you the live response. - Respond with a 2xx. Anything else (or a response slower than 10 seconds) counts as a failure and is retried.
The event envelope
Every delivery is a JSON body with the same envelope; the event-specific fields live under data:
{
"id": "evt_9f2c1a7d3b4e5f6071829a3b",
"type": "booking.created",
"created_at": "2026-07-01T15:30:00+00:00",
"business_id": "org_2aBcDeFgHiJkLmNoP",
"data": {
"booking_id": "6863f1a2b4c5d6e7f8091a2b",
"status": "scheduled",
"service_name": "Full Detail — Sedan",
"customer_name": "Jamie Rivera",
"customer_email": "jamie@example.com",
"customer_phone": "+1 555 010 2233",
"start": "2026-07-03T14:00:00+00:00",
"end": "2026-07-03T16:00:00+00:00",
"location": "123 Main St, Austin, TX",
"notes": null,
"source": "public_checkout",
"created_at": "2026-07-01T15:30:00+00:00"
}
}Each request also carries these headers:
| Header | Meaning |
|---|---|
X-Hustl-Event | The event type, e.g. booking.created. |
X-Hustl-Event-Id | Unique ID of the event. If your endpoint receives the same event twice (see retries), use this to deduplicate. |
X-Hustl-Delivery-Id | Unique ID of this delivery attempt chain — also shown in the dashboard delivery log. |
X-Hustl-Signature | HMAC signature — see below. |
User-Agent | Hustl-Webhooks/1.0 |
Verifying signatures
Every endpoint has its own signing secret (shown in the dashboard, starts with whsec_). Hustl signs each delivery so you can prove the request really came from us. The X-Hustl-Signature header looks like:
X-Hustl-Signature: t=1751382600,v1=5f8a2c94e1d7b3a6...v1 is the hex HMAC-SHA256 of the string {t}.{raw request body} using your endpoint secret as the key. To verify:
Node.js
const crypto = require('crypto');
function verifyHustlSignature(rawBody, signatureHeader, secret) {
// Header looks like: t=1751382600,v1=5f8a2c...
const parts = Object.fromEntries(
signatureHeader.split(',').map((p) => p.split('='))
);
const signedPayload = parts.t + '.' + rawBody;
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
// Reject stale timestamps (older than 5 minutes) to prevent replays.
const ageSeconds = Math.abs(Date.now() / 1000 - Number(parts.t));
if (ageSeconds > 300) return false;
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(parts.v1, 'hex')
);
}Python
import hashlib, hmac, time
def verify_hustl_signature(raw_body: str, signature_header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in signature_header.split(","))
signed_payload = parts["t"] + "." + raw_body
expected = hmac.new(
secret.encode(), signed_payload.encode(), hashlib.sha256
).hexdigest()
if abs(time.time() - int(parts["t"])) > 300: # 5 min replay window
return False
return hmac.compare_digest(expected, parts["v1"])Compute the HMAC over the raw request body, before any JSON parsing — re-serialized JSON may not match byte-for-byte. Verification is optional (n8n and Zapier recipes below skip it), but strongly recommended for endpoints that trigger actions with real-world side effects.
Event reference
All timestamps are ISO 8601 in UTC. IDs are stable strings you can store and correlate across events — for example, a booking.completed event carries the same booking_id as its earlier booking.created.
order.created
A payment was received and recorded as an order. Fires for storefront checkouts, paid invoices, and subscription charges. Amounts are in the order currency; net_amount is what the business keeps after the platform fee.
"data": {
"order_id": "6863f1a2b4c5d6e7f8091c3d",
"product_id": "6851aa10ff23bc45de67f890",
"payment_source": "direct",
"total": 120.00,
"platform_fee": 3.60,
"net_amount": 116.40,
"currency": "usd",
"status": "pending",
"available_at": "2026-07-08T15:30:00+00:00",
"customer_email": "jamie@example.com",
"created_at": "2026-07-01T15:30:00+00:00"
}booking.created
A booking or quote request was created — from a storefront checkout, the dashboard, a manual invoice, or a recurring-subscription schedule. Quote requests include "is_quote_request": true; test-mode bookings include "is_test": true.
"data": {
"booking_id": "6863f1a2b4c5d6e7f8091a2b",
"status": "scheduled",
"service_name": "Full Detail — Sedan",
"customer_name": "Jamie Rivera",
"customer_email": "jamie@example.com",
"customer_phone": "+1 555 010 2233",
"start": "2026-07-03T14:00:00+00:00",
"end": "2026-07-03T16:00:00+00:00",
"location": "123 Main St, Austin, TX",
"notes": null,
"source": "public_checkout",
"created_at": "2026-07-01T15:30:00+00:00"
}booking.rescheduled
A booking was moved to a new time. The data carries both the new and the previous times.
"data": {
"booking_id": "6863f1a2b4c5d6e7f8091a2b",
"status": "scheduled",
"service_name": "Full Detail — Sedan",
"customer_name": "Jamie Rivera",
"customer_email": "jamie@example.com",
"start": "2026-07-05T14:00:00+00:00",
"end": "2026-07-05T16:00:00+00:00",
"rescheduled_at": "2026-07-01T18:12:00+00:00",
"previous_start": "2026-07-03T14:00:00+00:00",
"previous_end": "2026-07-03T16:00:00+00:00",
"reschedule_count": 1,
"source": "public_checkout",
"created_at": "2026-07-01T15:30:00+00:00"
}booking.completed
The business marked a booking completed. A good trigger for review requests and post-service follow-ups.
"data": {
"booking_id": "6863f1a2b4c5d6e7f8091a2b",
"status": "completed",
"service_name": "Full Detail — Sedan",
"customer_name": "Jamie Rivera",
"customer_email": "jamie@example.com",
"start": "2026-07-03T14:00:00+00:00",
"end": "2026-07-03T16:00:00+00:00",
"completed_at": "2026-07-03T16:05:00+00:00",
"source": "public_checkout",
"created_at": "2026-07-01T15:30:00+00:00"
}booking.cancelled
A booking was cancelled. "refunded" tells you whether a linked payment was automatically refunded as part of the cancellation.
"data": {
"booking_id": "6863f1a2b4c5d6e7f8091a2b",
"status": "cancelled",
"service_name": "Full Detail — Sedan",
"customer_name": "Jamie Rivera",
"customer_email": "jamie@example.com",
"start": "2026-07-03T14:00:00+00:00",
"end": "2026-07-03T16:00:00+00:00",
"cancelled_at": "2026-07-02T09:00:00+00:00",
"cancel_reason": "Customer requested",
"refunded": true,
"source": "public_checkout",
"created_at": "2026-07-01T15:30:00+00:00"
}review.created
A customer left a review. Contains either "order_id" or "booking_id" depending on what was reviewed.
"data": {
"review_id": "6863f9b8c7d6e5f40312a1b0",
"order_id": "6863f1a2b4c5d6e7f8091c3d",
"rating": 5,
"comment": "Great work — car looks brand new!",
"customer_name": "Jamie Rivera",
"customer_email": "jamie@example.com",
"is_auto": false,
"created_at": "2026-07-04T10:15:00+00:00"
}dispute.opened
A customer opened a payment dispute. Respond quickly — disputes pause the related funds until resolved.
"data": {
"dispute_id": "6864a0c1d2e3f40516273849",
"booking_id": "6863f1a2b4c5d6e7f8091a2b",
"order_id": "6863f1a2b4c5d6e7f8091c3d",
"customer_name": "Jamie Rivera",
"customer_email": "jamie@example.com",
"service_name": "Full Detail — Sedan",
"order_total": 120.00,
"reason": "Service was not completed as described",
"status": "open",
"created_at": "2026-07-05T08:00:00+00:00"
}payout.sent
An available-balance payout was sent to the business account.
"data": {
"payout_id": "6865b1d2e3f4051627384950",
"amount": 250.00,
"currency": "usd",
"type": "business_payout",
"created_at": "2026-07-06T12:00:00+00:00"
}ping
Sent by the "Send test event" button in the dashboard, regardless of your event selection. Use it to verify your receiver end-to-end.
"data": {
"message": "Test event from Hustl. Your endpoint is receiving webhooks correctly."
}Retries & failures
A delivery succeeds when your endpoint returns any 2xx status within 10 seconds. On failure, Hustl retries automatically with backoff:
| Attempt | When |
|---|---|
| 1 | Immediately |
| 2 | ~30 seconds later |
| 3 | ~5 minutes later |
| 4 | ~30 minutes later |
| 5 | ~2 hours later |
| 6 (final) | ~8 hours later |
- Retries mean your endpoint can occasionally receive the same event more than once. Make handlers idempotent — dedupe on
X-Hustl-Event-Id(or the envelopeid). - Events are not guaranteed to arrive in order. Use the envelope
created_atif ordering matters. - An endpoint that keeps failing permanently (10 consecutive exhausted deliveries) is automatically paused and you get an in-app notification. Fix the receiver, then re-enable it from the dashboard.
- Every attempt is visible in the dashboard delivery log, including response codes and errors.
Example: n8n
Get a Discord/Slack message and a spreadsheet row for every new booking:
- In n8n, create a workflow starting with the Webhook trigger node. Set the method to
POST, copy the production URL (not the test URL — that one only works while you're listening in the editor). - In Hustl, add that URL as an endpoint subscribed to
booking.created, then hit Send test event while n8n is in "listen for test event" mode to capture the shape. - Add an IF node on
{{ $json.body.type }}sopingtests don't trigger the real flow. - Branch to a Discord/Slack node ("New booking:
{{ $json.body.data.service_name }}at{{ $json.body.data.start }}") and a Google Sheets → Append row node mapping thedatafields. - Activate the workflow. That's it — no polling, no cron.
Other popular n8n recipes: review.created → auto-post 5-star reviews to social; dispute.opened → create a task in Notion/Trello; order.created → add the customer to your email tool.
Example: Zapier
Send yourself an SMS or email for every new order:
- Create a Zap with the trigger Webhooks by Zapier → Catch Hook. Copy the hook URL Zapier gives you.
- In Hustl, add the URL as an endpoint subscribed to
order.created, then use Send test event so Zapier can pull in a sample request. - Add a Filter step: only continue when
typeexactly matchesorder.created(this skipspingtests). - Add your action — SMS by Zapier, Gmail → Send email, or Slack → Send message — and reference fields like
data totalanddata customer_emailfrom the trigger sample.
The same pattern works in Make (Custom webhook trigger) and any other automation platform that can receive an HTTP POST.
Limits & good practice
- Up to 10 endpoints per business; each can subscribe to any set of events or all of them.
- Endpoint URLs must be HTTPS on a publicly reachable host.
- Respond fast: acknowledge with a 2xx first, then do slow work asynchronously. Receivers have 10 seconds before the attempt times out.
- Treat your signing secret like a password. If it leaks, rotate it from the dashboard — the old secret stops working immediately.
- Webhooks stop (new events are skipped, not queued) if your plan is downgraded from Max, and resume when you upgrade again.