Developers

Webhooks

Get a message on your server the moment something happens in MSMEPulse, instead of asking the API again and again.

Setting up

An Admin adds your address in Settings โ†’ API and webhooks โ†’ Webhooks and chooses the events. The address must use HTTPS. Each webhook has its own secret, which you use to check messages really come from MSMEPulse.

Events

EventSent when
client.createdA client is added
invoice.issuedAn invoice is issued
invoice.cancelledAn invoice is cancelled
invoice.credit_noteA credit note is raised
payment.recordedA payment is recorded (manual or online)
proposal.acceptedA proposal is accepted
task.createdA task is created
ticket.createdA support ticket is opened
leave.approvedLeave is approved
claim.approvedAn expense claim is approved

The message

MSMEPulse sends a POST with a short JSON body saying what happened and to which record. Fetch the record with the API for its details.

{
  "id": "evt_3f9c2b7d41e04a8c9d0f1a2b3c4d5e6f",
  "type": "invoice.issued",
  "createdAt": "2026-10-26T09:30:00.000Z",
  "workspace": "65f0c2a1b4e8d9f0a1b2c3d4",
  "data": {
    "subjectType": "Invoice",
    "subjectId": "6a1b2c3d4e5f60718293a4b5",
    "summary": "Issued invoice INV/26-27/0042 to Nova Retail",
    "by": "Asha Rao"
  }
}
FieldTypeNotes
idstringThe event's id (evt_โ€ฆ); the same on every retry, so you can ignore repeats
typestringThe event, e.g. invoice.issued
createdAtstringWhen it happened (ISO 8601)
workspacestringThe workspace id
dataobject
data.subjectTypestring
data.subjectIdstring
data.summarystring
data.bystring

Headers:

  • X-MSMEPulse-Event: the event, such as invoice.issued
  • X-MSMEPulse-Delivery: this delivery's id
  • X-MSMEPulse-Signature: t=<unix time>,v1=<signature>

Checking the signature

The signature is a hex HMAC-SHA256 of <t>.<raw body>, keyed with the webhook's secret. Compute it from the raw body (before parsing the JSON), compare in constant time, and reject messages more than five minutes old.

Node.js

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

export function isFromMsmePulse(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
  return expected.length === parts.v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}

Python

import hashlib, hmac, time

def is_from_msmepulse(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    if abs(time.time() - int(parts["t"])) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])

PHP

function is_from_msmepulse(string $rawBody, string $header, string $secret): bool {
    parse_str(str_replace(',', '&', $header), $parts);
    if (abs(time() - (int) $parts['t']) > 300) return false;
    $expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
    return hash_equals($expected, $parts['v1']);
}

Answering, and retries

Answer with any 2xx status within 10 seconds; do the work afterwards. If MSMEPulse gets anything else, or no answer, it tries again after 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours, then stops. The same event keeps its id on every try, so ignore an id you've already handled. The Webhook log in Settings shows every delivery, and Send again resends any of them.