Gareth Wilson Gareth Wilson

Guide to Violet Webhooks: Features and Best Practices

Published


Violet is a unified checkout API. It lets a channel, an app or site that sells other merchants' products, take orders and sync catalogues across merchants' ecommerce platforms through one integration. Its webhooks keep the channel up to date as orders ship or are refunded, merchants connect or disconnect, offers change and payouts move.

This guide covers Violet's per-event webhooks, the header-based event metadata, verifying X-Violet-Hmac with your app secret, and the pause-and-disable rules that make fast acknowledgement important.

What are Violet webhooks?

A Violet webhook subscribes one event, such as ORDER_SHIPPED, to a remote_endpoint URL. You create one per event through the REST API, and several can share a URL. When the event happens, Violet posts the related entity, such as a fully composed Order or a Merchant object, as the request body.

The body has no envelope and no event field. What happened, and which event this is, travels in X-Violet-* headers.

Violet webhook features

FeatureDetails
Signaturebase64 HMAC-SHA256
Signature headerx-violet-hmac
BodyJSON
Event routingHeader field X-Violet-Topic

Every request carries these default headers, from Violet's events page:

HeaderMeaning
X-Violet-HmacThe signature
X-Violet-TopicThe event, such as ORDER_COMPLETED
X-Violet-Event-IdUnique event ID, for idempotency
X-Violet-Entity-IdID of the related entity
X-Violet-Entity-LengthSize of the entity in bytes
X-Violet-Webhook-IdThe webhook subscription

Some events add X-Violet-Order-Id, X-Violet-Bag-Id (the bag in the order that changed), X-Violet-Reason (why a merchant changed status), X-Violet-Entity-External-Id (the payment provider account ID for payout accounts) or X-Violet-Recovery-Event-Key. A resent event carries X-Violet-Event-Id-Source with the original event ID.

Events and payloads

Route on X-Violet-Topic.

| Event routing | Header field X-Violet-Topic |

All Documented Events (52)

  • Order: ORDER_ACCEPTED, ORDER_UPDATED, ORDER_COMPLETED, ORDER_SHIPPED, ORDER_DELIVERED, ORDER_REFUNDED, ORDER_CANCELLED, ORDER_FAILED
  • Merchant: MERCHANT_CONNECTED, MERCHANT_DISCONNECTED, MERCHANT_ENABLED, MERCHANT_DISABLED, MERCHANT_NEEDS_ATTENTION, MERCHANT_COMPLETE
  • Offer: OFFER_CREATED (deprecated), OFFER_ADDED, OFFER_UPDATED, OFFER_REMOVED, OFFER_DELETED
  • Collection: COLLECTION_CREATED, COLLECTION_UPDATED, COLLECTION_REMOVED, COLLECTION_OFFERS_UPDATED
  • Sync: PRODUCT_SYNC_STARTED, PRODUCT_SYNC_COMPLETED, PRODUCT_SYNC_FAILED, COLLECTION_SYNC_STARTED, COLLECTION_SYNC_COMPLETED, COLLECTION_SYNC_FAILED
  • Payout account: MERCHANT_PAYOUT_ACCOUNT_CREATED, MERCHANT_PAYOUT_ACCOUNT_REQUIREMENTS_UPDATED, MERCHANT_PAYOUT_ACCOUNT_ACTIVATED, MERCHANT_PAYOUT_ACCOUNT_DEACTIVATED, MERCHANT_PAYOUT_ACCOUNT_DELETED
  • Payment transaction: PAYMENT_TRANSACTION_CAPTURE_STATUS_UPDATED, PAYMENT_TRANSACTION_CAPTURE_STATUS_AUTHORIZED, PAYMENT_TRANSACTION_CAPTURE_STATUS_CAPTURED, PAYMENT_TRANSACTION_CAPTURE_STATUS_REFUNDED, PAYMENT_TRANSACTION_CAPTURE_STATUS_PARTIALLY_REFUNDED, PAYMENT_TRANSACTION_CAPTURE_STATUS_FAILED
  • Transfer: TRANSFER_SENT, TRANSFER_PARTIALLY_SENT, TRANSFER_FAILED, TRANSFER_UPDATED, TRANSFER_REVERSED, TRANSFER_PARTIALLY_REVERSED, TRANSFER_REVERSAL_FAILED
  • Recovery case: the docs list further RECOVERY_CASE_* events (see the events page)

