Using the API

Webhooks

Instead of checking a job repeatedly, register a URL and we'll send an HTTPS POST when a job ends.

Events

EventSent when
conversion.succeededA job finished with at least one page converted. Check pages_failed.
conversion.failedA job failed. See error.
conversion.cancelledA job was cancelled.

Events are deliberately small. To get the download links, call GET /v1/conversions/{id}/result with your API key.

{
  "id": "0192f3a4-7b1c-7d2e-9f00-5a6b7c8d9e0f",
  "type": "conversion.succeeded",
  "created_at": "2026-11-02T08:31:51Z",
  "data": {
    "conversion": {
      "id": "01a19b62-dd01-7c80-811b-74793f0a32b6",
      "state": "succeeded",
      "pages_selected": 2,
      "pages_succeeded": 2,
      "pages_failed": 0,
      "credits_consumed": 6,
      "results_expire_at": "2026-11-02T09:31:50Z",
      "error": null
    }
  }
}

Registering an endpoint

Endpoints are managed while signed in, like API keys, by a workspace owner or admin. The URL must be public HTTPS; private and local addresses are refused. A workspace can have up to 10 endpoints.

curl -b session.txt -X POST "https://rasternivo-api.swiftinfocorp.com/v1/webhook-endpoints" \
  -H "Content-Type: application/json" \
  -H "X-CSRF-Token: CSRF_TOKEN" \
  -d '{"url": "https://example.com/rasternivo-hook",
       "events": ["conversion.succeeded", "conversion.failed"],
       "description": "production"}'

The response contains the signing secret, shown only once:

{
  "secret": "whsec_IOCELY8aznQWcpmi…",
  "notice": "Store this signing secret now. It will not be shown again.",
  "webhook_endpoint": {
    "id": "01a19b62-e3e4-774e-837b-a3288a1a6181",
    "url": "https://example.com/rasternivo-hook",
    "description": "production",
    "events": ["conversion.failed", "conversion.succeeded"],
    "created_at": "2026-11-02T08:31:51Z",
    "previous_secret_expires_at": null
  }
}

Verifying deliveries

Anyone can send a request to your URL, so check the signature of every delivery. These receivers were tested with valid, forged and replayed deliveries.

<?php
// webhook.php: point your webhook endpoint at this file.
$secret = getenv('RASTERNIVO_WEBHOOK_SECRET'); // whsec_...
$body = file_get_contents('php://input');      // the raw body, before any JSON parsing

$timestamp = 0;
$signatures = [];
foreach (explode(',', $_SERVER['HTTP_RASTERNIVO_SIGNATURE'] ?? '') as $part) {
    [$name, $value] = array_pad(explode('=', $part, 2), 2, '');
    if ($name === 't') $timestamp = (int) $value;
    if ($name === 'v1') $signatures[] = $value;
}

$expected = hash_hmac('sha256', "$timestamp.$body", $secret);
$valid = false;
foreach ($signatures as $signature) {
    $valid = $valid || hash_equals($expected, $signature);
}
if (!$valid || abs(time() - $timestamp) > 300) { // also rejects replays older than 5 minutes
    http_response_code(400);
    exit;
}

$event = json_decode($body, true);
// De-duplicate with $_SERVER['HTTP_RASTERNIVO_EVENT_ID'], then do your work.
error_log($event['type'] . ' ' . $event['data']['conversion']['id']);
http_response_code(200);
# webhook.py: a small receiver using only the standard library.
import hashlib
import hmac
import json
import os
import time
from http.server import BaseHTTPRequestHandler, HTTPServer

SECRET = os.environ["RASTERNIVO_WEBHOOK_SECRET"]  # whsec_...


def is_valid(body: bytes, header: str) -> bool:
    parts = [p.split("=", 1) for p in header.split(",") if "=" in p]
    timestamp = next((v for k, v in parts if k == "t"), "0")
    if abs(time.time() - int(timestamp)) > 300:  # reject replays older than 5 minutes
        return False
    expected = hmac.new(SECRET.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest()
    return any(k == "v1" and hmac.compare_digest(expected, v) for k, v in parts)


class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        body = self.rfile.read(int(self.headers.get("Content-Length", 0)))  # raw body
        if not is_valid(body, self.headers.get("RasterNivo-Signature", "")):
            self.send_response(400)
            self.end_headers()
            return
        event = json.loads(body)
        # De-duplicate with the RasterNivo-Event-Id header, then do your work.
        print(event["type"], event["data"]["conversion"]["id"])
        self.send_response(200)
        self.end_headers()


HTTPServer(("", int(os.environ.get("PORT", 3000))), Handler).serve_forever()
// webhook.mjs: a small receiver using only built-in modules.
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";

const secret = process.env.RASTERNIVO_WEBHOOK_SECRET; // whsec_...

function isValid(body, header = "") {
  const parts = header.split(",").map((p) => p.split("="));
  const timestamp = parts.find(([k]) => k === "t")?.[1] ?? "0";
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // reject old replays
  const expected = Buffer.from(createHmac("sha256", secret).update(`${timestamp}.`).update(body).digest("hex"));
  return parts.some(([k, v]) => k === "v1" && v.length === expected.length && timingSafeEqual(Buffer.from(v), expected));
}

createServer((req, res) => {
  const chunks = [];
  req.on("data", (c) => chunks.push(c));
  req.on("end", () => {
    const body = Buffer.concat(chunks); // the raw body, before any JSON parsing
    if (!isValid(body, req.headers["rasternivo-signature"])) {
      res.writeHead(400).end();
      return;
    }
    const event = JSON.parse(body);
    // De-duplicate with the rasternivo-event-id header, then do your work.
    console.log(event.type, event.data.conversion.id);
    res.writeHead(200).end();
  });
}).listen(process.env.PORT ?? 3000);

Each delivery has these headers:

HeaderValue
RasterNivo-Signaturet=1793608311,v1=5257a869…: the time and the signature
RasterNivo-Event-IdThe event's ID, the same on every retry
RasterNivo-Event-TypeFor example conversion.succeeded
RasterNivo-Delivery-Attempt1 for the first attempt, then 2, 3…

To check a signature yourself:

  1. Read the raw request body, before any JSON parsing.
  2. Take t and each v1 from the signature header.
  3. Calculate the hex HMAC-SHA256 of t, a dot, then the raw body, using your whsec_… secret as the key.
  4. Compare it with each v1 using a constant-time comparison. One match is enough.
  5. Reject the delivery if t is more than 5 minutes from now.

Responding and retries

  • Answer with any 2xx within 10 seconds. Do slow work after answering.
  • Anything else is retried with increasing delays, up to 8 attempts over about 15 hours. Redirects are not followed.
  • A delivery can arrive more than once. Use RasterNivo-Event-Id to ignore repeats.
  • Events can arrive out of order, so check the job's current state with the API if order matters.

Managing endpoints

CallWhat it does
GET /v1/webhook-endpointsList endpoints
POST /v1/webhook-endpoints/{id}/testSend a webhook.test event
GET /v1/webhook-endpoints/{id}/deliveriesSee recent deliveries and each attempt
POST /v1/webhook-endpoints/{id}/rotate-secretGet a new secret. The old one keeps signing for 24 hours, so deliveries carry two v1 values and you can switch without downtime.
DELETE /v1/webhook-endpoints/{id}Stop deliveries