Skip to content
ClockLap Developers
API reference clocklap.com Get an API key

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#

  1. Add your URL

    In Settings → Developers → Webhooks, click Add endpoint. Enter an HTTPS address on your server, for example https://example.com/webhooks/clocklap.

  2. 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.

  3. 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
  }
}
FieldMeaning
idUnique per event. Store it to skip events you already handled.
typeWhat happened, as object.action. See the catalogue.
livemodefalse for events from the test sandbox.
location_idWhere it happened, when it happened at a location.
actor.typeuser, api_key, system (automatic, or a kiosk) or mcp (an AI assistant).
data.objectThe 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:

HeaderValue
TT-Signaturet=<unix seconds>,v1=<signature>: check it before trusting the body
TT-Event-Id, TT-Event-TypeThe event’s id and type, so you can route without parsing
TT-Delivery-IdThis delivery (dlv_…), shown in the console’s delivery log
TT-Attempt1 for the first try, 2 for the first retry, and so on
User-AgentClockLap-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:

  1. Read the header

    Split TT-Signature on commas. Take t and every v1 (there are two for 24 hours after you rotate the secret).

  2. 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.

  3. Compare and check the time

    Accept if any v1 matches, compared in constant time, and t is 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);

Respond, and retries#

Answer with any 2xx status within 10 seconds. Anything else, or no answer, counts as a failure and we try again later:

AttemptSent after the previous failure
21 minute
35 minutes
430 minutes
52 hours
66 hours
712 hours
824 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 consoleWith the API
send a test event (webhook.test)Send test event on the endpointPOST /v1/webhook-endpoints/{id}/test
see what was sent and how you answeredRecent deliveriesGET /v1/webhook-deliveries, GET /v1/webhook-deliveries/{id}
try one delivery again nowRetry nowPOST /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 secretRotate 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": … } in data.object: useful when your data policy says personal data shouldn't sit in queues.