Airpdf docs

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 the X-Airpdf-Timestamp header.
  • 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

  1. Reply 2xx immediately, then process async. Long endpoints risk timeout from your reverse proxy and cause us to retry (when we add retries).
  2. Verify before parsing. Skip parsing JSON until signature checks out.
  3. Log the X-Airpdf-Delivery header in your application logs — invaluable for debugging.
  4. Tolerate clock skew by syncing your server time (NTP / chrony).
  5. 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

Spotted a typo or stale claim? Open an issue or ping us in the workspace — docs are versioned with the product.