Setup guide · Data out
Receive signed webhooks
Sendalto can forward selected events — from the Events API or HubSpot sync — to your endpoint. Each request is signed, so you can prove it came from Sendalto.
Checked against Sendalto on
1. Add a subscription
- In Settings → API & privacy add a webhook with your endpoint URL and the exact event names to forward, such as
trial_startedorcrm.contact.changed(up to 50). - Copy the signing secret. It is shown once; store it on your server.
- Only events recorded after the subscription is created are delivered; history is not replayed.
The URL must be public https:// on port 443 with a host name — no IP addresses, private networks, logins in the URL or #fragment. Redirects are not followed.
2. What a delivery looks like
POST /sendalto-webhook HTTP/1.1
content-type: application/json
x-sendalto-signature: t=1791279000,v1=5f0c…e9a1
x-sendalto-delivery: 0b9d7c52-…
{
"id": "6c7e1d3a-…",
"type": "trial_started",
"occurredAt": "2026-10-06T09:30:00.000Z",
"data": {
"email": "[email protected]",
"properties": { "plan": "team", "seats": 5 }
}
}idis the event ID,typethe event name,occurredAtthe event time;datacarries the person’s email and the event properties, so treat deliveries as personal data.x-sendalto-deliveryidentifies this delivery and stays the same on retries.x-sendalto-signatureist=<unix seconds>,v1=<hex HMAC>.
3. Verify the signature
- Read the raw request body as bytes, before any JSON parsing.
- Split the header into
tandv1. Reject the request iftis more than about 5 minutes from your clock. - Compute HMAC-SHA256 with your signing secret over the string
<t>.<raw body>and hex-encode it. - Compare it with
v1in constant time. Reject on mismatch. - Skip deliveries whose
x-sendalto-deliveryyou have already processed, then answer2xx.
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';
const SECRET = process.env.SENDALTO_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
const app = express();
// Verify against the raw bytes: parsing and re-serialising JSON changes the body.
app.post('/sendalto-webhook', express.raw({ type: 'application/json' }), async (req, res) => {
const header = req.get('x-sendalto-signature') ?? '';
const parts = Object.fromEntries(header.split(',').map(part => part.trim().split('=', 2)));
const timestamp = Number(parts.t);
if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) {
return res.sendStatus(400); // stale or missing timestamp
}
const expected = createHmac('sha256', SECRET).update(`${parts.t}.`).update(req.body).digest();
const received = Buffer.from(parts.v1 ?? '', 'hex');
if (received.length !== expected.length || !timingSafeEqual(received, expected)) {
return res.sendStatus(401); // not signed with your secret
}
// Delivery is at least once: remember each x-sendalto-delivery and skip repeats.
const deliveryId = req.get('x-sendalto-delivery');
if (await alreadyProcessed(deliveryId)) return res.sendStatus(200);
const event = JSON.parse(req.body.toString('utf8'));
await handleEvent(event); // event.id, event.type, event.occurredAt, event.data.email, event.data.properties
await markProcessed(deliveryId);
res.sendStatus(200);
});4. Retries
- Any
2xxwithin 10 seconds counts as delivered. Answer quickly and do slow work afterwards. - Timeouts, network errors,
408,425,429and5xxare retried with growing pauses — about 1, 2, 4, 8, 16, 32 and 64 minutes — up to eight attempts in total. - Other
4xxanswers stop retries at once, so return4xxonly for requests you will never accept. - Delivery is at least once: a retry can arrive after you already processed the event, including after a Sendalto worker restart. Deduplicate by
x-sendalto-delivery. - Failed deliveries are listed in Settings → API & privacy, where you can retry them. You can pause a subscription while you move your receiver.
5. Rotate the secret
Rotating shows a new secret once; it signs every following attempt, including retries of earlier events. Deploy the new secret to your receiver at the same moment, or pause the subscription during the switch.