Webhook and status API
For an IT team that wants PMFriend jobs in its own system, and its system's progress back in PMFriend. Two halves:
- Out — a webhook. Every new job and every status change is POSTed to an https address you give us, as JSON signed with a secret only you and PMFriend know.
- In — a status call. Your system tells PMFriend "visit booked", "in progress", "work done" or "cancelled" for a job, with a key only your system has.
This is also how Microsoft Dynamics 365 connects: see Connect Microsoft Dynamics 365. If your system only reads a mailbox, the simpler route is jobs by e-mail.
Integrations are switched on per account, on request — write to support@pmfriend.com. Then an agency admin adds the webhook under Settings → Integrations.
Setting it up
- In Settings → Integrations, enter your https address under Or a webhook and press Add webhook.
- PMFriend shows three things once: the signing secret, the inbound key and the status address. Copy them into your system's secret store. If they leak, press New keys: the old pair stops working at once.
- PMFriend sends a
pingstraight away. When your address answers with any 2xx, the webhook is confirmed and starts receiving jobs. If it doesn't answer, fix it and press Ping again.
The address must be https and reachable on the internet. Addresses that point into a private network are refused.
What we send
A POST with these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | PMFriend-Webhooks/1 |
X-PMFriend-Event | work_order.created, work_order.status_changed or ping |
X-PMFriend-Delivery | the delivery id — the same on every retry, so you can ignore duplicates |
X-PMFriend-Timestamp | Unix time in seconds when we signed it |
X-PMFriend-Signature | v1= + hex HMAC-SHA256 of timestamp + "." + body, keyed with your signing secret |
And this body (a status change; for a new job previousStatus is null):
{
"id": "6c0f7d9e-…",
"event": "work_order.status_changed",
"occurredAt": "2026-10-07T07:10:00Z",
"agency": { "id": "…", "name": "Corniche Property Management" },
"workOrder": {
"id": "1a2b3c4d-5e6f-…",
"reference": "WO-1A2B3C4D",
"status": "DISPATCHED",
"previousStatus": "DRAFT",
"scope": "AC not cooling in bedroom 2",
"property": { "id": "…", "address": "Tower 2, Apt 1204, West Bay QA 63", "timeZone": "Asia/Qatar" },
"category": "HEATING_COOLING",
"urgency": "URGENT",
"resident": { "name": "Ahmed K.", "contact": "+974 5555 0101" },
"residentMessage": "The AC in the second bedroom blows warm air since yesterday.",
"access": { "arrangement": "CONTRACTOR_ARRANGES", "windowStart": null, "windowEnd": null, "note": null },
"contractor": { "id": "…", "name": "Doha Cooling Services" },
"scheduledAt": null,
"createdAt": "2026-10-07T07:00:00Z",
"workDoneAt": null,
"completedAt": null,
"link": "https://app.pmfriend.com/maintenance?select=…"
}
}
status/previousStatus:DRAFT,AWAITING_OWNER_APPROVAL,DISPATCHED,SCHEDULED,IN_PROGRESS,AWAITING_TENANT_CONFIRMATION(work done, the resident is asked "fixed or not?"),COMPLETED,CANCELLED.category:PLUMBING,ELECTRICAL,APPLIANCE,HEATING_COOLING,STRUCTURAL,PEST,SECURITY,GARDEN,CLEANING,EMERGENCY_SAFETY,OTHER.urgency:EMERGENCY,URGENT,NORMAL,LOW.- Times are ISO-8601 in UTC;
property.timeZonesays where the building is.nullmeans "not known or not set". - The
pingevent has the same envelope with"workOrder": null. - The body shows the job as it is when we send it, with the event's statuses.
- Fields are only ever added, never renamed or removed. Ignore fields you don't know.
You can choose new jobs only instead of every change in Settings.
Checking the signature
Recompute the HMAC over the timestamp, a dot and the raw body exactly as received, compare in constant time, and reject a timestamp more than 5 minutes old:
import hmac, hashlib, time
def is_from_pmfriend(secret: str, headers: dict, raw_body: bytes) -> bool:
ts = headers["X-PMFriend-Timestamp"]
if abs(time.time() - int(ts)) > 300:
return False
expected = "v1=" + hmac.new(secret.encode(), ts.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, headers["X-PMFriend-Signature"])
Retries
Answer with a 2xx within 10 seconds. We don't follow redirects. If there is no answer, or the answer is 408, 429 or a 5xx, we try again after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours (six attempts in all), with the same delivery id. Any other 4xx is not retried. At most 500 deliveries a day go to one webhook. Every attempt is listed under Recently sent.
Telling PMFriend how a job is going
POST https://app.pmfriend.com/api/v1/integrations/inbound/work-orders/{reference}/status
Authorization: Bearer pmf_in_…
Content-Type: application/json
{ "status": "work_done", "externalRef": "SR-1042", "note": "Compressor replaced" }
{reference}: the job'sWO-1A2B3C4Dreference or its fullid, from our webhook or e-mail.status:scheduled(optionally with"scheduledAt": "2026-10-13T07:00:00Z"),in_progress,work_done, orcancelled. (completedanddoneare read aswork_done.)externalRef(your ticket number) andnoteare optional; both are recorded on the job as "Reported by your host (ticket SR-1042): Compressor replaced".
What happens:
work_donedoesn't simply close the job. If PMFriend can reach the resident, they are asked "fixed or not?" and their answer closes it (or reopens it). With no resident to ask, it closes at once. Your system claiming "done" is exactly what the resident gets to confirm.- Steps your system skipped are taken in order (dispatched → visit booked → in progress → done), so the job's history stays a real sequence.
- A job not yet sent to a contractor in PMFriend can't be moved on from outside (409); it can be cancelled.
- Sending what is already true changes nothing and returns 200, so a retry is safe.
Answers: 200 with {"workOrderId", "reference", "status", "statusLabel"}; 401 wrong or
missing key; 403 the webhook is paused or integrations are switched off for the account;
404 no such job in your account; 409 the job can't move that way (the message says why);
400 unknown status.