Tanggapi peristiwa dengan webhook

Atur URL webhook, selesaikan uji verifikasi, periksa tiap tanda tangan, konfirmasi penerimaan dalam 5 detik, dan pulihkan peristiwa terlewat lewat polling.

Webhook mengirimkan HTTP POST ke server Anda saat ada perubahan pada akun Anda: ulasan diterima, profil bisnis ditayangkan, postingan dipublikasikan. Ada satu URL per akun dan tidak ada langganan per peristiwa, sehingga satu penerima menerima semua 18 peristiwa yang didokumentasikan dan menentukan cabang pemrosesan berdasarkan field event.

1. Atur URL dan rahasia di dasbor

Bangun penerima pada langkah 2 dan 3 terlebih dahulu. Pembuatan rahasia mewajibkan penandatanganan dan menjeda pengiriman hingga endpoint Anda lulus verifikasi, sehingga endpoint yang belum siap menghambat setiap peristiwa.

  1. Deploy endpoint HTTPS. Alamat privat ditolak dan pengalihan tidak diikuti, jadi simpan URL akhir tanpa pengalihan www atau pengalihan garis miring penutup sebelum URL tersebut. 2. Simpan URL di halaman Webhook pada dasbor. 3. Buat rahasia penandatanganan. Rahasia ini ditampilkan satu kali. Simpan sebagai LISTINGSAPI_WEBHOOK_SECRET di server Anda. 4. Klik Verifikasi endpoint. Platform mengirimkan tantangan dari langkah 2 dan menunggu hingga 10 detik.

Mengubah URL atau membuat ulang rahasia akan mereset verifikasi, jadi deploy rahasia baru terlebih dahulu, lalu verifikasi kembali. Detailnya tersedia di Ikhtisar webhook.

2. Jawab tantangan verifikasi

Tantangan tersebut berupa POST bertanda tangan dengan nama peristiwa yang dicadangkan dan sebuah nonce. Balas dengan 200 yang berisi HMAC-SHA256 dari nonce saja, dalam format heksadesimal dengan huruf kecil.

JSON
{  "event": "endpoint.verification",  "nonce": "9f2c1e642b1a4b0e9f1a2c7d5e8a1b3c",  "timestamp": "2026-08-25T14:32:10Z"}

3. Verifikasi tanda tangan pada setiap pengiriman

Setiap permintaan bertanda tangan memuat X-ListingsAPI-Signature: sha256=<base64 HMAC-SHA256 of the raw body>. Hitung digest yang sama dari byte persis seperti yang Anda terima, bandingkan dalam waktu konstan, dan kembalikan 401 jika tidak cocok. Jangan parse lalu serialisasi ulang JSON terlebih dahulu; tindakan tersebut mengubah byte. Kedua penerima di bawah ini menyimpan body mentah, melakukan verifikasi, menjawab tantangan, dan menyimpan peristiwa secara persisten sebelum membalas.

Flask receiver
import base64import hashlibimport hmacimport os from flask import Flask, abort, request app = Flask(__name__)SECRET = os.environ["LISTINGSAPI_WEBHOOK_SECRET"].encode()  def signature_is_valid(raw_body: bytes, header: str | None) -> bool:  digest = hmac.new(SECRET, raw_body, hashlib.sha256).digest()  expected = "sha256=" + base64.b64encode(digest).decode()  return hmac.compare_digest(header or "", expected)  @app.post("/webhooks/listingsapi")def webhooks():  raw = request.get_data()  # raw bytes, exactly as received  if not signature_is_valid(raw, request.headers.get("X-ListingsAPI-Signature")):      abort(401)   body = request.get_json(silent=True) or {}   # One-time handshake: hex digest of the nonce, not the body.  if body.get("event") == "endpoint.verification":      return hmac.new(SECRET, body["nonce"].encode(), hashlib.sha256).hexdigest(), 200   # Real event: durable write first, then acknowledge. Swap for your queue.  inbox.put(raw)  # must be committed before the 200 goes out  return {"received": True}, 200
Express receiver
import crypto from 'crypto';import express from 'express'; const app = express();const SECRET = process.env.LISTINGSAPI_WEBHOOK_SECRET as string; // express.raw keeps req.body as the untouched Buffer the HMAC covers.app.post('/webhooks/listingsapi', express.raw({ type: '*/*' }), async (req, res) => {const raw: Buffer = req.body;const digest = crypto.createHmac('sha256', SECRET).update(raw).digest('base64');const expected = Buffer.from('sha256=' + digest);const given = Buffer.from(req.get('X-ListingsAPI-Signature') ?? '');if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) {  return res.status(401).send('bad signature');} const body = JSON.parse(raw.toString('utf8')); // One-time handshake: hex digest of the nonce, not the body.if (body.event === 'endpoint.verification') {  const answer = crypto.createHmac('sha256', SECRET).update(body.nonce).digest('hex');  return res.status(200).send(answer);} // Real event: durable write first, then acknowledge. Swap for your queue.await inbox.put(raw); // must be committed before the 200 goes outreturn res.status(200).json({ received: true });});

