Skip to content

Webhooks ​

Webhooks let TronDesigner notify your server as soon as something happens — a print job is created or updated, a logo is uploaded, a proofreading or print data PDF is generated — so your backend can react without polling the REST API.

Webhooks are part of the Webhooks license feature. You configure them in the Admin Portal under Settings → Webhooks.

How delivery works ​

For every event that matches an endpoint's subscriptions, TronDesigner creates a delivery and sends the first attempt: an HTTP POST with a JSON body to your URL. A 2xx response marks the delivery as successful. Any other status, a timeout or a connection error counts as a failed attempt, and the delivery is retried until the endpoint's maximum number of attempts is reached; then it's moved to Dead Letter. After the n-th failed attempt the next one is scheduled 30 s × 2^n later (1 min, 2 min, 4 min, … — about 2 hours before the 8th attempt with the default settings); the queue is checked every 20 seconds, so attempts can start a little later. A timeout is logged as 408 and a connection error as 503.

Webhook delivery flow: send, check for a 2xx response, retry with backoff until the maximum is reached, then mark as delivered or move to dead letter

Your server doesn't need to return anything in the body. TronDesigner doesn't parse your response — it stores the status code, headers and a truncated body in the delivery log.

Set up an endpoint ​

  1. Open Settings → Webhooks in the Admin Portal and create a webhook endpoint.

  2. URL — a complete http:// or https:// address (up to 1,000 characters), for example https://shop.example.com/webhooks/trondesigner. Use HTTPS in production.

  3. Event subscriptions — pick one or more events:

    EventSent when
    logo.createdA new logo is stored: a successful upload (in the editor or via the REST API, but not a file that already existed and not a failed upload), or the result of an edit such as a recolor (including POST /api/logos/{logoGuid}/recolor), crop, background removal or vectorization. Edit results are usually Hidden logos.
    print_job.createdA new print job is saved.
    print_job.updatedAn existing print job changes (content, state, customer or order data).
    proofreading.createdA proofreading PDF is generated (in the editor or via the REST API).
    print_data.createdPrint data is generated.
  4. Custom headers (optional) — key/value pairs sent with every request, for example an extra token your gateway expects. They're sent in addition to the standard headers below.

  5. Security — the endpoint's secret, used to sign every request. Generate one (32 random characters) or enter your own, and copy it before you save: afterwards it's no longer fully visible.

  6. Delivery options — Timeout 2–60 seconds (default 20) and Max attempts 1–10 (default 8).

  7. Keep Enable switched on. You can disable an endpoint later without deleting it.

What TronDesigner sends ​

Headers ​

HeaderContent
User-AgentPromoTron-Webhooks/1.0
X-Webhook-EventGUID of the event. It's the same for all deliveries and attempts of one event. It is not the event name (print_job.created, …).
X-Webhook-IdA new GUID for every attempt, used as {eventId} in the signature (see below). Don't deduplicate on it.
X-Webhook-DeliveryID of the delivery (shown as Delivery ID in the Admin Portal). The same for all attempts and resends of the delivery.
X-Webhook-AttemptAttempt number, starting at 1. A manual resend continues the count.
X-Webhook-TimestampUnix time in seconds when the request was signed.
X-Webhook-SignatureSignature made with the current secret.
X-Webhook-Signature-PrevSignature made with the previous secret — only while a rotated secret is still valid.

NOTE

The event name isn't sent. Neither the headers nor the body say which event a request belongs to. The payload shape tells the resource apart (print job, logo, proofreading, print data), but print_job.created and print_job.updated look the same. If you need to tell them apart, create one endpoint per event; otherwise treat both as "upsert this print job".

Body ​

The body is the JSON representation of the resource that changed (no envelope), with PascalCase property names and enum values as strings. Every payload carries the PrintJobGuid (or the LogoGuid for logo.created) that you need for follow-up REST calls.

Example: proofreading.created
json
{
  "PrintJobGuid": "76b2be59-6403-4f89-9c9a-3a64588c05d3",
  "OutputGuid": "435b29be-3841-44e3-80c6-6d5adc74456e",
  "Format": "PDF",
  "Created": "2025-12-16T15:21:14.0672675+00:00"
}

OutputGuid is the proofreading GUID: download the file with GET /api/print-jobs/{PrintJobGuid}/proofreading/{OutputGuid}.

