# 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=
```

```json
{
  "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](https://www.standardwebhooks.com/) 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.

```ts
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.
