Webhooks

Receive real-time notifications when asynchronous PDF rendering completes using Posiboo webhooks.

Webhooks

Webhooks allow you to receive real-time HTTP notifications when asynchronous events occur in Posiboo, such as when a PDF render completes. Instead of polling the API, Posiboo sends a POST request to your specified endpoint with event details and a signed payload.


When Webhooks Are Sent

Posiboo sends a webhook notification when:

  • PDF rendering completes: After you submit a render request via /v1/render/template or /v1/render/source, Posiboo processes the PDF asynchronously and sends a webhook with a download URL when ready.

The webhook payload includes a short-lived presigned URL to download the generated PDF. You should download the file immediately upon receiving the webhook.


Webhook Payload Structure

Every webhook sent by Posiboo follows this JSON structure:

json
{
"eventType": "pdf.rendered",
"timestamp": "2026-02-09T04:50:47.107Z",
"data": {
"pdfDownloadUrl": "https://posiboo-storage.s3.amazonaws.com/..."
}
}

Fields:

  • eventType: The type of event (currently only "pdf.rendered" is supported)
  • timestamp: ISO 8601 timestamp when the event occurred
  • data.pdfDownloadUrl: A presigned S3 URL to download the PDF (valid for 1 hour)

The data object contains only the pdfDownloadUrl property. Download the PDF immediately to avoid expiration.


Webhook Security & Signature Verification

All webhook requests from Posiboo are signed using HMAC-SHA256. You must verify the signature before processing the payload to ensure the request genuinely came from Posiboo and has not been tampered with.

Headers Sent by Posiboo

Every webhook request includes two security headers:

  • X-Posiboo-Timestamp: Unix timestamp (in seconds) when the webhook was sent
  • X-Posiboo-Signature: HMAC signature in the format v1=<hex_signature>

Signing Algorithm

The signature is computed as follows:

plaintext
signed_payload = timestamp + "." + raw_request_body
signature = HMAC_SHA256(webhook_secret, signed_payload)
encoding = hex

Critical requirements:

  1. Use the raw request body: Compute the HMAC over the exact bytes received, before parsing JSON.
  2. Use the header timestamp: The timestamp from X-Posiboo-Timestamp, not the timestamp field in the JSON body.
  3. Use constant-time comparison: Prevent timing attacks by comparing signatures safely.
  4. Implement replay protection: Reject requests where the timestamp is older than 5 minutes.

Your Webhook Secret

Your webhook secret is generated when you configure a webhook endpoint in the Posiboo dashboard. Keep this secret secure and never commit it to version control.


Step-by-Step Verification Flow

To verify a webhook request:

  1. Extract the X-Posiboo-Timestamp header and convert it to a number
  2. Extract the X-Posiboo-Signature header and parse the signature (format: v1=<hex>)
  3. Read the raw request body as bytes (do not parse JSON yet)
  4. Construct the signed payload: timestamp + "." + raw_body
  5. Compute HMAC-SHA256 using your webhook secret and the signed payload
  6. Encode the result as a hexadecimal string
  7. Compare the computed signature with the received signature using constant-time comparison
  8. Check that the timestamp is not older than 5 minutes (replay protection)
  9. If verification passes, parse the JSON body and process the event

If any step fails, reject the request with a 400 or 401 status code.


Code Examples

Below are complete webhook verification examples for multiple languages. Each example includes raw body access, signature verification, and replay protection.

Node.js (Express)