Example: print_data.created
json
{
  "PrintJobGuid": "76b2be59-6403-4f89-9c9a-3a64588c05d3",
  "PrintingGuid": "5f60a5a1-4575-481a-90ad-7b0b11203aa8",
  "OutputGuid": "d1d16895-5c8f-485b-b444-d27bf362b920",
  "Created": "2025-12-16T15:21:19.950551+00:00",
  "PositionCodePtn": "front",
  "PositionCodeDisplay": "front",
  "TechnologyCodePtn": "PAD04",
  "TechnologyCodeDisplay": "PAD04",
  "PrintDataOptions": { "FileFormat": "PDF", "ShowPrintArea": true, "PageSize": "A4", "PageMargins": 0 }
}

WARNING

Known issue: print data generated for the whole print job (POST /api/print-jobs/{printJobGuid}/print-data) currently sends no print_data.created webhook. Only print data of a single printing is delivered.

Example: print_job.created / print_job.updated (shortened)
json
{
  "PrintJobGuid": "76b2be59-6403-4f89-9c9a-3a64588c05d3",
  "Supplier": "…", "SupplierName": "…",
  "Customer": {
    "CustomerGUID": "a70b77d6-f649-4d1d-8553-fbf150f45dc1",
    "CustomerEmail": "user@example.com",
    "CustomerIdentity": "user-42",
    "OrderCode": "ORDER-1001",
    "OrderItemCode": "1"
  },
  "Title": "…", "Code": "PJ00000069",
  "Created": "2025-12-16T15:20:01+00:00", "Updated": null,
  "LanguageCode": "en", "State": "PrepareNew",
  "ProductCodeShow": "100860", "ProductTitle": "…", "ProductVariantsCount": 1,
  "Printings": [{
    "PrintingGuid": "5f60a5a1-4575-481a-90ad-7b0b11203aa8",
    "PositionCodeDisplay": "front", "PositionName": "Front",
    "TechnologyCodeDisplay": "PAD04", "TechnologyName": "Pad print",
    "MotiveImage": "…", "MotiveImageFormat": "PNG"
  }]
}

The print job payload is a summary: custom properties, variants and printing details aren't included.

Example: logo.created
json
{
  "LogoGuid": "7fa15b7b-e809-461c-b976-1fedba0004a0",
  "Filename": "logo.png",
  "Name": "logo.png",
  "Type": "RASTER",
  "Colors": {
    "IsFullColor": false,
    "ColorList": [{ "MapHex": "#E30613FF", "PmsName": "PANTONE 485 C", "CmykHex": "#00FF8A00", "RgbHex": "#DA291CFF", "Source": "RGB" }],
    "ShouldUseDarkBackground": false
  },
  "FilesDescription": {
    "OriginalFormat": "PNG", "Original": "ORIGINAL_ORIGINAL.png",
    "ConvertedFormat": "PNG", "Converted": "CONVERTED_ORIGINAL.png", "ConvertedWhite": null, "ConvertedBlack": null,
    "PreviewFormat": "PNG", "Preview": "PREVIEW_ORIGINAL.png", "PreviewWhite": null, "PreviewBlack": null
  },
  "Size": { "Unit": "PIXELS", "Width": 1200, "Height": 800 },
  "Properties": [{ "ClassPath": "HasTransparency", "Value": "True" }]
}

This is the logo list item shape, not the detail of GET /api/logos/{logoGuid}: it has no State, Customer, OriginalLogoGuid or PreviousLogoGuid, and FilesDescription holds internal file names rather than download links. To tell your own REST uploads from logos created in the editor or by edits, match LogoGuid with the logoGuid returned by the upload; fetch the detail from the REST API when you need the state or the parent logo. The order of properties isn't guaranteed.

Treat the payload as a notification: for anything beyond the GUIDs, fetch the current state from the REST API (for example GET /api/print-jobs/{PrintJobGuid}).

Verify the signature ​

Each request is signed with HMAC-SHA256. The signed string combines the timestamp, the X-Webhook-Id value and a SHA-256 hash of the raw body:

text
v1:{X-Webhook-Timestamp}:{X-Webhook-Id}:{hex(sha256(raw body))}