Jika suatu akun belum pernah membuat rahasia, pengiriman akan diterima tanpa tanda tangan. Jangan membangun integrasi dengan mengandalkan mode tersebut; buat rahasia setelah penerima di atas aktif.

4. Konfirmasikan penerimaan dengan cepat dan proses secara asinkron

Satu pengiriman berupa satu POST dengan batas waktu koneksi 5 detik dan batas waktu baca 5 detik, yang dicoba tepat satu kali. Tidak ada percobaan ulang, backoff, atau pemutaran ulang. Jika handler Anda membutuhkan waktu 6 detik, atau server Anda tidak aktif selama 2 menit, peristiwa tersebut hilang. Hanya 2xx yang dianggap berhasil dikirim; 3xx, 4xx, dan 5xx dianggap gagal.

Jadi, alur penanganan permintaan hanya melakukan tiga hal: memverifikasi, menulis body mentah ke antrean atau tabel dan menunggu commit, lalu mengembalikan 200. Pengayaan data, panggilan kembali ke REST API, dan segala hal yang melibatkan pihak ketiga dijalankan dari antrean. Setiap peristiwa kecuali listing.submission memiliki struktur payload yang sama:

JSON
{  "event": "connection.location_connected",  "timestamp": "2026-08-25T14:32:10Z",  "account_id": 11073,  "location_id": "279381",  "data": { "platform": "google", "connected_account_id": "ba03bc4b-9f8c-4d3b-8e93-9628034c63cc" }}

location_id berupa string numerik, kecuali pada peristiwa interaction.*, yang meneruskannya tanpa normalisasi dan nilainya dapat berupa null. listing.submission tidak memiliki timestamp atau account_id dan menempatkan status serta live_link di tingkat teratas. Field opsional dalam data dihilangkan alih-alih dikirim sebagai null. Kembalikan 200 untuk setiap event yang tidak Anda kenali; kelompok peristiwa yang tidak didokumentasikan menggunakan URL yang sama.

5. Hilangkan duplikat dan tangani pengiriman yang tidak berurutan

Anda dapat menerima event yang sama dua kali, dan Anda akan menerima id sumber daya yang sama berkali-kali: interaction.review dipicu kembali dengan id interaksi yang sama setiap kali ulasan berubah, dan tidak ada payload yang memuat versi. Jadi, jangan pernah mengabaikan event berdasarkan kombinasi id dan nama event, karena Anda akan melewatkan perubahan yang sebenarnya. Deteksi duplikat yang persis sama dengan menghitung hash dari isi mentah permintaan dan mengabaikan pengulangannya dalam rentang waktu singkat, lalu gunakan id sumber daya sebagai kunci operasi penulisan agar pengulangan menghasilkan hasil yang sama.

Exact-duplicate check
import hashlibimport time SEEN: dict[str, float] = {}  # use Redis with a TTL in productionWINDOW_SECONDS = 600 def is_duplicate(raw_body: bytes) -> bool:  key = hashlib.sha256(raw_body).hexdigest()  now = time.time()  for k, seen_at in list(SEEN.items()):      if now - seen_at > WINDOW_SECONDS:          del SEEN[k]  if key in SEEN:      return True  SEEN[key] = now  return False

Urutan juga tidak dijamin. Event berasal dari pipeline yang independen, sehingga local_post.published dapat tiba sebelum local_post.created, dan timestamp diberi cap waktu saat pengiriman disiapkan, bukan saat perubahan terjadi. Jika urutan penting, baca kembali sumber daya dari REST API dan andalkan hasil pembacaan tersebut. profile.updated juga dibatasi melalui debounce menjadi satu event per lokasi per 60 detik, sehingga pembacaan ulang saat event tiba hanya melihat perubahan pertama; baca lagi setelah rentang waktu tersebut berakhir.

