Build a webhook receiver for finished PDFs and images

A webhook receiver is an HTTPS address on your own server that thirds.ai calls when a PDF or image is ready. With thirds.ai, you save a design once, such as an invoice, a report, or a social post. Then you send new details and get a finished file with your logo, colours, and fonts. A receiver means your app stops asking about each file again and again. You get one signed event when a file succeeds, fails, or is cancelled. Each event gets up to eight delivery attempts, and a replay sends a missed event again without spending credits.

Use with your agent

Give this prompt to your AI agent. The agent does the task for you.

Read https://thirds.ai/docs/webhooks and build a webhook receiver that verifies signatures and handles repeated render events safely

Each event gives you IDs and a job status URL. Your server reads that URL with its API key to get the result and download link. A receiver needs code on your server. To make the design itself without code, follow how to make a template you can reuse.

Register a receiver

Registering a webhook receiver tells thirds.ai where to send events for your PDFs and images. It takes one request, and first you need an API key and an HTTPS address on a server you control. The address must use port 443 and resolve only to public IP addresses. Localhost, private networks, URL credentials, and redirects aren't supported. Replace the URL and API key in this request with your own values:

POST /v1/webhooks
curl --fail-with-body --silent --show-error https://thirds.ai/v1/webhooks \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'Content-Type: application/json' \
  --data '{"url":"https://YOUR_RECEIVER.example/thirds-events","events":["render.succeeded","render.failed","render.cancelled"]}'

Save the returned signing secret in your receiver's secret store. You can read it only when you create the destination or rotate the secret. This secret checks incoming events. Your API key lets you read the job. Keep both out of URLs and logs.

An account can have ten destinations. Register and enable yours before you send the first-output request. One destination can receive events for both PDFs and images. A disabled destination receives no new events.

Verify before you act

Your webhook receiver checks the signature on each event in four steps before it trusts the event or does any work. You need the raw request body and the signing secret you saved when you registered the receiver. Read the raw body before you parse JSON. The thirds-signature header contains t=UNIX_SECONDS,v1=HEX_SIGNATURE. It can contain two v1 values while secrets rotate.

  1. Read the timestamp and reject it if it falls outside your accepted clock window. Five minutes is a reasonable window if your clocks stay in sync.
  2. Compute HMAC-SHA256 with your stored signing secret. The input is the exact timestamp text, a dot, and the raw body bytes. Compare the lowercase hex result with each v1 value using a constant-time comparison. Accept the event only if one matches.
  3. Parse the verified JSON. Store its event id with the work you queue. If you have already stored that ID, return success without repeating the work.
  4. Return a small 2xx response after you save the event and queued work to durable storage. Let your worker complete slow tasks.

Use the original body bytes for the signature check. Parsing and encoding JSON again can change spaces or key order, which changes the signature. Your platform's HMAC and constant-time comparison functions can handle the check.

Node verification

This function accepts the raw body as a Buffer. Give it both signing secrets during the 24-hour rotation window. Remove the old secret after that window ends.

JavaScript
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyThirdsSignature(
  rawBody,
  header,
  secrets,
  now = Date.now() / 1000,
) {
  if (!Buffer.isBuffer(rawBody) || typeof header !== "string") return false;
  if (!Array.isArray(secrets) || secrets.length === 0) return false;

  const match =
    /^t=([0-9]{1,12}),v1=([0-9a-f]{64})(?:,v1=([0-9a-f]{64}))?$/.exec(header);
  if (!match) return false;

  const timestamp = Number(match[1]);
  if (!Number.isSafeInteger(timestamp) || Math.abs(now - timestamp) > 300)
    return false;

  const signatures = match
    .slice(2)
    .filter(Boolean)
    .map((value) => Buffer.from(value, "hex"));
  return secrets.some((secret) => {
    if (typeof secret !== "string" || secret.length === 0) return false;
    const expected = createHmac("sha256", secret)
      .update(match[1])
      .update(".")
      .update(rawBody)
      .digest();
    return signatures.some(
      (signature) =>
        signature.length === expected.length &&
        timingSafeEqual(signature, expected),
    );
  });
}

Call this function before JSON.parse. For Express, use a route-specific raw body parser such as express.raw({ type: "application/json", limit: "64kb" }). Do not use the parsed req.body for this check.

JavaScript
const secrets = [
  process.env.THIRDS_WEBHOOK_SECRET,
  process.env.THIRDS_WEBHOOK_OLD_SECRET,
].filter(Boolean);

app.post(
  "/thirds-events",
  express.raw({ type: "application/json", limit: "64kb" }),
  (req, res) => {
    if (
      !verifyThirdsSignature(req.body, req.get("thirds-signature"), secrets)
    ) {
      return res.sendStatus(401);
    }
    const event = JSON.parse(req.body.toString("utf8"));
    // Save event.id and your queued work in one durable transaction.
    return res.sendStatus(204);
  },
);

