Webhooks

Registers move on their own schedule — a recall lands, a price changes, a product is withdrawn. Subscribe a URL and we POST you the batch instead of you polling /v1/changes forever.

Requires the webhooks:write scope, which starts at the Starter tier. You can also manage subscriptions without writing any code in the console.

Create a subscription

POST/v1/webhookswebhooks:write
curl
curl -s https://drug-database.com/v1/webhooks \
  -X POST \
  -H 'Authorization: Bearer dd_live_YOUR_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
    "url": "https://your.app/hooks/drug-database",
    "events": ["changes.insert", "changes.update", "changes.recall"]
  }'
201
{
  "id": "…-uuid",
  "url": "https://your.app/hooks/drug-database",
  "events": ["changes.insert", "changes.update", "changes.recall"],
  "created_at": "2026-09-12T08:14:00Z",
  "secret": "kR3v…"
}

The secret is in that response and nowhere else

Same one-time contract as an API key: 32 bytes of randomness, returned once at creation, never retrievable afterwards. Store it before you close the terminal. Lost it? Delete the subscription and create another — the replacement gets a new secret.

Events

EventFires when
changes.insertA row appeared in a register — a new product, a new pack, a new price.
changes.updateAn existing row changed. The payload carries before and after.
changes.deleteA row was withdrawn from a register.
changes.recallA safety recall was published.

Omit events and you get the first three — recalls are opt-in, because a recall feed is usually routed somewhere louder than the rest. An unrecognised event name is a 422 that lists the allowed set.

The url must parse and be http or https. Plain http is accepted so you can point a local tunnel at it during development; do not use it in production, where the signature is the only thing standing between you and a forged payload.

Delete a subscription

DELETE/v1/webhooks?id={uuid}webhooks:write
curl
curl -s -X DELETE 'https://drug-database.com/v1/webhooks?id=…-uuid' \
  -H 'Authorization: Bearer dd_live_YOUR_KEY'

Returns 204 on success and 404 webhook_not_found if the id is not yours — deletes are tenant-scoped, so one tenant can never remove another's hook.

What we send you

A delivery worker runs every 60 seconds. For each subscription it collects the change rows newer than the last successful delivery, filters them to the events you subscribed to, and POSTs one batch.

POST https://your.app/hooks/drug-database
{
  "delivery_id": "…-uuid",
  "since":  "2026-09-12T08:00:00Z",
  "until":  "2026-09-12T08:04:31Z",
  "events": ["changes.insert", "changes.update", "changes.recall"],
  "changes": [
    {
      "id": "…-uuid",
      "table_name": "drugs",
      "natural_key": { "swissmedic_no": "62013" },
      "drug_id": "…-uuid",
      "change_type": "insert",
      "before": null,
      "after": { "product_name": "…", "atc_code": "M01AE01" },
      "source": "swissmedic",
      "occurred_at": "2026-09-12T08:03:58Z"
    }
  ]
}

Headers

HeaderValue
x-drug-database-signaturesha256=<hex>
x-drug-database-deliveryThe delivery_id, also in the body. Use it to deduplicate.
x-drug-database-eventchanges.batch
user-agentDrugDatabase-Webhook/1.0

Verifying the signature

The signature is an HMAC-SHA256 of the raw request body bytes, keyed with your subscription secret, hex-encoded and prefixed with sha256=. Compute it over the bytes as received — parsing the JSON and re-serialising it will change whitespace and the signature will not match.

node
import { createHmac, timingSafeEqual } from 'node:crypto'

export function verify(rawBody, header, secret) {
  const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex')
  const a = Buffer.from(expected)
  const b = Buffer.from(header ?? '')
  // Lengths must match before timingSafeEqual, and comparing this way keeps
  // the check constant-time — a plain === leaks the prefix byte by byte.
  return a.length === b.length && timingSafeEqual(a, b)
}
python
import hmac, hashlib

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode(), raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, header or "")

Reject first, parse second

Anyone who learns your endpoint URL can POST to it. Verify the signature before you trust a single field, and return a non-2xx if it fails.

Delivery semantics

  • Timeout. We give your endpoint 15 seconds. Acknowledge fast and do the work asynchronously — a slow consumer delays its own next batch.
  • Success is any 2xx. On 2xx we advance your cursor to the newest change in the batch.
  • Failure retries the same window. On a non-2xx, a timeout or a connection error we record the status but leave the cursor where it was, so the next run re-sends that window plus anything new. There is no backoff and no dead-letter queue: a permanently failing endpoint is retried every minute until you delete it or fix it.
  • At-least-once, not exactly-once. A 2xx we never saw — because your process died after writing but before responding — is redelivered. Deduplicate on delivery_id, or make your handler idempotent on the change id.
  • Batches are capped. A single run reads at most 500 change rows per subscription; a larger backlog drains across consecutive runs.
  • Ordering. Changes within a batch are ordered by occurred_at ascending. Across batches the cursor only moves forward, so a later batch never contains older changes than an acknowledged one.

If you would rather poll

GET /v1/changes is the same feed, pull-shaped, and needs only changes:read — which the Free plan has. Webhooks exist to save you the polling loop, not to hold the data hostage. See the changes family in the reference.