Webhooks
Webhooks let you receive real-time HTTP callbacks when events happen in your tenant — appointments booked, leads created, payments succeed, etc. You subscribe an HTTPS endpoint to one or more event types, and we POST signed payloads to it as events fire.
Event taxonomy
32 public event types across 9 domains. All in snake_case.dotted form (Stripe convention):
- Appointments —
appointment.created,appointment.updated,appointment.rescheduled,appointment.cancelled,appointment.no_show,appointment.completed - Leads —
lead.created,lead.stage_changed,lead.assigned,lead.archived - Clients —
client.created,client.updated,client.merged,client.deleted - Forms —
form_submission.created,consent_submission.signed - Ideas —
idea.created,idea.stage_changed,idea.transferred - Messages —
message.created - Payments —
payment.succeeded,payment.failed,payment.refunded,payout.paid,deposit.held,deposit.released - Artists / Shops —
artist.created,artist.deactivated,shop.created - Subscriptions —
subscription.trial_ending,subscription.activated,subscription.cancelled,subscription.past_due - Other —
booking_link.sent
Subscribing
Create a WebhookEndpoint with the URL and event filter:
POST /v1/webhook_endpoints
{
"url": "https://your-app.com/webhooks/tattoobooking",
"events": ["appointment.created", "lead.created"],
"description": "Production main webhook"
}The response contains the endpoint ID and the signing secret:
{
"endpoint": {
"id": "whe_<uuid>",
"url": "...",
"status": "ACTIVE",
...
},
"secret": "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}The secret is shown only once. Save it somewhere secure (you'll need it to verify signatures). If lost, rotate viaPOST /v1/webhook_endpoints/:id/rotate_secret.
Use events: ["*"] to subscribe to every public event type.
Delivery shape
Every webhook delivery is a POST with JSON body and a signature header:
POST /webhooks/tattoobooking HTTP/1.1
Content-Type: application/json
User-Agent: TattooBooking-Webhook/1.0
TattooBooking-Signature: t=1700000000,v1=abc123...
TattooBooking-Event-Type: appointment.created
TattooBooking-Event-Id: evt_xxxxxxxx
TattooBooking-Delivery-Id: wbd_xxxxxxxx
TattooBooking-Attempt: 1
{
"id": "evt_xxxxxxxx",
"type": "appointment.created",
"api_version": "v1",
"tenant_id": "ten_abc...",
"created_at": "2026-05-27T15:00:00.000Z",
"data": { ... }
}Signature verification
The TattooBooking-Signature header is Stripe-format:t=<unix-seconds>,v1=<hex-sig>. ComputeHMAC-SHA256(secret, "${t}.${rawBody}") and compare in constant time.
Node.js
import { createHmac, timingSafeEqual } from 'crypto';
function verifyWebhookSignature(rawBody, header, secret) {
if (!header) return false;
const parts = header.split(',').map(p => p.trim());
let timestamp;
const sigs = [];
for (const p of parts) {
const [k, v] = p.split('=');
if (k === 't') timestamp = Number(v);
else if (k === 'v1') sigs.push(v);
}
if (!timestamp || sigs.length === 0) return false;
// Reject replays: ±5 minutes
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 300) return false;
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const expectedBuf = Buffer.from(expected, 'hex');
return sigs.some(sig => {
const sigBuf = Buffer.from(sig, 'hex');
return sigBuf.length === expectedBuf.length
&& timingSafeEqual(sigBuf, expectedBuf);
});
}Python
import hmac, hashlib, time
def verify_webhook(raw_body: bytes, header: str, secret: str) -> bool:
if not header:
return False
parts = [p.strip() for p in header.split(',')]
timestamp = None
sigs = []
for p in parts:
if '=' not in p: continue
k, v = p.split('=', 1)
if k == 't': timestamp = int(v)
elif k == 'v1': sigs.append(v)
if not timestamp or not sigs:
return False
if abs(time.time() - timestamp) > 300:
return False
expected = hmac.new(
secret.encode(),
f"{timestamp}.{raw_body.decode()}".encode(),
hashlib.sha256,
).hexdigest()
return any(hmac.compare_digest(expected, sig) for sig in sigs)Retries
We retry failed deliveries with exponential backoff over ~72 hours (12 attempts):
30s → 1m → 5m → 15m → 30m → 1h → 2h → 4h → 8h → 12h → 1d → 1d
A delivery counts as failed on:
- 5xx response
- 429 response (rate limited by your server)
- Network error (connection refused, timeout, DNS failure)
A delivery counts as terminally failed (no retry) on:
- Any 4xx response other than 429 — that's your server saying the payload is wrong, so retrying doesn't help.
After 50 consecutive failures, the endpoint is automaticallyDISABLED and you receive an email. Re-activate viaPATCH /v1/webhook_endpoints/:id with {"status": "ACTIVE"} once you've fixed the issue.
Idempotency
Your endpoint will receive every event at least once, sometimes more than once (during retries). Use the id field on the envelope (evt_xxxxxxxx) as an idempotency key — dedupe by storing recently- processed event IDs.
Secret rotation
To rotate without dropped deliveries:
POST /v1/webhook_endpoints/:id/rotate_secretThe response includes the new secret. For the next 48 hours, both the old and new secrets are valid (deliveries are signed with both — the header will have two v1= entries). Update your verifier to accept the new secret, redeploy, then optionally call POST /rotate_secret again to invalidate the old one immediately.
Replaying a failed delivery
POST /v1/deliveries/:id/replayRe-enqueues a specific delivery for another attempt. Useful when your server was down or you fixed a bug and want to backfill.
Delivery audit
See the history of attempts for an endpoint:
GET /v1/webhook_endpoints/:id/deliveries?status=DEADEach row records the request body, response status, response body (truncated to 8KB), duration, and any error message. Useful for debugging flaky deliveries.
Best practices
- Respond within 5 seconds. We time out at 10s, but if your handler takes that long you're holding a connection per delivery and will fall behind under volume.
- Acknowledge first, process async. Return 200 immediately, then process the event from a queue.
- Dedupe by event ID. Idempotency is your responsibility on the receiving side.
- Verify before trusting. Don't mutate state until you've verified the HMAC.
- Don't rely on delivery order. Retries can deliver out of order. Use
created_atin the envelope for ordering.
Plan requirement
Outbound webhooks are unlocked at the Solo plan ($49/mo). Number of active endpoints is plan-tiered (Solo: 3, Duo: 5, Crew: 10, Studio: 20, Empire: unlimited).