> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tikk.chat/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks. Booking updates pushed to your URL

> Receive booking events as they happen: events, payload, signature verification, retries and URL rules. A Pro feature.

export const BookingLifecycle = ({initialPaid = false, initialManual = false, events = false}) => {
  const [paid, setPaid] = useState(initialPaid);
  const [manual, setManual] = useState(initialManual);
  const [status, setStatus] = useState(null);
  const [log, setLog] = useState([]);
  const [reminded, setReminded] = useState(false);
  const badges = {
    pending: {
      label: "Pending",
      background: "#ffedd5",
      color: "#c2410c"
    },
    awaiting: {
      label: "Awaiting Payment",
      background: "#fef9c3",
      color: "#a16207"
    },
    accepted: {
      label: "Accepted",
      background: "#dcfce7",
      color: "#15803d"
    },
    declined: {
      label: "Declined",
      background: "#f4f4f5",
      color: "#71717a"
    },
    cancelled: {
      label: "Cancelled",
      background: "#f4f4f5",
      color: "#71717a"
    }
  };
  const slot = {
    pending: "Slot still open to others",
    awaiting: "Slot held for the booker",
    accepted: "On your calendar",
    declined: "Nothing booked",
    cancelled: "Slot open again"
  };
  const topic = "I'd like to talk about pricing.";
  const mail = {
    newRequest: {
      to: "You",
      subject: "New Meeting Request from Jane Doe"
    },
    confirmed: {
      to: "Jane",
      subject: "Request Accepted: Meeting confirmed with Sam"
    },
    booked: {
      to: "You",
      subject: "New Meeting Booked: " + topic
    },
    declined: {
      to: "Jane",
      subject: "Meeting Request Update from Sam"
    },
    paymentRequired: {
      to: "Jane",
      subject: "Action Required: Complete Payment for Your Meeting with Sam"
    },
    receiptHost: {
      to: "You",
      subject: "Payment Received: €60,00 from \"Jane Doe\""
    },
    receiptBooker: {
      to: "Jane",
      subject: "Payment Confirmation: €… for \"" + topic + "\""
    },
    rescheduled: {
      to: "Jane",
      subject: "Meeting Rescheduled: " + topic
    },
    cancelled: {
      to: "Jane",
      subject: "Meeting Cancelled: " + topic
    },
    reminder: {
      to: "Both",
      subject: "Reminder: Meeting Tomorrow — " + topic
    }
  };
  const confirm = paid ? [mail.receiptBooker, mail.receiptHost, mail.confirmed, mail.booked] : [mail.confirmed, mail.booked];
  const actions = {
    start: [manual ? {
      label: "Jane requests a time",
      to: "pending",
      mails: [mail.newRequest],
      event: "booking.requested"
    } : paid ? {
      label: "Jane books and goes to checkout",
      to: "awaiting",
      mails: []
    } : {
      label: "Jane books",
      to: "accepted",
      mails: confirm,
      event: "booking.confirmed"
    }],
    pending: [paid ? {
      label: "You accept",
      to: "awaiting",
      mails: [mail.paymentRequired]
    } : {
      label: "You accept",
      to: "accepted",
      mails: confirm,
      event: "booking.confirmed"
    }, {
      label: "You decline",
      to: "declined",
      mails: [mail.declined],
      event: "booking.declined"
    }, {
      label: "The slot time passes",
      to: "declined",
      mails: [mail.declined],
      event: "booking.declined"
    }],
    awaiting: [{
      label: "Jane pays",
      to: "accepted",
      mails: confirm,
      event: "booking.confirmed"
    }, {
      label: "Payment fails",
      to: "cancelled",
      mails: [],
      event: "booking.cancelled"
    }],
    accepted: [!reminded && ({
      label: "A day before",
      to: "accepted",
      mails: [mail.reminder],
      reminder: true
    }), {
      label: "You reschedule",
      to: "accepted",
      mails: [mail.rescheduled],
      event: "booking.rescheduled"
    }, paid ? null : {
      label: "You cancel",
      to: "cancelled",
      mails: [mail.cancelled],
      event: "booking.cancelled"
    }].filter(Boolean)
  };
  const available = actions[status || "start"] || [];
  const run = action => {
    setStatus(action.to);
    setLog(log.concat([action]));
    if (action.reminder) {
      setReminded(true);
    }
  };
  const restart = () => {
    setStatus(null);
    setLog([]);
    setReminded(false);
  };
  const reset = apply => value => {
    apply(value);
    restart();
  };
  const toggle = (options, value, onChange) => <div className="inline-flex gap-1 rounded-lg bg-zinc-200/70 p-1 dark:bg-zinc-800">
      {options.map(option => <button key={option.label} type="button" onClick={() => onChange(option.value)} className={"inline-flex items-center gap-1.5 rounded-md px-3 py-1 text-xs font-medium transition-colors " + (value === option.value ? "bg-white text-zinc-900 shadow-sm dark:bg-zinc-700 dark:text-white" : "text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200")}>
          {option.label}
          {option.pro && <span className="rounded bg-emerald-100 px-1 text-[10px] font-semibold text-emerald-700 dark:bg-emerald-900/60 dark:text-emerald-300">
              Pro
            </span>}
        </button>)}
    </div>;
  const date = useMemo(() => {
    const upcoming = new Date();
    upcoming.setDate(upcoming.getDate() + 5);
    return upcoming.toLocaleDateString("en-US", {
      month: "short",
      day: "numeric"
    });
  }, []);
  return <div className="not-prose my-6 rounded-2xl border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-900">
      <div className="mb-4 flex flex-wrap gap-2">
        {toggle([{
    label: "Free service",
    value: false
  }, {
    label: "Paid service",
    value: true
  }], paid, reset(setPaid))}
        {toggle([{
    label: "Instant",
    value: false
  }, {
    label: "Manual approval",
    value: true,
    pro: true
  }], manual, reset(setManual))}
      </div>

      {}
      <div className="rounded-2xl border border-zinc-200 bg-white p-2 text-black dark:border-zinc-800">
        {status ? <div className="flex items-center gap-3 px-2 py-2">
            <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-zinc-100 text-[11px] font-semibold text-zinc-500">
              JD
            </div>
            <div className="min-w-0 flex-1">
              <div className="flex items-baseline gap-2 truncate">
                <span className="truncate text-sm font-semibold text-black/80">Jane Doe</span>
                <span className="hidden text-black/20 sm:inline">·</span>
                <span className="hidden truncate text-sm text-black/40 sm:inline">"{topic}"</span>
              </div>
              <div className="truncate text-xs text-black/40">{slot[status]}</div>
            </div>
            <div className="flex shrink-0 items-center gap-3">
              <span className="hidden text-xs text-black/30 sm:inline">30min</span>
              {paid && <span className="hidden text-xs font-medium text-black/50 sm:inline">€60,00</span>}
              <span className="rounded-full px-2 py-0.5 text-[0.6rem] font-bold uppercase tracking-wider" style={{
    backgroundColor: badges[status].background,
    color: badges[status].color
  }}>
                {badges[status].label}
              </span>
              <span className="hidden text-xs text-black/30 sm:inline">{date}</span>
            </div>
          </div> : <div className="px-2 py-3.5 text-center text-xs text-black/30">
            Nothing in your Inbox yet
          </div>}
      </div>

      {}
      <div className="mt-3 flex flex-wrap items-center gap-2">
        {available.map(action => <button key={action.label} type="button" onClick={() => run(action)} className="rounded-lg border border-zinc-300 bg-white px-3 py-1.5 text-xs font-medium text-zinc-800 shadow-sm transition-colors hover:border-zinc-400 hover:bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700">
            {action.label} →
          </button>)}
        {available.length === 0 && <span className="text-xs text-zinc-400">That's the end of this booking.</span>}
        {log.length > 0 && <button type="button" onClick={restart} className="ml-auto text-xs text-zinc-400 underline hover:text-zinc-700 dark:hover:text-zinc-200">
            Start over
          </button>}
      </div>

      {}
      {log.length > 0 && <ol className="mt-4 space-y-2 border-l border-zinc-200 pl-4 dark:border-zinc-700">
          {log.map((entry, index) => <li key={index} className="text-xs">
              <div className="flex flex-wrap items-center gap-2 font-medium text-zinc-700 dark:text-zinc-300">
                {entry.label}
                {events && entry.event && <code className="rounded bg-zinc-100 px-1.5 py-0.5 text-[10px] font-normal text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
                    {entry.event}
                  </code>}
              </div>
              {entry.mails.length === 0 && <div className="mt-0.5 text-zinc-400">No email</div>}
              {entry.mails.map(item => <div key={item.subject} className="mt-0.5 flex gap-2 text-zinc-500 dark:text-zinc-400">
                  <span className="shrink-0">✉</span>
                  <span className="w-9 shrink-0 font-medium">{item.to}</span>
                  <span className="min-w-0 break-words">{item.subject}</span>
                </div>)}
            </li>)}
        </ol>}
    </div>;
};

Webhooks send your booking updates to a URL as they happen, so you don't have to poll [`GET /bookings`](/api-reference/bookings/list-bookings). They're a Pro feature: deliveries go out while the Tikk account is on Pro.

## Set up a webhook

You create webhooks through the API with [`POST /webhooks`](/api-reference/webhooks/create-webhook). The credential needs `webhooks.write` **and** `bookings.read`, because every delivery carries the full booking.

```bash theme={null}
curl -X POST https://app.tikk.chat/api/v1/webhooks \
  -H "Authorization: Bearer tikk_sk_..." \
  -H "Content-Type: application/json" \
  -d '{"url": "https://yourapp.com/tikk/webhooks", "events": ["booking.confirmed", "booking.cancelled"]}'
```

A credential only sees the webhooks it created. Webhooks created with an API key appear under **Settings → API Keys**, where the account owner can remove them. Webhooks created by a partner app aren't listed there; disconnecting the app removes them. Revoking an API key removes its webhooks too.

## Events

| Event | When it's sent |
| - | - |
| `booking.requested` | A booking is waiting for you to accept it. Only for services that need manual approval. |
| `booking.confirmed` | A booking is definitely happening: auto-accepted, accepted by you, or a paid booking whose payment went through. |
| `booking.declined` | You declined a pending booking, or it expired before you answered. |
| `booking.cancelled` | You cancelled the booking, or its payment failed or expired. |
| `booking.rescheduled` | You moved the booking to another time. |

<Note>
  A paid booking is only confirmed once it's paid. If the payment fails, you
  receive `booking.cancelled` for a booking you never saw confirmed. Group
  sessions send one event per booked seat.
</Note>

Move a booking along to see which event fires at each step:

<BookingLifecycle events />

## Payload

Each delivery is a `POST` with a JSON body. `data.booking` has the same shape as [`GET /bookings/{id}`](/api-reference/bookings/get-booking), as it was at the moment of the event.

```json theme={null}
{
  "id": "evt_01j9x3k6m2c8v4n7q5r0t2w8y6",
  "type": "booking.rescheduled",
  "created_at": "2026-10-01T10:00:00+00:00",
  "data": {
    "booking": {
      "id": 1234567,
      "status": "accepted",
      "scheduled_at": "2026-10-08T14:00:00+00:00"
    },
    "previous_scheduled_at": "2026-10-07T09:00:00+00:00"
  }
}
```

`previous_scheduled_at` is only included on `booking.rescheduled`.

Every delivery carries these headers:

| Header | Value |
| - | - |
| `Tikk-Signature` | `t=<unix timestamp>,v1=<signature>`. See below. |
| `Tikk-Webhook-Id` | The event `id`. The same on every retry. |
| `Tikk-Webhook-Event` | The event type. |
| `Tikk-Webhook-Attempt` | `1` for the first try, then `2`, `3` and so on. |
| `User-Agent` | `Tikk-Webhooks/1.0` |

## Verify the signature

Every delivery has a `Tikk-Signature` header:

```text theme={null}
Tikk-Signature: t=1696154400,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

`t` is when Tikk sent the request. `v1` is an HMAC-SHA256 of `t`, a dot and the raw request body, signed with your webhook secret. To check a delivery, compute the same HMAC and compare it to `v1`. Then reject requests older than a few minutes, so a captured request can't be replayed.

Your secret is the `signing_secret` returned by `POST /webhooks` if you use an API key, or your app's webhook signing secret from the [developer portal](https://developer.tikk.chat) if you're a partner app.

<CodeGroup>
  ```javascript Node.js theme={null}
  const crypto = require("crypto");

  const header = req.get("Tikk-Signature");
  const timestamp = header.match(/t=(\d+)/)[1];
  const signatures = [...header.matchAll(/v1=([a-f0-9]+)/g)].map((match) => match[1]);

  // Sign "{t}.{raw body}" with your secret and compare it to v1.
  const expected = crypto
    .createHmac("sha256", process.env.TIKK_WEBHOOK_SECRET)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  if (!signatures.includes(expected)) {
    throw new Error("Invalid signature");
  }

  // Reject requests older than five minutes.
  if (Date.now() / 1000 - Number(timestamp) > 300) {
    throw new Error("Signature expired");
  }
  ```

  ```php PHP theme={null}
  $header = $request->header('Tikk-Signature');
  preg_match('/t=(\d+)/', $header, $timestamp);
  preg_match_all('/v1=([a-f0-9]+)/', $header, $signatures);

  // Sign "{t}.{raw body}" with your secret and compare it to v1.
  $expected = hash_hmac('sha256', $timestamp[1].'.'.$request->getContent(), config('services.tikk.webhook_secret'));

  if (! in_array($expected, $signatures[1], true)) {
      abort(400, 'Invalid signature');
  }

  // Reject requests older than five minutes.
  if (time() - (int) $timestamp[1] > 300) {
      abort(400, 'Signature expired');
  }
  ```
</CodeGroup>

Use the raw body, before your framework parses the JSON: re-encoded JSON won't match. When a partner app rotates its secret, the header carries a `v1` for the old and the new secret for 24 hours, which is why the examples accept any matching `v1`.

## Respond and retry

* Answer with any `2xx` within 10 seconds. Do slow work after you respond.
* `408`, `429`, `5xx`, timeouts and connection errors are retried: after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours.
* Any other response is final for that event. Answering `410 Gone` also switches the webhook off.
* Redirects are not followed. Use the final URL.

Delivery is **at least once** and **unordered**. The same event can arrive twice, and a later event can arrive before an earlier one. Deduplicate on `Tikk-Webhook-Id`, order by `created_at`, and fetch [`GET /bookings/{id}`](/api-reference/bookings/get-booking) when you need the current state.

After 5 events in a row fail for good, the webhook is switched off and the account owner is emailed. Fix your endpoint, then switch it back on with [`PATCH /webhooks/{id}`](/api-reference/webhooks/update-webhook) and `{"active": true}`, which also clears the failure count.

## URL rules

The URL must use HTTPS and resolve only to public addresses. Tikk refuses:

* private, loopback, link-local and other reserved IP ranges, in IPv4 and IPv6
* hostnames like `localhost` or `*.internal`, and Tikk's own domains
* URLs with a username, password or fragment

The URL is checked when you save it and again before every delivery, so a hostname that later resolves to a private address stops receiving. To test locally, expose your machine through a tunnel such as ngrok or Cloudflare Tunnel.

## Plans

Webhooks need Pro on the Tikk account they belong to. On Free, the webhook endpoints return [`plan_required`](/api-reference/errors#plan-required-403). If the account downgrades, its webhooks pause: nothing is sent and nothing is deleted. Deliveries resume after an upgrade, but events from the paused period aren't replayed.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.