SendNeo Webhooks
SendNeo webhooks
Webhooks notify your application when a message or contact changes state. Register an HTTPS endpoint with POST /webhooks, then verify each request before processing it.
Events
SendNeo can send these events:
email.sentemail.deliveredemail.bouncedemail.complainedemail.failedcontact.subscribedcontact.unsubscribed
A delivery event should update your own record; it should not trigger a duplicate send without an explicit application decision.
Register an endpoint
curl https://sendneo.yorkermedia.com/api/webhooks \
-X POST \
-H "Authorization: Bearer $SENDNEO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://app.example.com/sendneo/webhook","events":["email.delivered","email.bounced"]}'
The response includes the webhook ID and its signing secret. Store that secret in your secret manager. Use GET /webhooks to list registrations and DELETE /webhooks/{id} to remove one.
Payload
The POST body is JSON. A representative delivery payload has this shape:
{
"type": "email.delivered",
"id": "evt_01J...",
"created_at": "2026-10-16T12:00:00Z",
"data": {
"email_id": "email_01J...",
"to": ["reader@example.net"]
}
}
Treat the raw request body as the source for signature verification. Parse JSON only after the signature passes. Your handler should be idempotent because providers and networks can retry delivery.
Signature format
Every POST carries:
SendNeo-Signature: t=1729080000,v1=hex_hmac_value
The HMAC-SHA256 message is <t>.<raw body>, using your webhook secret. Reject a timestamp older than five minutes, then compare the calculated digest with v1 using a constant-time comparison.
Node.js verification
This example uses the raw body from an Express route. Configure the body parser so it does not consume the bytes first.
import crypto from 'node:crypto';
export function verifySendNeoWebhook(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(',').map(part => part.split('='))
);
const timestamp = Number(parts.t);
const signature = parts.v1;
if (!timestamp || !signature) throw new Error('Invalid signature header');
if (Math.abs(Date.now() / 1000 - timestamp) > 300) {
throw new Error('Stale webhook');
}
const signed = `${timestamp}.${rawBody}`;
const expected = crypto.createHmac('sha256', secret)
.update(signed, 'utf8')
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(signature, 'hex');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
throw new Error('Invalid webhook signature');
}
}
After verification:
app.post('/sendneo/webhook', express.raw({ type: 'application/json' }), (req, res) => {
verifySendNeoWebhook(req.body.toString('utf8'), req.get('SendNeo-Signature'), process.env.SENDNEO_WEBHOOK_SECRET);
const event = JSON.parse(req.body.toString('utf8'));
// Record event.id once, then apply event.data.
res.sendStatus(200);
});
Python verification
import hashlib
import hmac
import time
def verify_sendneo_webhook(raw_body: bytes, header: str, secret: str) -> None:
values = dict(item.split("=", 1) for item in header.split(","))
timestamp = int(values["t"])
signature = values["v1"]
if abs(time.time() - timestamp) > 300:
raise ValueError("stale webhook")
signed = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
raise ValueError("invalid webhook signature")
Pass the exact bytes received by your framework before JSON decoding. Return a success status only after you have durably queued or recorded the event. Retry safely by checking the event ID.
Delivery log, resend and test events
Dashboard → Webhooks shows the last 50 deliveries with their HTTP status. Resend queues any delivery again, and
Send test posts a webhook.test event (also POST /api/webhooks/{id}/test). Failed deliveries retry automatically for
about a day with growing gaps.
Pull events instead
GET /api/events?after=<id>&type=delivered,bounced&limit=100 returns events in order with next_after for the next page —
useful for batch jobs or when you can't receive webhooks.