Buat percabangan berdasarkan event dan teruskan setiap event ke kode yang Anda tulis untuk panduan yang sesuai. Webhook memberi tahu kapan sesuatu terjadi; endpoint dalam panduan tersebut memberi tahu apa yang terjadi.

EventApa yang terjadiPanduan yang perlu dibaca
profile.createdSebuah lokasi dibuat. data.source bernilai user, api, atau bulk_edit.Tambahkan lokasi
profile.updatedSebuah lokasi diedit. data.changed_fields merupakan petunjuk, bukan rincian lengkap perbedaan.Perbarui lokasi
profile.deletedSebuah lokasi diarsipkan.Temukan dan arsipkan lokasi
listing.submissionPengajuan ke platform publikasi telah mendapatkan hasil: status bernilai success, incomplete, atau canceled.Periksa status profil bisnis dan tautan aktif
connection.location_connected, connection.location_disconnectedSebuah profil bisnis Google atau Facebook ditautkan atau dilepas tautannya.Hubungkan Google dan impor lokasi, Hubungkan Halaman Facebook
connection.reauth_required, connection.listing_inaccessibleKredensial kedaluwarsa, atau akses ke sebuah profil bisnis hilang.Hubungkan Google dan impor lokasi
connection.google_verification_verified, connection.google_verification_failedSebuah verifikasi Google selesai.Buat Google Business Profile
interaction.reviewSebuah ulasan diterima atau berubah. Field berada di bawah data.interaction.Ambil ulasan
interaction.responseSebuah balasan pemilik dipublikasikan, diedit, atau diarsipkan. parent_id adalah ulasannya.Tanggapi ulasan
local_post.created, local_post.published, local_post.rejected, local_post.deletedSebuah item postingan berubah status. Gunakan data.post_item_id sebagai kunci, satu per lokasi.Publikasikan postingan
review_analytics.daily_snapshot, review_analytics.weekly_snapshotAgregasi ulasan dijalankan untuk satu lokasi dan periode.Analitik ulasan

Tabel field dan contoh payload untuk setiap event tersedia dalam referensi event.

7. Pulihkan event yang terlewat dengan polling

Karena tidak ada percobaan ulang, setiap kelompok memerlukan pembacaan data otoritatif yang dapat Anda andalkan sebagai cadangan. Lakukan rekonsiliasi secara terjadwal, bukan hanya saat ada sesuatu yang tampak salah, dan jangan pernah menulis nilai nol atau "tidak ada perubahan" hanya karena sebuah event tidak tiba.

KelompokBaca kembali denganInterval
listing.submissionGET /locations/{locationId}/listings/premium untuk syncStatus dan listingUrl.Setiap hari per lokasi.
profile.*GET /locations atau GET /locations/search; updatedAt menunjukkan apa yang berubah.Setiap jam, serta 60 detik setelah setiap profile.updated.
connection.*GET /connected-accounts dan googleVerificationStatus pada setiap pembacaan data lokasi.Setiap jam; setiap beberapa menit selama verifikasi berlangsung.
interaction.*GET /rollup_interactions di seluruh akun, GET /reviewDetails berdasarkan id.Setiap 15 menit untuk ulasan baru, setiap hari untuk pemeriksaan menyeluruh.
local_post.*GET /posts/{postId} atau GET /locations/{locationId}/posts.Setiap jam selama postingan sedang diproses.
review_analytics.*GET /locations/{locationId}/review-analytics-overview dengan tanggal-tanggal periode tersebut.Setiap hari setelah agregasi.

Dasbor mencatat setiap upaya beserta statusnya. success berarti berhasil dikirim; kode HTTP, timeout, network_error, atau error berarti endpoint Anda berhasil dijangkau tetapi gagal memproses pengiriman; blocked_url, endpoint_unverified, dan rate_limited berarti tidak ada permintaan yang dibuat sama sekali, sehingga perbaikannya ada pada konfigurasi atau paket langganan, bukan kode. Daftar lengkapnya ada di Perilaku pengiriman.

Langkah berikutnya