The recovery case events are RECOVERY_CASE_OPENED, RECOVERY_CASE_GAP_REMAINING, RECOVERY_CASE_RECOVERED and RECOVERY_CASE_EXHAUSTED.

Order events carry the whole order, including all bags and SKUs, so read X-Violet-Bag-Id to see which bag changed. MERCHANT_ENABLED and MERCHANT_DISABLED carry the Merchant object, as in this example from Violet's handling webhooks page:

{
  "id": 10189,
  "name": "sample-store",
  "platform": "SHOPIFY",
  "store_url": "sample-store.myshopify.com",
  "custom_store_url": "sample-store.com",
  "status": "ACTIVE",
  "default_currency": "USD",
  "default_country_code": "US",
  "default_state_code": "WA",
  "default_language_code": "EN",
  "commission_rate": 10,
  "distribution_type": "PRIVATE",
  "referral_source": "VIOLET",
  "connection_status": "CONNECTED"
}

For merchant status changes, X-Violet-Reason may be APP_INSTALLED_ON_PLATFORM, APP_UNINSTALLED_FROM_PLATFORM, MERCHANT_PLATFORM_PLAN_INACTIVE, MERCHANT_CREDENTIAL_INVALID or MERCHANT_PLATFORM_SCOPES_INVALID.

Bodies always contain the full entity, however large. If you can't take a large body, X-Violet-Entity-Length tells you its size and you can fetch the event later by ID.

Setting up Violet webhooks

  1. Find your app secret on the overview page of the channel dashboard. Test Mode and Live Mode have different secrets. Store the one for this environment as VIOLET_APP_SECRET.
  2. Create a webhook for each event:
curl -X POST https://sandbox-api.violet.io/v1/events/webhooks \
  -H "X-Violet-App-Id: your-app-id-here" \
  -H "X-Violet-App-Secret: your-app-secret-here" \
  -H "X-Violet-Token: your-token-here" \
  -H "Content-type: application/json" \
  -d '{"event": "ORDER_SHIPPED", "remote_endpoint": "https://example.com/webhooks/violet"}'
  1. Repeat for each event, using https://api.violet.io for Live Mode.

The API can also list, modify, re-activate and remove webhooks, and POST /v1/events/webhooks/{webhook_id}/headers adds your own headers to every delivery (see Managing Webhooks).

To route through Hookdeck, create a Violet source with the app secret and use its URL as each remote_endpoint. For local development:

npx hookdeck-cli listen 3000 violet --path /webhooks/violet

Securing Violet webhooks

X-Violet-Hmac is the base64 HMAC-SHA256 of the raw body, keyed with the channel's app secret. The secret looks like hex but is used as UTF-8 text, as Violet's Java sample shows with secret.getBytes(StandardCharsets.UTF_8). Violet's Python sample compares with ==. Use a constant-time comparison instead.

Violet suggests queuing each request, responding, and then validating the HMAC in a worker. Checking the signature before responding, as below, is quick enough to fit in the 10 seconds and keeps unsigned requests out of your queue.

Node.js verification

const crypto = require('node:crypto');

function decodeBase64(value) {
  if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
  const bytes = Buffer.from(value, 'base64');
  return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
    ? bytes : null;
}

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const signature = headers['x-violet-hmac'];
  if (typeof signature !== 'string') return false;
  const key = Buffer.from(secret, 'utf8');
  const message = rawBody;
  const candidates = [signature];
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    const received = decodeBase64(candidate);
    if (!received) return false;
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

The handler reads the topic and event ID from the headers and stores the entity under that ID.

const express = require('express');

const app = express();

