メインコンテンツまでスキップ

Webhooks

Webhooks send an HTTP POST to your server when something happens on your account, so you don't have to poll. The one most health apps need is pet.bcs.reviewed, which arrives when a vet has scored a body condition check.

List the events​

curl -H "x-api-key: YOUR-API-KEY" https://api.thedogapi.com/v1/webhooks/topics
EventWhen
pet.bcs.review_requestedA body condition score has gone to a vet
pet.bcs.reviewedA vet has completed or rejected that review
image.uploadedAn image upload succeeded
image.processedAn image finished validation
image.labels_createdLabels were generated for an image
vote.created, favourite.createdA vote or favourite was recorded

Register an endpoint​

curl -X POST -H "x-api-key: YOUR-API-KEY" -H "Content-Type: application/json" \
-d '{
"url": "https://api.example.com/hooks/pets",
"description": "Production BCS results",
"enabled_events": ["pet.bcs.review_requested", "pet.bcs.reviewed"]
}' \
https://api.thedogapi.com/v1/webhooks

Event names in enabled_events aren't checked, so copy them from GET /webhooks/topics: a typo is accepted and never fires.

The response includes a signing_secret starting whsec_. This is the only time you'll see it. Store it with your other secrets. You can register up to five endpoints per account, and staging and production accounts each have their own.

Manage endpoints with GET /webhooks, GET /webhooks/{id} and DELETE /webhooks/{id}.

Verify the signature​

Every delivery carries an X-Webhook-Signature header:

X-Webhook-Signature: t=1790640000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

v1 is a hex HMAC-SHA256 of the timestamp, a full stop, and the raw request body, keyed with your signing secret. To verify, compute the same thing over the body exactly as received (before parsing it) and compare. Reject deliveries whose timestamp is more than five minutes old, so a captured request can't be replayed.

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

export function isFromAnimalApi(
rawBody: Buffer | string,
header: string | string[] | undefined,
secret: string,
toleranceSeconds = 300,
): boolean {
if (typeof header !== 'string') return false;
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=', 2)));
const t = Number(parts.t);
if (!Number.isInteger(t) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;

const expected = createHmac('sha256', secret)
.update(`${t}.`)
.update(rawBody)
.digest();
const received = Buffer.from(parts.v1, 'hex');
return received.length === expected.length && timingSafeEqual(received, expected);
}
import hashlib, hmac, time

def is_from_animal_api(raw_body: bytes, header: str | None, secret: str, tolerance: int = 300) -> bool:
try:
parts = dict(p.split("=", 1) for p in (header or "").split(","))
t = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected.encode(), parts.get("v1", "").encode())

Most frameworks parse JSON before your handler sees it. Make sure you verify against the raw body: in Express, express.raw({ type: 'application/json' }) on the webhook route.

Rotate a secret​

curl -X POST -H "x-api-key: YOUR-API-KEY" https://api.thedogapi.com/v1/webhooks/{id}/rotate-secret

Returns a new signing_secret, and the old one stops working immediately. Deploy the new secret as soon as you have it. Every retry is signed with the current secret, so a delivery that failed verification during the switch gets another chance once the new secret is live. For a switch with no gap at all, register a second endpoint, move over, then delete the first.

Endpoints created before signing was introduced send no X-Webhook-Signature header until you rotate their secret. Treat a missing header as a failed check once you've rotated.

Delivery and retries​

  • Reply with any 2xx within 5 seconds. Do slow work after you've responded.
  • Each delivery is attempted up to 3 times in total (the first try and two retries), with exponential backoff starting at one second.
  • After 10 failed attempts in a row, the endpoint is disabled and the account owner is emailed. With 3 attempts per event, that can happen after four undeliverable events. Disabled endpoints can't be re-enabled: fix the receiver, DELETE the old endpoint (disabled ones still count towards the five), then register it again.
  • The same event can occasionally arrive twice. Use the review id (or the resource's id) to ignore duplicates.

What's in a delivery​

The body is the resource the event is about, with _metadata.event_type and _metadata.timestamp added (and _metadata.country_code where we know it). Slack and Discord webhook URLs get a formatted message instead of the raw JSON. For the pet.bcs.* events that's the review, plus the pet's sub_id and external_owner_id, so you can route it to the right user without another call. See Body condition score for an example.