# Webhooks

> Send club events such as new members, bookings and payments to Zapier, Make, Power Automate or your own code the moment they happen in ClubPal.

Canonical: https://www.clubpal.app/docs/general/webhooks
Section: General

A webhook tells another service the moment something happens in ClubPal. You give ClubPal a web address (an **endpoint**), choose which events to send, and every time one of them happens ClubPal sends the details to that address. The service at the other end, whether that is Zapier, Make, Power Automate or code your club runs itself, can then act on it.

Clubs use webhooks to build the automation that suits them rather than waiting for ClubPal to build it:

- **Onboarding.** When someone joins, add them to your mailing list, send a welcome pack, or create a task for the membership secretary.
- **Leaver journeys.** When a membership ends or an automatic renewal fails, send a "we miss you" email a week later, or ask why they left.
- **Waiting lists.** When someone joins a waiting list, or moves from it into a place, post a message to the coaches' group chat.
- **Cross-posting.** When a blog post goes live, share it on Facebook or in your newsletter tool.
- **Reporting.** Copy payments, orders or attendance into a spreadsheet or your accounts package.

Webhooks are available to every club, on every plan.

## Before you start

- **Only a System Administrator can manage webhooks.** The **Webhooks** page needs the **System Administrator** permission, because an endpoint sends club data outside ClubPal. See [Permissions](https://www.clubpal.app/docs/general/permissions).
- **Each endpoint sends members' personal data to the service at that URL.** Events include the details of the people they are about, such as names, email addresses, phone numbers and dates of birth, and they go to whoever runs the address you enter. Your club is responsible for that service and for how it uses the details, so check it fits your club's privacy notice before adding it. Card details, medical information and form answers are never included.
- **The address must start with `https://`** and be reachable on the public internet. Addresses on a private or internal network are refused.
- **A club can have up to 10 endpoints.**

> **Note**
>
> ClubPal keeps a record in **Admin > Audit Log** of who added, changed or deleted each endpoint, so you can always see who pointed your club's data at which address.

## Set up an endpoint

1. Get a web address from the service that will receive the events. The [recipes](#recipes-for-zapier-make-and-power-automate) below show where Zapier, Make and Power Automate give you one.
2. In ClubPal, go to **Admin > Webhooks** and click **Add endpoint**.
3. Paste the address into **Endpoint URL**.
4. Optionally add a **Description**, a note for other administrators such as "Welcome emails in Zapier".
5. Under **Events to send**, tick the events this endpoint should receive. The box on each group's heading ticks every event in that group, and **Select all** ticks everything; each heading shows how many of its events are ticked. Type in **Search events** to find an event by its name or what it does (the group boxes and **Select all** then tick only the events the search shows), and turn on **Selected only** to check what the endpoint will receive. Choose only what the automation needs: fewer events means less personal data leaves ClubPal.
6. Click **Save endpoint**.
7. ClubPal shows the endpoint's **Signing secret**, starting `whsec_`. Click **Copy** and keep it somewhere safe, or paste it straight into your code. **It is not shown again.** If you lose it, use **Roll secret** on the endpoint's page to get a new one.

![The Add endpoint form with an example.com address in Endpoint URL, the description Post new blog posts to Facebook, the API version 2026-10-26, and blog_post.published ticked under Events to send, with every other event unticked](https://www.clubpal.app/img/docs/webhooks-add-endpoint.png)

*Tick only the events the automation needs. The names stay in English because they are what the other service filters on.*

![The Endpoint added card naming the example.com address events will now be sent to, the signing secret starting whsec_ with a Copy button, a warning that the secret is not shown again, and the Back to webhooks and Open the endpoint buttons](https://www.clubpal.app/img/docs/webhooks-signing-secret.png)

*Copy the secret before you leave this page: ClubPal only shows it once.*

The **API version** shown on the form is the shape of the events this endpoint receives. It stays fixed for the life of the endpoint, so when ClubPal adds a new version your automations keep working unchanged.

> **Tip**
>
> Use one endpoint per automation, subscribed to just the events it needs. A Zap that welcomes new members does not need booking or payment events, and keeping them separate means one broken automation never holds up another.

**Admin > Webhooks** lists every endpoint with its status, how many events it receives and how its last delivery went. Click an address to open the endpoint's page.

![The Webhooks page listing two example.com endpoints, both Enabled with one event each: Club automation in Zapier, whose last delivery was Delivered, and Post new blog posts to Facebook, with nothing sent yet](https://www.clubpal.app/img/docs/webhooks-list.png)

*Status and Last delivery show at a glance whether an endpoint is receiving events.*

## Send a test event

On the endpoint's page, click **Send test event**. ClubPal sends a `webhook.test` event straight away, through the same route as a real event, and it appears in the **Delivery log**. A test event is sent once with no retries, and a failed test never disables the endpoint.

The test body is small and fixed:

```json
{
  "id": "evt_Xb3kQ9vLmT2pWc8RfYz1Hn4d",
  "object": "event",
  "type": "webhook.test",
  "apiVersion": "2026-10-26",
  "created": "2026-11-03T18:22:05Z",
  "clubId": "8d1f4c2a-6b3e-4f7a-9c5d-2e1b0a9f8c7d",
  "data": {
    "object": {
      "object": "test",
      "endpointId": "a7c3e9f1-2b4d-4e6f-8a0c-1d3e5f7a9b2c",
      "message": "This is a test event from ClubPal."
    },
    "previous": null
  }
}
```

The tools in the recipes below often need a real example of an event to learn its fields. For that, make a real change in ClubPal that raises the event you subscribed to, for example a test booking on a session, rather than relying on the test event.

## Events

Event names stay in English in every language, because they are what the receiving service filters on. They never change once released.

**Members**

| Event | Sent when |
|---|---|
| `member.created` | Someone was added to the club: they registered, an admin added them, they accepted an invitation, or they were imported. |
| `member.updated` | A person's details changed. `previous` holds the old values. A change to something the payload does not include sends nothing. |
| `member.deleted` | A person was deleted from the club. The payload holds only their `id` and `email` and `"deleted": true`, so you can find and remove them in the other service. Nothing else about them is sent. |

**Memberships**

| Event | Sent when |
|---|---|
| `membership.activated` | A membership started, including someone rejoining after it lapsed. A member who renews by hand at checkout also sends this. |
| `membership.pending_approval` | A membership is waiting for an admin to approve it. |
| `membership.renewed` | A membership renewed automatically. |
| `membership.renewal_failed` | An automatic renewal could not be completed. `failureCode` says why: `no_payment_method` (no saved card to charge), `package_unavailable` (the membership type no longer exists or no longer allows renewal), `payment_declined` (the card was declined) or `other` (anything else, such as a payment the card provider refused for a reason other than a decline, a card payment that needed the member to approve it, which cannot happen during an automatic renewal, or a renewal that could not be finished after the card was charged). If the payment may still go through, for example because the payment provider did not answer in time or is still processing it, nothing is sent: when it goes through, the renewal completes and sends `membership.renewed` instead. |
| `membership.expired` | A membership ended, including one an admin cancelled and a trial that ran out. A membership replaced by its own renewal does not send this. A trial is usually set on the member rather than as a membership, so its event has `trial: true`, no `id` and no membership type; every other membership event has `trial: false`. |

**Session bookings**

| Event | Sent when |
|---|---|
| `session_booking.confirmed` | A place on a session was booked. |
| `session_booking.waitlisted` | Someone joined a session's waiting list, including when an admin lowers a session's capacity and places move back onto the waiting list. |
| `session_booking.promoted` | Someone moved from a waiting list into a place, including when someone on the waiting list is checked in. |
| `session_booking.cancelled` | A booking was cancelled, by the member, by an admin, or because the session was cancelled (one event per booking). |
| `session_booking.swapped` | A booking was moved to another date or session. The payload is the new booking, and `previous` holds the old session's details. |
| `session_booking.attended` | Attendance was recorded. `attended` is `true` when someone is checked in and `false` when a check-in is undone. Once a session has been over for 2 hours and nobody has been checked in for 2 hours, anyone booked but still not checked in gets `false`, as long as at least one person was checked in. |

Booking events are about participants' places only. Coaches' places on a session never send a booking event.

Only a coach or an administrator can check people in, so `session_booking.attended` always comes from someone running the session. Checking in a walk-in who had not booked sends `session_booking.confirmed` and then `session_booking.attended`. If the session is already full, the walk-in is added to the waiting list rather than taking a place, so it sends `session_booking.waitlisted` and then `session_booking.attended`. Checking in someone already on the waiting list sends `session_booking.promoted`.

**Timetable**

| Event | Sent when |
|---|---|
| `session.created` | A session was added to the timetable. |
| `session.updated` | A session, or one date of a recurring session, changed. `occurrenceDate` is set when only one date changed. |
| `session.cancelled` | A session, or one of its dates, was cancelled. `occurrenceDate` says which date. Shortening a recurring session, or turning it into a one-off, sends one for each date it no longer runs on in the next 12 months. Extending a recurring session does not send an event for each new date. |

**Payments**

| Event | Sent when |
|---|---|
| `payment.succeeded` | A payment was received, including offline payments an admin records. `paymentMethod` says which. Payments of zero send nothing. |
| `payment.refunded` | A payment was refunded, fully or in part. One event per refund. The payment's `refunds` list holds every refund so far, each with its own id starting `rf_`, so you can tell which ones you have already handled. |

**Payment requests**

| Event | Sent when |
|---|---|
| `payment_request.created` | A payment request was sent to someone, by an admin or automatically (an instalment or a hire balance, for example). |
| `payment_request.overdue` | A payment request is past its due date and still unpaid. Sent once per due date. |

When a payment request has been cancelled, its `cancellationReason` is a code, not a sentence: `membership_ended`, `booking_cancelled`, `booking_paid`, `member_deleted`, `membership_rejected`, `membership_cancelled_by_admin`, or `cancelled_by_admin` when an admin cancelled it by hand.

**Shop orders**

| Event | Sent when |
|---|---|
| `shop_order.placed` | A shop order was placed. |
| `shop_order.updated` | A shop order changed status or was cancelled. |

**Member passes**

| Event | Sent when |
|---|---|
| `member_pass.purchased` | A pass was bought, renewed automatically, or given to someone by an admin. |
| `member_pass.expired` | A pass reached its expiry date, or an admin expired it. A pass whose sessions are used up has not expired and does not send this. |

**Event tickets**

| Event | Sent when |
|---|---|
| `event_ticket_order.confirmed` | Tickets for an event were bought. |

**Facility hire**

| Event | Sent when |
|---|---|
| `facility_booking.confirmed` | A hire booking was confirmed, by the hirer or by an admin. |
| `facility_booking.cancelled` | A confirmed hire booking was cancelled. A booking abandoned at checkout, before it was ever confirmed, sends nothing. |

**Blog**

| Event | Sent when |
|---|---|
| `blog_post.published` | A blog post went live. Saving a post so that it goes live sends this straight away, as long as its publish date is within the last 30 days, so a draft started a few weeks ago still announces but an old or back-dated post does not. A post scheduled for later is sent within about 15 minutes of its publish time, and so are the posts of a blog that has just been published, but these are only sent when their publish date is within the last 2 days, so publishing a blog full of older posts does not cross-post them all. Each post is sent once, even if it is unpublished and published again. `membersOnly` is `true` when only signed-in members can read it. |

## What an event looks like

Every event arrives as a JSON `POST` with the same outer shape, the **envelope**:

| Field | What it holds |
|---|---|
| `id` | The event's unique id, starting `evt_`. The same event always has the same id, including on retries. |
| `object` | Always `"event"`. |
| `type` | The event name, such as `session_booking.cancelled`. |
| `apiVersion` | The payload version the endpoint is pinned to, such as `2026-10-26`. |
| `created` | When the event happened, in UTC. |
| `clubId` | Your club's id in ClubPal. |
| `data.object` | The record the event is about, as it is now: a member, membership, session booking, session, payment, payment request, shop order, member pass, event ticket order, hire booking or blog post. Its own `object` field says which, and its `lastUpdated` says when it was last saved, in UTC. |
| `data.previous` | On updates and status changes, the fields that changed, with their old values. `null` otherwise. `lastUpdated` never appears here. |

A few rules hold throughout:

- Field names are camelCase.
- Times ending in `Z` are UTC, in the form `2026-11-03T18:22:05Z`. Where your club thinks in local time (session times, hire bookings, blog publish dates) the payload also gives the local date and time, in fields such as `sessionStartTime`, a session's `startDate`, `startTime`, `endDate` and `endTime`, or a post's `publishDateLocal`.
- Money is a plain number in pounds, euros or whichever currency your club uses (`6.50`, not `650`), with the currency's code alongside it, such as `"currency": "GBP"`.
- Choices and statuses are lower case with underscores, such as `waiting_list` or `pending_approval`.

### Sample: session_booking.cancelled

```json
{
  "id": "evt_Kq3vT9xLmB2pWc8RfYz1Hn4d",
  "object": "event",
  "type": "session_booking.cancelled",
  "apiVersion": "2026-10-26",
  "created": "2026-07-12T08:00:00Z",
  "clubId": "8d1f4c2a-6b3e-4f7a-9c5d-2e1b0a9f8c7d",
  "data": {
    "object": {
      "object": "session_booking",
      "id": "bk-1",
      "memberId": "mem-1",
      "name": "Sam Taylor",
      "sessionId": "ses-1",
      "sessionName": "Evening Row",
      "sessionDate": "2026-07-14",
      "sessionStartDateTime": "2026-07-14T18:00:00Z",
      "sessionEndDateTime": "2026-07-14T19:30:00Z",
      "sessionStartTime": "19:00",
      "sessionEndTime": "20:30",
      "bookingStatus": "cancelled",
      "attended": null,
      "hasBeenWaitlisted": false,
      "cost": 6.50,
      "amountPaid": 6.50,
      "currency": "GBP",
      "isPaid": true,
      "paymentMethod": "when_booking",
      "dateAdded": "2026-07-10T12:01:02Z",
      "dateCancelled": "2026-07-12T08:00:00Z",
      "membershipStatus": "active",
      "paymentId": "rcpt-1",
      "memberPassId": null,
      "lastUpdated": "2026-07-12T08:00:00Z"
    },
    "previous": {
      "bookingStatus": "confirmed",
      "dateCancelled": null
    }
  }
}
```

### Sample: member.created

```json
{
  "id": "evt_Nw0f9eD7cB5a4L93827zQy",
  "object": "event",
  "type": "member.created",
  "apiVersion": "2026-10-26",
  "created": "2026-03-02T10:15:30Z",
  "clubId": "8d1f4c2a-6b3e-4f7a-9c5d-2e1b0a9f8c7d",
  "data": {
    "object": {
      "object": "member",
      "id": "mem-1",
      "name": "Sam Taylor",
      "firstName": "Sam",
      "lastName": "Taylor",
      "email": "sam@example.com",
      "status": "pending_approval",
      "membershipTypeId": "pkg-1",
      "membershipTypeName": "Senior",
      "membershipStarts": "2026-04-01",
      "membershipExpires": "2027-03-31",
      "autoRenew": true,
      "groups": ["grp-1", "grp-2"],
      "gender": "non_binary",
      "dateOfBirth": "1990-05-17",
      "phone": "01234 567890",
      "mobile": "07700 900123",
      "city": "Henley",
      "postCode": "RG9 1AA",
      "joined": "2026-03-02T10:15:30Z",
      "consentToContact": true,
      "enableEmail": false,
      "isManaged": true,
      "lastUpdated": "2026-03-02T10:15:30Z"
    },
    "previous": null
  }
}
```

### Sample: member.deleted

A deleted person's event carries only what you need to find them in the other service. Its `lastUpdated` is the time of the deletion.

```json
{
  "id": "evt_Pm7nR2sTq4vW8xYz1aBc3dEf",
  "object": "event",
  "type": "member.deleted",
  "apiVersion": "2026-10-26",
  "created": "2026-09-01T14:02:11Z",
  "clubId": "8d1f4c2a-6b3e-4f7a-9c5d-2e1b0a9f8c7d",
  "data": {
    "object": {
      "object": "member",
      "id": "mem-1",
      "email": "sam@example.com",
      "deleted": true,
      "lastUpdated": "2026-09-01T14:02:11Z"
    },
    "previous": null
  }
}
```

When a person is deleted, ClubPal also deletes every stored event about them, such as their bookings, their memberships or a payment request sent to them, together with its deliveries. Those entries disappear from the delivery log and cannot be resent. The `member.deleted` event itself is kept. Events about other people stay: a payment request an admin sent names that admin only by id (`requestedByMemberId`), so deleting the admin leaves it alone. Event ticket holders are stored as names only, so a ticket order someone else bought that lists the deleted person as a ticket holder keeps their name until the event expires after 30 days.

### Sample: payment.refunded

```json
{
  "id": "evt_Hj5kL7mN9pQ2rS4tU6vW8xY0",
  "object": "event",
  "type": "payment.refunded",
  "apiVersion": "2026-10-26",
  "created": "2026-07-15T10:00:00Z",
  "clubId": "8d1f4c2a-6b3e-4f7a-9c5d-2e1b0a9f8c7d",
  "data": {
    "object": {
      "object": "payment",
      "id": "rcpt-1",
      "memberId": "mem-1",
      "name": "Sam Taylor",
      "email": "sam@example.com",
      "description": "Main Hall hire",
      "paymentDateTime": "2026-07-14T09:29:12Z",
      "amount": 120.00,
      "currency": "GBP",
      "category": "facility_hire",
      "entityId": "fb-1",
      "paymentMethod": "Card",
      "refundedAmount": 20.00,
      "status": "partially_refunded",
      "refunds": [
        {
          "id": "rf_fTT4QjIz2Sz5n5lH",
          "amount": 20.00,
          "date": "2026-07-15T10:00:00Z"
        }
      ],
      "lastUpdated": "2026-07-15T10:00:00Z"
    },
    "previous": null
  }
}
```

### Sample: payment_request.overdue

A payment request names the person it is for (`memberId` and `name`). Whoever sent it appears only by id, as `requestedByMemberId`, never by name.

```json
{
  "id": "evt_Rt6uV8wX0yZ2aB4cD6eF8gH0",
  "object": "event",
  "type": "payment_request.overdue",
  "apiVersion": "2026-10-26",
  "created": "2026-07-14T09:30:00Z",
  "clubId": "8d1f4c2a-6b3e-4f7a-9c5d-2e1b0a9f8c7d",
  "data": {
    "object": {
      "object": "payment_request",
      "id": "pr-1",
      "memberId": "mem-1",
      "name": "Sam Taylor",
      "description": "Balance for court hire",
      "note": "Pay by Friday please.",
      "amount": 45.00,
      "currency": "GBP",
      "status": "outstanding",
      "dueDate": "2026-07-10",
      "overdue": true,
      "origin": "automatic",
      "subject": "facility_booking",
      "subjectId": "fb-1",
      "batchId": "batch-1",
      "requestedByMemberId": "admin-1",
      "dateCreated": "2026-07-01T11:00:00Z",
      "datePaid": null,
      "paymentId": null,
      "paidOffline": false,
      "dateCancelled": null,
      "cancellationReason": null,
      "reminderCount": 1,
      "lastRemindedDateTime": "2026-07-08T09:00:00Z",
      "autoCharge": true,
      "chargeFailed": true,
      "attemptCount": 2,
      "lastAttemptAt": "2026-07-11T02:00:00Z",
      "nextAttemptAt": "2026-07-15T02:00:00Z",
      "lastUpdated": "2026-07-14T09:29:45Z"
    },
    "previous": null
  }
}
```

The ids above are shortened for reading. Real ids are longer, but treat every id as an opaque string.

The fastest way to see the exact fields of any other event is to subscribe to it, cause it in ClubPal, and open the delivery in the **Delivery log**, which shows the full **Request body**.

## Headers

Every request carries:

| Header | Value |
|---|---|
| `Content-Type` | `application/json` |
| `User-Agent` | `ClubPal-Webhooks/1.0` |
| `ClubPal-Event-Id` | The event's `id`, the same as in the body. |
| `ClubPal-Event-Type` | The event name, the same as `type` in the body. |
| `ClubPal-Version` | The payload version, the same as `apiVersion` in the body. |
| `ClubPal-Signature` | `t=<time>,v1=<signature>`, explained below. |

## Check that a request came from ClubPal

Anyone who knows your endpoint's address could send it a request. If you write your own receiver, check the `ClubPal-Signature` header on every request before trusting it.

The header looks like this:

```
ClubPal-Signature: t=1700000000,v1=35495024f4ef3f94e5a93e22221544c4b75e9a42300cd965ab81cb85cd994e91
```

- `t` is when ClubPal sent the request, in seconds since 1 January 1970 (UTC).
- `v1` is a hex HMAC-SHA256 signature. To check it, take `t`, a full stop and the **raw request body exactly as received**, compute the HMAC-SHA256 of that text using the whole signing secret (including `whsec_`) as the key, and compare the result to `v1`.
- For 24 hours after you roll a secret, the header carries two `v1=` values, one for the new secret and one for the old. Accept the request if any of them matches.

Then:

1. **Reject requests older than 5 minutes.** Compare `t` with your own clock and refuse anything more than 300 seconds out. This stops someone replaying a request they captured. A retry is signed afresh, so a genuine retry always passes.
2. **De-duplicate by event id.** A retry resends the same event with the same `id`, and an event can occasionally arrive twice. Store the ids you have handled and ignore one you have seen before.
3. **Do not rely on the order events arrive in.** A retry can arrive after a later event about the same record. Every record in `data.object` carries `lastUpdated`, the time it was last saved: keep the latest `lastUpdated` you have applied for each record, and ignore an event whose `lastUpdated` is older than that, because it describes the record as it was before a change you already have.

Verify against the raw body. If your framework parses the JSON first and you turn it back into text, the spacing changes and the signature will not match.

### Node.js

```js
const crypto = require("crypto");

function verifyClubPalSignature(rawBody, header, secret) {
  let timestamp = null;
  const signatures = [];
  for (const part of header.split(",")) {
    const i = part.indexOf("=");
    const key = part.slice(0, i), value = part.slice(i + 1);
    if (key === "t") timestamp = value;
    if (key === "v1") signatures.push(value);
  }
  if (!timestamp) return false;

  // Refuse anything signed more than 5 minutes ago (or ahead).
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!(age <= 300)) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest();

  return signatures.some((sig) => {
    const given = Buffer.from(sig, "hex");
    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
}
```

With Express, keep the raw body by using `express.raw` on the webhook route:

```js
app.post("/clubpal", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifyClubPalSignature(req.body, req.get("ClubPal-Signature") || "", process.env.CLUBPAL_WEBHOOK_SECRET)) {
    return res.sendStatus(400);
  }
  const event = JSON.parse(req.body);
  // Ignore event.id if you have already handled it, then do the work.
  res.sendStatus(200);
});
```

### Python

```python

def verify_clubpal_signature(raw_body: bytes, header: str, secret: str) -> bool:
    timestamp, signatures = None, []
    for part in header.split(","):
        key, _, value = part.partition("=")
        if key == "t":
            timestamp = value
        elif key == "v1":
            signatures.append(value)
    if timestamp is None or not timestamp.isdigit():
        return False

    # Refuse anything signed more than 5 minutes ago (or ahead).
    if abs(time.time() - int(timestamp)) > 300:
        return False

    expected = hmac.new(
        secret.encode("utf-8"),
        timestamp.encode("utf-8") + b"." + raw_body,
        hashlib.sha256,
    ).hexdigest()

    return any(hmac.compare_digest(expected, sig) for sig in signatures)
```

In Flask, pass `request.get_data()` as `raw_body`.

To check your own code, the secret `whsec_test`, the time `1700000000` and the body `{}` give the signature `35495024f4ef3f94e5a93e22221544c4b75e9a42300cd965ab81cb85cd994e91`.

## Responding, retries and failures

Reply with any `2xx` status within 10 seconds to tell ClubPal the event arrived. If there is slow work to do, reply first and do it afterwards. ClubPal does not follow redirects: a `3xx` reply counts as a failure, so give ClubPal the final address.

If a delivery fails (an error status, a redirect, no answer within 10 seconds, or no connection at all), ClubPal tries again: after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours and then every 10 hours. That is 12 attempts over about 3 days. Each retry sends the same body and the same event id, with a fresh `t` and signature. If something on ClubPal's side holds a delivery up before it is tried, ClubPal picks it up again within about 15 minutes, so an event is never silently left behind. A delivery still waiting 4 days after the event (a test event, an hour after it was sent) is given up on and marked **Failed**; that never disables the endpoint or emails anyone.

If ClubPal cannot send a delivery because of a problem on its own side, the attempt shows **Not sent: a problem on ClubPal's side** in the delivery log and is retried on the same schedule. If every attempt fails that way, the delivery is marked **Failed**, but the endpoint stays on and nobody is emailed, because nothing is wrong at your end. If at least half of a delivery's attempts failed at your end, though (an error status, a timeout or no connection), the delivery counts as failing at your end, and it can disable the endpoint as described below even if its last attempt failed on ClubPal's side.

**What disables an endpoint.** If an event is still not delivered after its last attempt, and nothing else has been delivered to that endpoint since that event was first tried, ClubPal stops sending to it. An endpoint that rejects one particular event but keeps accepting others stays on: that event is marked **Failed** in the delivery log, and the rest keep flowing.

When an endpoint is turned off, its status changes to **Disabled after failures**, and every System Administrator gets one email, **Your Webhook Endpoint Has Been Turned Off**, saying that an event could not be delivered to it for about three days and nothing else got through in that time, with a **View Endpoint** button. The email is sent even if your club has turned off ClubPal's emails. New events are not sent to the endpoint, and other deliveries still waiting for it are marked **Skipped** (shown as **Stopped: endpoint turned off or deleted, or event no longer stored**; some of them may have been tried before).

**Re-enabling.** Fix the problem at the other service, then go to **Admin > Webhooks**, open the endpoint and click **Enable**. Events that happened while it was disabled are not sent automatically. To catch up, use **Resend** on the entries you need in the delivery log.

You can also switch an endpoint off yourself with **Disable**, for example while you rebuild an automation, and back on with **Enable**. Nothing is sent to a disabled endpoint, and nothing that happens meanwhile is queued for it.

## The delivery log

The endpoint's page shows the **Delivery log**: everything sent to that endpoint in the last 30 days, newest first. Filter it by **Status** (**Pending**, **Delivered**, **Failed** or **Skipped**) or by **Event type**, and open a row to see the **Request body** and every attempt: when it was made, the reply status or the error (such as **Timed out** or **Could not connect**), how long it took and the start of the reply.

![An enabled endpoint's page: its example.com address, the description Club automation in Zapier, its API version and the blog_post.published event, the Disable, Send test event and Roll secret buttons, and a delivery log with a booking.confirmed and a webhook.test delivery, both Delivered, the first opened to show its request body and one attempt that got HTTP 200](https://www.clubpal.app/img/docs/webhooks-endpoint.png)

*Open a row to see exactly what was sent and how the other service replied.*

**Resend** sends the same event to the endpoint again as a new delivery. It keeps the same event id, so a receiver that de-duplicates will ignore it if it already handled the event: resend is for events that never arrived, not for running an automation twice. The endpoint has to be enabled to resend. An event older than 30 days is no longer stored and cannot be resent.

## Rolling the secret

If a secret may have leaked, or someone who knew it has left, click **Roll secret** on the endpoint's page and confirm. ClubPal shows the **New signing secret** once, with a note saying until when the old secret keeps working; copy it, then click **I have copied it**. Requests are signed with both the new and old secrets for the next 24 hours, so you have a day to update your receiver before the old one stops working.

If you roll again within those 24 hours, for example because you lost the new secret before saving it, the secret made by the last roll stops working at once and is replaced by another new one. The secret from before you started rolling keeps working until its original 24 hours are up, and the note with the new secret says exactly when. So a receiver that still has the original secret keeps working while you sort it out.

## Deleting an endpoint

On the endpoint's page, click **Delete**. ClubPal asks twice: first **Delete this endpoint?**, explaining that nothing more is sent to it, including retries still waiting, and that the other service keeps what it already received; then **Delete the endpoint for good? This cannot be undone.** Click **Yes** both times.

To stop sending for a while without losing the endpoint and its delivery log, use **Disable** instead.

## Recipes for Zapier, Make and Power Automate

Zapier, Make and Power Automate all accept webhooks without any code. They generally cannot check the `ClubPal-Signature` header, so **the address they give you is the only thing protecting the automation**. Treat it like a password: do not post it in a chat, a forum or a screenshot, and if it does get out, create a new trigger with a new address, update the endpoint in ClubPal, and delete the old one.

### Zapier

1. Create a Zap. For the trigger, choose **Webhooks by Zapier** and the event **Catch Hook**. Webhooks by Zapier is one of Zapier's premium apps, so you may need a paid Zapier plan.
2. Copy the webhook URL Zapier shows.
3. In ClubPal, add an endpoint with that URL and the events the Zap needs.
4. Make the event happen in ClubPal (for `member.created`, add a test member), then in Zapier click **Test trigger**. Zapier pulls in the event and lists its fields, such as **Data Object Email** and **Data Object First Name**.
5. Add the actions you want, using those fields.

If one endpoint sends several kinds of event to one Zap, add a **Filter by Zapier** step on **Type** so each action only runs for the events it is meant for.

### Make

1. Create a scenario. Add the **Webhooks** module and choose **Custom webhook**.
2. Click **Add**, give the webhook a name, and save. Copy the address Make shows.
3. In ClubPal, add an endpoint with that address and the events the scenario needs.
4. In Make, the module waits to learn the data structure. Make the event happen in ClubPal and Make reads the fields from it. If it is no longer listening, click **Redetermine data structure** and cause the event again.
5. Add the modules you want, using the fields under `data` › `object`.

Use a filter on `type` between modules when one webhook receives several kinds of event.

### Power Automate

1. Create an **Automated cloud flow** (or an instant flow) with the trigger **When an HTTP request is received**. This is a premium trigger, so it needs a Power Automate licence that includes premium connectors.
2. Set **Who can trigger the flow?** to **Anyone**, because ClubPal does not sign in to Microsoft.
3. Under **Request Body JSON Schema**, click **Use sample payload to generate schema** and paste one of the samples above, or a real request body copied from the ClubPal delivery log.
4. Save the flow. Power Automate then shows the **HTTP URL**. Copy it.
5. In ClubPal, add an endpoint with that URL and the events the flow needs.
6. Add the actions you want, using the fields from the schema.

The Power Automate URL contains its own access key (`sig=` in the address), which is why it must be kept private.

## Tips

- If you are writing your own receiver, record the `ClubPal-Event-Id` before you start the work, so a retry arriving while the first attempt is still running is spotted as a duplicate.
- When something does not arrive, the **Delivery log** is the first place to look: the reply status and the start of the reply usually say exactly what went wrong.
- Changes made by ClubPal support staff, such as correcting a member's details at your request, send events just like changes your own admins make.
- Events are kept for 30 days. After that they leave the delivery log and cannot be resent.
- If the endpoint changes while you are editing it, for example because another administrator edits, disables or rolls it, your save is refused with **This endpoint changed after you opened it. Open it again to see the changes.** Open it again and redo your change, so one change never silently undoes another. This also happens if you changed it yourself in another tab. If it was deleted meanwhile, ClubPal says it could not be saved.

## See also

- [Permissions](https://www.clubpal.app/docs/general/permissions): what the **System Administrator** permission grants
- [Connect an AI assistant](https://www.clubpal.app/docs/general/connect-an-ai-assistant): the other way to use your club's data outside ClubPal
