Last updated: 2026-03-03•6 min read

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:

AttemptDelay
1Immediate
25 minutes
330 minutes
42 hours
524 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.

Webhooks | Truncus Manual