The signature header holds comma-separated parts; the lowercase hex HMAC is the part that starts with sig=. To verify a request:

  1. Read the raw request body. Don't parse and re-serialize the JSON first — any change breaks the hash.
  2. Reject the request if X-Webhook-Timestamp is more than a few minutes away from your clock (replay protection).
  3. Compute HMAC-SHA256(secret, signing string) and compare it with sig= from X-Webhook-Signature in constant time.
  4. During secret rotation, also accept X-Webhook-Signature-Prev checked with your previous secret.
  5. Answer 401 if verification fails and 2xx if it succeeds.
js
// Express: npm install express
const express = require('express');
const crypto = require('crypto');

const SECRET = process.env.TD_WEBHOOK_SECRET; // current secret
const PREV_SECRET = process.env.TD_WEBHOOK_SECRET_PREV; // previous secret, only during rotation
const TOLERANCE_SECONDS = 5 * 60;

function signatureFrom(header) {
  const part = (header || '').split(',').map((p) => p.trim()).find((p) => p.startsWith('sig='));
  return part ? part.slice(4) : '';
}

function matches(header, secret, signingString) {
  if (!header || !secret) return false;
  const received = Buffer.from(signatureFrom(header), 'hex');
  const expected = crypto.createHmac('sha256', secret).update(signingString, 'utf8').digest();
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

function verify(rawBody, headers) {
  const timestamp = Number.parseInt(headers['x-webhook-timestamp'], 10);
  if (!Number.isFinite(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
  const payloadHash = crypto.createHash('sha256').update(rawBody).digest('hex');
  const signingString = `v1:${timestamp}:${headers['x-webhook-id']}:${payloadHash}`;
  return matches(headers['x-webhook-signature'], SECRET, signingString)
    || matches(headers['x-webhook-signature-prev'], PREV_SECRET, signingString);
}

const app = express();
app.post('/webhooks/trondesigner', express.raw({ type: '*/*' }), (req, res) => {
  if (!verify(req.body, req.headers)) return res.status(401).end();
  const deliveryId = req.headers['x-webhook-delivery']; // deduplicate on this
  const payload = JSON.parse(req.body.toString('utf8'));
  // Store or enqueue (deliveryId, payload) and return quickly; do the real work in the background.
  return res.status(204).end();
});
app.listen(3000);
php
<?php
// PHP 8+
const TOLERANCE_SECONDS = 300;

function td_signature_from(?string $header): string
{
    foreach (explode(',', $header ?? '') as $part) {
        $part = trim($part);
        if (str_starts_with($part, 'sig=')) {
            return substr($part, 4);
        }
    }
    return '';
}

function td_verify(string $payload, array $headers, string $secret, string $prevSecret): bool
{
    $headers = array_change_key_case($headers, CASE_LOWER);
    $timestamp = $headers['x-webhook-timestamp'] ?? '';
    if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > TOLERANCE_SECONDS) {
        return false;
    }
    $signingString = 'v1:' . $timestamp . ':' . ($headers['x-webhook-id'] ?? '') . ':' . hash('sha256', $payload);
    $matches = fn (?string $header, string $key): bool => $header !== null && $key !== ''
        && hash_equals(hash_hmac('sha256', $signingString, $key), td_signature_from($header));

    return $matches($headers['x-webhook-signature'] ?? null, $secret)
        || $matches($headers['x-webhook-signature-prev'] ?? null, $prevSecret);
}

$payload = file_get_contents('php://input');
$secret = (string) getenv('TD_WEBHOOK_SECRET');
$prevSecret = (string) getenv('TD_WEBHOOK_SECRET_PREV'); // empty unless you're rotating

if (!td_verify($payload, getallheaders(), $secret, $prevSecret)) {
    http_response_code(401);
    exit;
}

$deliveryId = $_SERVER['HTTP_X_WEBHOOK_DELIVERY'] ?? ''; // deduplicate on this
$data = json_decode($payload, true);
// Store or enqueue ($deliveryId, $data) and return quickly.
http_response_code(204);
csharp
// ASP.NET Core minimal API (.NET 8)
using System.Security.Cryptography;
using System.Text;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var secret = builder.Configuration["TronDesigner:WebhookSecret"];
var prevSecret = builder.Configuration["TronDesigner:WebhookSecretPrev"]; // only during rotation

app.MapPost("/webhooks/trondesigner", async (HttpRequest request) =>
{
    using var buffer = new MemoryStream();
    await request.Body.CopyToAsync(buffer);
    var payload = buffer.ToArray(); // raw bytes, exactly as received

    if (!WebhookSignature.Verify(payload, request.Headers, secret, prevSecret))
        return Results.Unauthorized();

    var deliveryId = request.Headers["X-Webhook-Delivery"].ToString(); // deduplicate on this
    // Store or enqueue (deliveryId, payload) and return quickly.
    return Results.NoContent();
});

app.Run();

static class WebhookSignature
{
    private static readonly TimeSpan Tolerance = TimeSpan.FromMinutes(5);

    public static bool Verify(byte[] payload, IHeaderDictionary headers, string? secret, string? prevSecret)
    {
        if (!long.TryParse(headers["X-Webhook-Timestamp"], out var timestamp)) return false;
        var age = DateTimeOffset.UtcNow - DateTimeOffset.FromUnixTimeSeconds(timestamp);
        if (age.Duration() > Tolerance) return false;

        var payloadHash = Convert.ToHexString(SHA256.HashData(payload)).ToLowerInvariant();
        var signingString = Encoding.UTF8.GetBytes($"v1:{timestamp}:{headers["X-Webhook-Id"]}:{payloadHash}");

        return Matches(headers["X-Webhook-Signature"], secret, signingString)
            || Matches(headers["X-Webhook-Signature-Prev"], prevSecret, signingString);
    }

    private static bool Matches(string? header, string? secret, byte[] signingString)
    {
        if (string.IsNullOrEmpty(header) || string.IsNullOrEmpty(secret)) return false;
        var received = header.Split(',', StringSplitOptions.TrimEntries)
            .FirstOrDefault(p => p.StartsWith("sig="))?[4..];
        if (received is null) return false;

        var expected = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), signingString);
        try
        {
            return CryptographicOperations.FixedTimeEquals(Convert.FromHexString(received), expected);
        }
        catch (FormatException)
        {
            return false;
        }
    }
}