javascript
const express = require('express');
const crypto = require('crypto');
const app = express();
const WEBHOOK_SECRET = process.env.POSIBOO_WEBHOOK_SECRET;
// Use express.raw() to get the raw body buffer
app.post('/webhooks/posiboo', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.headers['x-posiboo-timestamp'];
const signature = req.headers['x-posiboo-signature'];
if (!timestamp || !signature) {
return res.status(400).send('Missing signature headers');
}
// Extract signature value (format: v1=<hex>)
const signatureValue = signature.split('=')[1];
if (!signatureValue) {
return res.status(400).send('Invalid signature format');
}
// Construct signed payload
const rawBody = req.body.toString('utf8');
const signedPayload = `${timestamp}.${rawBody}`;
// Compute expected signature
const expectedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(signedPayload)
.digest('hex');
// Constant-time comparison
const isValid = crypto.timingSafeEqual(
Buffer.from(signatureValue),
Buffer.from(expectedSignature)
);
if (!isValid) {
return res.status(401).send('Invalid signature');
}
// Replay protection (5 minutes)
const now = Math.floor(Date.now() / 1000);
const timestampAge = now - parseInt(timestamp, 10);
if (timestampAge > 300) {
return res.status(401).send('Timestamp too old');
}
// Signature verified, parse body
const event = JSON.parse(rawBody);
if (event.eventType === 'pdf.rendered') {
const pdfUrl = event.data.pdfDownloadUrl;
console.log('PDF ready:', pdfUrl);
// Download the PDF immediately
// (see "Downloading the PDF" section below)
}
res.status(200).send('OK');
});
app.listen(3000);

TypeScript (Express)

typescript
import express, { Request, Response } from 'express';
import crypto from 'crypto';
const app = express();
const WEBHOOK_SECRET = process.env.POSIBOO_WEBHOOK_SECRET!;
interface WebhookPayload {
eventType: string;
timestamp: string;
data: {
pdfDownloadUrl: string;
};
}
app.post(
'/webhooks/posiboo',
express.raw({ type: 'application/json' }),
(req: Request, res: Response) => {
const timestamp = req.headers['x-posiboo-timestamp'] as string;
const signature = req.headers['x-posiboo-signature'] as string;
if (!timestamp || !signature) {
return res.status(400).send('Missing signature headers');
}
const signatureValue = signature.split('=')[1];
if (!signatureValue) {
return res.status(400).send('Invalid signature format');
}
const rawBody = (req.body as Buffer).toString('utf8');
const signedPayload = `${timestamp}.${rawBody}`;
const expectedSignature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(signedPayload)
.digest('hex');
const isValid = crypto.timingSafeEqual(
Buffer.from(signatureValue),
Buffer.from(expectedSignature)
);
if (!isValid) {
return res.status(401).send('Invalid signature');
}
const now = Math.floor(Date.now() / 1000);
const timestampAge = now - parseInt(timestamp, 10);
if (timestampAge > 300) {
return res.status(401).send('Timestamp too old');
}
const event: WebhookPayload = JSON.parse(rawBody);
if (event.eventType === 'pdf.rendered') {
const pdfUrl = event.data.pdfDownloadUrl;
console.log('PDF ready:', pdfUrl);
}
res.status(200).send('OK');
}
);
app.listen(3000);

Python (Flask)

python
import os
import hmac
import hashlib
import time
from flask import Flask, request, jsonify
app = Flask(__name__)
WEBHOOK_SECRET = os.environ["POSIBOO_WEBHOOK_SECRET"].encode()
@app.route("/webhooks/posiboo", methods=["POST"])
def handle_webhook():
timestamp = request.headers.get("X-Posiboo-Timestamp")
signature = request.headers.get("X-Posiboo-Signature")
if not timestamp or not signature:
return "Missing signature headers", 400
# Extract signature value (format: v1=<hex>)
try:
signature_value = signature.split("=")[1]
except IndexError:
return "Invalid signature format", 400
# Get raw body
raw_body = request.get_data(as_text=True)
signed_payload = f"{timestamp}.{raw_body}"
# Compute expected signature
expected_signature = hmac.new(
WEBHOOK_SECRET,
signed_payload.encode(),
hashlib.sha256
).hexdigest()
# Constant-time comparison
is_valid = hmac.compare_digest(signature_value, expected_signature)
if not is_valid:
return "Invalid signature", 401
# Replay protection (5 minutes)
now = int(time.time())
timestamp_age = now - int(timestamp)
if timestamp_age > 300:
return "Timestamp too old", 401
# Parse body
event = request.get_json()
if event["eventType"] == "pdf.rendered":
pdf_url = event["data"]["pdfDownloadUrl"]
print(f"PDF ready: {pdf_url}")
return "OK", 200
if __name__ == "__main__":
app.run(port=3000)

