Webhooks

Webhooks push events to your server as they happen in connected accounts: a bill created, a payment confirmed, a tenant connecting LINE.

Setting up an endpoint

Add endpoints in the portal under Webhooks, or through the API with POST /v1/webhook-endpoints (scope webhooks:manage). Each endpoint has:

  • a URL: HTTPS on the public internet. Private, loopback and internal addresses are refused.
  • a mode: test endpoints receive sandbox events, live endpoints receive events from real connected accounts.
  • the events it wants, or all of them.
  • a signing secret (whsec_…), returned when the endpoint is created. You can reveal or rotate it later.

An endpoint receives an event only if the landlord granted your application the matching read scope. For example, payment.* events need payments:read.

Event types

Event When
property.created / property.updated / property.deleted A property was created, changed or deleted (deleting also removes its rooms, readings and bills).
room.created / room.updated / room.deleted A room was created (by the landlord, an import, the API or a tenant registering on LINE), changed or deleted.
tenant.created / tenant.updated / tenant.deleted A tenant moved in, their details or LINE settings changed, or they moved out.
tenant.line_registered A tenant connected their LINE account to DOOLAE LINE.
meter.reading.created / meter.reading.updated A month's reading was recorded or corrected.
bill.created / bill.updated / bill.overdue / bill.deleted A bill was created, recalculated or changed status (including paid), marked overdue, or deleted.
payment.pending A tenant opened a QR payment.
payment.paid The payment provider confirmed a payment. Sent exactly once per payment.
payment.failed A QR payment ended unpaid (expired or canceled; check data.object.status).
notification.sent / notification.failed A LINE message reached the tenant, or DOOLAE stopped retrying it.
webhook.test Sent when you press Send test event in the portal.

The delivery

POST /webhooks/doolae HTTP/1.1
Content-Type: application/json; charset=utf-8
User-Agent: DOOLAE-Webhooks/1.0
X-DOOLAE-Event: payment.paid
X-DOOLAE-Event-ID: evt_cm2x8k1qz0001a8b3c4d5e6f7
X-DOOLAE-Delivery-ID: whdl_3f2a9c1d7b8e4f60a1b2c3d4
X-DOOLAE-Timestamp: 1790000000
X-DOOLAE-Signature: sha256=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
X-DOOLAE-Attempt: 1

{
  "id": "evt_cm2x8k1qz0001a8b3c4d5e6f7",
  "object": "event",
  "type": "payment.paid",
  "api_version": "v1",
  "mode": "live",
  "account_id": "acct_cm2x8k1qz0001a8b3c4d5e6f8",
  "created_at": "2026-10-07T03:15:00.000Z",
  "data": {
    "object": {
      "id": "pay_cm2x8k1qz0001a8b3c4d5e6f9",
      "object": "payment",
      "bill_id": "bill_cm2x8k1qz0001a8b3c4d5e6fa",
      "status": "paid",
      "amount": 543600,
      "amount_paid": 543613,
      "currency": "THB",
      "…": "…"
    }
  }
}

data.object is the object as the API returns it. The snapshot is taken once, a few seconds after the event, and every retry and replay sends the same body.

Verifying the signature

Always verify before trusting a delivery:

  1. Take the raw request body: not a re-serialized JSON object.
  2. Compute HMAC-SHA256(secret, "<X-DOOLAE-Timestamp>.<raw body>") as lowercase hex.
  3. Compare it, in constant time, with each sha256=… entry in X-DOOLAE-Signature. After a secret rotation there are two entries for 24 hours.
  4. Refuse the delivery if the timestamp is more than 5 minutes old. This stops replays.

Node.js (Express)

import crypto from "node:crypto";
import express from "express";

const app = express();
app.post("/webhooks/doolae", express.raw({ type: "application/json" }), (req, res) => {
  const ts = req.get("X-DOOLAE-Timestamp");
  const header = req.get("X-DOOLAE-Signature") ?? "";
  const expected = crypto.createHmac("sha256", process.env.DOOLAE_WEBHOOK_SECRET).update(`${ts}.${req.body}`).digest("hex");
  const ok = header.split(",").some((part) => {
    const sig = part.trim().replace(/^sha256=/, "");
    return sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
  });
  if (!ok || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400);
  const event = JSON.parse(req.body);
  // De-duplicate on event.id, store it, then do the work in the background.
  res.sendStatus(200);
});

Python (Flask)

import hashlib, hmac, os, time
from flask import Flask, request, abort

app = Flask(__name__)

@app.post("/webhooks/doolae")
def doolae_webhook():
    ts = request.headers.get("X-DOOLAE-Timestamp", "")
    body = request.get_data()  # raw bytes
    expected = hmac.new(os.environ["DOOLAE_WEBHOOK_SECRET"].encode(), f"{ts}.".encode() + body, hashlib.sha256).hexdigest()
    sigs = [s.strip().removeprefix("sha256=") for s in request.headers.get("X-DOOLAE-Signature", "").split(",")]
    if not any(hmac.compare_digest(s, expected) for s in sigs) or abs(time.time() - int(ts or 0)) > 300:
        abort(400)
    event = request.get_json()
    return "", 200

PHP

<?php
$body = file_get_contents("php://input");
$ts = $_SERVER["HTTP_X_DOOLAE_TIMESTAMP"] ?? "";
$expected = hash_hmac("sha256", $ts . "." . $body, getenv("DOOLAE_WEBHOOK_SECRET"));
$ok = false;
foreach (explode(",", $_SERVER["HTTP_X_DOOLAE_SIGNATURE"] ?? "") as $part) {
    if (hash_equals($expected, preg_replace('/^sha256=/', "", trim($part)))) $ok = true;
}
if (!$ok || abs(time() - (int) $ts) > 300) { http_response_code(400); exit; }
$event = json_decode($body, true);
http_response_code(200);

Responding, retries and failures

  • Answer with any 2xx within 10 seconds. Do slow work after you respond.
  • Redirects are not followed, and anything other than 2xx counts as a failure.
  • Failed deliveries are retried after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h (8 attempts, about 45 hours, with a little jitter). After that the delivery is marked failed.
  • If every delivery to an endpoint fails for 3 days, the endpoint is turned off and the portal shows it. Fix it, send a test event, then enable it again.

Duplicates and ordering

  • The same event can arrive more than once, for example when your server timed out after processing it. De-duplicate on X-DOOLAE-Event-ID (equal to id in the body).
  • DOOLAE itself never creates two events for one occurrence. A payment confirmed twice by the provider still produces one payment.paid.
  • Order is not guaranteed. Use created_at, or fetch the object's current state from the API, when order matters.

Replay

Every delivery and each of its attempts is listed on the endpoint's page in the portal for 30 days, with the status code and the start of your response. Resend delivers the same event again now, with the same event ID.

If your endpoint was down, list what you missed with GET /v1/events?created_after=…. Events are kept for 30 days.

Rotating the secret

Rotate secret in the portal, or POST /v1/webhook-endpoints/{endpoint_id}/rotate-secret, returns a new secret. For 24 hours, deliveries carry signatures made with both the new and the old secret. Deploy the new secret within that time.