Rotate the secret ​

Rotate the secret regularly and whenever it might have leaked:

  1. Edit the endpoint, open Rotate Secret in the Security section and choose Generate New Secret.
  2. The new secret is used for signing immediately. The old one becomes the previous secret and stays valid until the Overlap until date (10 days ahead by default; you can change it).
  3. While the overlap lasts, requests carry both X-Webhook-Signature (new secret) and X-Webhook-Signature-Prev (previous secret). Deploy the new secret to your server as the current one and keep the old one as the previous one.
  4. After the overlap date, remove the previous secret from your configuration.

Handle retries and duplicates ​

  • Respond fast. Verify, store the event, return 2xx, and process it asynchronously. A slow handler hits the endpoint timeout and triggers a retry even if your code eventually succeeds.
  • Be idempotent. The same event can arrive more than once (retries, manual resends). Deduplicate on X-Webhook-Delivery, and make the processing itself safe to repeat — for example upsert by PrintJobGuid (or LogoGuid) instead of inserting. Rarely the same change is published twice and arrives with a new X-Webhook-Delivery, so the upsert is what really protects you.
  • Don't rely on order. print_job.updated can arrive before you've finished processing print_job.created. Fetch the current print job from the REST API when order matters.
  • Only 2xx is success. Any other status counts as a failed attempt and is retried — return 2xx also for events you decide to ignore.

Monitor deliveries ​

Open an endpoint in Settings → Webhooks to see its deliveries with status (Pending, Success, Retrying or Dead Letter), attempt count, last attempt and next attempt time. Expand a delivery to list its attempts (time, response code, latency, error message); the attempt detail shows the request headers and body and your server's response, both truncated.

Each attempt also has a Resend action: after fixing your endpoint, send a failed delivery again. It needs the permission to change settings (TronDesigner.Settings.Update); users who can only view settings see the deliveries but not the action.

A resend is a new attempt of the same delivery: it gets a fresh X-Webhook-Timestamp, X-Webhook-Id and signature (so it passes the timestamp check), keeps X-Webhook-Delivery, and updates the delivery status: Success, or Dead Letter if it fails again — a resend is not retried automatically.

See also ​

Czech Republic – The Country for the FutureFunded by the European Union – NextGenerationEUThe project was implemented within the The Country for the Future programme of the Ministry of Industry and Trade and co-financed by the European Union under NextGenerationEU.Support · Examples

Czech Republic – The Country for the FutureFunded by the European Union – NextGenerationEUThe project was implemented within the The Country for the Future programme of the Ministry of Industry and Trade and co-financed by the European Union under NextGenerationEU.Support · Examples