Go

go
package main
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
type WebhookPayload struct {
EventType string `json:"eventType"`
Timestamp string `json:"timestamp"`
Data struct {
PDFDownloadURL string `json:"pdfDownloadUrl"`
} `json:"data"`
}
func handleWebhook(w http.ResponseWriter, r *http.Request) {
webhookSecret := os.Getenv("POSIBOO_WEBHOOK_SECRET")
timestamp := r.Header.Get("X-Posiboo-Timestamp")
signature := r.Header.Get("X-Posiboo-Signature")
if timestamp == "" || signature == "" {
http.Error(w, "Missing signature headers", http.StatusBadRequest)
return
}
// Extract signature value (format: v1=<hex>)
parts := strings.Split(signature, "=")
if len(parts) != 2 {
http.Error(w, "Invalid signature format", http.StatusBadRequest)
return
}
signatureValue := parts[1]
// Read raw body
rawBody, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusInternalServerError)
return
}
// Construct signed payload
signedPayload := timestamp + "." + string(rawBody)
// Compute expected signature
mac := hmac.New(sha256.New, []byte(webhookSecret))
mac.Write([]byte(signedPayload))
expectedSignature := hex.EncodeToString(mac.Sum(nil))
// Constant-time comparison
if subtle.ConstantTimeCompare([]byte(signatureValue), []byte(expectedSignature)) != 1 {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
// Replay protection (5 minutes)
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
http.Error(w, "Invalid timestamp", http.StatusBadRequest)
return
}
now := time.Now().Unix()
if now-ts > 300 {
http.Error(w, "Timestamp too old", http.StatusUnauthorized)
return
}
// Parse body
var event WebhookPayload
if err := json.Unmarshal(rawBody, &event); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if event.EventType == "pdf.rendered" {
pdfURL := event.Data.PDFDownloadURL
fmt.Println("PDF ready:", pdfURL)
}
w.WriteHeader(http.StatusOK)
}
func main() {
http.HandleFunc("/webhooks/posiboo", handleWebhook)
http.ListenAndServe(":3000", nil)
}

Java (Spring Boot)

java
import org.springframework.web.bind.annotation.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
@RestController
@RequestMapping("/webhooks")
public class WebhookController {
private static final String WEBHOOK_SECRET = System.getenv("POSIBOO_WEBHOOK_SECRET");
@PostMapping(value = "/posiboo", consumes = "application/json")
public String handleWebhook(
@RequestHeader("X-Posiboo-Timestamp") String timestamp,
@RequestHeader("X-Posiboo-Signature") String signature,
@RequestBody String rawBody
) {
if (timestamp == null || signature == null) {
throw new IllegalArgumentException("Missing signature headers");
}
// Extract signature value (format: v1=<hex>)
String signatureValue = signature.split("=")[1];
// Construct signed payload
String signedPayload = timestamp + "." + rawBody;
// Compute expected signature
String expectedSignature = computeHmac(signedPayload, WEBHOOK_SECRET);
// Constant-time comparison
if (!MessageDigest.isEqual(
signatureValue.getBytes(StandardCharsets.UTF_8),
expectedSignature.getBytes(StandardCharsets.UTF_8)
)) {
throw new SecurityException("Invalid signature");
}
// Replay protection (5 minutes)
long now = Instant.now().getEpochSecond();
long timestampAge = now - Long.parseLong(timestamp);
if (timestampAge > 300) {
throw new SecurityException("Timestamp too old");
}
// Parse JSON and process
// (use Jackson or Gson to parse rawBody)
return "OK";
}
private String computeHmac(String data, String secret) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKey = new SecretKeySpec(
secret.getBytes(StandardCharsets.UTF_8),
"HmacSHA256"
);
mac.init(secretKey);
byte[] hash = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
return bytesToHex(hash);
} catch (Exception e) {
throw new RuntimeException("Failed to compute HMAC", e);
}
}
private String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
}

