---
title: "Webhooks"
description: "Send registrations, attendance, offer clicks, poll answers and questions to Zapier, Make or your own system, with signed requests and automatic retries."
---

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

# Webhooks

A webhook sends a message to a web address the moment something happens in your webinar. Use one to pass data to Zapier, Make, a CRM that isn't [listed](/connect) or your own system. Webhooks carry more detail than [automation rules](/webinars/automations), including personal join links and answers to your registration questions.

![The Webhooks page](/images/workspace-webhooks.jpg)

Endpoints belong to the workspace, so each one receives events from every webinar and challenge you run. There's no filter by webinar, but each message says which webinar it's about, so you can filter at the other end.

## Add an endpoint

1. **Get a URL**

   Get the address that will receive the events. In Zapier, that's a **Webhooks by Zapier** trigger with **Catch Hook**. In Make, it's a **Custom webhook** module. For your own system, it's whichever address you've built.
2. **Add it to FullHouse**

   Go to **Workspace → Webhooks** and click **Add endpoint**. Paste the address into **Endpoint URL**.
3. **Choose your events**

   All the events under **Events** are ticked to start with. Untick those you don't need.
4. **Save and copy the secret**

   Click **Add endpoint**. A **Signing secret** appears once at the top of the page. Copy it now and store it safely. You'll need it to [verify requests](#verify-requests).

### Rules for the URL

The address must start with `https://` and be reachable from the public internet. It can be up to 500 characters and can include a query string, but no username, password or `#` part.

| Message | What to do |
| --- | --- |
| Enter a valid HTTPS URL. | Check that it's a complete address starting with `https://`. |
| Use a public HTTPS URL without credentials or a fragment. | Remove any `user:password@` or `#` part. Local and internal addresses aren't allowed. |
| Choose at least one supported event. | Tick at least one event. |

## Manage endpoints

Each endpoint on the page shows its URL, number of events, when it was added, how many deliveries have failed, and a chip for each event.

- **Switch** to pause or resume it. While it's paused, no new deliveries are created, so events that happen in that time are never sent. A delivery that was waiting to retry when you paused it isn't sent either.
- **Trash icon** to delete it, after you confirm "Remove this endpoint?". Its delivery history goes with it.

You can't change an endpoint's URL or events, or see or replace its secret, once it's created. Delete it and add a new one, which gets a new secret, and update your receiving system.

