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
| Event | Sent when |
|---|---|
client.created | A client is added |
invoice.issued | An invoice is issued |
invoice.cancelled | An invoice is cancelled |
invoice.credit_note | A credit note is raised |
payment.recorded | A payment is recorded (manual or online) |
proposal.accepted | A proposal is accepted |
task.created | A task is created |
ticket.created | A support ticket is opened |
leave.approved | Leave is approved |
claim.approved | An 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"
}
}
| Field | Type | Notes |
|---|---|---|
id | string | The event's id (evt_โฆ); the same on every retry, so you can ignore repeats |
type | string | The event, e.g. invoice.issued |
createdAt | string | When it happened (ISO 8601) |
workspace | string | The workspace id |
data | object | |
data.subjectType | string | |
data.subjectId | string | |
data.summary | string | |
data.by | string |
Headers:
X-MSMEPulse-Event: the event, such asinvoice.issuedX-MSMEPulse-Delivery: this delivery's idX-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.