Rust

rust
use actix_web::{post, web, App, HttpRequest, HttpResponse, HttpServer};
use hmac::{Hmac, Mac};
use serde::Deserialize;
use sha2::Sha256;
use std::time::{SystemTime, UNIX_EPOCH};
type HmacSha256 = Hmac<Sha256>;
#[derive(Deserialize)]
struct WebhookPayload {
#[serde(rename = "eventType")]
event_type: String,
timestamp: String,
data: WebhookData,
}
#[derive(Deserialize)]
struct WebhookData {
#[serde(rename = "pdfDownloadUrl")]
pdf_download_url: String,
}
#[post("/webhooks/posiboo")]
async fn handle_webhook(req: HttpRequest, body: web::Bytes) -> HttpResponse {
let webhook_secret = std::env::var("POSIBOO_WEBHOOK_SECRET")
.expect("POSIBOO_WEBHOOK_SECRET not set");
let timestamp = match req.headers().get("x-posiboo-timestamp") {
Some(v) => v.to_str().unwrap(),
None => return HttpResponse::BadRequest().body("Missing timestamp header"),
};
let signature = match req.headers().get("x-posiboo-signature") {
Some(v) => v.to_str().unwrap(),
None => return HttpResponse::BadRequest().body("Missing signature header"),
};
// Extract signature value (format: v1=<hex>)
let signature_value = signature.split('=').nth(1).unwrap_or("");
// Construct signed payload
let raw_body = String::from_utf8(body.to_vec()).unwrap();
let signed_payload = format!("{}.{}", timestamp, raw_body);
// Compute expected signature
let mut mac = HmacSha256::new_from_slice(webhook_secret.as_bytes())
.expect("Invalid key length");
mac.update(signed_payload.as_bytes());
let result = mac.finalize();
let expected_signature = hex::encode(result.into_bytes());
// Constant-time comparison
use subtle::ConstantTimeEq;
if signature_value.as_bytes().ct_eq(expected_signature.as_bytes()).unwrap_u8() != 1 {
return HttpResponse::Unauthorized().body("Invalid signature");
}
// Replay protection (5 minutes)
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let timestamp_value: u64 = timestamp.parse().unwrap();
if now - timestamp_value > 300 {
return HttpResponse::Unauthorized().body("Timestamp too old");
}
// Parse body
let event: WebhookPayload = serde_json::from_str(&raw_body).unwrap();
if event.event_type == "pdf.rendered" {
println!("PDF ready: {}", event.data.pdf_download_url);
}
HttpResponse::Ok().body("OK")
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| App::new().service(handle_webhook))
.bind(("0.0.0.0", 3000))?
.run()
.await
}

Ruby

