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
{
"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:
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.
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));
}
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
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
- They reject old timestamps. Without that, a request captured once stays valid forever.
- They compare in constant time. A plain string comparison leaks how much of the signature matched, which is enough to guess the rest.
Retries
- Answer
2xxand we consider it delivered. - A
5xxor a timeout is retried after 30 seconds, then 2 minutes, 10 minutes, 1 hour and 6 hours. - A
4xxis not retried. You understood it and refused, and sending it again will not change that. - After 20 failures in a row we switch the endpoint off and show you why. You can re-enable it once it is fixed.
Answer quickly and do the work afterwards. We time out after 5 seconds, and a slow endpoint turns into retries you did not need.