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
| Event | Sent when |
|---|---|
conversion.succeeded | A job finished with at least one page converted. Check pages_failed. |
conversion.failed | A job failed. See error. |
conversion.cancelled | A 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:
| Header | Value |
|---|---|
RasterNivo-Signature | t=1793608311,v1=5257a869…: the time and the signature |
RasterNivo-Event-Id | The event's ID, the same on every retry |
RasterNivo-Event-Type | For example conversion.succeeded |
RasterNivo-Delivery-Attempt | 1 for the first attempt, then 2, 3… |
To check a signature yourself:
- Read the raw request body, before any JSON parsing.
- Take
tand eachv1from the signature header. - Calculate the hex HMAC-SHA256 of
t, a dot, then the raw body, using yourwhsec_…secret as the key. - Compare it with each
v1using a constant-time comparison. One match is enough. - Reject the delivery if
tis more than 5 minutes from now.
Responding and retries
- Answer with any
2xxwithin 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-Idto ignore repeats. - Events can arrive out of order, so check the job's current state with the API if order matters.
Managing endpoints
| Call | What it does |
|---|---|
GET /v1/webhook-endpoints | List endpoints |
POST /v1/webhook-endpoints/{id}/test | Send a webhook.test event |
GET /v1/webhook-endpoints/{id}/deliveries | See recent deliveries and each attempt |
POST /v1/webhook-endpoints/{id}/rotate-secret | Get 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 |