> ## Documentation Index
> Fetch the complete documentation index at: https://api.housera.ro/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Find out instantly what happens with your listings: events, signature, retries, and recovery.

A webhook is an address on your server to which Housera sends a `POST` every time something happens with one of your listings: it's published, rejected, edited in the portal. You no longer need to ask periodically.

Webhooks follow the open specification **Standard Webhooks**, so any compatible library will verify your signature.

## Configuration

From your account, at [Settings → API](https://housera.ro/setari/api), in the Webhooks section:

<Steps>
  <Step title="Add address">
    A public `https://` address, for example `https://crm.exemplu.ro/hooks/housera`. You choose the events you want to receive.
  </Step>

  <Step title="Keep secret">
    Upon creation, you receive the webhook secret, in the form `whsec_…`. It's shown **only once**. You use it to verify the signature of every delivery.
  </Step>

  <Step title="Send a test">
    The “Send a test” button delivers an event `webhook.test`, signed like any other, solely to that webhook.
  </Step>
</Steps>

An account can have at most **5 webhooks**. Each can be stopped and started from the same page. There, you also see the latest deliveries, with the status received from your server, and you can resend them manually.

If the secret has reached where it shouldn't, you **rotate** it: you receive a new one (shown only once), and the old one won't sign anything anymore, immediately.

## The events

| Event | When | Additional fields in `data` |
| - | - | - |
| `listing.published` | The listing has become `active`: approved by moderation or confirmed again as available. | – |
| `listing.rejected` | Moderation has rejected the listing. | `reason`, `rejection_codes` |
| `listing.paused` | The listing has been paused. | – |
| `listing.reactivated` | The listing has returned from pause. | – |
| `listing.sold` | The listing has been marked sold or rented. | – |
| `listing.withdrawn` | The listing has been hidden by moderation or deleted. | `by` (`moderation` or `owner`), `deleted` on deletion |
| `listing.edited_in_portal` | Someone edited the listing from the Housera portal, not through API. | `etag`, new version |
| `media.failed` | One or more pictures were rejected for processing or could not be downloaded from the provided address (`reason: download_failed`, with the list `urls`). | `count`, `reason`, `urls` (on download) |
| `webhook.test` | You pressed “Send a test”. | `message` |

State changes produce events regardless of where they originated: through API, from the portal, or from moderation. Events of a listing reach the account that owns the listing only if that account has at least one API key.

## The format of an event

The body of each delivery is a JSON object:

```json theme={null}
{
  "id": "01JAQ7Z3M8V4K2N6P9R5T0W1XY",
  "type": "listing.published",
  "created_at": "2026-10-19T10:15:02+03:00",
  "data": {
    "code": "HEWPUTA8",
    "external_ref": "CRM-10293",
    "status": "active",
    "url": "https://housera.ro/anunt/hewputa8/iasi/apartament-de-vanzare-3-camere-65-mp-copou"
  }
}
```

<ResponseField name="id" type="string">
  The unique ID of the event. It's the same as the header `webhook-id`. You use it so you don't process the same event twice.
</ResponseField>

<ResponseField name="type" type="string">
  The type of the event, from the table above.
</ResponseField>

<ResponseField name="created_at" type="string">
  The moment of the event, ISO 8601.
</ResponseField>

<ResponseField name="data" type="object">
  For listing events: `code`, `external_ref` (or `null`), `status` (the state of the listing at the time of the event), `url`, plus fields specific to the type.
</ResponseField>

<AccordionGroup>
  <Accordion title="listing.rejected">
    ```json theme={null}
    {
      "id": "01JAQ80B5C7D9E1F3G5H7J9K2M",
      "type": "listing.rejected",
      "created_at": "2026-10-19T11:02:40+03:00",
      "data": {
        "code": "K7RM2QXP",
        "external_ref": "CRM-10301",
        "status": "rejected",
        "url": "https://housera.ro/anunt/k7rm2qxp/bucuresti/spatiu-comercial-de-inchiriat-120-mp-piata-victoriei",
        "reason": "Date de contact în text. Te rugăm să scoți numărul de telefon din descriere.",
        "rejection_codes": ["contact_in_text"]
      }
    }
    ```
  </Accordion>

  <Accordion title="listing.withdrawn">
    ```json theme={null}
    {
      "id": "01JAQ8C2D4F6G8H0J2K4M6N8P0",
      "type": "listing.withdrawn",
      "created_at": "2026-10-19T12:30:00+03:00",
      "data": {
        "code": "HEWPUTA8",
        "external_ref": "CRM-10293",
        "status": "hidden",
        "url": "https://housera.ro/anunt/hewputa8/iasi/apartament-de-vanzare-3-camere-65-mp-copou",
        "by": "moderation"
      }
    }
    ```

    Upon deletion of the listing by you, `by` is `owner` and appears `"deleted": true`.
  </Accordion>

  <Accordion title="listing.edited_in_portal">
    ```json theme={null}
    {
      "id": "01JAQ8F9G1H3J5K7M9N1P3Q5R7",
      "type": "listing.edited_in_portal",
      "created_at": "2026-10-19T14:05:12+03:00",
      "data": {
        "code": "HEWPUTA8",
        "external_ref": "CRM-10293",
        "status": "active",
        "url": "https://housera.ro/anunt/hewputa8/iasi/apartament-de-vanzare-3-camere-65-mp-copou",
        "etag": "\"c4ca4238a0b923820dcc509a6f75849b\""
      }
    }
    ```

    Your copy is now behind. Read the listing with `GET /account/listings/{code}` and decide what to keep. See [For CRMs](/en/crm).
  </Accordion>

  <Accordion title="media.failed">
    ```json theme={null}
    {
      "id": "01JAQ8H4J6K8M0N2P4Q6R8S0T2",
      "type": "media.failed",
      "created_at": "2026-10-19T14:20:31+03:00",
      "data": {
        "code": "HEWPUTA8",
        "external_ref": "CRM-10293",
        "status": "pending",
        "url": "https://housera.ro/anunt/hewputa8/iasi/apartament-de-vanzare-3-camere-65-mp-copou",
        "count": 2,
        "reason": "unprocessable"
      }
    }
    ```

    Possible reasons are in [The Photos](/en/guides/photos).
  </Accordion>

  <Accordion title="webhook.test">
    ```json theme={null}
    {
      "id": "01JAQ8K7M9N1P3Q5R7S9T1V3W5",
      "type": "webhook.test",
      "created_at": "2026-10-19T09:00:00+03:00",
      "data": { "message": "Housera webhook test" }
    }
    ```
  </Accordion>
</AccordionGroup>

## Delivery

Each delivery is a `POST` with the JSON body and headers:

```http theme={null}
POST /hooks/housera HTTP/1.1
Content-Type: application/json
User-Agent: Housera-Webhooks/1
webhook-id: 01JAQ7Z3M8V4K2N6P9R5T0W1XY
webhook-timestamp: 1792394102
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
```

* **Respond with a status `2xx` within 10 seconds at most.** Any other status, a redirect, or a timeout is a failed delivery.
* **Respond quickly, process later.** Put the event in your own queue and confirm immediately; do not do all the synchronization while Housera waits.
* **Redirects are not followed.** Configure the final address.

## Retries

A failed delivery is retried automatically 6 more times, after 30 seconds, 2 minutes, 10 minutes, 30 minutes, 2 hours, and 6 hours. In total, Housera tries for about 9 hours. Events `webhook.test` are not retried.

Because a delivery can arrive multiple times (for example, you processed it, but your response got lost) and because the order is not guaranteed:

* **remember the processed IDs** (`webhook-id`) and ignore an already seen event;
* **do not rely on the order of arrival**: for the current state of a listing, the source of truth remains `GET /account/listings/{code}`.

## Signature verification

Any server can send a `POST` to your address. The signature proves that the delivery comes from Housera and has not been altered.

The signature is an HMAC-SHA256 in base64, calculated over the string `{webhook-id}.{webhook-timestamp}.{corp}`, where `{corp}` are the exact bytes of the received body. The HMAC key is the part after `whsec_` in the secret, **decoded from base64**.

Steps:

1. Read the **raw** body, before any JSON parsing. A re-serialized body will not yield the same signature.
2. Check `webhook-timestamp`: reject deliveries older or newer than **5 minutes** compared to your time. This way, an intercepted delivery cannot be resent later.
3. Calculate the expected signature and compare it **in constant time** with each signature in `webhook-signature` (the header may contain multiple, separated by space, each prefixed with `v1,`).

<CodeGroup>
  ```php PHP theme={null}
  <?php
  function houseraWebhookIsValid(string $body, string $id, string $timestamp, string $signatures, string $secret): bool
  {
      // 1. A delivery older or newer than 5 minutes is refused (replay protection).
      if (! ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
          return false;
      }

      // 2. The HMAC key is the base64-decoded part after "whsec_".
      $key = base64_decode(substr($secret, strlen('whsec_')), true);
      if ($key === false) {
          return false;
      }
      $expected = base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$body}", $key, true));

      // 3. Constant-time comparison with every "v1,<signature>" in the header.
      foreach (explode(' ', $signatures) as $candidate) {
          [$version, $signature] = array_pad(explode(',', $candidate, 2), 2, '');
          if ($version === 'v1' && hash_equals($expected, $signature)) {
              return true;
          }
      }

      return false;
  }

  $body = file_get_contents('php://input');
  $valid = houseraWebhookIsValid(
      $body,
      $_SERVER['HTTP_WEBHOOK_ID'] ?? '',
      $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '',
      $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '',
      getenv('HOUSERA_WEBHOOK_SECRET'),
  );

  if (! $valid) {
      http_response_code(401);
      exit;
  }

  $event = json_decode($body, true);
  // Queue $event for processing, then answer fast.
  http_response_code(204);
  ```

  ```javascript Node.js theme={null}
  import crypto from 'node:crypto';
  import express from 'express';

  const SECRET = process.env.HOUSERA_WEBHOOK_SECRET; // "whsec_..."
  const KEY = Buffer.from(SECRET.slice('whsec_'.length), 'base64');

  function isValid(rawBody, id, timestamp, signatures) {
    // 1. A delivery older or newer than 5 minutes is refused (replay protection).
    const ts = Number(timestamp);
    if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false;

    // 2. HMAC-SHA256 over "id.timestamp.body", base64.
    const expected = crypto
      .createHmac('sha256', KEY)
      .update(`${id}.${timestamp}.`)
      .update(rawBody)
      .digest();

    // 3. Constant-time comparison with every "v1,<signature>" in the header.
    return (signatures || '').split(' ').some((candidate) => {
      const [version, signature] = candidate.split(',', 2);
      if (version !== 'v1' || !signature) return false;
      const given = Buffer.from(signature, 'base64');
      return given.length === expected.length && crypto.timingSafeEqual(given, expected);
    });
  }

  const app = express();

  // The raw body: the signature is computed over the exact bytes received.
  app.post('/hooks/housera', express.raw({ type: 'application/json' }), (req, res) => {
    const ok = isValid(
      req.body,
      req.get('webhook-id'),
      req.get('webhook-timestamp'),
      req.get('webhook-signature'),
    );
    if (!ok) return res.sendStatus(401);

    const event = JSON.parse(req.body.toString('utf8'));
    // Queue the event for processing, then answer fast.
    res.sendStatus(204);
  });

  app.listen(3000);
  ```

  ```python Python theme={null}
  import base64, hashlib, hmac, json, os, time
  from flask import Flask, request

  SECRET = os.environ["HOUSERA_WEBHOOK_SECRET"]  # "whsec_..."
  KEY = base64.b64decode(SECRET[len("whsec_"):])

  def is_valid(body: bytes, msg_id: str, timestamp: str, signatures: str) -> bool:
      # 1. A delivery older or newer than 5 minutes is refused (replay protection).
      if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
          return False

      # 2. HMAC-SHA256 over "id.timestamp.body", base64.
      signed = f"{msg_id}.{timestamp}.".encode() + body
      expected = base64.b64encode(hmac.new(KEY, signed, hashlib.sha256).digest()).decode()

      # 3. Constant-time comparison with every "v1,<signature>" in the header.
      for candidate in signatures.split(" "):
          version, _, signature = candidate.partition(",")
          if version == "v1" and hmac.compare_digest(expected, signature):
              return True
      return False

  app = Flask(__name__)

  @app.post("/hooks/housera")
  def housera_webhook():
      body = request.get_data()  # the raw bytes, before any JSON parsing
      if not is_valid(
          body,
          request.headers.get("webhook-id", ""),
          request.headers.get("webhook-timestamp", ""),
          request.headers.get("webhook-signature", ""),
      ):
          return "", 401

      event = json.loads(body)
      # Queue the event for processing, then answer fast.
      return "", 204
  ```
</CodeGroup>

<Warning>
  Do not compare the signatures with `==`. A regular comparison stops at the first differing character, and the timing difference can be used to guess the signature. Always use the constant-time comparison function of your language.
</Warning>

## Event retrieval

A webhook that your server did not receive (was down for more than 9 hours, or the webhook was off) is not lost. Each event is kept for **35 days** and you read it with `GET /events` (the right `listings:read`):

```bash theme={null}
curl "https://housera.ro/api/v1/events?after=01JAQ7Z3M8V4K2N6P9R5T0W1XY&limit=100" \
  -H "Authorization: Bearer $HOUSERA_KEY" \
  -H "Accept: application/json"
```

```json theme={null}
{
  "data": [
    { "id": "01JAQ80B5C7D9E1F3G5H7J9K2M", "type": "listing.rejected", "created_at": "2026-10-19T11:02:40+03:00", "data": { "code": "K7RM2QXP", "external_ref": "CRM-10301", "status": "rejected", "url": "https://housera.ro/anunt/k7rm2qxp/bucuresti/spatiu-comercial-de-inchiriat-120-mp-piata-victoriei", "reason": "Date de contact în text.", "rejection_codes": ["contact_in_text"] } }
  ],
  "meta": { "next": null }
}
```

* Events come in order, from the oldest to the newest, in the same form as in the webhook.
* `after` is the ID of the last event you processed. Without `after`, you start from the oldest stored event.
* `limit` ranges from 1 to 500 (default 100).
* As long as `meta.next` is not `null`, there are more events: you request the next page with `after` equal to `meta.next`.
* When `meta.next` is `null`, you have reached the end. Keep the ID of the last event from `data` and start from it next time.

A robust pattern: you process webhooks as they arrive and, every few minutes (or when restarting the server), run `GET /events?after=<ultimul id>` to catch anything missed. The IDs tell you what you've already processed.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.