Outbound webhooks reference
Valotrix Cart Rewards can push events to your own HTTPS endpoint as they happen - gift added, customer picked a choice, order completed, campaign turned on/off, etc. 9 event types, HMAC-SHA-256 signed, automatic retry on transient failures, max ~64 KB per delivery, 10-second per-attempt timeout.
Webhooks require the Scale plan. On lower plans, the Webhooks section in Settings is locked.
Configuring an endpoint
In Settings → Webhooks, click Add endpoint. Provide:
| Field | Notes |
|---|---|
| URL | Your HTTPS endpoint. HTTP rejected. |
| Subscribed events | Multi-select; pick which events should fire to this endpoint. |
| Signing secret | Auto-generated as 64 hex chars. You can paste your own (min 32 chars). Shown once at creation, hashed at rest. |
You can have multiple endpoints subscribed to overlapping events - useful for "production endpoint + dev tunnel" setups.
Event types (9)
| Event | Trigger | Notes |
|---|---|---|
gift.added | Valotrix Cart Rewards added a gift line to a cart | The "money" event for analytics pipelines |
gift.removed | Gift line was removed | Cart fell below threshold, customer removed it, higher tier replaced it, or campaign was disabled mid-cart |
choice.shown | Gift-choice popup was displayed | One per popup display |
choice.selected | Customer picked a gift in the choice popup | One per selection |
order.converted | An order completed and at least one Valotrix Cart Rewards gift line was in it | Fires on Shopify's orders/create webhook |
campaign.activated | A campaign was enabled | Manual toggle, Flow action, scheduled time, upgrade restore, API call, bulk action |
campaign.deactivated | A campaign was disabled | Manual, Flow, downgrade pruning, auto-disable on stockout, API call, deletion (deletion uses this; there's no separate campaign.deleted) |
campaign.limit_reached | Reserved, not currently emitted | Declared in the dispatcher but no call sites yet - future use |
ping | Test event | Fire from the admin's webhook endpoints page to verify reachability |
Common envelope
Every webhook is a POST with Content-Type: application/json. Body shape:
{
"event": "gift.added",
"id": "01HXG4N6Z3WQ8K2TYC5MV3R7AE",
"timestamp": "2026-05-10T13:24:50.391Z",
"data": { /* event-specific payload - see below */ }
}
| Field | Type | Notes |
|---|---|---|
event | string | One of the 9 event types |
id | string | Unique per delivery. Use as your idempotency key on the receiver side. |
timestamp | ISO 8601 | Time the event was emitted server-side |
data | object | Event-specific shape (see below) |
Event payloads
gift.added and gift.removed
{
"event": "gift.added",
"id": "...",
"timestamp": "...",
"data": {
"campaignId": "clxabc123",
"ruleId": "ruleabc",
"variantId": "gid://shopify/ProductVariant/45678901234",
"cartToken": "Z2NwLXVzLW1pZ..."
}
}
Notes:
cartTokenis omitted when the shop has Track cart sessions disabled in Settings → Data.variantIdis the Shopify GID of the gift variant added (or the variant that was removed).
choice.shown and choice.selected
Same shape as gift.added - campaignId, ruleId, variantId, cartToken. For choice.shown, variantId may be omitted (the popup shows multiple options before the customer picks one). For choice.selected, variantId is the picked variant.
order.converted
{
"event": "order.converted",
"id": "...",
"timestamp": "...",
"data": {
"orderId": "gid://shopify/Order/8901234567890",
"orderTotal": "82.50",
"campaignIds": ["clxabc123"],
"giftVariantIds": ["gid://shopify/ProductVariant/45678901234"]
}
}
Notes:
orderTotalis a string in the order's currency, with two decimals.campaignIdsandgiftVariantIdsare arrays - an order can carry gifts from multiple campaigns.- Fires once per qualifying Shopify
orders/createwebhook delivery; if Shopify retries, you may receive duplicates (usebody.idto dedupe).
campaign.activated and campaign.deactivated
{
"event": "campaign.activated",
"id": "...",
"timestamp": "...",
"data": {
"campaignId": "clxabc123",
"campaignName": "Spend $50, get a free tote bag",
"trigger": "manual"
}
}
trigger enumerates the source:
| Value | Meaning |
|---|---|
manual | Toggle in the admin UI |
bulk | Bulk-action on the campaign list |
flow | Shopify Flow's "Enable promotion campaign" / "Disable promotion campaign" action |
api | Public REST API (/campaigns/:id/enable, /campaigns/:id/disable, or PUT/DELETE state transition) |
import | Bulk-import via /campaigns/import |
schedule | Scheduled-time activation (rare, future use) |
For campaign.deactivated, the value api also covers DELETE - there's no separate campaign.deleted event.
campaign.limit_reached
Reserved. The event type is declared but no dispatch sites currently emit it. Don't depend on it; use gift.added events with your own counting logic if you need limit-reached detection.
ping
{
"event": "ping",
"id": "...",
"timestamp": "...",
"data": { "message": "Test ping from Valotrix" }
}
Fired only from the admin (Webhooks → Test ping next to an endpoint). Use it during initial setup to confirm your endpoint can receive deliveries.
Headers
Every webhook request includes:
| Header | Notes |
|---|---|
Content-Type | application/json |
User-Agent | Valotrix-Webhooks/1.0 |
X-Vltrx-Webhook-Signature | sha256=<hex> - HMAC of the raw request body, computed with your endpoint's signing secret |
X-Vltrx-Webhook-Id | The event's unique id (matches body.id) |
X-Vltrx-Webhook-Timestamp | The event's ISO timestamp (matches body.timestamp) |
X-Vltrx-Webhook-Event | The event type (matches body.event) - handy for routing without parsing JSON |
X-Vltrx-Webhook-Attempt | Attempt number, 1-indexed (1 = first delivery, 2 = first retry, …) |
Verifying the signature
Signature verification is mandatory for production endpoints. Anyone who knows your endpoint URL can send forged events otherwise.
The signature is HMAC-SHA-256 of the raw request body (not the parsed JSON), keyed with your endpoint's signing secret, hex-encoded, prefixed with sha256=.
Node.js (Express)
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const app = express();
const SECRET = process.env.VLTRX_WEBHOOK_SECRET;
app.post(
"/vltrx-webhooks",
// Capture raw body before JSON parsing - needed for HMAC.
express.raw({ type: "application/json", limit: "100kb" }),
(req, res) => {
const sig = req.headers["x-vltrx-webhook-signature"];
const expected =
"sha256=" +
createHmac("sha256", SECRET).update(req.body).digest("hex");
if (
typeof sig !== "string" ||
sig.length !== expected.length ||
!timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString());
// … handle event …
res.sendStatus(200);
}
);
Node.js (Fastify)
import Fastify from "fastify";
import { createHmac, timingSafeEqual } from "node:crypto";
const fastify = Fastify();
const SECRET = process.env.VLTRX_WEBHOOK_SECRET;
fastify.addContentTypeParser(
"application/json",
{ parseAs: "buffer" },
(req, body, done) => {
req.rawBody = body;
try {
done(null, JSON.parse(body.toString()));
} catch (err) {
done(err);
}
}
);
fastify.post("/vltrx-webhooks", async (req, reply) => {
const sig = req.headers["x-vltrx-webhook-signature"];
const expected =
"sha256=" +
createHmac("sha256", SECRET).update(req.rawBody).digest("hex");
if (
typeof sig !== "string" ||
sig.length !== expected.length ||
!timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
) {
return reply.code(401).send();
}
// req.body is the parsed JSON event
return { ok: true };
});
Cloudflare Worker
export default {
async fetch(request, env) {
const rawBody = await request.text();
const sig = request.headers.get("x-vltrx-webhook-signature");
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(env.VLTRX_WEBHOOK_SECRET),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const macBuffer = await crypto.subtle.sign(
"HMAC",
key,
new TextEncoder().encode(rawBody)
);
const expected =
"sha256=" +
[...new Uint8Array(macBuffer)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
if (sig !== expected) return new Response(null, { status: 401 });
const event = JSON.parse(rawBody);
// … handle event …
return new Response(null, { status: 200 });
},
};
Python (Flask)
import hmac, hashlib, os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["VLTRX_WEBHOOK_SECRET"].encode()
@app.post("/vltrx-webhooks")
def vltrx_hook():
sig = request.headers.get("X-Vltrx-Webhook-Signature", "")
expected = "sha256=" + hmac.new(
SECRET, request.get_data(), hashlib.sha256
).hexdigest()
if not hmac.compare_digest(sig, expected):
abort(401)
event = request.get_json()
# … handle event …
return "", 200
Constant-time compare
Always use a constant-time comparison (timingSafeEqual / hmac.compare_digest) when comparing signatures - direct string equality is timing-attack-vulnerable.
Response semantics
Your endpoint should return:
| Status | Valotrix Cart Rewards's interpretation |
|---|---|
2xx | Delivered. No retry. |
3xx | Same as 5xx - treated as a transient failure |
4xx (except 429) | Permanent failure. Recorded but not retried. |
429 | Rate limited. Honor Retry-After header on Valotrix Cart Rewards's side. |
5xx | Transient failure. Retried per the schedule below. |
| Network error / timeout | Transient. Retried per the schedule below. |
Return 2xx as fast as possible - Valotrix Cart Rewards's per-attempt timeout is 10 seconds. Queue heavy work to a background job on your side.
Retry policy
Failed deliveries (non-2xx responses or network errors) retry on this schedule:
| Attempt | Delay from previous attempt |
|---|---|
| 1 | immediate |
| 2 | 5 min |
| 3 | 30 min |
| 4 | 2 h |
| 5 | 12 h |
After the 5th failure, the endpoint is auto-disabled and an admin email is dispatched. Re-enable from the admin's webhook endpoints page (you'll be prompted to confirm the URL is healthy first).
Body-size and timeout
- Maximum payload size: ~64 KB per event.
- Per-attempt request timeout: 10 seconds. If your endpoint hasn't responded in 10s, Valotrix Cart Rewards records the attempt as a transient failure and queues the next retry.
Ordering and delivery guarantees
- Per-shop ordering is not guaranteed. A
gift.removedmay arrive before its correspondinggift.addedif the original delivery retried. Usebody.idandbody.timestampto dedupe and order your side. - At-least-once delivery. Network blips can cause Valotrix Cart Rewards to retry an event your endpoint already processed successfully (e.g. you returned 200 but the response never reached us). Your handler must be idempotent: dedupe by
body.id. - Different events for the same cart can interleave. A cart might emit
gift.added,gift.removed,gift.addedin rapid succession; webhooks fire async per event with no guarantees about which lands first. Your handler should reconcile against the canonical record (the order, when it completes).
Testing locally
Use a tunneling tool (ngrok, Cloudflare Tunnel) to expose your local dev server with HTTPS, point a webhook endpoint at the tunnel URL, and use the Test ping button in Settings → Webhooks to verify the round-trip. Real events fire on the real shop only when carts trigger them.
For unit testing your handler, capture a real delivery (or use a sample from this article) and replay against your handler with a known-good signature. The signature for a sample body is:
const sig =
"sha256=" +
createHmac("sha256", testSecret)
.update(rawBodyAsString)
.digest("hex");