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
- 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"
}'
The response includes a
secretfield. Store it securely for signature verification.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 forapi_version— notapiVersion. (The REST API endpoints likeGET /eventandGET /webhookEndpointreturn camelCase as usual.)
api_versionidentifies the version used for the delivered webhook payload. If Buddy Punch bumps its canonical webhook version, deliveries may begin reporting the newerapi_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
t=— Unix timestamp of when the signature was generatedv1=— HMAC-SHA256 hex digest
The signed payload is {timestamp}.{body} — the timestamp and raw request body joined by a dot.
How to verify
- Extract the
t=andv1=values from the header - Construct the signed payload:
{t}.{raw_body} - Compute HMAC-SHA256 using your endpoint's
secretas the key - Compare the computed signature to the
v1=value - 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
- Respond quickly. Return a
200status code within 30 seconds. Process the payload asynchronously if needed. - Use HTTPS. Webhook URLs must use HTTPS for security.
- Verify signatures. Always validate the
X-BuddyPunch-Signatureheader before trusting the payload. - Handle duplicates. In rare cases, the same event may be delivered more than once. Use the event
idto deduplicate. - Monitor delivery. Use the Events API (
GET /event) to inspect delivery attempts and troubleshoot failures.
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
202from step 3 means the ping was queued, not delivered. The actual HTTP POST to your receiver runs on a background worker and thedelivery_attempts[]entry only appears AFTER it completes (success or failure). If the firstGETshowsdelivery_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 /event — not 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
statusisenabled. If the endpoint was auto-disabled (10 consecutive delivery failures) or you disabled it manually while debugging, redelivery will succeed withendpointsNotified: 0— you have to flipstatusback toenabledviaPOST /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
202here means the redelivery was queued; the new entries in thedelivery_attempts[]array onGET /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.
Related Guides
- Getting Started — first request, envelope, pagination, rate limits
- Errors — full error catalog (including webhook-delivery failures on
/event) - Idempotency — safe retry semantics (same philosophy as our outbound delivery retries)
- Code Examples — signature-verification samples in Node.js / Python / C#
- Data Model —
WebhookEndpointandWebhookEventfield reference