ruby
require 'sinatra'
require 'openssl'
require 'json'
WEBHOOK_SECRET = ENV['POSIBOO_WEBHOOK_SECRET']
post '/webhooks/posiboo' do
timestamp = request.env['HTTP_X_POSIBOO_TIMESTAMP']
signature = request.env['HTTP_X_POSIBOO_SIGNATURE']
halt 400, 'Missing signature headers' if timestamp.nil? || signature.nil?
# Extract signature value (format: v1=<hex>)
signature_value = signature.split('=')[1]
halt 400, 'Invalid signature format' if signature_value.nil?
# Read raw body
request.body.rewind
raw_body = request.body.read
# Construct signed payload
signed_payload = "#{timestamp}.#{raw_body}"
# Compute expected signature
expected_signature = OpenSSL::HMAC.hexdigest(
'SHA256',
WEBHOOK_SECRET,
signed_payload
)
# Constant-time comparison
unless Rack::Utils.secure_compare(signature_value, expected_signature)
halt 401, 'Invalid signature'
end
# Replay protection (5 minutes)
now = Time.now.to_i
timestamp_age = now - timestamp.to_i
halt 401, 'Timestamp too old' if timestamp_age > 300
# Parse body
event = JSON.parse(raw_body)
if event['eventType'] == 'pdf.rendered'
pdf_url = event['data']['pdfDownloadUrl']
puts "PDF ready: #{pdf_url}"
end
status 200
'OK'
end

PHP

php
<?php
$webhookSecret = getenv('POSIBOO_WEBHOOK_SECRET');
$timestamp = $_SERVER['HTTP_X_POSIBOO_TIMESTAMP'] ?? null;
$signature = $_SERVER['HTTP_X_POSIBOO_SIGNATURE'] ?? null;
if (!$timestamp || !$signature) {
http_response_code(400);
die('Missing signature headers');
}
// Extract signature value (format: v1=<hex>)
$parts = explode('=', $signature);
if (count($parts) !== 2) {
http_response_code(400);
die('Invalid signature format');
}
$signatureValue = $parts[1];
// Read raw body
$rawBody = file_get_contents('php://input');
// Construct signed payload
$signedPayload = $timestamp . '.' . $rawBody;
// Compute expected signature
$expectedSignature = hash_hmac('sha256', $signedPayload, $webhookSecret);
// Constant-time comparison
if (!hash_equals($signatureValue, $expectedSignature)) {
http_response_code(401);
die('Invalid signature');
}
// Replay protection (5 minutes)
$now = time();
$timestampAge = $now - intval($timestamp);
if ($timestampAge > 300) {
http_response_code(401);
die('Timestamp too old');
}
// Parse body
$event = json_decode($rawBody, true);
if ($event['eventType'] === 'pdf.rendered') {
$pdfUrl = $event['data']['pdfDownloadUrl'];
error_log('PDF ready: ' . $pdfUrl);
}
http_response_code(200);
echo 'OK';

C# (.NET)

csharp
using Microsoft.AspNetCore.Mvc;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
[ApiController]
[Route("webhooks")]
public class WebhookController : ControllerBase
{
private readonly string _webhookSecret;
public WebhookController()
{
_webhookSecret = Environment.GetEnvironmentVariable("POSIBOO_WEBHOOK_SECRET")
?? throw new InvalidOperationException("POSIBOO_WEBHOOK_SECRET not set");
}
[HttpPost("posiboo")]
public async Task<IActionResult> HandleWebhook()
{
var timestamp = Request.Headers["X-Posiboo-Timestamp"].FirstOrDefault();
var signature = Request.Headers["X-Posiboo-Signature"].FirstOrDefault();
if (string.IsNullOrEmpty(timestamp) || string.IsNullOrEmpty(signature))
{
return BadRequest("Missing signature headers");
}
// Extract signature value (format: v1=<hex>)
var signatureValue = signature.Split('=')[1];
// Read raw body
using var reader = new StreamReader(Request.Body);
var rawBody = await reader.ReadToEndAsync();
// Construct signed payload
var signedPayload = $"{timestamp}.{rawBody}";
// Compute expected signature
var expectedSignature = ComputeHmac(signedPayload, _webhookSecret);
// Constant-time comparison
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(signatureValue),
Encoding.UTF8.GetBytes(expectedSignature)))
{
return Unauthorized("Invalid signature");
}
// Replay protection (5 minutes)
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var timestampAge = now - long.Parse(timestamp);
if (timestampAge > 300)
{
return Unauthorized("Timestamp too old");
}
// Parse body
var @event = JsonSerializer.Deserialize<WebhookPayload>(rawBody);
if (@event?.EventType == "pdf.rendered")
{
var pdfUrl = @event.Data.PdfDownloadUrl;
Console.WriteLine($"PDF ready: {pdfUrl}");
}
return Ok("OK");
}
private string ComputeHmac(string data, string secret)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(data));
return Convert.ToHexString(hash).ToLower();
}
}
public class WebhookPayload
{
public string EventType { get; set; }
public string Timestamp { get; set; }
public WebhookData Data { get; set; }
}
public class WebhookData
{
public string PdfDownloadUrl { get; set; }
}