Python verification

This function also checks the exact body bytes before JSON decoding. Pass the new and old secrets during rotation.

Python
import hashlib
import hmac
import re
import time


def verify_thirds_signature(raw_body, header, secrets, now=None):
    if not isinstance(raw_body, bytes) or not isinstance(header, str):
        return False
    if not secrets:
        return False

    match = re.fullmatch(
        r"t=([0-9]{1,12}),v1=([0-9a-f]{64})(?:,v1=([0-9a-f]{64}))?", header
    )
    if not match:
        return False

    stamp = match.group(1)
    timestamp = int(stamp)
    current_time = time.time() if now is None else now
    if abs(current_time - timestamp) > 300:
        return False

    signatures = [value for value in match.groups()[1:] if value is not None]
    for secret in secrets:
        if not isinstance(secret, str) or not secret:
            continue
        expected = hmac.new(
            secret.encode("utf-8"), stamp.encode("ascii") + b"." + raw_body, hashlib.sha256
        ).hexdigest()
        if any(hmac.compare_digest(expected, signature) for signature in signatures):
            return True
    return False

In Flask, read the body once with raw = request.get_data(), pass raw to the verifier, then parse it with json.loads(raw). In Django, use request.body. Set a body size limit at your web server or framework. Do not log the body, signature header, or signing secret when verification fails.

Read the finished job

A thirds.ai webhook event tells your receiver which PDF or image job finished, but the event doesn't carry the file. The event contains id, type, created_at, and data.job. The job contains id, state, output, and url. The event type is render.succeeded, render.failed, or render.cancelled. The output field is pdf or image.

After you verify the signature, check the job URL before you send your API key. It must belong to https://thirds.ai and match the expected /v1/pdf/{id} or /v1/image/{id} route. Then read the job and check its status. The HTTP request can succeed even when the render fails. Follow the recovery guide for failed jobs.

Events can repeat or arrive out of order. Use each event ID to detect repeats. Every retry and replay keeps the same event ID and body, but each attempt has a new signature timestamp.

Test and check delivery

You can test a webhook receiver from the thirds.ai studio before you make any real PDF or image, and a test uses no credits. Open Webhooks and choose Deliveries and test for a destination. Click Send test event to send one signed sample render.succeeded event. It creates no render. You can test a disabled destination before you enable it. A recorded test result schedules no retry, and tests can't be replayed. If a worker stops before it records a result, recovery can send the same event ID again. Deduplicate test events by ID too.

The result shows the HTTP response code, or No response if the receiver doesn't answer. A queued test can still be in progress. Choose Refresh log to read its result before you send another. You can send one test per destination per minute.

A test uses the sample job ID pdf_00000000000000000000000000000000. Verify its signature, then acknowledge it without polling a job. This ID has no render or download behind it. The delivery log marks these attempts as Test.

The delivery log shows the latest 20 attempts with their UTC time, event, response code, and retry count. The first attempt has zero retries. Attempts expire after 30 days. The log stores no event body or response body. The destination row also shows its last attempt time and consecutive failure count.

API clients use POST /v1/webhooks/{webhook_id}/test and GET /v1/webhooks/{webhook_id}/deliveries. A test answers 200 when its attempt finishes or 202 while it is queued or in progress. Both responses carry status_code and delivered. A queued response has null and false until the log records the attempt.

Recover delivery

A failed delivery to your webhook receiver gets retried, and after you fix the receiver you can replay any event that still failed. Your receiver has ten seconds to answer. Keep the complete response, including headers, within 16 KiB. Only the response status counts, and the rest of the response is thrown away. Each event or replay gets eight attempts. Delays increase between attempts, up to eight hours. After the last failure, you must replay the event to try delivery again.

Fifty delivery failures in a row disable a destination. Read the destination to check its state, then repair your receiver. Enable the destination if needed and call the replay endpoint. Replay sends failed events again with their original IDs. It creates no new render and spends no render credits. The API reference lists the endpoints for disabling, enabling, replaying, deleting, and rotating secrets.

When you rotate a secret, save the new value when the API returns it. The previous secret stays valid for 24 hours, giving you time to update your receiver. Deleting a destination stops deliveries to it.

A missed event doesn't change the render result or refund a credit. You can still poll the job while you repair webhook delivery. To save each event and download every finished file once, follow build a webhook endpoint to collect PDFs and images.

For runnable checks in both languages, read How do I verify webhook signatures in Node.js and Python?. To see a registration webhook turn into a finished document, read Make event ticket PDFs from registration data.

Ready to make your first file?

Start in the playground with a gallery template, or get an API key and send your first request from your own terminal.