A paused endpoint gets no deliveries. A delivery that falls due while it's paused is marked `failed`, without using an attempt, with "This endpoint is paused. Turn it back on, then retry." Turning the endpoint back on doesn't resend these automatically, so retry each one from [Debug](/workspace/debug#retry-a-failed-webhook).

**Recent deliveries** at the bottom of the page shows the latest 20 across all your endpoints, with their status and the response your endpoint gave. To retry a failure, use [Debug](/workspace/debug#retry-a-failed-webhook).

| Status | Meaning |
| --- | --- |
| `pending` | Waiting to send, or waiting to retry. |
| `success` | Your endpoint replied with a 2xx status. |
| `failed` | All three attempts failed, or the endpoint was paused when it was due. It stays this way unless you retry it from Debug. |

## Events

| Event | When it fires | How often |
| --- | --- | --- |
| `registrant.created` | Someone new registers, including through an embed or [Pinball](/connect/pinball). A challenge sign-up sends this once, for Day 1. Adding someone on the [Registrants](/run/registrants#add-a-registrant) tab sends it too. [Deleting a registrant](/run/registrants#delete-a-registrant) sends nothing. | Once per registration. Registering again with the same email doesn't send another. |
| `attendee.joined` | Someone enters the live room for the first time. | Once per registrant |
| `attendee.watched_percent` | Someone's live watch time passes 10%, 25%, 50%, 75%, 90% or 100%. | Once per milestone at most. If two milestones pass at once, one event is sent, for the higher one. |
| `offer.seen` | An offer is shown to someone, live or on replay. | Once for the live room and once for replay, per registrant. It doesn't fire again for a second offer. |
| `offer.clicked` | Someone clicks an offer, live or on replay. | **Every click**, so expect repeats |
| `poll.answered` | Someone answers a poll. | **Every answer**, including when they change it |
| `question.asked` | Someone sends a question from the Q&A tab. Chat messages don't fire this. | Every question |
| `replay.viewed` | Someone opens the replay for the first time. | Once per registrant |

## What each message looks like

Every message is a `POST` request with a JSON body and three parts at the top level: `event`, `created_at` (when it happened, in UTC) and `data`.

### Fields in every event

| Field | What it holds |
| --- | --- |
| `data.webinar_id` | The webinar's number. For a challenge, the day's webinar. |
| `data.webinar_title` | Its title. |
| `data.session_id` | The session's number. |
| `data.session_starts_at` | When the session starts, in UTC. |
| `data.registrant.id` | The registrant's number. |
| `data.registrant.email` | Their email address. |
| `data.registrant.first_name` | Their first name. |
| `data.registrant.last_name` | Their last name, or `null`. |
| `data.registrant.phone` | Their phone number, or `null`. |
| `data.registrant.timezone` | Their time zone, or `null`. |
| `data.registrant.custom` | Answers to [registration questions](/webinars/registration-page), or `null`. Each key is a question's ID, holding the `question` and `answer`. Unanswered questions are left out. |
| `data.utm` | UTM values from registration, or `null`. Keys are `source`, `medium`, `campaign`, `term` and `content`, and only those with a value appear. |
| `data.join_link` | The person's private link to the room. |
| `data.replay_link` | Their private link to the replay. |
| `data.attended` | Whether they've entered the live room. |
| `data.watch_seconds` | Seconds watched in the live room so far. |
| `data.percent_watched` | The same as a percentage of the webinar, up to 100. Sent as a decimal, such as `50.0`. |
| `data.clicked_offer` | Whether they've clicked an offer in the live room. |

Watch figures only count the live room, so someone watching the replay can show `0`.

:::caution
These messages contain personal data and private links. Anyone with a person's `join_link` can enter the room as them, so don't post them anywhere public.
:::

### registrant.created

No extra fields. The person hasn't watched anything yet.

```json
{
  "created_at": "2026-06-09T09:12:44+00:00",
  "data": {
"attended": false,
"clicked_offer": false,
"join_link": "https://showtell.live/w/scaling-webinars/join/3f9a1c7e5b2d4f60a8c1e7d39b4f2a6c",
"percent_watched": 0.0,
"registrant": {
  "custom": {
    "q_7d1f0c2ab34e": {
      "answer": "Under 10 people",
      "question": "How big is your team?"
    }
  },
  "email": "ada@example.com",
  "first_name": "Ada",
  "id": 48213,
  "last_name": "Lovelace",
  "phone": "+44 7700 900123",
  "timezone": "Europe/London"
},
"replay_link": "https://showtell.live/w/scaling-webinars/replay/3f9a1c7e5b2d4f60a8c1e7d39b4f2a6c",
"session_id": 905,
"session_starts_at": "2026-06-10T18:00:00+00:00",
"utm": {
  "campaign": "june-launch",
  "medium": "email",
  "source": "newsletter"
},
"watch_seconds": 0,
"webinar_id": 12,
"webinar_title": "Scaling with evergreen webinars"
  },
  "event": "registrant.created"
}
```

### attendee.joined

Adds `data.join_offset_seconds`, how far into the webinar they were when they arrived.

```json
{
  "created_at": "2026-06-10T18:01:12+00:00",
  "data": {
"attended": true,
"clicked_offer": false,
"join_link": "https://showtell.live/w/scaling-webinars/join/3f9a1c7e5b2d4f60a8c1e7d39b4f2a6c",
"join_offset_seconds": 72,
"percent_watched": 0.0,
"registrant": {
  "custom": {
    "q_7d1f0c2ab34e": {
      "answer": "Under 10 people",
      "question": "How big is your team?"
    }
  },
  "email": "ada@example.com",
  "first_name": "Ada",
  "id": 48213,
  "last_name": "Lovelace",
  "phone": "+44 7700 900123",
  "timezone": "Europe/London"
},
"replay_link": "https://showtell.live/w/scaling-webinars/replay/3f9a1c7e5b2d4f60a8c1e7d39b4f2a6c",
"session_id": 905,
"session_starts_at": "2026-06-10T18:00:00+00:00",
"utm": {
  "campaign": "june-launch",
  "medium": "email",
  "source": "newsletter"
},
"watch_seconds": 0,
"webinar_id": 12,
"webinar_title": "Scaling with evergreen webinars"
  },
  "event": "attendee.joined"
}
```

### attendee.watched_percent

Adds `data.milestone`, the milestone passed: 10, 25, 50, 75, 90 or 100.

```json
{
  "created_at": "2026-06-10T18:30:40+00:00",
  "data": {
"attended": true,
"clicked_offer": false,
"join_link": "https://showtell.live/w/scaling-webinars/join/3f9a1c7e5b2d4f60a8c1e7d39b4f2a6c",
"milestone": 50,
"percent_watched": 50.0,
"registrant": {
  "custom": {
    "q_7d1f0c2ab34e": {
      "answer": "Under 10 people",
      "question": "How big is your team?"
    }
  },
  "email": "ada@example.com",
  "first_name": "Ada",
  "id": 48213,
  "last_name": "Lovelace",
  "phone": "+44 7700 900123",
  "timezone": "Europe/London"
},
"replay_link": "https://showtell.live/w/scaling-webinars/replay/3f9a1c7e5b2d4f60a8c1e7d39b4f2a6c",
"session_id": 905,
"session_starts_at": "2026-06-10T18:00:00+00:00",
"utm": {
  "campaign": "june-launch",
  "medium": "email",
  "source": "newsletter"
},
"watch_seconds": 1800,
"webinar_id": 12,
"webinar_title": "Scaling with evergreen webinars"
  },
  "event": "attendee.watched_percent"
}
```

### offer.seen

Adds `data.offer`, with the offer's `id` and `headline`.

```json
{
  "created_at": "2026-06-10T18:42:00+00:00",
  "data": {
"attended": true,
"clicked_offer": false,
"join_link": "https://showtell.live/w/scaling-webinars/join/3f9a1c7e5b2d4f60a8c1e7d39b4f2a6c",
"offer": {
  "headline": "Join the Evergreen Accelerator",
  "id": 3307
},
"percent_watched": 67.0,
"registrant": {
  "custom": {
    "q_7d1f0c2ab34e": {
      "answer": "Under 10 people",
      "question": "How big is your team?"
    }
  },
  "email": "ada@example.com",
  "first_name": "Ada",
  "id": 48213,
  "last_name": "Lovelace",
  "phone": "+44 7700 900123",
  "timezone": "Europe/London"
},
"replay_link": "https://showtell.live/w/scaling-webinars/replay/3f9a1c7e5b2d4f60a8c1e7d39b4f2a6c",
"session_id": 905,
"session_starts_at": "2026-06-10T18:00:00+00:00",
"utm": {
  "campaign": "june-launch",
  "medium": "email",
  "source": "newsletter"
},
"watch_seconds": 2400,
"webinar_id": 12,
"webinar_title": "Scaling with evergreen webinars"
  },
  "event": "offer.seen"
}
```

### offer.clicked

Adds `data.offer`, with the offer's `id`, `headline` and `url`.

```json
{
  "created_at": "2026-06-10T18:44:31+00:00",
  "data": {
"attended": true,
"clicked_offer": true,
"join_link": "https://showtell.live/w/scaling-webinars/join/3f9a1c7e5b2d4f60a8c1e7d39b4f2a6c",
"offer": {
  "headline": "Join the Evergreen Accelerator",
  "id": 3307,
  "url": "https://yoursite.com/accelerator"
},
"percent_watched": 71.0,
"registrant": {
  "custom": {
    "q_7d1f0c2ab34e": {
      "answer": "Under 10 people",
      "question": "How big is your team?"
    }
  },
  "email": "ada@example.com",
  "first_name": "Ada",
  "id": 48213,
  "last_name": "Lovelace",
  "phone": "+44 7700 900123",
  "timezone": "Europe/London"
},
"replay_link": "https://showtell.live/w/scaling-webinars/replay/3f9a1c7e5b2d4f60a8c1e7d39b4f2a6c",
"session_id": 905,
"session_starts_at": "2026-06-10T18:00:00+00:00",
"utm": {
  "campaign": "june-launch",
  "medium": "email",
  "source": "newsletter"
},
"watch_seconds": 2550,
"webinar_id": 12,
"webinar_title": "Scaling with evergreen webinars"
  },
  "event": "offer.clicked"
}
```

### poll.answered

Adds `data.poll`, with its `id`, `question` and the chosen `answer`.

```json
{
  "created_at": "2026-06-10T18:15:03+00:00",
  "data": {
"attended": true,
"clicked_offer": false,
"join_link": "https://showtell.live/w/scaling-webinars/join/3f9a1c7e5b2d4f60a8c1e7d39b4f2a6c",
"percent_watched": 25.0,
"poll": {
  "answer": "Webinars",
  "id": 3299,
  "question": "Where do most of your leads come from?"
},
"registrant": {
  "custom": {
    "q_7d1f0c2ab34e": {
      "answer": "Under 10 people",
      "question": "How big is your team?"
    }
  },
  "email": "ada@example.com",
  "first_name": "Ada",
  "id": 48213,
  "last_name": "Lovelace",
  "phone": "+44 7700 900123",
  "timezone": "Europe/London"
},
"replay_link": "https://showtell.live/w/scaling-webinars/replay/3f9a1c7e5b2d4f60a8c1e7d39b4f2a6c",
"session_id": 905,
"session_starts_at": "2026-06-10T18:00:00+00:00",
"utm": {
  "campaign": "june-launch",
  "medium": "email",
  "source": "newsletter"
},
"watch_seconds": 900,
"webinar_id": 12,
"webinar_title": "Scaling with evergreen webinars"
  },
  "event": "poll.answered"
}
```

### question.asked

Adds `data.question`, the text they sent.

```json
{
  "created_at": "2026-06-10T18:20:18+00:00",
  "data": {
"attended": true,
"clicked_offer": false,
"join_link": "https://showtell.live/w/scaling-webinars/join/3f9a1c7e5b2d4f60a8c1e7d39b4f2a6c",
"percent_watched": 33.0,
"question": "Is there a payment plan?",
"registrant": {
  "custom": {
    "q_7d1f0c2ab34e": {
      "answer": "Under 10 people",
      "question": "How big is your team?"
    }
  },
  "email": "ada@example.com",
  "first_name": "Ada",
  "id": 48213,
  "last_name": "Lovelace",
  "phone": "+44 7700 900123",
  "timezone": "Europe/London"
},
"replay_link": "https://showtell.live/w/scaling-webinars/replay/3f9a1c7e5b2d4f60a8c1e7d39b4f2a6c",
"session_id": 905,
"session_starts_at": "2026-06-10T18:00:00+00:00",
"utm": {
  "campaign": "june-launch",
  "medium": "email",
  "source": "newsletter"
},
"watch_seconds": 1200,
"webinar_id": 12,
"webinar_title": "Scaling with evergreen webinars"
  },
  "event": "question.asked"
}
```

### replay.viewed

No extra fields.

```json
{
  "created_at": "2026-06-11T08:05:09+00:00",
  "data": {
"attended": false,
"clicked_offer": false,
"join_link": "https://showtell.live/w/scaling-webinars/join/3f9a1c7e5b2d4f60a8c1e7d39b4f2a6c",
"percent_watched": 0.0,
"registrant": {
  "custom": {
    "q_7d1f0c2ab34e": {
      "answer": "Under 10 people",
      "question": "How big is your team?"
    }
  },
  "email": "ada@example.com",
  "first_name": "Ada",
  "id": 48213,
  "last_name": "Lovelace",
  "phone": "+44 7700 900123",
  "timezone": "Europe/London"
},
"replay_link": "https://showtell.live/w/scaling-webinars/replay/3f9a1c7e5b2d4f60a8c1e7d39b4f2a6c",
"session_id": 905,
"session_starts_at": "2026-06-10T18:00:00+00:00",
"utm": {
  "campaign": "june-launch",
  "medium": "email",
  "source": "newsletter"
},
"watch_seconds": 0,
"webinar_id": 12,
"webinar_title": "Scaling with evergreen webinars"
  },
  "event": "replay.viewed"
}
```

## Headers

Every request includes these headers:

| Header | What it holds |
| --- | --- |
| `Content-Type` | `application/json` |
| `X-FullHouse-Event` | The event name, such as `registrant.created`. |
| `X-FullHouse-Delivery` | A number for this delivery. It stays the same on every retry, so use it to spot repeats. |
| `X-FullHouse-Signature` | The signature, so you can check the message is genuine. |

## Verify requests

If you've built your own endpoint, check the signature before trusting a message. Otherwise, anyone who finds your URL could send you fake registrations. Tools like Zapier and Make don't usually need this, because their URLs are long and hard to guess.

`X-FullHouse-Signature` is an HMAC-SHA256 of the raw request body, using your signing secret as the key, written as lowercase hex. There's no prefix or timestamp.

Always work from the exact bytes received. Parsing the JSON and converting it back to text can change it, and the signature won't match.

```js
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const secret = process.env.FULLHOUSE_WEBHOOK_SECRET;

// express.raw keeps the body exactly as it arrived.
app.post('/fullhouse', express.raw({ type: 'application/json' }), (req, res) => {
  const expected = crypto
.createHmac('sha256', secret)
.update(req.body)
.digest('hex');
  const received = req.get('X-FullHouse-Signature') ?? '';

  const valid =
received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));

  if (!valid) {
return res.status(401).send('Invalid signature');
  }

  // Reply first, then do any slow work. You have 10 seconds.
  res.sendStatus(200);

  const event = JSON.parse(req.body);
  const delivery = req.get('X-FullHouse-Delivery');
  console.log(delivery, event.event, event.data.registrant.email);
});

app.listen(3000);
```

Since the signature has no timestamp, a captured request stays valid. If that matters to you, keep the `X-FullHouse-Delivery` numbers you've handled and ignore repeats.

## Retries

An attempt succeeds if your endpoint replies with a 2xx status within 10 seconds. Anything else counts as a failure, including a redirect, because FullHouse doesn't follow them. It allows 5 seconds of that to connect.

| Attempt | When |
| --- | --- |
| 1st | As soon as the event happens |
| 2nd | About 30 seconds after the first fails |
| 3rd | About 5 minutes after the second fails |

After the third failure, the delivery is marked `failed` and shows under [Failed webhook deliveries in Debug](/workspace/debug#retry-a-failed-webhook), where you can click **Retry** to send it again with a fresh three attempts. A retried message has its original contents, including its original `created_at`. There's no automatic retry after that, and an endpoint isn't switched off for failing.

So your system should:

- **Reply fast.** Return a 2xx straight away, and do slow work afterwards.
- **Expect repeats.** A message can arrive twice, for example if your reply gets lost. Use `X-FullHouse-Delivery` to ignore repeats.
- **Not rely on order.** Messages can arrive out of order, particularly after a retry. Use `created_at` if the order matters.

| Error | What it means |
| --- | --- |
| Webhook returned HTTP 500. | Your endpoint replied with that status. Check your system. |
| Webhook request failed. | FullHouse couldn't connect or didn't get a reply in time, the address no longer points to a public location, or the reply was over 1 MB. |

## Test an endpoint

There's no test button. To send a real event:

1. Add your endpoint. If you want a look at the messages first, use Zapier's **Catch Hook**, or any request-inspection tool, as a temporary URL.
2. Publish a webinar, or use one that's already live, and register with a new email address, such as `you+test2@yoursite.com`. That sends `registrant.created`.
3. Check **Recent deliveries** on the Webhooks page for the status.
4. For other events, join the session and watch, answer a poll, ask a question and click the offer.

Registering again with the same email doesn't send another `registrant.created`, so use a new address for each try.

## Related

- [Debug](/workspace/debug) for failed deliveries
- [Automations](/webinars/automations) for tagging without code
- [Registrants](/run/registrants) for what you can see without a webhook

Source: https://docs.meetfullhouse.com/connect/webhooks/index.mdx
