Webhooks
Dibs can send every alert as an HTTPS POST to a URL you own, signed so you can tell it came from us. Add an endpoint under Alerts in your settings. Webhooks are on every plan, including Free.
Adding an endpoint
The URL must use https:// and resolve to a public address. When you add it we send one test delivery (a listing titled “Test delivery from Dibs”); your endpoint must answer it with a 2xx. Then we show your signing secret, once. It starts with whsec_. We never show it again: if you lose it, remove the endpoint and add it again to get a new one. Each endpoint has its own secret.
The test delivery is signed with the new secret before you have seen it, so accept it without verifying, or verify it once you have copied the secret.
The request
One listing per request:
POST <your URL>
content-type: application/json
user-agent: dibs-webhooks/1 (+https://dibs.deals)
dibs-event: listing.found
dibs-signature: t=1760647800,v1=5f2c8e…
{
"v": 1,
"type": "listing.found",
"sentAt": "2026-10-16T20:50:00.000Z",
"listing": {
"id": "7c1e2f4a-3b5d-4e6f-8a9b-0c1d2e3f4a5b",
"title": "Herman Miller Aeron chair, size B",
"url": "https://www.facebook.com/marketplace/item/1234567890/",
"priceRaw": "$450",
"priceCents": 45000,
"location": "Austin, TX",
"imageUrl": "https://dibs.deals/i/7c1e2f4a-3b5d-4e6f-8a9b-0c1d2e3f4a5b",
"source": "facebook-marketplace",
"searchId": "2d4f6a8c-1e3b-4d5f-9a7c-8b6e4d2f0a1c",
"searchLabel": "Office chairs",
"listedAt": "2026-10-16T20:41:00.000Z",
"firstSeenAt": "2026-10-16T20:49:12.000Z",
"priceDrop": null
}
}dibs-event is listing.found for a new listing or listing.price_drop when a listing you were already sent drops in price. It always matches type in the body.
Payload
The body is { v, type, sentAt, listing }. v is 1. We only ever add fields to version 1, so ignore any you don’t recognise; a breaking change would come as v: 2.
| listing. | Type | Meaning |
|---|---|---|
| id | string | The listing id. Stable for the life of the listing. |
| title | string | The listing title, as the marketplace shows it. |
| url | string | Link to the listing. |
| priceRaw | string | null | The price as the marketplace wrote it. |
| priceCents | number | null | The price in cents, when it parses. For a price drop, the new price. |
| location | string | null | Where the listing says it is. |
| imageUrl | string | null | The first photo. |
| source | string | facebook-marketplace, craigslist or offerup. |
| searchId | string | The saved search that found it. |
| searchLabel | string | null | That search’s name. |
| listedAt | string | null | When the seller listed it (ISO 8601), when known. |
| firstSeenAt | string | When Dibs first saw it (ISO 8601). |
| priceDrop | object | null | Set on listing.price_drop: fromCents, toCents, dropCents, dropPercent, since. |
| startingSample | boolean (optional) | True when the listing was already up when the search started watching. |
Verifying the signature
The dibs-signature header is t=<unix seconds>,v1=<hex>. v1 is the HMAC-SHA256, hex-encoded, of the string t, a period, and the raw request body, keyed with your full secret including the whsec_ prefix. It is the same scheme Stripe uses.
- Read the raw body before parsing the JSON. Re-serialising it changes the bytes and the signature won’t match.
- Split the header on
,and readtandv1. - Compute
HMAC-SHA256(secret, t + "." + rawBody)as hex and compare it tov1in constant time. - Reject the request if
tis more than about five minutes from your clock. The timestamp is signed, so this stops replays.
Node
const crypto = require('node:crypto')
// rawBody: the request body as a Buffer or string, before JSON.parse.
function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=', 2)))
const expected = crypto
.createHmac('sha256', secret)
.update(parts.t + '.')
.update(rawBody)
.digest('hex')
const a = Buffer.from(expected)
const b = Buffer.from(parts.v1 ?? '')
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= toleranceSeconds
return a.length === b.length && crypto.timingSafeEqual(a, b) && fresh
}Python
import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str, tolerance=300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, sig = parts["t"], parts["v1"]
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig) and abs(time.time() - int(t)) <= toleranceResponding and retries
Answer with any 2xx within 10 seconds. Redirects are not followed and count as a failure, so give us the final URL. Anything else, or no answer in time, is a failure: we retry after a minute, then wait twice as long each time, up to an hour between tries. After 8 failures in a row we turn the endpoint off and show the last error in your settings, where you can turn it back on.
A delivery can arrive more than once, for example if your endpoint did the work but answered after the timeout. Use listing.id with type to skip ones you have already handled.
Help
Email support@dibs.deals.