Webhooks
A signed call to your endpoint each time a database you chose is updated.
A webhook destination calls your endpoint with a database.updated event each time a database you chose is published with new contents. The event names the build and links each file with its size and SHA-256, so your receiver can download it straight away with the Database API, or note that it's there.
Setting one up
In the console, under Database, Delivery, click Add destination and pick Webhook. Give it your endpoint's URL, tick Announce beside each database it should hear about, save, and click Test connection: we send a database.test event and wait for a 2xx answer. Then Activate it. Each database you chose is announced once straight away, then on every new build.
The endpoint has to be https:// on a public address. Under the destination's Signing secret, Reveal shows the secret your receiver verifies with, and Rotate replaces it. The old secret keeps signing beside the new one for 24 hours, so you can update your receiver without missing a delivery.
Events
| Event | Sent when |
|---|---|
database.updated | A database you chose was published with new contents. |
database.delivered | New files reached one of your organization's storage destinations. |
database.delivery_failed | Deliveries to one of your storage destinations have failed for a day. |
database.test | You tested this destination from the console. |
Every webhook destination in your organization receives database.delivered and database.delivery_failed for the organization's storage destinations too, so one receiver can follow them all.
Each event is a JSON body of type, timestamp and data:
{
"type": "database.updated",
"timestamp": "2026-10-21T06:12:03.000Z",
"data": {
"dataset": "vpn_ip_v1",
"name": "VPN IP",
"build": "20261021",
"published_at": "2026-10-21T06:09:51.839Z",
"files": [
{
"format": "mmdb",
"size": 1203039,
"sha256": "6e2ec045663adaccbbafbafdea348108b1079e46e320cca399be2b6d1f8327e8",
"url": "https://api.vpndetection.io/api/v1/database/download?id=vpn_ip_v1&format=mmdb"
}
]
}
}A url takes your API key as any download does, as a header or by adding &apikey=.
database.delivered carries the destination (id, name, kind), the build and each file's dataset, format, path, sha256 and size. database.delivery_failed carries the destination, failing_since, an error_code and the provider's error.
Verifying a delivery
We sign every delivery to the Standard Webhooks spec, so any of its libraries verifies it. Each request carries three headers:
| Header | Holds |
|---|---|
webhook-id | The message's id, the same on every retry of it. Use it to drop a duplicate. |
webhook-timestamp | When it was sent, in Unix seconds. |
webhook-signature | One or more v1,<signature>, separated by spaces. During a rotation there are two. |
The signature is an HMAC-SHA256 of <webhook-id>.<webhook-timestamp>.<body>, keyed with the secret after its whsec_ prefix, base64-decoded. Verify against the raw body, before any JSON parsing, and refuse a timestamp more than five minutes old:
import base64, hashlib, hmac, time
def verify(secret: str, headers: dict, body: bytes) -> bool:
if abs(time.time() - int(headers["webhook-timestamp"])) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f'{headers["webhook-id"]}.{headers["webhook-timestamp"]}.'.encode() + body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
return any(
hmac.compare_digest(sig.split(",", 1)[1], expected)
for sig in headers["webhook-signature"].split()
)Answering
Answer with any 2xx within 30 seconds; the body is ignored. Do the work after answering, or a slow download makes the delivery look failed. Anything else, or no answer, is tried again 5 seconds later, then after 5 minutes, 30 minutes, 2 hours, 5 hours, and twice more 10 hours apart, about 28 hours in all. An event can arrive more than once, so key what you do on webhook-id.
An endpoint that has answered nothing but failures for three days is paused, and we email the people who manage delivery in your organization. Resume it from the console once it's fixed. The destination's History lists every delivery with the status your endpoint returned, and Resend sends one again.