Verifying signatures
Verify each delivery before acting on its payload. Your endpoint is public, so a request reaching it is not proof that Epostix sent it.
Use the SDK verifier
The JavaScript SDK exports EpostixWebhookVerifier from @epostix/sdk/webhooks. It checks the signature in constant time, checks that the timestamps agree, and rejects timestamps more than five minutes from the current time by default.
This Next.js App Router handler verifies a test event. It uses Node.js 22 or later and the JavaScript SDK.
import { EpostixWebhookVerificationError } from '@epostix/sdk';
import { EpostixWebhookVerifier } from '@epostix/sdk/webhooks';
export const runtime = 'nodejs';
export async function POST(request: Request) {
const secret = process.env.EPOSTIX_WEBHOOK_SECRET;
if (!secret) {
return new Response('Webhook secret is not configured', { status: 500 });
}
const verifier = new EpostixWebhookVerifier({ secrets: [secret] });
const rawBody = new Uint8Array(await request.arrayBuffer());
try {
const { event } = verifier.verifyAndDecode(rawBody, request.headers);
console.log({ eventId: event.id, eventType: event.type });
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof EpostixWebhookVerificationError) {
return new Response('Invalid webhook', { status: 400 });
}
throw error;
}
}
Set EPOSTIX_WEBHOOK_SECRET to the complete secret shown when you create the endpoint. The example logs verified events. In an application, persist the event or finish the required work before returning success. A 2xx tells Epostix it can stop retrying.
Wire format
Webhook-Id: delivery-log-id
Webhook-Timestamp: 1755610000
Webhook-Signature: t=1755610000,v1=hex-signature
Content-Type: application/json
User-Agent: Epostix-Webhooks/1.0
The signature is a hex-encoded HMAC-SHA256 over the timestamp, a dot, and the raw request body:
signature = hex(hmac_sha256(secret, "{timestamp}.{raw body}"))
Use the secret exactly as supplied, including its whsec_ prefix. Do not base64-decode it. This is Epostix's signing format; a generic Standard Webhooks verifier is not interchangeable with it.
If you implement verification yourself, compare in constant time, check the timestamp tolerance, and require the header timestamp to match the signed timestamp. Parse JSON only after verification. Re-serializing parsed JSON changes the signed bytes.
Rotation
Secret rotation returns a new secret and a previous_secret_expires_at timestamp. Keep both secrets in the verifier while the change is deployed:
const verifier = EpostixWebhookVerifier.fromRotation(
currentSecret,
previousSecret,
);
Here currentSecret and previousSecret are the values from your secret store. Remove the previous one when the returned grace period ends. See rotation.
Duplicates and retries
Webhook-Id identifies the delivery log and stays the same during its automatic retries. Use the payload's event id as your application deduplication key, so a replay or a second endpoint cannot apply the same event twice.
Persist the event ID atomically with the work, or enforce uniqueness when enqueueing it. A check followed by a separate write can still race.
The retry gaps are five minutes, 30 minutes, two hours and 24 hours after the preceding failed attempt, up to five attempts total. See webhook setup.