Engineering•3 min read

Designing Webhook Consumers That Survive Piisend Retries

Learn how to build robust webhook handlers for Piisend delivery events, focusing on idempotency, signature verification, and proper HTTP response codes to manage retries and timeouts effectively.

Designing Webhook Consumers That Survive Piisend Retries

Section 1

Understanding Piisend Webhook Delivery and Retries

Piisend webhooks provide real-time notifications about your email delivery events, such as bounces, opens, and clicks. However, network issues or temporary outages on your server can prevent immediate delivery. To ensure event-driven email reliability, Piisend implements a robust retry mechanism, attempting to redeliver events if your endpoint doesn't respond successfully within a timeout period. This means your webhook consumer must be designed to handle multiple delivery attempts for the same event.

json
1{
2 "event_id": "evt_1234567890abcdef",
3 "event_type": "email.delivered",
4 "timestamp": 1678886400,
5 "payload": {
6 "email_id": "eml_abcdef1234567890",
7 "to": "recipient@example.com",
8 "subject": "Your order has shipped!",
9 "status": "delivered",
10 "delivery_time": 1678886395
11 }
12}

Section 2

Building Idempotent Webhook Handlers for Reliability

Due to Piisend's retry policy, your webhook endpoint might receive the same event multiple times. To prevent duplicate processing (e.g., sending multiple notifications for a single email delivery), your handler must be idempotent. This means that processing the same event multiple times should have the same effect as processing it once. You can achieve this by storing and checking a unique identifier, like the event_id provided in the webhook payload, before performing any actions.

python
1import json
2import os
3
4def handle_webhook_event(event_data):
5 event_id = event_data.get("event_id")
6 if not event_id:
7 print("Error: Missing event_id in webhook payload")
8 return 400
9
10 # In a real application, use a database or cache to store processed event IDs
11 # For demonstration, we'll use a simple in-memory set (not suitable for production)
12 processed_events = set()
13
14 if event_id in processed_events:
15 print(f"Event {event_id} already processed. Skipping.")
16 return 200 # Acknowledge success even if already processed
17
18 # Simulate processing the event
19 print(f"Processing event: {event_id} - Type: {event_data.get('event_type')}")
20 processed_events.add(event_id)
21
22 # Perform actual business logic here, e.g., update user status, log delivery
23 # ...
24
25 return 200
26
27# Example usage (in a Flask/Django view or similar)
28# event_payload = json.loads(request.data)
29# status_code = handle_webhook_event(event_payload)
30# return Response(status=status_code)

Section 3

Verifying Piisend Webhook Signatures and Timestamps

Security is paramount for webhooks. You must verify that incoming requests genuinely originate from Piisend and haven't been tampered with. Piisend sends two crucial headers: X-Webhook-Signature (an HMAC-SHA256 hash of the raw request body, signed with your webhook secret) and X-Webhook-Timestamp. Verifying the signature ensures authenticity, while checking the timestamp helps protect against replay attacks by rejecting requests that are too old.

javascript
1const crypto = require('crypto');
2
3const WEBHOOK_SECRET = process.env.PIISEND_WEBHOOK_SECRET;
4
5function verifyWebhookSignature(requestBody, signature, timestamp) {
6 if (!WEBHOOK_SECRET) {
7 throw new Error('PIISEND_WEBHOOK_SECRET is not set.');
8 }
9
10 // Check for replay attacks (e.g., within 5 minutes)
11 const FIVE_MINUTES_IN_SECONDS = 5 * 60;
12 const now = Math.floor(Date.now() / 1000);
13 if (Math.abs(now - timestamp) > FIVE_MINUTES_IN_SECONDS) {
14 console.warn('Webhook timestamp is too old or in the future.');
15 return false;
16 }
17
18 const signedPayload = `${timestamp}.${requestBody}`;
19 const expectedSignature = crypto
20 .createHmac('sha256', WEBHOOK_SECRET)
21 .update(signedPayload)
22 .digest('hex');
23
24 return crypto.timingSafeEqual(
25 Buffer.from(signature, 'utf8'),
26 Buffer.from(expectedSignature, 'utf8')
27 );
28}
29
30// Example usage in an Express.js route:
31// app.post('/webhook', (req, res) => {
32// const signature = req.headers['x-webhook-signature'];
33// const timestamp = parseInt(req.headers['x-webhook-timestamp'], 10);
34// const rawBody = req.rawBody; // Ensure you have raw body access, e.g., with body-parser.raw()
35
36// if (!verifyWebhookSignature(rawBody, signature, timestamp)) {
37// return res.status(401).send('Invalid webhook signature or timestamp');
38// }
39
40// // Process the webhook event
41// res.status(200).send('OK');
42// });

Section 4

How Your HTTP Responses Affect Piisend Retries

The HTTP status code your webhook endpoint returns dictates how Piisend handles retries. A 2xx status (e.g., 200 OK) signals successful receipt and processing, stopping any further retries for that event. A 4xx status (e.g., 400 Bad Request, 401 Unauthorized) indicates a permanent client-side error, and Piisend will typically cease retrying. Conversely, a 5xx status (e.g., 500 Internal Server Error, 503 Service Unavailable) tells Piisend that a temporary server-side issue occurred, prompting it to retry the webhook delivery after a delay.

http
1HTTP/1.1 200 OK
2Content-Type: text/plain
3Content-Length: 2
4
5OK

Start sending

Ship transactional email in minutes

Create an API key, verify a domain, and send your first message with Piisend.