Buddy Punch

Webhooks Guide

Webhooks let your application receive real-time notifications when events happen in Buddy Punch. Instead of polling the API, you register a URL and Buddy Punch sends an HTTP POST to it whenever a subscribed event occurs.


Quick Start

  1. Create a webhook endpoint via the API:
curl -X POST https://api2.buddypunch.com/webhookEndpoint \
  -H "Authorization: Bearer bp_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.com/webhooks/buddypunch",
    "eventTypes": ["employee.created", "punch.created"],
    "description": "My integration"
  }'
  1. The response includes a secret field. Store it securely for signature verification.

  2. Buddy Punch will POST events to your URL as they happen.


Event Types

Subscribe to specific events or use * to receive all events.

Resource Event Description
Employee employee.created A new employee was added
employee.updated An employee profile was modified
employee.deleted An employee was deactivated or removed
Punch punch.created An employee punched in or out
punch.updated A punch was edited
punch.deleted A punch was deleted
Time Entry time_entry.created A time entry was added to a time card
time_entry.updated A time entry was modified
time_entry.deleted A time entry was removed
Shift shift.created A new shift was scheduled
shift.updated A shift was modified
shift.deleted A shift was removed
shift.published A shift was published to the employee
Location location.created A new location was added
location.updated A location was modified
location.deleted A location was removed
Job Code job_code.created A new department code was added
job_code.updated A department code was modified
job_code.deleted A department code was removed
Position position.created A new position was added
position.updated A position was modified
position.deleted A position was removed
Group group.created A new group was added
group.updated A group was modified
group.deleted A group was removed
Geofence geofence.created A new geofence was added
geofence.updated A geofence was modified
geofence.deleted A geofence was removed
PTO Request pto_request.created A PTO request was submitted
pto_request.updated A PTO request was modified
pto_request.approved A PTO request was approved
pto_request.denied A PTO request was denied
pto_request.cancelled A PTO request was cancelled
Pay Period pay_period.approved A pay period was approved/closed
pay_period.reopened A pay period was reopened
Time Card time_card.approved A time card was approved
time_card.rejected A time card was rejected
Schedule schedule.published A schedule was published
Wildcard * Subscribe to all events

Payload Format

Every webhook delivery sends a JSON payload with this structure:

{
  "id": "evt_abc123",
  "object": "event",
  "type": "employee.created",
  "api_version": "2026-03-19",
  "created": 1712588400,
  "data": {
    "id": 42,
    "firstName": "Jane",
    "lastName": "Smith",
    "email": "jane@example.com",
    "isActive": true
  }
}

Naming convention: The webhook delivery payload uses snake_case field names (api_version, created) because the delivery serializer is independent of the REST API's camelCase convention. When reading webhook POSTs, look for api_version — not apiVersion. (The REST API endpoints like GET /event and GET /webhookEndpoint return camelCase as usual.)

api_version identifies the version used for the delivered webhook payload. If Buddy Punch bumps its canonical webhook version, deliveries may begin reporting the newer api_version, so treat this field as the payload version that was sent with that specific event.

The data field contains the resource that triggered the event, serialized in the same format as the corresponding API endpoint. The created field is a Unix timestamp.


Signature Verification

Every webhook delivery includes an X-BuddyPunch-Signature header in Stripe-style format:

X-BuddyPunch-Signature: t=1712588400,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

The signed payload is {timestamp}.{body} — the timestamp and raw request body joined by a dot.

How to verify

  1. Extract the t= and v1= values from the header
  2. Construct the signed payload: {t}.{raw_body}
  3. Compute HMAC-SHA256 using your endpoint's secret as the key
  4. Compare the computed signature to the v1= value
  5. Optionally check the timestamp is within 5 minutes to prevent replay attacks

Examples

All three samples follow the same defensive pattern: trim header segments, reject missing t / v1, use a constant-time comparison, and return false on malformed input instead of throwing. See the Code Examples guide for the same verifiers in copy-paste form.

Node.js:

const crypto = require('crypto');

