Developer guide
Webhooks
Unboredly can tell the tools your company already uses when something happens: a task starts or finishes, someone reports a blocker, a Journey reaches day 30, 60 or 90. Admins set this up under Settings → Integrations → Automation → Webhooks. Webhooks are outbound only.
Setting one up
- Open Integrations → Webhooks and choose Add webhook.
- Give it a name, the HTTPS address of your receiver, and the events you want.
- Save. The signing secret (
whsec_…) is shown once. Store it where your receiver can read it. - Use Send test to receive a
webhook.testevent and confirm your verifier works.
Up to 10 webhooks per workspace. Addresses must be https:// on port 443 or 8443, with a hostname that resolves to a public address. Private networks, loopback, link-local and cloud-metadata addresses, embedded credentials and redirects are refused, at save time and again at every delivery.
The request
Every delivery is an HTTP POST with a JSON body and these headers:
| Content-Type | application/json |
|---|---|
| X-Unboredly-Event | The event name, e.g. task.completed |
| X-Unboredly-Event-Id | The event id (evt_…). Stable across retries. |
| X-Unboredly-Delivery | The delivery id (dlv_…), one per endpoint per event. Stable across retries. |
| X-Unboredly-Timestamp | Unix seconds when this attempt was signed |
| X-Unboredly-Signature | v1=<hex HMAC-SHA256> over timestamp + "." + body |
The body is a versioned envelope:
{
"id": "evt_8k2m1q0z7x3c9v4b2n6d",
"api_version": "2026-09-01",
"event": "task.completed",
"created_at": "2026-09-27T14:03:12.481Z",
"organization_id": "Xk29fh2Lq0",
"data": {
"task": { "id": "…", "title": "Get CRM access", "category": "it_setup", "status": "completed", "previous_status": "in_progress", "due_date": "2026-10-01", "journey_id": "…" },
"employee": { "id": "…", "name": "Jane Smith" },
"actor": { "id": "…", "role": "employee" }
}
}data is built for the event, not copied from storage. It carries ids, titles, statuses, dates and the employee’s display name. It never carries email addresses, reporting lines, salaries, scores or notes that are not part of the event. The api_version changes only when a field’s meaning changes; new fields may be added at any time, so ignore what you do not know. Respond with any 2xx status within 10 seconds. The response body is not read.
Verifying the signature
Compute HMAC-SHA256 with your secret over timestamp + "." + rawBody, where the timestamp is the X-Unboredly-Timestamp header and the body is the exact bytes received (parse the JSON only after verifying). Compare in constant time with the hex after v1=. Reject timestamps more than five minutes from now.
Node
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyUnboredly(rawBody, headers, secret, toleranceSeconds = 300) {
const timestamp = headers['x-unboredly-timestamp'];
const signature = headers['x-unboredly-signature'] ?? '';
if (!timestamp || !/^\d+$/.test(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
const given = signature.split(',').map(s => s.trim()).find(s => s.startsWith('v1='))?.slice(3) ?? '';
const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
const a = Buffer.from(given, 'hex');
const b = Buffer.from(expected, 'hex');
return a.length === b.length && a.length > 0 && timingSafeEqual(a, b);
}Python
import hmac, hashlib, time
def verify_unboredly(raw_body: bytes, headers: dict, secret: str, tolerance: int = 300) -> bool:
timestamp = headers.get("X-Unboredly-Timestamp", "")
signature = headers.get("X-Unboredly-Signature", "")
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > tolerance:
return False
given = next((s.strip()[3:] for s in signature.split(",") if s.strip().startswith("v1=")), "")
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(given, expected)Retries and idempotency
A delivery that fails (no response, a timeout, a 5xx, 408, 425 or 429) is retried about 1 minute, 5 minutes, 30 minutes and 2 hours later, five attempts in all. Any other 4xx, a redirect, or an address that has become private is not retried. Every attempt is recorded in the delivery history, where an admin can also retry by hand.
The same event is delivered with the same event id and delivery id on every attempt, so keep a record of the ids you have processed and treat a repeat as a no-op. An event sent to two endpoints has one event id and two delivery ids.
Events
| task.assigned | Task assigned |
|---|---|
| task.started | Task started |
| task.completed | Task completed |
| task.status_changed | Task status changed |
| blocker.created | Blocker created |
| blocker.acknowledged | Blocker acknowledged |
| blocker.resolved | Blocker resolved |
| journey.started | Journey started |
| journey.completed | Journey completed |
| journey.day30_reached | Day 30 reached |
| journey.day60_reached | Day 60 reached |
| journey.day90_reached | Day 90 reached |
| outcome.assigned | Outcome assigned |
| outcome.completed | Outcome completed |
| webhook.test | Sent by Send test. data.sample is true and the rest is made up. |
Task and blocker events are sent as they happen, and so is journey.completed when the last task of a Journey is completed. Day 30/60/90 milestones, and the start of Journeys assigned from the browser, are noticed by a daily check and can arrive up to a day later. task.assigned is sent for Journeys assigned through the handoff flow; tasks assigned from the browser do not emit it yet.
Rotating the secret
Rotate secret issues a new one and shows it once. The old secret stops working immediately, so update the receiver right away. Deliveries made in between fail your verification and can be retried from the history once the receiver has the new secret.
Questions? See the Help Center.