Guide to Simpro Webhooks: Features and Best Practices
Simpro webhooks tell your systems when work changes in a trade business's Simpro build: a job is created or moves stage, a quote is accepted, an invoice or payment is recorded, a customer's details change, or a schedule is updated. If you build integrations for trade and field-service businesses that run on Simpro, webhooks save you polling the API to find out what changed.
This guide covers how Simpro webhook subscriptions are created and signed, the events and their short notification payloads, what Simpro doesn't document, and the practices that keep a Simpro receiver reliable.
What are Simpro webhooks?
A Simpro webhook subscription has a name, a callback URL, a Secret and a list of events. When a subscribed event happens, Simpro sends a JSON POST to the callback URL.
The body is a notification, not the record. It says what changed and gives the IDs you need to fetch the record from the Simpro API.
Each delivery carries an X-Response-Signature header: the hex HMAC-SHA1 of the raw request body, keyed with the Secret you entered on the subscription. You choose the Secret yourself. Simpro describes it as "a random string of your choice, used to verify that the message you received came from Simpro".
Simpro webhook features
| Feature | Details |
|---|---|
| Who can create | Someone with administrative privileges in the Simpro build |
| Configuration | System > Setup > API > Webhook Subscriptions, or the Webhook Subscriptions API |
| Subscription | A list of events per subscription, and a status of Enabled or Disabled |
| Payload | ID, build, description, name, action, reference, date_triggered |
| Signature header | X-Response-Signature |
| Scheme | HMAC-SHA1, hex digest (40 characters), no prefix |
| Signed content | The raw request body only |
| Secret | A random string you choose and enter on the subscription |
| Testing | Test Subscription sends a test.updated body |
| Debugging | An optional email address receives debugging information |
| Retries, timeout and source IPs | Not published |
Common events
The Webhook Subscriptions API lists 109 events, each named <object>.<action>:
| Area | Examples |
|---|---|
| Jobs | job.created, job.updated, job.deleted, job.status, job.stage.pending, job.stage.progress, job.stage.complete, job.stage.invoiced, job.stage.archived |
| Quotes and leads | quote.created, quote.updated, quote.status, lead.created, lead.status |
| Invoices and payments | invoice.created, invoice.status, invoice.stage.approved, credit.created, payment.created, recurring.invoice.created |
| Customers and contacts | company.customer.created, company.customer.updated, individual.customer.created, contact.updated, customer.contract.created, customer.note.created |
| Schedules | job.schedule.created, quote.schedule.updated, lead.schedule.deleted, activity.schedule.created |
| Sites and assets | site.created, asset.updated, job.asset.tested, quote.asset.assigned |
| Purchasing and stock | purchase.order.created, purchase.order.receipt.created, stock.allocation.deleted |
| People | employee.created, employee.updated, employee.deleted |
Notes and attachments have their own events on jobs, quotes, leads, invoices and sites, such as job.note.created and job.attachment.updated.
Here is Simpro's example for an updated company customer:
{
"ID": "company.customer.updated",
"build": "{BUILD_URL}",
"description": "Company customer #518 has been updated.",
"name": "Company customer",
"action": "updated",
"reference": {
"companyID": 0,
"customerID": 518,
"companyCustomerID": 518
},
"date_triggered": "2019-06-28T16:35:12+10:00"
}
IDis the event name, not a record ID. Route on it.buildis the URL of the Simpro build that sent the event, which matters if you integrate with several businesses.referenceholds the IDs to pass to the API, such ascompanyIDandcustomerID. Its keys depend on the event.date_triggeredis ISO 8601 with the build's UTC offset.
Setting up Simpro webhooks
Generate a long random Secret first, for example with openssl rand -hex 32, and store it server-side as SIMPRO_WEBHOOK_SECRET.
- Ask an administrator of the Simpro build to open System > Setup, hover over API near the bottom and click Webhook Subscriptions. If API isn't there, the user isn't an administrator.
- Click Create Subscription.
- Enter a name and, optionally, an email address for debugging information.
- Enter your Secret.
- Enter your HTTPS endpoint as the Callback URL.
- Select the events to subscribe to, and save.
- Click Test Subscription. Simpro sends a
test.updatedbody to your URL.
Through the Webhook Subscriptions API, the same subscription takes Name, CallbackURL, Secret, Email, Description, Events and Status. Only the name and callback URL are required, but always set Secret, because it is what lets you verify deliveries.
With Hookdeck. Create a source with the Simpro source type and paste the same Secret into Webhook Signing Secret, then use the Hookdeck source URL as the callback URL. A delivery whose X-Response-Signature doesn't match still gets a 200 from Hookdeck, but Hookdeck marks it as failed verification and doesn't deliver it to your service.
Securing Simpro webhooks
Compute an HMAC-SHA1 of the raw request body with the Secret as a UTF-8 key, hex-encode it, and compare it with X-Response-Signature in constant time. Simpro's own PHP sample does exactly this with hash_ and hash_equals.
const crypto = require('crypto');
const express = require('express');
function verifySimproSignature(rawBody, header, secret) {
if (typeof header !== 'string' || !secret) return false;
// Check the shape first: Buffer.from(x, 'hex') silently stops at invalid characters.
if (!/^[0-9a-f]{40}$/i.test(header.trim())) return false;
const expected = crypto.createHmac('sha1', secret).update(rawBody).digest();
const received = Buffer.from(header.trim(), 'hex');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
const app = express();
app.post('/webhooks/simpro', express.raw({ type: '*/*' }), (req, res) => {
const secret = process.env.SIMPRO_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500); // fail closed
if (!verifySimproSignature(req.body, req.get('x-response-signature'), secret)) {
return res.sendStatus(401);
}
const notice = JSON.parse(req.body.toString('utf8'));
// Route on notice.ID, then fetch the record from the API using notice.reference.
res.sendStatus(200);
});
The same check in Python:
import hashlib
import hmac
def verify_simpro_signature(raw_body: bytes, header, secret: str) -> bool:
if not header or not secret:
return False
expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha1).hexdigest()
# Compare bytes so a non-ASCII header fails instead of raising TypeError.
return hmac.compare_digest(expected.encode("utf-8"), header.strip().lower().encode("utf-8"))
# In FastAPI: raw_body = await request.body()
# verify_simpro_signature(raw_body, request.headers.get("x-response-signature"),
# os.environ["SIMPRO_WEBHOOK_SECRET"])
To check your verifier, use the Secret somerandomstring (the value in Simpro's sample) and this exact body:
{"ID":"company.customer.updated","build":"https://example.simprosuite.com","description":"Company customer #518 has been updated.","name":"Company customer","action":"updated","reference":{"companyID":0,"customerID":518,"companyCustomerID":518},"date_triggered":"2019-06-28T16:35:12+10:00"}
The signature should be:
459ed50d5bcc1832729356005c71792d55e55d9d
HMAC-SHA1 is still sound as a message authentication code, and it is what Simpro sends, so there is nothing to choose here. What matters more is a long, random Secret.
Make Simpro webhooks production-ready. Hookdeck Event Gateway verifies the
X-Response-Signatureheader on every Simpro delivery, stores each job, quote and invoice event, and delivers it to your service with retries and replay you control.
Simpro webhook limitations and pain points
No timestamp means no replay protection
The Problem: A captured request can be sent again later, and it still verifies.
Why It Happens: Only the body is signed. date_triggered is inside the body, so it is covered by the signature, but Simpro doesn't say how old a delivery can be, and nothing stops the same body arriving twice.
Workarounds:
- Keep the endpoint on HTTPS.
- Make handlers idempotent, keyed on
ID, thereferenceIDs anddate_triggered. - Because the body is only a notice, fetch the current record from the API before acting. A replayed notice then causes, at worst, a redundant read.
How Hookdeck Can Help: Hookdeck's deduplication can drop repeated deliveries with the same body before they reach your handler.
Notifications, not data
The Problem: Every event needs at least one API call before you can do anything with it.
Why It Happens: Simpro sends the event name and the IDs in reference, not the record. The reference keys differ from event to event.
Workarounds:
- Map each
IDto the API endpoint that fetches its record, using the keys inreference. - Queue the fetch rather than doing it inside the webhook request.
How Hookdeck Can Help: Hookdeck can limit the delivery rate to your service, which keeps your follow-up API calls to Simpro steady during bursts.
Retries and failures aren't documented
The Problem: You don't know whether Simpro resends a notice that your endpoint missed.
Why It Happens: Simpro documents no timeout, retry schedule or success rule. The subscription's email address receives "debugging information", without further detail.
Workarounds:
- Set the debugging email on every subscription and watch it.
- Return a
2xxquickly, and reconcile important records through the API after an outage.
How Hookdeck Can Help: Hookdeck accepts each delivery as soon as it arrives and stores it, then delivers to your service with automatic retries and lets you replay anything that failed.
The Secret is optional
The Problem: A subscription created through the API without a Secret can't be verified.
Why It Happens: The Webhook Subscriptions API requires only a name and a callback URL. Simpro doesn't document what a subscription without a Secret sends.
Workarounds:
- Always set a long random Secret, and reject requests without a valid
X-Response-Signature.
How Hookdeck Can Help: Hookdeck's Simpro source requires the Secret, so a subscription that sends unsigned or wrongly signed requests shows up as failed verification in the request log.
Best practices
Verify against the raw body
The HMAC covers the exact bytes Simpro sent. Use express.raw({ type: '*/*' }) in Express, await request.text() in a Next.js route handler and await request.body() in FastAPI, and parse only after the check passes.
Handle test.updated
Test Subscription sends "ID": "test.updated", which isn't a subscribable event. Acknowledge it with a 2xx without processing it.
Key data by build
If you integrate with several Simpro customers, use build and your stored subscription details to find the right tenant, and keep a separate Secret for each subscription.
Acknowledge fast and process asynchronously
Verify, store the notice, return 200, then fetch the record and do the work. See why to process webhooks asynchronously and our guide to webhook idempotency.
Conclusion
Simpro signs each webhook notice with a hex HMAC-SHA1 of the raw body in X-Response-Signature, keyed with a Secret you choose. Always set that Secret, verify against the raw bytes, route on ID, and fetch the full record using the IDs in reference.
Hookdeck Event Gateway verifies every Simpro signature, paces delivery to your service, and keeps each notice until it has been processed.
Get started with Hookdeck for free and handle Simpro webhooks reliably in minutes.