Kotlin

kotlin
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.http.*
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import java.security.MessageDigest
import java.time.Instant
fun main() {
embeddedServer(Netty, port = 3000) {
routing {
post("/webhooks/posiboo") {
val webhookSecret = System.getenv("POSIBOO_WEBHOOK_SECRET")
val timestamp = call.request.header("X-Posiboo-Timestamp")
val signature = call.request.header("X-Posiboo-Signature")
if (timestamp == null || signature == null) {
call.respondText("Missing signature headers", status = HttpStatusCode.BadRequest)
return@post
}
// Extract signature value (format: v1=<hex>)
val signatureValue = signature.split("=")[1]
// Read raw body
val rawBody = call.receiveText()
// Construct signed payload
val signedPayload = "$timestamp.$rawBody"
// Compute expected signature
val expectedSignature = computeHmac(signedPayload, webhookSecret)
// Constant-time comparison
if (!MessageDigest.isEqual(
signatureValue.toByteArray(),
expectedSignature.toByteArray()
)) {
call.respondText("Invalid signature", status = HttpStatusCode.Unauthorized)
return@post
}
// Replay protection (5 minutes)
val now = Instant.now().epochSecond
val timestampAge = now - timestamp.toLong()
if (timestampAge > 300) {
call.respondText("Timestamp too old", status = HttpStatusCode.Unauthorized)
return@post
}
// Parse body and process
// (use kotlinx.serialization or Gson)
call.respondText("OK")
}
}
}.start(wait = true)
}
fun computeHmac(data: String, secret: String): String {
val mac = Mac.getInstance("HmacSHA256")
val secretKey = SecretKeySpec(secret.toByteArray(), "HmacSHA256")
mac.init(secretKey)
val hash = mac.doFinal(data.toByteArray())
return hash.joinToString("") { "%02x".format(it) }
}

Swift

swift
import Vapor
import CryptoKit
import Foundation
func routes(_ app: Application) throws {
app.post("webhooks", "posiboo") { req async throws -> String in
let webhookSecret = Environment.get("POSIBOO_WEBHOOK_SECRET")!
guard let timestamp = req.headers.first(name: "X-Posiboo-Timestamp"),
let signature = req.headers.first(name: "X-Posiboo-Signature") else {
throw Abort(.badRequest, reason: "Missing signature headers")
}
// Extract signature value (format: v1=<hex>)
let signatureValue = signature.split(separator: "=")[1]
// Read raw body
let rawBody = req.body.string ?? ""
// Construct signed payload
let signedPayload = "\(timestamp).\(rawBody)"
// Compute expected signature
let key = SymmetricKey(data: Data(webhookSecret.utf8))
let signatureData = Data(signedPayload.utf8)
let hmac = HMAC<SHA256>.authenticationCode(for: signatureData, using: key)
let expectedSignature = hmac.map { String(format: "%02x", $0) }.joined()
// Constant-time comparison
guard signatureValue == expectedSignature else {
throw Abort(.unauthorized, reason: "Invalid signature")
}
// Replay protection (5 minutes)
let now = Int(Date().timeIntervalSince1970)
let timestampAge = now - (Int(timestamp) ?? 0)
guard timestampAge <= 300 else {
throw Abort(.unauthorized, reason: "Timestamp too old")
}
// Parse body
let decoder = JSONDecoder()
let event = try decoder.decode(WebhookPayload.self, from: Data(rawBody.utf8))
if event.eventType == "pdf.rendered" {
let pdfUrl = event.data.pdfDownloadUrl
print("PDF ready: \(pdfUrl)")
}
return "OK"
}
}
struct WebhookPayload: Codable {
let eventType: String
let timestamp: String
let data: WebhookData
}
struct WebhookData: Codable {
let pdfDownloadUrl: String
}

