Webhooks
Airpdf delivers a signed POST to your webhook_url when an async render finishes (succeeded or failed). All deliveries are signed with HMAC-SHA256 and replay-protected.
When are webhooks fired?
When you submit a render with async: true and include webhook_url in the request body, Airpdf delivers a webhook on terminal state (succeeded or failed).
Sync renders (async: false) also fire a fire-and-forget webhook if you provide webhook_url — useful for audit trails. The HTTP response of the sync call still contains the PDF or URL.
Payload
POST https://your-app.com/airpdf/webhooks
Content-Type: application/json
X-Airpdf-Event: render.succeeded
X-Airpdf-Delivery: 019398a6-d6f4-7c4e-9c8f-2b1a4f5e6d7c
X-Airpdf-Timestamp: 1714780800
X-Airpdf-Signature: sha256=8b7d3a1f...
{
"id": "01939e6b-d6f4-7c4e-9c8f-2b1a4f5e6d7c",
"status": "succeeded",
"duration_ms": 412,
"bytes": 18324,
"artifact_id": "01939e6b-..."
}
For failed renders the body shape is:
{
"id": "01939e6b-...",
"status": "failed",
"error_code": "render_timeout",
"error_message": "Render timeout after 25s."
}
Headers
| Header | Description |
|---|---|
X-Airpdf-Event |
Event type. Currently render.succeeded or render.failed. |
X-Airpdf-Delivery |
UUIDv7 unique per delivery. Use for idempotency on your side. |
X-Airpdf-Timestamp |
Unix seconds when Airpdf computed the signature. |
X-Airpdf-Signature |
sha256=<hex> of HMAC_SHA256(secret, "<timestamp>.<raw_body>"). |
Signature scheme
signature = HMAC_SHA256(
secret,
timestamp + "." + raw_body
)
-
secret— your tenant-specific webhook secret. Provided by Airpdf at workspace creation. Available in/<workspace>/app/api-keys(Webhook secret tab) — coming soon, currently shared via secure channel. -
timestamp— value of theX-Airpdf-Timestampheader. -
raw_body— the raw HTTP request body bytes. Do not JSON-parse before verifying.
Encode the result as lowercase hex. Compare with X-Airpdf-Signature in constant time (every official SDK does this for you).
Replay protection
Reject deliveries whose X-Airpdf-Timestamp differs by more than 5 minutes from your server's clock. Each official SDK enforces this by default — overridable via the tolerance option.
Verifying — Node SDK
import express from "express";
import { verifyWebhook, AirpdfWebhookVerificationError } from "@airpdf/sdk";
const app = express();
app.post(
"/airpdf/webhooks",
express.raw({ type: "application/json" }), // RAW body — required
(req, res) => {
try {
verifyWebhook({
body: req.body,
signature: req.header("x-airpdf-signature")!,
timestamp: req.header("x-airpdf-timestamp")!,
secret: process.env.AIRPDF_WEBHOOK_SECRET!,
});
} catch (err) {
if (err instanceof AirpdfWebhookVerificationError) {
return res.status(401).end();
}
throw err;
}
const event = JSON.parse(req.body.toString("utf8"));
// Process event
res.status(204).end();
},
);
Verifying — Python SDK
from fastapi import FastAPI, Header, HTTPException, Request
from airpdf import verify_webhook, AirpdfWebhookVerificationError
import os
app = FastAPI()
@app.post("/airpdf/webhooks")
async def handle(
request: Request,
x_airpdf_signature: str = Header(...),
x_airpdf_timestamp: str = Header(...),
):
body = await request.body()
try:
verify_webhook(
body=body,
signature=x_airpdf_signature,
timestamp=x_airpdf_timestamp,
secret=os.environ["AIRPDF_WEBHOOK_SECRET"],
)
except AirpdfWebhookVerificationError:
raise HTTPException(401, "invalid signature")
event = await request.json()
# Process event
return {"ok": True}
Verifying — Ruby SDK
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def airpdf
Airpdf::Webhook.verify!(
body: request.raw_post,
signature: request.headers["X-Airpdf-Signature"],
timestamp: request.headers["X-Airpdf-Timestamp"],
secret: ENV.fetch("AIRPDF_WEBHOOK_SECRET")
)
AirpdfJob.perform_later(JSON.parse(request.raw_post))
head :no_content
rescue Airpdf::WebhookVerificationError
head :unauthorized
end
end
Verifying — Go SDK
http.HandleFunc("/airpdf/webhooks", func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
if err := airpdf.VerifyWebhook(airpdf.VerifyWebhookOptions{
Body: body,
Signature: r.Header.Get("X-Airpdf-Signature"),
Timestamp: r.Header.Get("X-Airpdf-Timestamp"),
Secret: []byte(os.Getenv("AIRPDF_WEBHOOK_SECRET")),
}); err != nil {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusNoContent)
})
Verifying — manually (no SDK)
If you're integrating with a language we don't ship, the algorithm is identical. Pseudocode:
function verify(body, sig_header, ts_header, secret, tolerance = 300):
expect sig_header to start with "sha256="
expect abs(now() - parse_int(ts_header)) <= tolerance
payload = ts_header + "." + body
expected = hmac_sha256(secret, payload).hex()
received = sig_header[7:] # strip "sha256="
return constant_time_eq(expected, received)
Idempotency
Each delivery has a unique X-Airpdf-Delivery UUID. If your endpoint times out, Airpdf does not currently retry — but client-side idempotency on X-Airpdf-Delivery is good practice for the future.
Retries are coming in v1.1. We'll attempt the webhook 5 times with exponential backoff (1s, 5s, 30s, 5min, 30min) before giving up.
Best practices
- Reply 2xx immediately, then process async. Long endpoints risk timeout from your reverse proxy and cause us to retry (when we add retries).
- Verify before parsing. Skip parsing JSON until signature checks out.
-
Log the
X-Airpdf-Deliveryheader in your application logs — invaluable for debugging. - Tolerate clock skew by syncing your server time (NTP / chrony).
- Don't trust the body without verification — anyone on the public internet could send a fake POST.
Common pitfalls
| Symptom | Likely cause | Fix |
|---|---|---|
signature does not match |
Body was JSON-parsed before verifying |
Use raw body middleware (e.g. express.raw(), FastAPI's await request.body()) |
delivery outside tolerance window |
Server clock skew or webhook held in queue too long |
NTP sync, or increase tolerance to 600s |
| Webhook never arrives | URL not publicly reachable, or returns non-2xx | Use ngrok / Cloudflare Tunnel for local dev |
| Receives webhook but signature missing | Reverse proxy strips custom headers |
Configure proxy to forward X-Airpdf-* headers |