Webhooks
Receive real-time HTTP POST notifications whenever something happens in your TruCustom account — new stores, design saves, finalized customizations, completed bulk orders, and more. Build reactive integrations without polling the API.
Configure endpoints in your dashboard
Head to Dashboard → Webhooks to register an endpoint, pick which events to subscribe to, copy the signing secret, and inspect delivery history.
Quick Start
- Open Dashboard → Webhooks and click Add endpoint.
- Paste the HTTPS URL on your server that should receive events.
- Pick which events to subscribe to (or choose All events to auto-subscribe to anything we add later).
- Copy the signing secret — you’ll need it to verify the
X-TruCustom-Signatureheader. - Click Send test event to confirm your endpoint is reachable.
Payload Shape
Every webhook is a JSON POST with the following envelope:
{
"id": "9f1e8b2c-7d34-4f6a-9c8b-1a2b3c4d5e6f",
"event": "design.finalized",
"account_id": "01234567-89ab-cdef-0123-456789abcdef",
"occurred_at": "2026-05-27T19:42:11Z",
"api_version": "2026-05",
"data": { /* event-specific resource payload */ }
}
The id field is unique per delivery and is also sent in the X-TruCustom-Delivery header. Use it as your idempotency key — we may retry a delivery multiple times, but the id stays the same.
Request Headers
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | TruCustom-Webhooks/1.0 |
X-TruCustom-Event | The event ID, e.g. design.finalized |
X-TruCustom-Delivery | Unique UUID for this delivery (idempotency key) |
X-TruCustom-Timestamp | Unix timestamp (seconds) the request was signed |
X-TruCustom-Signature | sha256=<hex> — HMAC of <timestamp>.<raw_body> |
Event Catalog
Below is every event TruCustom can emit, grouped by resource. New events are added to this catalog as we ship features — subscribe to * to auto-receive any event added in the future.
Stores
| Event | Description |
|---|---|
store.created |
A new store was connected to your account |
store.updated |
Store settings, theme, or credentials were updated |
store.deleted |
A store was disconnected and removed |
store.connected |
A store's connection status flipped to `connected` |
store.disconnected |
A store's connection status changed away from `connected` |
store.synced |
A product sync against the platform completed successfully |
store.sync_failed |
A product sync against the platform failed (the store is left in `error` state) |
store.customizer_enabled |
The customizer was turned on store-wide (the widget renders on the storefront again) |
store.customizer_disabled |
The customizer was turned off store-wide (the widget stops rendering on every product) |
Products
| Event | Description |
|---|---|
product.created |
A new customizable product was added |
product.updated |
A product's metadata, customization config, or images changed |
product.deleted |
A product was removed |
Print Areas
| Event | Description |
|---|---|
print_area.created |
A new customizable zone was defined on a product |
print_area.updated |
A print area's bounds, dimensions, or background changed |
print_area.deleted |
A print area was removed |
Templates
| Event | Description |
|---|---|
template.created |
A new design template was created |
template.updated |
A template's name, canvas data, or active flag changed |
template.deleted |
A template was deleted |
Designs
| Event | Description |
|---|---|
design.created |
A customer started a new design session |
design.updated |
A design's canvas data was saved (autosave or explicit save) |
design.finalized |
A customer completed customization (status flipped to `finalized`) |
design.cart_added |
A finalized design was added to the storefront cart and locked |
design.shared |
A customer enabled sharing on their design (a public share link was generated) |
design.deleted |
A design was deleted |
Assets
| Event | Description |
|---|---|
asset.created |
A new asset (clipart, background, or logo) was uploaded |
asset.updated |
An asset's metadata or file was updated |
asset.deleted |
An asset was removed |
font.created |
A new font was uploaded |
font.updated |
A font's metadata or file was updated |
font.deleted |
A font was removed |
Bulk Orders
| Event | Description |
|---|---|
bulk_order.created |
A new bulk order was created (multiple variants of one design) |
bulk_order.updated |
A bulk order's items, quantities, or status changed |
bulk_order.completed |
A bulk order's status flipped to `completed` |
Print Files
| Event | Description |
|---|---|
print_file.generated |
A high-res print file was rendered for a finalized design |
Mockups
| Event | Description |
|---|---|
mockup.generated |
A bulk marketing mockup ZIP was generated |
Pricing
| Event | Description |
|---|---|
pricing_rule.created |
A new pricing rule was added |
pricing_rule.updated |
A pricing rule's scope, formula, or active flag changed |
pricing_rule.deleted |
A pricing rule was removed |
printing_method.created |
A new printing method was added (DTG, screen print, etc.) |
printing_method.updated |
A printing method's pricing or settings changed |
printing_method.deleted |
A printing method was removed |
Webhooks
| Event | Description |
|---|---|
webhook_endpoint.disabled |
An endpoint was auto-disabled after exceeding the consecutive-failure threshold |
Example Payloads
store.created
{
"id": "f0a1b2c3-...",
"event": "store.created",
"account_id": "01234567-...",
"occurred_at": "2026-05-27T19:42:11Z",
"api_version": "2026-05",
"data": {
"id": "abc-store-uuid",
"account_id": "01234567-...",
"name": "Acme T-Shirts",
"platform": "shopify",
"domain": "acme-tshirts.myshopify.com",
"store_url": "https://acme-tshirts.myshopify.com",
"external_store_id": "62841091201",
"connection_status": "pending",
"active": true,
"created_at": "2026-05-27T19:42:11Z",
"updated_at": "2026-05-27T19:42:11Z"
}
}
design.finalized
{
"id": "9f1e8b2c-...",
"event": "design.finalized",
"account_id": "01234567-...",
"occurred_at": "2026-05-27T19:42:11Z",
"api_version": "2026-05",
"data": {
"id": "design-uuid",
"product_id": "product-uuid",
"store_id": "store-uuid",
"session_token": "abc123...",
"status": "finalized",
"cart_locked": false,
"preview_url": "https://app.trucustom.com/previews/design-uuid",
"canvas_data": { /* Fabric.js JSON, keyed by print_area_id */ },
"metadata": { "quantity": 1 }
}
}
bulk_order.completed
{
"id": "ab9c1d2e-...",
"event": "bulk_order.completed",
"account_id": "01234567-...",
"occurred_at": "2026-05-27T19:42:11Z",
"api_version": "2026-05",
"data": {
"id": "bulk-order-uuid",
"design_id": "design-uuid",
"status": "completed",
"total_quantity": 144,
"items": [
{ "id": "item-uuid-1", "variant_options": { "size": "M", "color": "Black" }, "quantity": 48 },
{ "id": "item-uuid-2", "variant_options": { "size": "L", "color": "Black" }, "quantity": 96 }
]
}
}
Signature Verification
Every request is signed with HMAC-SHA256 using your endpoint’s signing secret. Always verify the signature before trusting a payload — the X-TruCustom-Signature header is your only proof that the request actually came from TruCustom.
The signing string is:
<X-TruCustom-Timestamp>.<raw_request_body>
The signature is the lowercase hex HMAC-SHA256 digest of that string with your secret as the key, prefixed with sha256=.
Ruby (Rails)
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def trucustom
body = request.raw_post
timestamp = request.headers["X-TruCustom-Timestamp"].to_s
signature = request.headers["X-TruCustom-Signature"].to_s
secret = ENV.fetch("TRUCUSTOM_WEBHOOK_SECRET")
expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{body}")
return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(expected, signature)
# Reject deliveries older than 5 minutes to neuter replay attacks
return head :unauthorized if Time.current.to_i - timestamp.to_i > 300
event = JSON.parse(body)
case event["event"]
when "design.finalized" then handle_design_finalized(event["data"])
when "store.created" then handle_store_created(event["data"])
end
head :ok
end
end
Node.js (Express)
const crypto = require('crypto');
const express = require('express');
const app = express();
app.post('/webhooks/trucustom',
express.raw({ type: 'application/json' }),
(req, res) => {
const timestamp = req.headers['x-trucustom-timestamp'];
const signature = req.headers['x-trucustom-signature'];
const secret = process.env.TRUCUSTOM_WEBHOOK_SECRET;
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${req.body}`)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
return res.status(401).send('bad signature');
}
if (Math.floor(Date.now() / 1000) - parseInt(timestamp, 10) > 300) {
return res.status(401).send('stale request');
}
const event = JSON.parse(req.body.toString('utf8'));
console.log('Got TruCustom event:', event.event, event.id);
res.status(200).send('ok');
}
);
Python (Flask)
import hmac, hashlib, time, os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ['TRUCUSTOM_WEBHOOK_SECRET']
@app.post('/webhooks/trucustom')
def trucustom():
raw = request.get_data()
ts = request.headers.get('X-TruCustom-Timestamp', '')
sig = request.headers.get('X-TruCustom-Signature', '')
expected = 'sha256=' + hmac.new(
SECRET.encode(),
f"{ts}.{raw.decode()}".encode(),
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, sig):
abort(401)
if int(time.time()) - int(ts) > 300:
abort(401)
event = request.get_json()
return ('', 200)
Responding to Webhooks
Your endpoint should respond with any 2xx status code within 30 seconds:
200 OK— webhook received and processed202 Accepted— webhook received, will process asynchronously204 No Content— received, no body
Anything else (3xx, 4xx, 5xx) or a timeout counts as a failure and triggers retries.
Retry Policy
If your endpoint returns a non-2xx status (or times out), TruCustom retries with exponential backoff:
- Attempt 1 (initial delivery)
- Attempt 2: 1 minute later
- Attempt 3: 5 minutes later
- Attempt 4: 30 minutes later
- Attempt 5: 2 hours later
- Attempt 6: 12 hours later (final attempt)
After the final attempt fails, the delivery is marked failed in your dashboard. The next event still gets attempted — one bad delivery does not block subsequent ones.
Auto-Disable
If 5 consecutive deliveries exhaust their retries (i.e. fail completely), TruCustom auto-disables the endpoint to avoid hammering a broken server. When this happens:
- The endpoint flips to
auto-disabledand stops receiving events. - A
webhook_endpoint.disabledmeta-event is sent to other active endpoints in the same account so you get notified through a different path. - The endpoint shows a red banner in your dashboard with a Re-enable button.
Re-enabling resets the consecutive-failure counter and resumes deliveries on the next event.
Best Practices
- Verify the signature on every request — it’s your only proof of authenticity.
- Respond fast. Acknowledge with
200immediately, queue any heavy work for a background job. - Be idempotent. Use
X-TruCustom-Deliveryas a dedupe key — retries deliver the same envelopeid. - Reject stale deliveries. Compare
X-TruCustom-Timestampagainstnow()and reject anything more than ~5 minutes old to neuter replay attacks. - Subscribe to
*if you want to future-proof your integration. New events get auto-included. - Use a separate endpoint per environment. Don’t share the same staging URL across production stores — you’ll get cross-talk and noisy retries.
Managing Endpoints via API
You can also manage endpoints programmatically through the REST API:
GET /api/v1/webhook_endpoints # list
POST /api/v1/webhook_endpoints # create
GET /api/v1/webhook_endpoints/:id # show (includes signing secret)
PATCH /api/v1/webhook_endpoints/:id # update
DELETE /api/v1/webhook_endpoints/:id # delete
All endpoints require an API key — see Authentication for details.