Webhooks push events from Duckday to your system the moment they happen — no polling. Duckday POSTs a signed JSON payload to your HTTPS endpoint for every event type you subscribe to.
Endpoints are managed by a company administrator in Duckday (or via the admin API). Each endpoint has:
https and publicly reachable (private and
local addresses are rejected);whsec_...) — shown in the admin UI, rotatable at
any time.Every delivery is a POST with this JSON body:
{
"id": "evt_9f2c1a7e0b4d3a5c6e8f0a1b",
"type": "shifts.created",
"api_version": "v1",
"created_at": "2026-07-20T10:30:00+00:00",
"company_id": "65f000000000000000000001",
"data": { "...": "the resource, same shape as the corresponding GET" }
}
data is rendered through the same resource model as the REST API (e.g. a
shifts.created event carries exactly a Shift resource) — a webhook never
exposes more than the corresponding GET endpoint.
Headers on every delivery:
| Header | Meaning |
|---|---|
X-Onwork-Event | The event type (e.g. shifts.created). |
X-Onwork-Event-Id | Stable event id — the same across retries and endpoints. |
X-Onwork-Delivery-Id | Unique per delivery attempt chain. |
X-Onwork-Signature | HMAC signature — see below. |
The signature header has the form t=<unix_ts>,v1=<hex> where
hex = HMAC_SHA256(secret, "<t>.<raw_body>"). Always verify before trusting
a delivery:
import hashlib, hmac, time
def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t = int(parts["t"])
if abs(time.time() - t) > tolerance:
return False # replay protection
expected = hmac.new(secret.encode(), f"{t}.".encode() + body,
hashlib.sha256).hexdigest()
return hmac.compare_digest(parts["v1"], expected)
Use the raw request body bytes — re-serializing the JSON breaks the digest.
Respond with any 2xx within 10 seconds — do heavy processing asynchronously. Anything else (including timeouts) schedules a retry with exponential backoff: ~1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours. After 6 failed attempts the delivery is marked dead.
An endpoint that accumulates 5 consecutive dead deliveries is automatically disabled and the company administrators are notified; it can be re-enabled from the settings after fixing the receiver. Re-enabling resets the failure count.
Ask them to set a technical contact on the endpoint: an email address warned when that automatic disabling happens. It is the one notification that reaches you rather than them, and it needs no Duckday account — which is the point, since you probably do not have one. The address is optional, can be changed or cleared at any time from the same settings page, and is written only on that event: never on a single failed delivery. One address receives at most 5 of these alerts per day (UTC), however many endpoints name it.
Deliveries can arrive more than once (retries after a lost response) and
out of order — deduplicate on X-Onwork-Event-Id and treat the payload
as a snapshot, not a diff.
Events mirror the published resources: <resource>.<created|updated|deleted>.
Like scopes, each event belongs to a platform module — subscriptions require
the module to be active, and deactivating a module silences its events.
| Event | Module |
|---|---|
company.updated | Core |
users.created / users.updated / users.deleted | Core |
employees.created / employees.updated / employees.deleted | Core |
projects.created / projects.updated / projects.deleted | Projects |
shifts.created / shifts.updated / shifts.deleted | Projects |
absences.created / absences.updated / absences.deleted | Projects |
expenses.created / expenses.updated / expenses.deleted | Projects |
todos.created / todos.updated / todos.deleted | To-Do |
*.deleted fires on soft-deletion (the platform's standard delete). The
catalog grows together with the resource catalog — see the
changelog.