TattooBookingDevelopers

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):

  • Appointmentsappointment.created, appointment.updated, appointment.rescheduled, appointment.cancelled, appointment.no_show, appointment.completed
  • Leadslead.created, lead.stage_changed, lead.assigned, lead.archived
  • Clientsclient.created, client.updated, client.merged, client.deleted
  • Formsform_submission.created, consent_submission.signed
  • Ideasidea.created, idea.stage_changed, idea.transferred
  • Messagesmessage.created
  • Paymentspayment.succeeded, payment.failed, payment.refunded, payout.paid, deposit.held, deposit.released
  • Artists / Shopsartist.created, artist.deactivated, shop.created
  • Subscriptionssubscription.trial_ending, subscription.activated, subscription.cancelled, subscription.past_due
  • Otherbooking_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_secret

The 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/replay

Re-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=DEAD

Each 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_at in 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).