DigitalPigeon

Webhooks

We post a small JSON body to your URL when something happens to a message. The body carries an id rather than the whole message — look it up if you need more.

The payload

POST to your endpoint
{
  "type": "message.bounced",
  "created_at": "2026-09-03T08:47:09Z",
  "data": {
    "id": "01a06673-4d4b-7173-a296-487a2e8917b1",
    "to": "kupac@example.com",
    "from": "Prodavnica <hello@mail.prodavnica.rs>",
    "subject": "Narudžbina #4471 je potvrđena",
    "state": "bounced"
  }
}

Events

message.sent Handed to the delivery backend.
message.delivered The receiving server accepted it.
message.bounced Permanently rejected. The address is now suppressed.
message.complained The recipient marked it as spam. Also suppressed.
message.failed We gave up after retrying.
message.opened Only if open tracking is on for that domain.
message.clicked Only if click tracking is on for that domain.
message.unsubscribed Someone used the unsubscribe link.

Verify before you trust it

Every request carries a DP-Signature header:

Header
DP-Signature: t=1757059200,v1=5f2b…

It is an HMAC-SHA256 over timestamp.body, keyed with your endpoint's signing secret. Check it against the raw request body, before any JSON parsing — re-serialising changes the bytes and the signature will not match.

Node
import crypto from "node:crypto";

export function verify(rawBody, header, secret) {
  const timestamp = header.match(/t=(\d+)/)?.[1];
  const signature = header.match(/v1=([a-f0-9]+)/)?.[1];
  if (!timestamp || !signature) return false;

  // Reject anything older than five minutes: without this, a captured
  // request stays valid forever.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  // Constant-time: a plain === leaks how much of the signature matched.
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
Ruby
def verified?(raw_body, header, secret)
  timestamp = header[/t=(\d+)/, 1]
  signature = header[/v1=([a-f0-9]+)/, 1]
  return false if timestamp.nil? || signature.nil?

  # A captured request would otherwise stay valid forever.
  return false if (Time.now.to_i - timestamp.to_i).abs > 300

  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}")
  ActiveSupport::SecurityUtils.secure_compare(expected, signature)
end
Python
import hashlib, hmac, re, time

def verified(raw_body: bytes, header: str, secret: str) -> bool:
    timestamp = re.search(r"t=(\d+)", header)
    signature = re.search(r"v1=([a-f0-9]+)", header)
    if not timestamp or not signature:
        return False

    # A captured request would otherwise stay valid forever.
    if abs(time.time() - int(timestamp.group(1))) > 300:
        return False

    expected = hmac.new(
        secret.encode(),
        f"{timestamp.group(1)}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(expected, signature.group(1))

Two things the snippets above do on purpose

Retries

Answer quickly and do the work afterwards. We time out after 5 seconds, and a slow endpoint turns into retries you did not need.