Webhooks
Truncus delivers delivery events to your endpoint in real time.
Event shape
{
"event_id": "<event id>",
"event_type": "delivered",
"timestamp": "2026-10-17T09:30:00.000Z",
"email": {
"id": "<email id>",
"to": "user@example.com",
"subject": "Welcome!",
"status": "delivered"
},
"data": {}
}
event_type is the type of the event, for example delivered, bounce, complaint or failed. email.status is the email's status when the webhook was sent. data holds the event's details and depends on the event type. If you selected event types for an endpoint, only those are sent to it.
Events are sent as HTTPS POST requests with Content-Type: application/json.
Request headers
Content-Type: application/json
X-Truncus-Signature: <64 hex characters>
X-Truncus-Event-Id: <event id>
X-Truncus-Delivery-Id: <delivery id>
Test events sent with "Send test event" in the dashboard carry X-Truncus-Test: true instead of X-Truncus-Delivery-Id, and "test": true in the body.
Your endpoint must respond with an HTTP 2xx status within 30 seconds to acknowledge receipt. Any other response, or a timeout, triggers a retry.
Verifying signatures
All webhook requests are signed with HMAC-SHA256 using your endpoint's signing secret. Verify every incoming request.
The X-Truncus-Signature header holds the hex-encoded HMAC-SHA256 of the raw request body. There is no timestamp and no prefix:
hex(HMAC_SHA256(webhook_secret, raw_body))
Node.js verification:
import crypto from 'crypto'
function verifyWebhookSignature(
secret: string,
rawBody: string,
signature: string
): boolean {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody, 'utf8')
.digest('hex')
const a = Buffer.from(signature, 'hex')
const b = Buffer.from(expected, 'hex')
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
Use timingSafeEqual to prevent timing attacks. Reject the request if the signature does not match.
Deduplicating events
A retry sends the same event again with the same X-Truncus-Event-Id. Store the event IDs you have processed and skip any you have already seen:
const eventId = req.headers['x-truncus-event-id'] as string
if (await alreadyProcessed(eventId)) {
return res.status(200).send('ok')
}
// ... handle the event, then:
await markProcessed(eventId)
Complete handler example
import express from 'express'
import crypto from 'crypto'
const app = express()
app.use(express.raw({ type: 'application/json' }))
app.post('/webhooks/truncus', async (req, res) => {
const signature = (req.headers['x-truncus-signature'] as string) ?? ''
const eventId = req.headers['x-truncus-event-id'] as string
const rawBody = req.body.toString('utf8')
// Signature verification
const expected = crypto
.createHmac('sha256', process.env.TRUNCUS_WEBHOOK_SECRET!)
.update(rawBody, 'utf8')
.digest('hex')
const a = Buffer.from(signature, 'hex')
const b = Buffer.from(expected, 'hex')
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send('Invalid signature')
}
// Deduplicate: a retry carries the same event ID
if (await alreadyProcessed(eventId)) {
return res.status(200).send('ok')
}
const event = JSON.parse(rawBody)
if (event.event_type === 'bounce') {
// update delivery record — event.data has detail
}
if (event.event_type === 'complaint') {
// suppress address — do not send again
}
await markProcessed(eventId)
res.status(200).send('ok')
})
Important: parse as raw bytes
Parse the request body as raw bytes before JSON parsing. Signature verification must run on the exact bytes received.
In Express: express.raw({ type: 'application/json' })
In Next.js Route Handler:
export async function POST(request: Request) {
const rawBody = await request.text()
const signature = request.headers.get('x-truncus-signature') ?? ''
const eventId = request.headers.get('x-truncus-event-id') ?? ''
// ... verify then parse JSON
}
Retry schedule
If your endpoint does not return a 2xx response, Truncus retries. Each delay counts from the previous attempt:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 24 hours |
After the fifth failed attempt the delivery is marked failed, about 26.5 hours after the first attempt. You can retry a failed delivery from the dashboard. Events remain replayable indefinitely.
Manual replay
To send an email's stored events to your webhooks again, call the sandbox replay endpoint with an API key that has the send scope:
POST /api/v1/sandbox/replay
Content-Type: application/json
{ "source_message_id": "<email id>" }
Truncus makes a sandbox copy of the email and sends each of its stored events to your webhooks again. The email is not resent. Replayed events get new event IDs and carry "replay": true in data.