Skip to main content

Webhooks

A webhook is DeskOS calling you. Instead of your system asking "has anything changed?" every few minutes, you give DeskOS an https URL once and we POST a small JSON document to it the moment a member, a company, a membership or a campus changes.

For a WiFi controller, an access-control platform or a printing service this is the difference between a member waiting up to a polling interval for their access to work and their access working before they have sat down.

Webhooks and the API are separate things

Webhooks push events out. They do not let anyone read or write anything, and they carry no API key. You will almost always want both: webhooks to know when something changed, and the Members API to read the full picture or write values back.


Setting one up​

Webhooks are configured by an operator in the admin console, not through an API. There is deliberately no endpoint that creates a webhook: a URL that receives an operator's member data is a decision a human makes.

  1. Open Developer Settings from the avatar menu, top right, then the Webhooks tab.
  2. Add a webhook. Give it a name (who is receiving it), the https URL to POST to, and tick the events you want.
  3. Optionally restrict it to one or more campuses. Leave the campus list empty and it receives events from every campus.
  4. Copy the signing secret. It is shown once, at creation, and never again. If you lose it, rotate it.

The Send a test delivery button on each webhook sends a ping straight away, so you can confirm your endpoint is reachable before a real event depends on it.


What a delivery looks like​

We POST application/json and expect any 2xx response within 10 seconds.

POST /hooks/deskos HTTP/1.1
Host: example.com
Content-Type: application/json
User-Agent: DeskOS-Webhooks/1.0
X-DeskOS-Event: member.created
X-DeskOS-Delivery: 9f2c6b3a-1d44-4f0e-9c31-0a7b5e2d8c10
X-DeskOS-Timestamp: 2026-09-13T09:21:04.512331+00:00
X-DeskOS-Signature: sha256=6c1f0d...ab93
{
"id": "9f2c6b3a-1d44-4f0e-9c31-0a7b5e2d8c10",
"event": "member.created",
"occurred_at": "2026-09-13T09:21:04.512331+00:00",
"organization": {
"id": 12,
"name": "Acme Coworking",
"slug": "acme"
},
"campus_id": 7,
"data": { "...": "the subject of the event" },
"changes": {}
}
FieldWhat it is
idThe delivery id. The same value as X-DeskOS-Delivery, and the key to deduplicate on.
eventThe event name, for example member.updated.
occurred_atWhen the event happened, ISO 8601 with a timezone.
organizationWhich workspace this came from: numeric id, name, and the slug you see in Developer Settings.
campus_idThe campus the subject belongs to, or null for events with no campus.
dataThe subject of the event, in the same shape the Members API returns.
changesOn update events, {"field": {"from": old, "to": new}} for the fields that actually moved. Empty on creates.

Because data is the same shape as GET members/<id>/ or GET companies/<id>/, you write one parser for your initial sync and your live feed. See the Members API for the field-by-field description.


Verifying the signature​

Every delivery carries X-DeskOS-Signature: sha256=<hex>, an HMAC-SHA256 of the raw request body using your signing secret.

Verify before you trust

Your webhook URL is on the public internet. Anyone can POST to it. The signature is the only thing that distinguishes us from them. Hash the raw bytes you received, not a re-serialised copy of the parsed JSON: re-encoding changes key order and whitespace, and the signature will never match.

Python (Flask)​

import hashlib
import hmac

from flask import Flask, request, abort

SECRET = "whsec_..." # from Developer Settings, stored as a secret
app = Flask(__name__)


@app.post("/hooks/deskos")
def deskos_webhook():
body = request.get_data() # raw bytes, not request.json
sent = request.headers.get("X-DeskOS-Signature", "")
expected = "sha256=" + hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sent, expected): # constant time, not ==
abort(401)

event = request.headers["X-DeskOS-Event"]
payload = request.get_json()
enqueue(event, payload) # do the slow work after replying
return "", 200

Node.js (Express)​

const crypto = require('crypto');
const express = require('express');

const SECRET = process.env.DESKOS_WEBHOOK_SECRET;
const app = express();

