Webhooks

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.

ResponseCause
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.
403The 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 server

The client secret must never reach a browser. A change (PUT, DELETE or POST) sent with an Origin header, or with a Sec-Fetch-Site header other than none, returns 403 before the credentials are read. Server-side HTTP clients send neither header.

Endpoints

EndpointDescription
GET /{env}/v1/app/webhookReturns the webhook, or data: null. Never returns the secret.
PUT /{env}/v1/app/webhookCreates the webhook (201, with its secret, shown once) or updates its url and event_types (200, without the secret).
DELETE /{env}/v1/app/webhookDeletes the webhook and its delivery log.
POST /{env}/v1/app/webhook/rotate-secretIssues a new secret, shown once. The previous secret continues to sign deliveries for 24 hours.
POST /{env}/v1/app/webhook/testSends a webhook.test delivery. Limited to 10 per hour.
POST /{env}/v1/app/webhook/enableRe-enables a webhook that was disabled for failing.
GET /{env}/v1/app/webhook/deliveriesLists deliveries, newest first. Accepts status, cursor and limit.
POST /{env}/v1/app/webhook/deliveries/{id}/retryMakes 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 secret

The 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.

FieldRules
urlhttps 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_typesUp 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

HeaderDescription
Content-Typeapplication/json
User-AgentDJfindr-Webhooks/1
DJfindr-Webhook-IdThe delivery id. Unchanged across retries; use it to de-duplicate.
DJfindr-Event-IdThe event id.
DJfindr-Webhook-TimestampThe attempt time, in Unix seconds.
DJfindr-Signaturev1= 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:

FieldDescription
delivery_idThe delivery id, the same as the DJfindr-Webhook-Id header.
api_versionThe payload version.
user_idThe 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.

  1. Compute the expected signature with your secret and the DJfindr-Webhook-Timestamp header.
  2. Compare it in constant time with every v1= value in DJfindr-Signature. For 24 hours after a rotation the header carries two.
  3. Reject timestamps more than 5 minutes from your clock.
  4. 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 vector

With the secret whsec_test_4f2c1b0e9d8a7c6b5a49382716051f0e, the timestamp 1760893331 and the body {"id":"aev_01JABCDEF0123456789ABCDEFG","type":"webhook.test"}, the signature is v1=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:

AttemptDelay after the previous attempt
21 minute
35 minutes
430 minutes
52 hours
66 hours
712 hours
824 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 classMeaning
timeoutNo response within 10 seconds.
dns_refused, url_refusedThe URL failed the public-host check.
redirectThe server answered with a redirect.
tls, networkThe connection failed.
crashedThe delivery stopped on DJfindr's side mid-attempt.
http_<status>The server answered with a non-2xx status.

A skipped delivery states why:

skipped_reasonMeaning
grant_revokedThe person disconnected your application.
scopeThe person's approved scopes do not cover the event type.
accountThe person's account can no longer use connected applications.
planThe 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_disabledYour application is disabled, or the account that registered it is restricted or closed.
webhook_disabledThe webhook is disabled, or the event happened before the webhook was turned back on.
event_expiredThe 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].