Skip to main content

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:

FieldNotes
URLYour HTTPS endpoint. HTTP rejected.
Subscribed eventsMulti-select; pick which events should fire to this endpoint.
Signing secretAuto-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)

EventTriggerNotes
gift.addedValotrix Cart Rewards added a gift line to a cartThe "money" event for analytics pipelines
gift.removedGift line was removedCart fell below threshold, customer removed it, higher tier replaced it, or campaign was disabled mid-cart
choice.shownGift-choice popup was displayedOne per popup display
choice.selectedCustomer picked a gift in the choice popupOne per selection
order.convertedAn order completed and at least one Valotrix Cart Rewards gift line was in itFires on Shopify's orders/create webhook
campaign.activatedA campaign was enabledManual toggle, Flow action, scheduled time, upgrade restore, API call, bulk action
campaign.deactivatedA campaign was disabledManual, Flow, downgrade pruning, auto-disable on stockout, API call, deletion (deletion uses this; there's no separate campaign.deleted)
campaign.limit_reachedReserved, not currently emittedDeclared in the dispatcher but no call sites yet - future use
pingTest eventFire 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 */ }
}
FieldTypeNotes
eventstringOne of the 9 event types
idstringUnique per delivery. Use as your idempotency key on the receiver side.
timestampISO 8601Time the event was emitted server-side
dataobjectEvent-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:

  • cartToken is omitted when the shop has Track cart sessions disabled in Settings → Data.
  • variantId is 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:

  • orderTotal is a string in the order's currency, with two decimals.
  • campaignIds and giftVariantIds are arrays - an order can carry gifts from multiple campaigns.
  • Fires once per qualifying Shopify orders/create webhook delivery; if Shopify retries, you may receive duplicates (use body.id to 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:

ValueMeaning
manualToggle in the admin UI
bulkBulk-action on the campaign list
flowShopify Flow's "Enable promotion campaign" / "Disable promotion campaign" action
apiPublic REST API (/campaigns/:id/enable, /campaigns/:id/disable, or PUT/DELETE state transition)
importBulk-import via /campaigns/import
scheduleScheduled-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:

HeaderNotes
Content-Typeapplication/json
User-AgentValotrix-Webhooks/1.0
X-Vltrx-Webhook-Signaturesha256=<hex> - HMAC of the raw request body, computed with your endpoint's signing secret
X-Vltrx-Webhook-IdThe event's unique id (matches body.id)
X-Vltrx-Webhook-TimestampThe event's ISO timestamp (matches body.timestamp)
X-Vltrx-Webhook-EventThe event type (matches body.event) - handy for routing without parsing JSON
X-Vltrx-Webhook-AttemptAttempt 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:

StatusValotrix Cart Rewards's interpretation
2xxDelivered. No retry.
3xxSame as 5xx - treated as a transient failure
4xx (except 429)Permanent failure. Recorded but not retried.
429Rate limited. Honor Retry-After header on Valotrix Cart Rewards's side.
5xxTransient failure. Retried per the schedule below.
Network error / timeoutTransient. 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:

AttemptDelay from previous attempt
1immediate
25 min
330 min
42 h
512 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.removed may arrive before its corresponding gift.added if the original delivery retried. Use body.id and body.timestamp to 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.added in 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");

Next: Run Valotrix Cart Rewards on a headless storefront →