// express.raw, not express.json: you need the exact bytes to hash.
app.post('/hooks/deskos', express.raw({ type: 'application/json' }), (req, res) => {
const expected =
'sha256=' + crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');
const sent = req.get('X-DeskOS-Signature') || '';

const ok =
sent.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sent), Buffer.from(expected));
if (!ok) return res.sendStatus(401);

const payload = JSON.parse(req.body.toString('utf8'));
enqueue(req.get('X-DeskOS-Event'), payload);
res.sendStatus(200);
});

PHP​

$body = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $body, getenv('DESKOS_WEBHOOK_SECRET'));
if (!hash_equals($expected, $_SERVER['HTTP_X_DESKOS_SIGNATURE'] ?? '')) {
http_response_code(401);
exit;
}

Events​

EventWhen it firesdata
member.createdA member was added, from the admin console, the API or a signup.The member
member.updatedA member's details changed.The member, plus changes
member.deactivatedA member was deactivated or banned and should lose access.The member, plus changes
member.deletedA member record was removed outright. Rare.id, email, display_name only
membership.activatedA member's subscription went live. They are entitled from now on.The member
membership.updatedA live membership's window moved.The member
membership.on_noticeNotice was served. Still entitled, but with an end date.The member
membership.endedA membership expired or was cancelled. Entitlement has stopped.The member
company.createdA company was added to a campus.The company
company.updatedA company's details changed.The company, plus changes
company.deletedA company record was removed outright.id, name, campus_id only
campus.updatedA campus was renamed or its details changed.The campus, plus changes
pingA test delivery sent from Developer Settings. Never raised by the product, and not subscribable.A short message

Event names are permanent. New events are added over time; names are never renamed or reused, so anything you bind to today keeps working.

Membership events are per member, not per company​

Subscriptions in DeskOS are company-wide, but what a vendor acts on is a person. So one subscription going live produces one membership.activated per active member of that company, each carrying that member's own payload with its freshly computed status. Provision per delivery and you do not have to expand companies into people yourself.

A live subscription is written on every payment, seat change and invoice run. Those saves do not produce events: membership.updated fires only when the subscription's start or end date actually moves, so real transitions are not buried in noise you would have to filter anyway.

Deactivation is its own event​

When a member goes inactive or banned you get member.deactivated, instead of member.updated, never both. That is the one an access-control or WiFi vendor has to act on immediately, so it is worth its own name and does not have to be dug out of a diff.


Retries, ordering and duplicates​

Retries. A delivery that does not return 2xx within 10 seconds is retried up to 6 times: immediately, then 10s, 20s, 40s, 80s and 160s later. After that the delivery is marked gave up and stays in the log, where it can be replayed by hand.

Auto-disable. After 15 consecutive failures the endpoint is switched off and DeskOS stops sending to it. The Webhooks tab says when and why. Fix your receiver, then turn it back on there; re-enabling by hand clears the failure count.

Duplicates are normal. Our retry fires when we did not see a 2xx, which is not the same as you not having processed it. A timeout after your handler committed produces a second delivery of the same event. Treat id as an idempotency key: record it, and make a second arrival a no-op.

Order is not guaranteed. Deliveries go out in parallel and retries interleave. Do not assume member.created reaches you before the member.updated that follows it a second later. Where order matters, compare occurred_at, or read the current state from the Members API rather than replaying a sequence of diffs.

Reply first, work later. Acknowledge with a 2xx as soon as the signature checks out and you have queued the work. A handler that provisions a WiFi account inline will eventually exceed 10 seconds, and we will retry an event you are still in the middle of handling.


The deliveries log​

Under the endpoints on the Webhooks tab is every attempt DeskOS has made: when, to which endpoint, which event, the status, your HTTP response code, which attempt it was, and the first kilobyte of what you sent back. Filter it by endpoint, event or status.

Each row has a replay button, which sends the same payload again as a new delivery. A replay carries a new id and a replay_of field naming the original, so your deduplication does not silently drop it and you can still tell it apart from a first delivery.