Downloading the PDF

Once you've verified the webhook signature, extract the pdfDownloadUrl from the data object and download the PDF immediately. The presigned URL is valid for 1 hour but downloading it right away is recommended.

javascript
async function downloadPDF(url, outputPath) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download PDF: ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// Save to disk
const fs = require('fs');
fs.writeFileSync(outputPath, buffer);
console.log(`PDF saved to ${outputPath}`);
}
// Usage in webhook handler:
if (event.eventType === 'pdf.rendered') {
const pdfUrl = event.data.pdfDownloadUrl;
await downloadPDF(pdfUrl, './output.pdf');
}

After downloading, you can:

  • Store the PDF in your own storage (S3, GCS, Cloudflare R2, etc.)
  • Send it to your users via email
  • Process it further (merge, split, watermark, etc.)
  • Archive it for record-keeping

Common Mistakes

Avoid these common pitfalls when implementing webhook verification:

1. Parsing JSON before verifying

Wrong:

javascript
// ❌ Don't do this
const event = req.body; // Already parsed by express.json()
const rawBody = JSON.stringify(event); // Re-stringified JSON may differ
const signedPayload = `${timestamp}.${rawBody}`;

Correct:

javascript
// ✅ Use raw body middleware
app.use(express.raw({ type: 'application/json' }));
// Later in handler:
const rawBody = req.body.toString('utf8');

The raw bytes must be used exactly as received. Re-stringifying parsed JSON can introduce formatting differences that break verification.

2. Using the wrong timestamp

Wrong:

javascript
// ❌ Don't use the body timestamp
const event = JSON.parse(rawBody);
const signedPayload = `${event.timestamp}.${rawBody}`;

Correct:

javascript
// ✅ Use the header timestamp
const timestamp = req.headers['x-posiboo-timestamp'];
const signedPayload = `${timestamp}.${rawBody}`;

Always use the X-Posiboo-Timestamp header value, not the timestamp field in the JSON body.

3. Not using constant-time comparison

Wrong:

javascript
// ❌ Vulnerable to timing attacks
if (signatureValue === expectedSignature) {
// ...
}

Correct:

javascript
// ✅ Use constant-time comparison
const isValid = crypto.timingSafeEqual(
Buffer.from(signatureValue),
Buffer.from(expectedSignature)
);

Regular string comparison can leak timing information. Use your language's constant-time comparison function.

4. Letting the presigned URL expire

The pdfDownloadUrl is a short-lived presigned URL (valid for 1 hour). Download the PDF immediately upon receiving the webhook. If you wait too long, the URL will expire and you'll need to re-render the PDF.

5. Not implementing replay protection

Always check that the timestamp is recent (within 5 minutes). This prevents attackers from replaying old webhook requests.


Testing Webhooks Locally

To test webhooks during development:

  1. Use a tunneling service like ngrok to expose your local server:
bash
ngrok http 3000
  1. Copy the public URL (e.g., https://abc123.ngrok.io)

  2. In the Posiboo dashboard, configure your webhook endpoint URL:

    https://abc123.ngrok.io/webhooks/posiboo
    
  3. Trigger a PDF render and watch your local server receive the webhook

Alternatively, you can use webhook testing tools like webhook.site to inspect incoming payloads before implementing verification logic.


Next Steps

Webhooks | Posiboo | Posiboo