Send events to your tools with webhooks

Send events to your tools with webhooks

A webhook sends an event from your workspace to a URL of yours as soon as it happens: someone joins a program, completes an activity, or buys an offer. Your tool gets each event as a signed POST with a JSON body.

Only an organization Owner, Admin or Implementation Admin can add webhooks. Event payloads include members’ names and email addresses, so only send them to tools you trust with that data.

The events

EventSent when
program.member_joinedSomeone joins a program: added by an admin, invited, or through an offer. An invited person counts as joining when the invite is sent; invitation.accepted follows when they accept. A role change is not a join
invitation.acceptedAn invited person accepts and joins the workspace
activity.completedA member completes an activity for the first time
event.rsvpA member answers an event invitation, or changes their answer
enrollment.createdSomeone buys an offer, or joins a free one
program.member_removedSomeone is removed from a program

Add a webhook

  1. Open Admin Area > Integrations > Webhooks
  2. Enter the Endpoint URL. It must be https on the standard port (443), and reach the public internet
  3. Under Events, tick the events to send
  4. Under Program, keep Every program or pick one program. With one program picked, invitation.accepted is sent only when the invitation includes that program
  5. Click Add webhook
  6. Copy the signing secret from the green box. It won’t be shown again

The Webhooks page: the signing secret for a new webhook in a green box, blurred here, above the Add a webhook form

A workspace can have up to 25 webhooks added on this page. To add one from your own code instead, use POST /webhooks in the endpoint reference.

What a delivery looks like

Every delivery is a POST with these headers:

HeaderWhat it is
Upcoach-EventThe event type, as in the body’s type
Upcoach-DeliveryThis attempt’s id. A retry gets a new one
Upcoach-Signaturet=<unix time>,v1=<signature>, see below

The body of a real program.member_joined delivery:

{
  "id": "evt_729bbc9e-71f4-4fcb-996f-0ddd1aa7c76c",
  "type": "program.member_joined",
  "created_at": "2026-11-11T05:55:43Z",
  "workspace": { "id": "org-1b547af4-c0cb-4f48-8d4e-d7a1fef601b2", "name": "Brightpath Coaching" },
  "data": {
    "program": { "id": "ef6e8b2e-7e21-4b84-af23-453203c0444e", "name": "12-Week Leadership Program", "url": "https://brightpath.upcoach.com/s/spaces/coaching-cohort" },
    "member": { "id": "6548f98c-5eb0-458c-80e9-441bf5116cae", "name": "Sam Rivera", "email": "[email protected]", "timezone": "UTC" },
    "role": "participant",
    "source": "invite",
    "joined_at": "2026-11-11T05:55:43Z"
  }
}

source is invite, purchase, free_join or admin_add. The other events carry:

  • activity.completed: the same fields as completions
  • enrollment.created: the same fields as enrollments
  • program.member_removed: program, member, removed_at
  • event.rsvp: member, response (yes or no), event, program, responded_at
  • invitation.accepted: member, invited_by, programs, joined_at

To see the payloads your workspace sent recently, call GET /webhooks/samples/{event} (see the endpoint reference).

Check the signature

Check every delivery before you trust it. The Upcoach-Signature header looks like this:

t=1794376543,v1=710602c5ef2d760829143be5881eae4a5d21f82e847b69c899c9ab024898e70a

v1 is a hex HMAC-SHA256 signature made with your signing secret. To check it:

  1. Read t and every v1 from the header
  2. Compute the HMAC-SHA256 of <t>.<raw body> with your secret. Use the body exactly as received, before parsing it
  3. Accept the delivery if any v1 matches, compared in constant time
  4. Refuse it if t is more than 5 minutes from now

In Node.js:

const crypto = require('crypto')

function isFromUpcoach(header, rawBody, secret) {
  const parts = header.split(',').map((part) => part.trim().split('='))
  const t = parts.find(([key]) => key === 't')?.[1]
  const signatures = parts.filter(([key]) => key === 'v1').map(([, value]) => value)
  if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false
  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
  return signatures.some((signature) =>
    /^[0-9a-f]{64}$/.test(signature) && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)),
  )
}

In PHP:

function isFromUpcoach(string $header, string $rawBody, string $secret): bool
{
    $t = null;
    $signatures = [];
    foreach (explode(',', $header) as $part) {
        [$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
        if ($key === 't') { $t = (int) $value; }
        if ($key === 'v1') { $signatures[] = $value; }
    }
    if ($t === null || abs(time() - $t) > 300) { return false; }
    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
    foreach ($signatures as $signature) {
        if (hash_equals($expected, $signature)) { return true; }
    }
    return false;
}

Answer, retries and duplicates

  • Answer with any 2xx within 10 seconds. upcoach doesn’t follow redirects.
  • Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours: 8 attempts over about two days.
  • The same event can arrive more than once. Its body id stays the same across retries, so skip an id you have already handled.
  • A 410 Gone to an event delivery deletes the webhook. Nothing more is sent to it. A 410 to Send test doesn’t.
  • A webhook that keeps failing is turned off: when an event fails all 8 attempts and nothing else got through meanwhile, or when nothing gets through for 3 days. The workspace’s owners get an email. One event your endpoint refuses doesn’t stop the others.

Test it and read the delivery log

Next to each webhook under Webhooks:

  • Send test sends a webhook.test event and shows what your endpoint answered. You can send 5 tests a minute
  • Deliveries shows the last 50 deliveries: event, time, attempt, your endpoint’s answer, and the body. Bodies are kept for 7 days, the rest for 30
  • Rotate secret gives you a new signing secret. For 24 hours, deliveries carry two v1 values, one per secret, while you update your endpoint
  • Delete stops the webhook and deletes its delivery log

A webhook with its delivery log open: a delivered webhook.test event, answered 200, with its body

Webhook status

The list shows each webhook’s status:

  • Active: deliveries go out
  • Off: deliveries failed: click Turn on once your endpoint works again
  • Off: its creator lost access: the person who made it lost their admin role, or the token or app that made it was revoked or disconnected. It stays off. Delete it and add a new one

Webhooks made over the API show up in this list too, marked “Made over the API”.

What to do next