function verifySignature(header, body, secret) {
  if (!header) return false;

  const parts = Object.fromEntries(
    header.split(',').map(p => {
      const [k, v] = p.split('=', 2);
      return [k?.trim(), v?.trim()];
    })
  );
  const timestamp = parts.t;
  const provided  = parts.v1;
  if (!timestamp || !provided) return false;

  const ts = Number(timestamp);
  if (!Number.isFinite(ts)) return false;

  // Replay window: reject if > 5 minutes off in either direction.
  if (Math.abs(Math.floor(Date.now() / 1000) - ts) > 300) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${body}`, 'utf8')
    .digest('hex');

  const expectedBuf = Buffer.from(expected);
  const providedBuf = Buffer.from(provided);
  // timingSafeEqual throws on length mismatch — check first.
  if (expectedBuf.length !== providedBuf.length) return false;
  return crypto.timingSafeEqual(expectedBuf, providedBuf);
}

Python:

import hmac
import hashlib
import time

def verify_signature(header: str, body: str, secret: str) -> bool:
    if not header:
        return False

    parts = {}
    for segment in header.split(","):
        if "=" not in segment:
            continue
        key, value = segment.split("=", 1)
        parts[key.strip()] = value.strip()

    timestamp = parts.get("t")
    provided = parts.get("v1")
    if not timestamp or not provided:
        return False

    try:
        ts = int(timestamp)
    except ValueError:
        return False

    # Replay window: reject if > 5 minutes off in either direction.
    if abs(int(time.time()) - ts) > 300:
        return False

    expected = hmac.new(
        secret.encode(), f"{timestamp}.{body}".encode(), hashlib.sha256
    ).hexdigest()
    # compare_digest is length-safe: returns False on length mismatch.
    return hmac.compare_digest(expected, provided)

C#:

using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;

bool VerifySignature(string header, string body, string secret)
{
    if (string.IsNullOrWhiteSpace(header)) return false;

    var parts = new Dictionary<string, string>(StringComparer.Ordinal);
    foreach (var segment in header.Split(','))
    {
        var kv = segment.Split('=', 2);
        if (kv.Length != 2) continue;
        parts[kv[0].Trim()] = kv[1].Trim();
    }

    if (!parts.TryGetValue("t", out var timestampStr) ||
        !parts.TryGetValue("v1", out var provided) ||
        !long.TryParse(timestampStr, out var timestamp))
    {
        return false;
    }

    // Replay window: reject if > 5 minutes off in either direction.
    if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - timestamp) > 300) return false;

    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{timestamp}.{body}"));
    var expected = Convert.ToHexString(hash).ToLowerInvariant();

    // MUST be constant-time. Do NOT compare with `==` — that's a timing-attack
    // vector. FixedTimeEquals is length-safe (returns false on mismatch).
    return CryptographicOperations.FixedTimeEquals(
        Encoding.UTF8.GetBytes(expected),
        Encoding.UTF8.GetBytes(provided));
}

Retry Policy

If your endpoint returns a non-2xx status code or times out, Buddy Punch retries delivery with exponential backoff:

Attempt Delay
1 Immediate
2 1 minute
3 5 minutes
4 30 minutes
5 2 hours

After 5 failed attempts, the delivery is marked as failed. If an endpoint accumulates too many consecutive failures, it will be automatically disabled. You can re-enable it via the API.


Best Practices


Managing Webhook Endpoints

Operation Method Endpoint
List endpoints GET /webhookEndpoint
Get an endpoint GET /webhookEndpoint/{id}
Create an endpoint POST /webhookEndpoint
Update an endpoint POST /webhookEndpoint/{id}
Delete an endpoint DELETE /webhookEndpoint/{id}
Send a test ping POST /webhookEndpoint/{id}/ping
List events GET /event
Get event details GET /event/{id}
Redeliver an event POST /event/{id}/redeliver

See the interactive API reference for full request/response schemas and Try It functionality.


Testing locally

The fastest way to develop a webhook receiver is to point Buddy Punch at a public URL that tunnels back to your local server. We recommend ngrok (free for personal use); Cloudflare Tunnel and webhook.site work too.

1. Tunnel your local receiver to a public HTTPS URL

# Start your local receiver on, say, port 3000
node receiver.js  # or rails s -p 3000, dotnet run, etc.

# In another terminal, tunnel it
ngrok http 3000

ngrok prints an HTTPS forwarding URL like https://random-string.ngrok-free.app. Buddy Punch requires HTTPS, so use the https:// variant — not http://.

2. Register the tunnel as a webhook endpoint

curl -X POST https://api2.buddypunch.com/webhookEndpoint \
  -H "Authorization: Bearer $BUDDYPUNCH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://random-string.ngrok-free.app/webhook",
    "enabledEvents": ["employee.created", "punch.created"],
    "description": "Local dev tunnel"
  }'

Save the secret from the response (whsec_…). It's only returned on this create response — GET /webhookEndpoint/{id} will omit it for safety, so if you lose it you have to delete the endpoint and recreate.

3. Send a test event with `POST /webhookEndpoint/

curl -X POST https://api2.buddypunch.com/webhookEndpoint/123/ping \
  -H "Authorization: Bearer $BUDDYPUNCH_API_KEY"

The response is a 202 with the generated event id:

{
  "eventId": "evt_abc123…",
  "endpointId": 123,
  "object": "webhook_endpoint_ping",
  "message": "Ping event queued for delivery. Inspect the delivery attempt at GET /event/{eventId}."
}

Your receiver should see an HTTP POST with X-BuddyPunch-Event: webhook_endpoint.ping and the standard signed envelope. Ping bypasses the endpoint's enabledEvents filter, so you don't need to subscribe to a synthetic event type to test wiring.

4. Inspect the delivery attempt

curl https://api2.buddypunch.com/event/evt_abc123… \
  -H "Authorization: Bearer $BUDDYPUNCH_API_KEY"

The response includes the event plus a delivery_attempts[] array with the HTTP status code your receiver returned, the response body (truncated to 4000 chars), duration, and any error message. Use this to diagnose 4xx / 5xx responses without running ngrok in verbose mode.

Heads up — the attempt is recorded asynchronously. The 202 from step 3 means the ping was queued, not delivered. The actual HTTP POST to your receiver runs on a background worker and the delivery_attempts[] entry only appears AFTER it completes (success or failure). If the first GET shows delivery_attempts: [], wait ~1–5 seconds and re-poll. A persistent empty array typically means the worker hasn't picked up the message yet, not that ping is broken.

5. Replay a real event after fixing your receiver

If your receiver was down when a real domain event fired (e.g. punch.created) and now you've fixed it, re-fan-out the event to currently-enabled, subscribed endpoints without waiting for a new mutation. Use the evt_… id of an actual domain event from GET /eventnot the ping event id from step 3, because ping events are synthetic single-endpoint diagnostics and the API rejects redelivery of them with 400 cannot_redeliver_synthetic_event (call /ping again to re-test the endpoint instead).

Heads up — re-enable disabled endpoints first. Redelivery only fans out to endpoints whose status is enabled. If the endpoint was auto-disabled (10 consecutive delivery failures) or you disabled it manually while debugging, redelivery will succeed with endpointsNotified: 0 — you have to flip status back to enabled via POST /webhookEndpoint/{id} first.

# Find a real event id from the events list, e.g. a punch.created event
curl https://api2.buddypunch.com/event?type=punch.created \
  -H "Authorization: Bearer $BUDDYPUNCH_API_KEY"

# Then redeliver one of them
curl -X POST https://api2.buddypunch.com/event/evt_real_event_id/redeliver \
  -H "Authorization: Bearer $BUDDYPUNCH_API_KEY"

The response advertises how many endpoints will see the redelivery:

{
  "eventId": "evt_real_event_id",
  "endpointsNotified": 1,
  "object": "webhook_event_redelivery",
  "message": "Redelivery queued. Inspect the new delivery attempts at GET /event/{eventId}."
}

Redelivery targets the currently-enabled, subscribed endpoint set (not the snapshot from original publish time), so endpoints added after the original event can still backfill via redelivery — and disabled / auto-disabled endpoints are excluded. The event id stays the same — every (re)delivery attempt shows up grouped under the original event in GET /event/{id}.

Same async-attempt caveat as the ping flow. The 202 here means the redelivery was queued; the new entries in the delivery_attempts[] array on GET /event/{eventId} show up after the background worker actually POSTs to each receiver. Poll the event back periodically rather than expecting the array to grow synchronously with the redelivery call.