Webhooks

Sumhour posts a signed JSON event to your URL when something happens in a workspace. Admins set them up under Settings → Integrations → Webhooks.

The request

POST https://your-server.example/hooks/sumhour
content-type: application/json
user-agent: Sumhour-Webhooks/1.0 (+https://app.sumhour.com/developers/webhooks)
webhook-id: msg_9f1c…
webhook-timestamp: 1758700000
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{
  "id": "evt_9f1c…",
  "type": "invoice.send",
  "timestamp": "2026-09-24T09:17:09.120Z",
  "workspace": { "id": "…", "name": "Pixel Studio", "slug": "pixel-studio" },
  "actor": { "id": "…", "name": "Habib", "type": "user", "via": "web", "client": null },
  "summary": "Invoice sent: #1042 for Larkspur Health (USD 4,800.00)",
  "url": "https://app.sumhour.com/app/invoices/…",
  "data": {
    "entity": "invoice",
    "entityId": "…",
    "invoice": { "number": "1042", "status": "sent", "total": "4800.00", "currency": "USD", "client": { "name": "Larkspur Health" } },
    "before": null,
    "after": { "number": "1042" }
  }
}

Subscribe an endpoint to exact events, a prefix such as invoice.*, or everything with *.

Time: time_entry.create, time_entry.update, time_entry.delete, time_entry.bulk_update, timer.start, timer.stop, suggestion.accept

Timesheets: timesheet.submit, timesheet.auto_submit, timesheet.withdraw, timesheet.approve, timesheet.reject, timesheet.reopen, timesheet.lock, timesheet.lock_through

Expenses: expense.create, expense.update, expense.delete, expense.reimburse

Invoices: invoice.create, invoice.update, invoice.send, invoice.payment, invoice.payment_delete, invoice.void, invoice.delete, invoice.push, recurring.create

Estimates: estimate.create, estimate.send, estimate.to_invoice, estimate.to_project, estimate.delete

Projects and clients: project.create, project.update, project.member_add, project.rate_change, project.progress, client.create, client.update

Month end and workspace: monthend.write_off, monthend.write_down, member.role_change, member.remove, import.start, import.revert, webhook.test

Verify the signature

Sumhour follows Standard Webhooks 1.0, so any of its libraries verifies these requests. By hand: the secret is whsec_ plus base64; sign ${webhook-id}.${webhook-timestamp}.${raw body} with HMAC-SHA256, base64 it, prefix v1,, compare in constant time, and reject timestamps more than five minutes old.

import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(secret: string, h: Record<string, string>, rawBody: string) {
  const id = h["webhook-id"], ts = h["webhook-timestamp"], sig = h["webhook-signature"];
  if (!id || !ts || !sig || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${ts}.${rawBody}`).digest("base64");
  return sig.split(" ").some((s) => {
    const given = s.replace(/^v1,/, "");
    return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
  });
}

Delivery and retries

  • Answer with any 2xx within 10 seconds. The body is ignored.
  • Anything else is retried 7 times: after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours. Then the delivery is marked as given up; retry it by hand from the delivery log.
  • webhook-id stays the same across retries; use it to ignore duplicates.
  • After 20 failures in a row the endpoint pauses itself. Fix the receiver and turn it back on.
  • Order is not guaranteed; trust timestamp and the entity's current state.

Slack

For a team channel rather than your own code, paste a Slack incoming webhook URL into the Slack card on the same Integrations page and pick which events to post.