Skip to main content

Implement a Webhook Receiver

This guide walks through implementing a webhook receiver that:

  1. Verifies the HMAC signature.
  2. Deduplicates by event_id.
  3. Returns 200 fast (under 10 seconds).
  4. Enqueues heavy work for async processing.

Architecture​

  1. 1Receive the POST

    DVS sends a JSON body and signature headers to your endpoint.

  2. 2Verify the HMAC signature

    Reject with 401 if invalid. See Verify HMAC Signatures.

  3. 3Check idempotency

    Look up X-DVS-Event-Id in your processed_events table. If present, return 200 immediately without reprocessing.

  4. 4Insert idempotency record + enqueue

    Atomic insert into processed_events (unique constraint on event_id). Then push the event to your internal queue/worker.

  5. 5Return 200 OK

    Within 10 seconds. Do not do business logic synchronously.

Why decouple from business logic​

DVS times out the webhook delivery at 10 seconds. If your receiver does heavy work synchronously (database queries, notifying downstream systems, sending emails), you risk timeout and DVS will retry — leading to duplicate processing.

The pattern is: receive, verify, enqueue, respond fast. A separate worker dequeues and processes at its own pace.

Idempotency table​

CREATE TABLE processed_webhook_events (
event_id VARCHAR(255) PRIMARY KEY,
event_type VARCHAR(50) NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Insert with ON CONFLICT DO NOTHING semantics. If the insert returns 0 rows, the event was already processed — respond 200 without re-enqueuing.

Clean up records older than 30 days with a daily cron — DVS won't retry events more than 30 minutes old anyway.

Full receiver — Python (FastAPI)​

import hmac
import hashlib
import time
from fastapi import FastAPI, Request, HTTPException, Header
from sqlalchemy import insert
from sqlalchemy.dialects.postgresql import insert as pg_insert

# Assume `db`, `events_table`, `internal_queue` are already configured.

DVS_WEBHOOK_SECRET = os.environ["DVS_WEBHOOK_SECRET"]
SIGNATURE_TOLERANCE_SECONDS = 300


def verify_signature(raw_body: bytes, sig: str, ts: str, secret: str) -> bool:
if not sig or not ts: return False
try: ts_int = int(ts)
except ValueError: return False
if abs(time.time() - ts_int) > SIGNATURE_TOLERANCE_SECONDS: return False
parts = {k: v for k, v in (kv.split("=", 1) for kv in sig.split(","))}
if "v1" not in parts: return False
expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(parts["v1"], expected)


def mark_processed(event_id: str, event_type: str) -> bool:
"""Return True if newly inserted (first time), False if already existed."""
stmt = pg_insert(events_table).values(event_id=event_id, event_type=event_type).on_conflict_do_nothing()
result = db.execute(stmt)
return result.rowcount == 1


app = FastAPI()

@app.post("/integrations/dvs/webhooks")
async def receive(request: Request,
x_dvs_signature: str = Header(None),
x_dvs_signature_timestamp: str = Header(None),
x_dvs_event_id: str = Header(None),
x_dvs_event_type: str = Header(None)):
raw_body = await request.body()

if not verify_signature(raw_body, x_dvs_signature, x_dvs_signature_timestamp, DVS_WEBHOOK_SECRET):
raise HTTPException(401, "Invalid signature")

if not mark_processed(x_dvs_event_id, x_dvs_event_type):
return {"status": "duplicate_ignored"}

internal_queue.send_message(MessageBody=raw_body.decode(), MessageAttributes={
"event_type": {"StringValue": x_dvs_event_type, "DataType": "String"},
"event_id": {"StringValue": x_dvs_event_id, "DataType": "String"},
})
return {"status": "received"}

The worker (separate process) reads from internal_queue, parses the JSON, and runs business logic.

Response code semantics (what DVS does with your reply)​

Your statusDVS behavior
2xxDelivered. No retry.
3xxTreated as error. Retried. DVS does NOT follow redirects.
4xxTreated as error. Retried up to max attempts.
5xxTreated as error. Retried.
Timeout (>10s)Treated as failure. Retried.

Returning 200 for duplicate events keeps DVS happy and prevents pointless retries.

What to do if your endpoint is down​

DVS will retry up to 3 times (immediate, +60s, +300s). After 3 failures the event goes to DVS's dead-letter queue and is not auto-recovered.

Fallback: poll GET /v1/classification-requests/{id} (or validation-requests) for resources whose webhook you missed. The id is also in the response of the original POST.

After 10 consecutive failures, DVS auto-disables your endpoint. Contact OSIGU to reactivate.

Testing locally​

Use ngrok to expose your local server. See Testing with ngrok.