app.post('/webhooks/violet', express.raw({ type: '*/*' }), async (req, res) => {
  const secret = process.env.VIOLET_APP_SECRET;
  if (!secret) return res.sendStatus(500);
  if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

  const topic = req.headers['x-violet-topic'];
  const eventId = req.headers['x-violet-event-id'];
  if (typeof topic !== 'string' || typeof eventId !== 'string') return res.sendStatus(400);

  let entity;
  try {
    entity = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }
  if (!entity || typeof entity !== 'object' || Array.isArray(entity)) return res.sendStatus(400);

  try {
    if (await alreadyHandled(eventId)) return res.sendStatus(200);
    await queue.add('violet-event', {
      eventId,
      topic,
      entityId: req.headers['x-violet-entity-id'],
      bagId: req.headers['x-violet-bag-id'],
      reason: req.headers['x-violet-reason'],
      entity,
    });
    await markHandled(eventId);
  } catch {
    return res.sendStatus(503);
  }
  res.sendStatus(200);
});

queue, alreadyHandled and markHandled are placeholders for your queue and dedupe storage.

A Hookdeck Violet source verifies requests the same way. Violet gets a 200 back either way, and a mismatch is recorded on the request as failed verification. With no timestamp in Violet's signature, Hookdeck applies no age limit either.

Make Violet webhooks production-ready. Hookdeck Event Gateway acknowledges Violet within its 10-second window, so your outages don't pause or disable the webhook, and holds each order and merchant update until your service is ready.

Violet webhook limitations and pain points

Failures pause, then disable, the webhook

The Problem: A sustained outage stops delivery, and once the webhook is disabled, events aren't even created.

Why It Happens: More than 50 failures in 30 minutes pauses delivery for 1 hour, then 3 hours, then up to 24 hours. After three pauses the webhook is DISABLED until you re-activate it, and no events are created in the meantime.

Workarounds:

  • Queue and respond within 10 seconds, as Violet recommends.
  • Act on Violet's emails about pauses, and re-activate disabled webhooks through the API.

How Hookdeck Can Help: Hookdeck accepts every request, so Violet never sees failures from your side, and retries your service on its own schedule.

Events fail after 24 hours

The Problem: An event that can't be delivered within a day is marked FAILED.

Why It Happens: Violet makes 10 attempts with back-off over 24 hours.

Workarounds:

  • Retrieve missed events by ID through Violet's API.
  • Reconcile orders after any long outage.

How Hookdeck Can Help: Hookdeck stores each request on arrival, with no 24-hour cut-off for retrying or replaying it to your service.

The event lives in headers

The Problem: Tools that only keep the body lose the event name and ID.

Why It Happens: The body is the bare entity, and X-Violet-Topic and X-Violet-Event-Id carry the metadata.

Workarounds:

  • Store the headers with the body when you queue a request.
  • Route on X-Violet-Topic, not on the entity's shape.

How Hookdeck Can Help: Hookdeck keeps the original headers on every request, and filters can route on X-Violet-Topic.

One webhook per event, per mode

The Problem: A full integration needs dozens of webhooks, in both Test and Live Mode.

Why It Happens: Each webhook subscribes to a single event, and the two modes have separate hosts and secrets.

Workarounds:

  • Script webhook creation through the API.
  • Point all of a mode's webhooks at one URL and keep the secret per environment.

How Hookdeck Can Help: Use one Hookdeck source per mode, each with its own app secret, and route from there.

Best practices

Deduplicate on X-Violet-Event-Id

Violet provides it for idempotency. For resent events, X-Violet-Event-Id-Source holds the original ID. See the idempotency guide.

Use the bag ID

An order can contain several bags, and X-Violet-Bag-Id tells you which one changed.

Keep merchant state from reasons

Store X-Violet-Reason with merchant status changes, so support can see why a merchant was disabled.

Queue before processing

Violet's own advice is to queue, respond and process in a separate thread. Processing webhooks asynchronously explains the pattern.

Conclusion

Violet posts the full entity for each subscribed event, with the event name and ID in X-Violet-* headers and a base64 HMAC-SHA256 keyed by the app secret. Respond within 10 seconds and deduplicate on X-Violet-Event-Id, because sustained failures pause and then disable the webhook.

Hookdeck Event Gateway verifies Violet requests, keeps their headers and retries delivery to your services.

Get started with Hookdeck for free and handle Violet webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.