Guides

Webhooks

Subscribe to Monument events, receive signed deliveries, and verify the signature on your server.

Webhooks push events to your server as they happen, so you do not have to poll. You register a subscription with a URL and a list of events, and Monument sends a signed JSON POST to that URL whenever a matching event occurs.

Create a subscription

Create a webhook with a name, a url, and at least one event. Creating, updating, and deleting subscriptions requires an admin key. The response includes a signing secret prefixed with whsec_; store it, because you need it to verify deliveries.

cURL
curl -X POST 'https://api.monument.so/api/v1/webhooks' \
  -H 'X-API-Key: monument_your_api_key' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "Billing sync",
    "url": "https://example.com/hooks/monument",
    "events": ["invoice.paid", "invoice.overdue"]
  }'

Events you can subscribe to

Subscribe to any of these event types. You can also fetch the current list from GET /api/v1/webhooks/events.

  • Invoices: invoice.created, invoice.sent, invoice.paid, invoice.overdue
  • Quotes: quote.created, quote.sent, quote.accepted, quote.rejected
  • Projects and tasks: project.created, project.completed, task.created, task.completed
  • Time and expenses: time_entry.created, expense.created, expense.approved
  • Milestones: milestone.completed

What a delivery looks like

Monument POSTs each event to your URL with the payload as the body and these headers:

Delivery headers
X-Monument-Event: invoice.paid
X-Monument-Signature: sha256=6b2f…
X-Monument-Delivery: 2f9c…

The body is the event payload:

Delivery body
{
  "event": "invoice.paid",
  "timestamp": "2026-07-10T12:00:00.000Z",
  "data": { }
}

Respond with a 2xx status quickly. A non-2xx response or a timeout is retried, up to five attempts, with the delivery recorded either way.

Verify the signature

Compute an HMAC-SHA256 of the raw request body using your webhook secret, then compare it to the hex value after sha256= in the X-Monument-Signature header. Use the raw bytes you received, not a re-serialized object, or the signatures will not match.

Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';

function isValidSignature(rawBody, signatureHeader, secret) {
  const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
  const received = signatureHeader.replace('sha256=', '');

  const a = Buffer.from(expected);
  const b = Buffer.from(received);
  return a.length === b.length && timingSafeEqual(a, b);
}

Manage subscriptions

  • List and inspect: GET /api/v1/webhooks and GET /api/v1/webhooks/:id
  • Update or delete: PATCH /api/v1/webhooks/:id and DELETE /api/v1/webhooks/:id
  • Rotate the secret: POST /api/v1/webhooks/:id/rotate-secret
  • Review delivery history: GET /api/v1/webhooks/:id/deliveries
  • Send a test delivery: POST /api/v1/webhooks/:id/test

See the Webhooks domain in the reference for the full request and response schemas.