Guides
Webhooks
A webhook is an HTTPS request ClockLap sends to your server the moment something happens: a clock-in, an approved timesheet, a new person. No polling, and every request is signed so you know it came from us.
Set up an endpoint in 3 steps#
- Add your URL
In Settings → Developers → Webhooks, click Add endpoint. Enter an HTTPS address on your server, for example
https://example.com/webhooks/clocklap. - Choose events
Pick the events you want (see the event catalogue), or Every event, including ones added later. Optionally limit it to one location and everything inside it.
- Save the signing secret
It starts with
whsec_and is shown only once. Store it next to your server's other secrets. Then click Send test event to check your endpoint answers.
For local development, http://localhost URLs are allowed. To receive real events on your laptop, use a tunnel such as ngrok or Cloudflare Tunnel and register its HTTPS address.
What we send#
A POST with a JSON body. Every event has the same envelope; data.object is the thing that changed, and data.previous its earlier values when something was updated.
{
"id": "evt_01J9Z4K…",
"type": "time_entry.clocked_in",
"api_version": "2026-10-01",
"created_at": "2026-10-26T12:02:14.512Z",
"client_id": "cli_01J8…",
"region": "ca",
"livemode": true,
"location_id": "loc_01J9Q…",
"actor": { "type": "user", "id": "usr_01J9…", "name": "Ava Singh", "via": "app" },
"data": {
"object": {
"id": "ent_01J9Z4…",
"user_id": "usr_01J9…",
"user": { "id": "usr_01J9…", "name": "Ava Singh", "employee_no": "E1002" },
"location_id": "loc_01J9Q…",
"location": { "id": "loc_01J9Q…", "name": "Toronto Warehouse", "type": "site" },
"clock_in_at": "2026-10-26T12:02:00.000Z",
"clock_out_at": null,
"status": "open",
"on_break": false,
"timezone": "America/Toronto",
"local_date": "2026-10-26",
"source_in": "geofence",
"totals": { "worked_s": 0, "unpaid_break_s": 0 }
},
"previous": null
}
}| Field | Meaning |
|---|---|
id | Unique per event. Store it to skip events you already handled. |
type | What happened, as object.action. See the catalogue. |
livemode | false for events from the test sandbox. |
location_id | Where it happened, when it happened at a location. |
actor.type | user, api_key, system (automatic, or a kiosk) or mcp (an AI assistant). |
data.object | The thing that changed, as the API returns it. With an ID only (thin) endpoint, just { "id": … }: fetch the rest from the API. |
Headers on every request:
| Header | Value |
|---|---|
TT-Signature | t=<unix seconds>,v1=<signature>: check it before trusting the body |
TT-Event-Id, TT-Event-Type | The event’s id and type, so you can route without parsing |
TT-Delivery-Id | This delivery (dlv_…), shown in the console’s delivery log |
TT-Attempt | 1 for the first try, 2 for the first retry, and so on |
User-Agent | ClockLap-Webhooks/1.0 |
Verify the signature#
Anyone can send a request to your URL, so check that it came from ClockLap. The signature is an HMAC-SHA256 of <t>.<raw body> using your whole signing secret (including whsec_), written in hex:
- Read the header
Split
TT-Signatureon commas. Taketand everyv1(there are two for 24 hours after you rotate the secret). - Compute the expected signature
HMAC-SHA256 of the string
t + "." + body, with your secret as the key, as hex. Use the body exactly as received, before any JSON parsing. - Compare and check the time
Accept if any
v1matches, compared in constant time, andtis within 5 minutes of now (this stops replays of old requests).
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.CLOCKLAP_WEBHOOK_SECRET; // whsec_…
// Verify against the raw body, exactly as sent: parsing and re-serialising JSON changes it.
app.post('/webhooks/clocklap', express.raw({ type: 'application/json' }), (req, res) => {
if (!verify(req.body.toString('utf8'), req.get('TT-Signature'))) return res.status(400).send('Bad signature');
const event = JSON.parse(req.body);
// Answer quickly; do slow work after (or in a queue).
res.sendStatus(200);
handle(event);
});
function verify(body, header = '') {
const parts = header.split(',').map((p) => p.split('='));
const t = parts.find(([k]) => k === 't')?.[1];
const signatures = parts.filter(([k]) => k === 'v1').map(([, v]) => v);
if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // older than 5 minutes: refuse
const expected = crypto.createHmac('sha256', SECRET).update(`${t}.${body}`).digest('hex');
return signatures.some((s) => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}
function handle(event) {
if (event.type === 'time_entry.clocked_in') console.log(event.data.object.user_id, 'clocked in');
}
app.listen(3000);import hashlib, hmac, json, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["CLOCKLAP_WEBHOOK_SECRET"].encode() # whsec_…
def verify(body: bytes, header: str) -> bool:
parts = [p.split("=", 1) for p in header.split(",") if "=" in p]
t = next((v for k, v in parts if k == "t"), None)
signatures = [v for k, v in parts if k == "v1"]
if not t or abs(time.time() - int(t)) > 300: # older than 5 minutes: refuse
return False
expected = hmac.new(SECRET, f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(s, expected) for s in signatures)
@app.post("/webhooks/clocklap")
def clocklap_webhook():
if not verify(request.get_data(), request.headers.get("TT-Signature", "")):
abort(400)
event = json.loads(request.get_data())
if event["type"] == "time_entry.clocked_in":
print(event["data"]["object"]["user_id"], "clocked in")
return "", 200Respond, and retries#
Answer with any 2xx status within 10 seconds. Anything else, or no answer, counts as a failure and we try again later:
| Attempt | Sent after the previous failure |
|---|---|
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 hours |
| 7 | 12 hours |
| 8 | 24 hours |
| 9 (last) | 24 hours |
That's about three days in all. If an endpoint has been failing for more than 3 days it's switched off, and admins are told by email and in the app. Turn it back on in the console and choose Turn back on and resend to get what you missed.
Handle duplicates and order. A retry or a resend can deliver the same event again, and events can arrive out of order. Use the event id to skip ones you've seen, and created_at (or fetch the latest from the API) when order matters.
Test, replay and inspect#
| To… | In the console | With the API |
|---|---|---|
send a test event (webhook.test) | Send test event on the endpoint | POST /v1/webhook-endpoints/{id}/test |
| see what was sent and how you answered | Recent deliveries | GET /v1/webhook-deliveries, GET /v1/webhook-deliveries/{id} |
| try one delivery again now | Retry now | POST /v1/webhook-deliveries/{id}/retry |
| resend everything from a time window (up to 30 days) | Resend events… | POST /v1/webhook-endpoints/{id}/replay with since, until |
| change the secret | Rotate secret (the old one keeps working for 24 hours) | POST /v1/webhook-endpoints/{id}/rotate-secret |
Resent events keep their original id, so a receiver that skips seen IDs handles them safely.
Create endpoints with the API#
curl -X POST https://ca-api.clocklap.com/v1/webhook-endpoints \
-H "Authorization: Bearer $CLOCKLAP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/clocklap",
"description": "Payroll sync",
"event_types": ["time_entry.clocked_in", "time_entry.clocked_out", "timesheet.*"],
"payload": "full"
}'The response includes secret, once. event_types takes exact names, a whole group such as timesheet.*, or ["*"] for everything. Add location_id to get only events at that location and inside it. Needs the webhooks:manage scope.
Rather pull than push?#
GET /v1/events returns the same events, newest last, kept for 30 days. Filter with type (for example timesheet.*) and since, and page with after_id. Handy for a nightly job, or to catch up after downtime.
Good to know#
- URLs must be HTTPS and reach a public address. We check it when you save and before every send, and never follow redirects.
- Events and delivery logs are kept for 30 days.
- An ID only endpoint gets
{ "id": … }indata.object: useful when your data policy says personal data shouldn't sit in queues.