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

# Webhook-uri

> Afli pe loc ce se întâmplă cu anunțurile tale: evenimentele, semnătura, reîncercările și recuperarea.

Un webhook e o adresă de pe serverul tău la care Housera trimite un `POST` de fiecare dată când se întâmplă ceva cu un anunț al tău: e publicat, respins, editat în portal. Nu mai trebuie să întrebi periodic.

Webhook-urile urmează specificația deschisă **Standard Webhooks**, deci orice bibliotecă compatibilă îți verifică semnătura.

## Configurarea

Din contul tău, la [Setări → API](https://housera.ro/setari/api), secțiunea Webhook-uri:

<Steps>
  <Step title="Adaugă adresa">
    O adresă `https://` publică, de exemplu `https://crm.exemplu.ro/hooks/housera`. Alegi evenimentele pe care vrei să le primești.
  </Step>

  <Step title="Păstrează secretul">
    La creare primești secretul webhook-ului, de forma `whsec_…`. Se arată **o singură dată**. Îl folosești ca să verifici semnătura fiecărei livrări.
  </Step>

  <Step title="Trimite un test">
    Butonul „Trimite un test” livrează un eveniment `webhook.test`, semnat ca oricare altul, doar către acel webhook.
  </Step>
</Steps>

Un cont are cel mult **5 webhook-uri**. Fiecare se poate opri și porni din aceeași pagină. Tot acolo vezi ultimele livrări, cu statusul primit de la serverul tău, și le poți retrimite manual.

Dacă secretul a ajuns unde nu trebuia, îl **rotești**: primești unul nou (arătat tot o singură dată), iar cel vechi nu mai semnează nimic, imediat.

## Evenimentele

| Eveniment | Când | Câmpuri în plus în `data` |
| - | - | - |
| `listing.published` | Anunțul a devenit `active`: aprobat de moderare sau confirmat din nou ca disponibil. | – |
| `listing.rejected` | Moderarea a respins anunțul. | `reason`, `rejection_codes` |
| `listing.paused` | Anunțul a fost pus pe pauză. | – |
| `listing.reactivated` | Anunțul a revenit din pauză. | – |
| `listing.sold` | Anunțul a fost marcat vândut sau închiriat. | – |
| `listing.withdrawn` | Anunțul a fost ascuns de moderare sau șters. | `by` (`moderation` sau `owner`), `deleted` la ștergere |
| `listing.edited_in_portal` | Cineva a editat anunțul din portalul Housera, nu prin API. | `etag`, versiunea nouă |
| `media.failed` | Una sau mai multe poze au fost refuzate la procesare sau n-au putut fi descărcate de la adresa trimisă (`reason: download_failed`, cu lista `urls`). | `count`, `reason`, `urls` (la descărcare) |
| `webhook.test` | Ai apăsat „Trimite un test”. | `message` |

Schimbările de stare produc evenimente indiferent de unde au pornit: prin API, din portal sau de la moderare. Evenimentele unui anunț ajung la contul care deține anunțul, doar dacă acel cont are cel puțin o cheie API.

## Forma unui eveniment

Corpul fiecărei livrări e un obiect JSON:

```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">
  Id-ul unic al evenimentului. E același cu antetul `webhook-id`. Îl folosești ca să nu procesezi de două ori același eveniment.
</ResponseField>

<ResponseField name="type" type="string">
  Tipul evenimentului, din tabelul de mai sus.
</ResponseField>

<ResponseField name="created_at" type="string">
  Momentul evenimentului, ISO 8601.
</ResponseField>

<ResponseField name="data" type="object">
  Pentru evenimentele de anunț: `code`, `external_ref` (sau `null`), `status` (starea anunțului în momentul evenimentului), `url`, plus câmpurile specifice tipului.
</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"
      }
    }
    ```

    La ștergerea anunțului de către tine, `by` e `owner` și apare `"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\""
      }
    }
    ```

    Copia ta e acum în urmă. Citește anunțul cu `GET /account/listings/{code}` și decide ce păstrezi. Vezi [Pentru CRM-uri](/ro/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"
      }
    }
    ```

    Motivele posibile sunt în [Pozele](/ro/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>

## Livrarea

Fiecare livrare e un `POST` cu corpul JSON și antetele:

```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=
```

* **Răspunde cu un status `2xx` în cel mult 10 secunde.** Orice alt status, o redirecționare sau o depășire de timp e o livrare eșuată.
* **Răspunde repede, procesează apoi.** Pune evenimentul într-o coadă proprie și confirmă imediat; nu face toată sincronizarea în timp ce Housera așteaptă.
* **Redirecționările nu sunt urmate.** Configurează adresa finală.

## Reîncercările

O livrare eșuată se reîncearcă automat de încă 6 ori, după 30 de secunde, 2 minute, 10 minute, 30 de minute, 2 ore și 6 ore. În total, Housera încearcă aproximativ 9 ore. Evenimentele `webhook.test` nu se reîncearcă.

Pentru că o livrare poate ajunge de mai multe ori (de exemplu, ai procesat-o, dar răspunsul tău s-a pierdut) și pentru că ordinea nu e garantată:

* **ține minte id-urile procesate** (`webhook-id`) și ignoră un eveniment deja văzut;
* **nu te baza pe ordinea sosirii**: pentru starea curentă a unui anunț, sursa de adevăr rămâne `GET /account/listings/{code}`.

## Verificarea semnăturii

Orice server poate trimite un `POST` la adresa ta. Semnătura dovedește că livrarea vine de la Housera și nu a fost modificată.

Semnătura e un HMAC-SHA256 în base64, calculat peste șirul `{webhook-id}.{webhook-timestamp}.{corp}`, unde `{corp}` sunt octeții exacți ai corpului primit. Cheia HMAC e partea de după `whsec_` din secret, **decodată din base64**.

Pașii:

1. Citește corpul **brut**, înainte de orice parsare JSON. Un corp re-serializat nu mai dă aceeași semnătură.
2. Verifică `webhook-timestamp`: respinge livrările mai vechi sau mai noi de **5 minute** față de ora ta. Așa, o livrare interceptată nu poate fi retrimisă mai târziu.
3. Calculează semnătura așteptată și compar-o **în timp constant** cu fiecare semnătură din `webhook-signature` (antetul poate conține mai multe, separate prin spațiu, fiecare cu prefixul `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>
  Nu compara semnăturile cu `==`. O comparație obișnuită se oprește la primul caracter diferit, iar diferența de timp poate fi folosită ca să ghicești semnătura. Folosește mereu funcția de comparare în timp constant a limbajului tău.
</Warning>

## Recuperarea evenimentelor

Un webhook pe care serverul tău nu l-a primit (a fost căzut mai mult de 9 ore, sau webhook-ul era oprit) nu e pierdut. Fiecare eveniment se păstrează **35 de zile** și îl citești cu `GET /events` (dreptul `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 }
}
```

* Evenimentele vin în ordine, de la cel mai vechi la cel mai nou, în aceeași formă ca la webhook.
* `after` e id-ul ultimului eveniment pe care l-ai procesat. Fără `after`, pornești de la cel mai vechi eveniment păstrat.
* `limit` merge de la 1 la 500 (implicit 100).
* Cât timp `meta.next` nu e `null`, mai sunt evenimente: ceri pagina următoare cu `after` egal cu `meta.next`.
* Când `meta.next` e `null`, ai ajuns la capăt. Păstrează id-ul ultimului eveniment din `data` și pornește de la el data viitoare.

Un tipar robust: procesezi webhook-urile pe măsură ce vin și, la fiecare câteva minute (sau la repornirea serverului), rulezi `GET /events?after=<ultimul id>` ca să prinzi orice a scăpat. Id-urile îți spun ce ai procesat deja.


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