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.sent
  • email.delivered
  • email.bounced
  • email.complained
  • email.failed
  • contact.subscribed
  • contact.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.