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

    1. Open Integrations → Webhooks and choose Add webhook.
    2. Give it a name, the HTTPS address of your receiver, and the events you want.
    3. Save. The signing secret (whsec_…) is shown once. Store it where your receiver can read it.
    4. Use Send test to receive a webhook.test event 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-Typeapplication/json
    X-Unboredly-EventThe event name, e.g. task.completed
    X-Unboredly-Event-IdThe event id (evt_…). Stable across retries.
    X-Unboredly-DeliveryThe delivery id (dlv_…), one per endpoint per event. Stable across retries.
    X-Unboredly-TimestampUnix seconds when this attempt was signed
    X-Unboredly-Signaturev1=<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.assignedTask assigned
    task.startedTask started
    task.completedTask completed
    task.status_changedTask status changed
    blocker.createdBlocker created
    blocker.acknowledgedBlocker acknowledged
    blocker.resolvedBlocker resolved
    journey.startedJourney started
    journey.completedJourney completed
    journey.day30_reachedDay 30 reached
    journey.day60_reachedDay 60 reached
    journey.day90_reachedDay 90 reached
    outcome.assignedOutcome assigned
    outcome.completedOutcome completed
    webhook.testSent 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.