Deliveries are kept for 30 days.


Campus restriction​

A webhook with no campuses receives events from the whole organisation. Tick campuses and it receives only events whose subject belongs to one of them. That is how an operator gives a vendor who serves two of their five buildings a feed of exactly those two.

Events that carry no campus, such as member.deleted, always pass the restriction and reach every subscribed endpoint. Losing an event is worse than one a receiver can ignore.


Security notes​

  • https only. Plain http is refused, as are addresses that resolve to private, loopback or link-local ranges. Your endpoint has to be reachable from the internet.
  • Rotate rather than share. If a secret leaks, rotate it on the Webhooks tab. Deliveries are signed with the new secret from that moment, so deploy it first or accept a short window of rejected deliveries.
  • The secret is shown once. DeskOS stores it to sign with, and Developer Settings only ever shows you a hint like whsec_1a2b3...9f0e afterwards, enough to tell two secrets apart and not enough to sign with.
  • Do not authenticate on the URL alone. A secret path is not a secret for long. Verify the signature on every request.

Instructions for AI agents​

You cannot create a webhook. Only an operator can, in Developer Settings, and only they see the signing secret. If a human asks you to "set up a webhook", what you can do is:

  1. Tell them exactly what to enter: the URL, and which of the events above to tick. Ask for the smallest set that does the job.
  2. Ask them to paste the signing secret into their own secret store, never into a chat, a file in the repo, or a prompt.
  3. Build the receiver: verify the signature against the raw body, reply 2xx fast, queue the work, and deduplicate on id.
  4. Have them press Send a test delivery and confirm you received a ping with a valid signature before you rely on it.

Behaviour rules:

  • Never log a payload with a secret in it, and never echo the signing secret back to the human.
  • Never skip verification "for now". An unverified endpoint that provisions access is an open door.
  • Do not treat a webhook as a complete record. Events can be missed while an endpoint is down, and they are not resent once retries are exhausted. Keep a periodic reconciliation against GET members/?updated_since= as a floor.
  • Do not act on status you infer from the event name. data.status is computed and authoritative; membership.on_notice still means entitled.

Frequently asked questions​

Can I create or edit webhooks through the API? No. It is an admin console action, so a human decides which URL receives their members' data.

What happens to events raised while my endpoint was down? Each delivery retries for about five minutes and then gives up. It stays in the deliveries log for 30 days and can be replayed from there. Nothing is queued indefinitely, so after a long outage reconcile with the Members API rather than expecting a backlog.

Can two systems receive the same events? Yes. Add one webhook per receiver. Each gets its own secret, its own event list, its own campuses and its own health, so one broken receiver never affects the other.

Why did I get an event I did not subscribe to? You did not. ping is the only thing that arrives without being ticked, and only when someone presses the test button.

Do webhooks count against my API key's daily limit? No. They are unrelated to keys, and outbound deliveries have no quota.

My endpoint was switched off. Do I lose the subscription? No. The configuration is intact. Turn it back on in the Webhooks tab and deliveries resume.

Are lead webhooks the same thing? Not yet. The webhooks that fire on new leads are configured separately in the leads module and use their own signing scheme. They will move onto this machinery in a later release.


Quick reference​

Set up:    Developer Settings, Webhooks tab (admin console)
Method: POST application/json, expect 2xx within 10s
Headers: X-DeskOS-Event, X-DeskOS-Delivery, X-DeskOS-Timestamp, X-DeskOS-Signature
Signature: sha256=HMAC-SHA256(secret, raw request body), hex
Retries: 6 attempts, 10s 20s 40s 80s 160s apart
Disable: after 15 consecutive failures
Retention: deliveries kept 30 days, replayable

Events: member.created member.updated member.deactivated member.deleted
membership.activated membership.updated membership.on_notice membership.ended
company.created company.updated company.deleted
campus.updated
ping (test only)

Getting help​

Webhooks, their secrets, the deliveries log and replay are all self-service from Developer Settings in your admin console. For anything else, reach the DeskOS team through your usual support channel.