A webhook notifies your application when something changes for a person who has connected it: a booking moves, a payment is made, a message arrives. DJfindr sends each change to one URL your application registers, signs it, and retries it until your server accepts it.
Every delivery is also an item of GET /me/updates. An application that cannot receive webhooks polls the feed instead, and an application that does can use the feed to recover after downtime.
Authentication
Register the webhook in the developer portal on your app's page, or with your client credentials at the endpoints below. The portal manages the same webhook: see Register an app.
A webhook belongs to the application, not to a person, so its endpoints take the application's OAuth client credentials rather than an access token:
Authorization: Basic base64(client_id:client_secret)This is the client_secret_basic format the token endpoint accepts: each value is form-urlencoded, then base64-encoded. Only confidential clients (clients with a secret) can register a webhook. Public clients, such as most MCP hosts, poll the updates feed. Registrations made by Claude or ChatGPT for their connectors cannot register a webhook.
| Response | Cause |
|---|---|
401 with WWW-Authenticate: Basic realm="DJfindr apps" | The client id or secret is missing, unknown or rotated. The response is identical in every case. |
403 | The application is disabled (turned off by DJfindr, or its owner's account is restricted or closed), or a change was sent from a web page. |
Call these endpoints from your serverThe client secret must never reach a browser. A change (
PUT,DELETEorPOST) sent with anOriginheader, or with aSec-Fetch-Siteheader other thannone, returns403before the credentials are read. Server-side HTTP clients send neither header.
Endpoints
| Endpoint | Description |
|---|---|
GET /{env}/v1/app/webhook | Returns the webhook, or data: null. Never returns the secret. |
PUT /{env}/v1/app/webhook | Creates the webhook (201, with its secret, shown once) or updates its url and event_types (200, without the secret). |
DELETE /{env}/v1/app/webhook | Deletes the webhook and its delivery log. |
POST /{env}/v1/app/webhook/rotate-secret | Issues a new secret, shown once. The previous secret continues to sign deliveries for 24 hours. |
POST /{env}/v1/app/webhook/test | Sends a webhook.test delivery. Limited to 10 per hour. |
POST /{env}/v1/app/webhook/enable | Re-enables a webhook that was disabled for failing. |
GET /{env}/v1/app/webhook/deliveries | Lists deliveries, newest first. Accepts status, cursor and limit. |
POST /{env}/v1/app/webhook/deliveries/{id}/retry | Makes one more attempt for a failed delivery. |
Register a webhook
curl -X PUT https://api.djfindr.com/prod/v1/app/webhook \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/djfindr/webhook", "event_types": ["booking.*", "payment.*"] }'{
"success": true, "error": "", "code": "", "fields": {},
"data": {
"id": "whk_01JC9Z4S1X2Y3Z4A5B6C7D8E9F",
"url": "https://example.com/djfindr/webhook",
"event_types": ["booking.*", "payment.*"],
"status": "active",
"disabled_reason": null,
"created_at": "2026-10-20T12:00:00.000Z",
"updated_at": "2026-10-20T12:00:00.000Z",
"secret": "whsec_..."
}
}curl -u sends the credentials as given, which matches form-urlencoding when the client id and secret contain only letters, digits, - and _.
Store the secretThe secret is shown once, when the webhook is created or the secret is rotated. Responses that contain it are sent with
Cache-Control: no-store.
| Field | Rules |
|---|---|
url | https on port 443, at most 2,048 characters, without a user name, password or fragment. The host must be a public host name whose addresses are all public: not an IP address, a local name or a DJfindr host. Checked when set (422 on fields.url) and before every delivery. Redirects are not followed. |
event_types | Up to 50 entries: * (the default), exact types such as booking.confirmed, or families such as booking.*, payment.*, payout.*, message.* and link.*. |
Endpoints that return the secret take no Idempotency-Key; they are safe to repeat. Other changes accept an optional Idempotency-Key, as on the Management API. Every response uses the Management API envelope (see Responses and status codes). Webhook endpoints are limited to 60 requests per minute per application (429 with Retry-After).
Deliveries
DJfindr delivers every update of each person who has connected your application, for as long as the connection lasts, and only for the event types that person's approved scopes cover (booking events require bookings:read, for example). Delivery follows the connection, not an individual access token: when the person disconnects your application, delivery stops and queued deliveries are skipped. Events about an action link are delivered only to the application that created the link. Your event_types filter applies on top.
Headers
| Header | Description |
|---|---|
Content-Type | application/json |
User-Agent | DJfindr-Webhooks/1 |
DJfindr-Webhook-Id | The delivery id. Unchanged across retries; use it to de-duplicate. |
DJfindr-Event-Id | The event id. |
DJfindr-Webhook-Timestamp | The attempt time, in Unix seconds. |
DJfindr-Signature | v1= followed by the signature, once per valid secret, comma-separated. |
Payload
{
"id": "aev_01JC9Z4S1X2Y3Z4A5B6C7D8E9F",
"delivery_id": "whd_01JC9Z4S1X2Y3Z4A5B6C7D8E9F",
"type": "booking.confirmed",
"created_at": "2026-10-20T12:00:00.000Z",
"seq": 1050,
"api_version": "2026-10-19",
"user_id": "usr_01JC9Z4S1X2Y3Z4A5B6C7D8E9F",
"role": "client",
"subject": { "type": "proposal", "id": "prop_01JC9Z4S1X2Y3Z4A5B6C7D8E9F" },
"proposal_id": "prop_01JC9Z4S1X2Y3Z4A5B6C7D8E9F",
"payment_id": null,
"conversation_id": null,
"data": {
"proposal_id": "prop_01JC9Z4S1X2Y3Z4A5B6C7D8E9F",
"event_id": "evt_01JC9Z4S1X2Y3Z4A5B6C7D8E9F",
"from": "awaiting_payment",
"to": "confirmed",
"updated_at": "2026-10-20T12:00:00.000Z"
}
}The payload is the updates feed item with three additional fields:
| Field | Description |
|---|---|
delivery_id | The delivery id, the same as the DJfindr-Webhook-Id header. |
api_version | The payload version. |
user_id | The person the event belongs to. Use that person's access token to read the record. |
The payload identifies what changed, without the details: data holds ids, statuses, kinds and amounts only. Read the record with the person's access token, which applies their current scopes. Ignore event types your application does not recognize. A webhook.test delivery has no person: user_id and role are null.
Verify signatures
The signature is hex(HMAC-SHA256(secret, timestamp + "." + raw_body)), computed over the raw request bytes, not re-serialized JSON.
- Compute the expected signature with your secret and the
DJfindr-Webhook-Timestampheader. - Compare it in constant time with every
v1=value inDJfindr-Signature. For 24 hours after a rotation the header carries two. - Reject timestamps more than 5 minutes from your clock.
- De-duplicate on
DJfindr-Webhook-Id.
import { createHmac, timingSafeEqual } from 'node:crypto';
// headers: Node's req.headers, whose names are lowercase (a repeated header is an array).
// rawBody: the request body as received, the Buffer express.raw() gives you (a string works too).
export function verifyDjfindr(secret, headers, rawBody) {
const header = (name) => [].concat(headers[name] ?? []).join(',');
const timestamp = header('djfindr-webhook-timestamp');
const age = Date.now() / 1000 - Number(timestamp);
if (!/^\d+$/.test(timestamp) || Math.abs(age) > 300) return false;
// The body's own bytes, not a string decoded from them.
const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest();
return header('djfindr-signature')
.split(',')
.map((part) => part.trim())
.filter((part) => part.startsWith('v1='))
.some((part) => {
const given = Buffer.from(part.slice(3), 'hex');
return given.length === expected.length && timingSafeEqual(given, expected);
});
}import hashlib, hmac, re, time
def verify_djfindr(secret: str, headers, raw_body: bytes) -> bool:
# Header names are case-insensitive: some frameworks keep "DJfindr-Signature", others lowercase it.
h = {k.lower(): v for k, v in headers.items()}
timestamp = h.get("djfindr-webhook-timestamp", "")
# isdigit() alone is True for "²", which int() refuses: a forged header must answer False, not raise.
if not (timestamp.isascii() and timestamp.isdigit()) or abs(time.time() - int(timestamp)) > 300:
return False
expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
given = [p.strip()[3:] for p in h.get("djfindr-signature", "").split(",") if p.strip().startswith("v1=")]
# compare_digest raises on a str that is not ASCII, so compare well-formed hex only.
return any(hmac.compare_digest(g, expected) for g in given if re.fullmatch(r"[0-9a-f]{64}", g))
Test vectorWith the secret
whsec_test_4f2c1b0e9d8a7c6b5a49382716051f0e, the timestamp1760893331and the body{"id":"aev_01JABCDEF0123456789ABCDEFG","type":"webhook.test"}, the signature isv1=63ecd16e344b36520d1f89c3aaf1918a5e131ac6a62f22fb32dc7c6f005e9156. The timestamp is in the past, so run the check with your clock set to it or without the 5-minute rule.
Retries and failures
Respond with any 2xx status within 10 seconds and process the event afterwards. Any other outcome (another status, a redirect, a timeout, a TLS or network error) is retried under the same DJfindr-Webhook-Id:
| Attempt | Delay after the previous attempt |
|---|---|
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 hours |
| 7 | 12 hours |
| 8 | 24 hours |
After the eighth attempt (about 45 hours), the delivery is marked failed. Deliveries can arrive more than once and out of order: de-duplicate on the delivery id and order by seq.
Delivery log
GET /app/webhook/deliveries lists deliveries for 30 days, with their attempts, the last status your server returned, an error class and timing. Response bodies are never stored. POST /app/webhook/deliveries/{id}/retry retries a failed delivery.
| Error class | Meaning |
|---|---|
timeout | No response within 10 seconds. |
dns_refused, url_refused | The URL failed the public-host check. |
redirect | The server answered with a redirect. |
tls, network | The connection failed. |
crashed | The delivery stopped on DJfindr's side mid-attempt. |
http_<status> | The server answered with a non-2xx status. |
A skipped delivery states why:
skipped_reason | Meaning |
|---|---|
grant_revoked | The person disconnected your application. |
scope | The person's approved scopes do not cover the event type. |
account | The person's account can no longer use connected applications. |
plan | The DJ's plan does not include third-party apps; the event stays on /me/updates. A skipped delivery is not resent; events created after the DJ upgrades to Platinum are delivered. |
client_disabled | Your application is disabled, or the account that registered it is restricted or closed. |
webhook_disabled | The webhook is disabled, or the event happened before the webhook was turned back on. |
event_expired | The event is no longer available. |
Disabled webhooks
More than 50 consecutive failed attempts over more than 24 hours disable the webhook (status: "disabled", disabled_reason: "failing"), and pending deliveries are skipped. After fixing the endpoint, call POST /app/webhook/enable or choose Turn back on in the developer portal. The webhook receives events created from that point; read GET /me/updates from your stored cursor to recover the gap.
A webhook that DJfindr turned off (disabled_reason: "by_staff") cannot be enabled by the application or its owner. Contact [email protected].