# Guide to VTEX Webhooks: Features and Best Practices

VTEX webhooks tell you when an order changes. The Orders API hook calls your URL each time an order moves into a status you care about, such as `payment-approved`, `ready-for-handling` or `invoiced`, so your ERP, warehouse system or middleware can pick it up without polling. The hook is the push counterpart of the Orders feed, which holds the same updates in a queue for you to fetch.

This guide covers how the hook is configured, what a notification contains, how to authenticate it with a header you add yourself, the configuration ping, the retry and expiry rules, and the best practices for running it in production.

## What are VTEX webhooks?

VTEX has two ways to follow order updates: Feed v3 and Hook. The feed keeps updates in a queue that your integration reads and commits. The hook sends each update to a URL you give it as soon as it happens. Both use the same filter, so the choice is between pulling at your own pace and being pushed to as updates happen.

The hook has no settings page in the VTEX Admin. You create it with a call to the Orders API, and each `appKey` can have exactly one. Configuring a hook again with the same `appKey` replaces the existing one, so if several integrations share a key, they share a hook too. VTEX recommends one hook per `appKey` per user.

VTEX doesn't sign hook notifications. The hook configuration has a `headers` object that VTEX describes as the credentials to use when calling your endpoint, so you add a secret header there and your receiver checks it.

## VTEX webhook features

| Feature | Details |
| --- | --- |
| Configuration | API only: Create or update hook configuration on the Orders API |
| Hooks per key | One per `appKey`. Configuring again replaces it |
| Filter | `FromWorkflow` (a list of statuses) or `FromOrders` (a JSONata expression) |
| Method | `POST` with a JSON body |
| Event field | `State`, the order's new status |
| Authentication | Headers you set in `hook.headers` |
| Signature | None |
| Setup check | A `{"hookConfig": "ping"}` request that must get a `200` |
| Response | `200` within 5,000 ms |
| Retries | Intervals from an internal geometric progression |
| Expiry | Removed after three days with no notifications |
| Source IPs | Not published |

## Order statuses and the payload

There's no separate event type. A notification carries the order's new status in `State`, and the values are VTEX's order statuses. Common ones include:

| `State` | Meaning |
| --- | --- |
| `order-created` | The order was created |
| `payment-pending` | Waiting for payment |
| `payment-approved` | Payment was approved |
| `waiting-ffmt-authorization` | Waiting for fulfilment authorisation |
| `ready-for-handling` | Ready for the seller to handle |
| `start-handling` | Handling is about to start |
| `handling` | The order is being handled |
| `invoiced` | An invoice was issued |
| `cancel` | The order entered the cancellation flow |
| `canceled` | The order was cancelled |

The full list is in VTEX's order flow documentation, and it differs slightly between marketplace and seller flows, so handle values you don't recognise without failing. If you use a `FromOrders` filter, a notification can fire on a change that isn't a status change, and `State` then holds the unchanged status.

The body is deliberately small:

```json
{
  "Domain": "Marketplace",
  "OrderId": "v40484048naf-01",
  "State": "payment-approved",
  "LastChange": "2019-07-29T23:17:30.0617185Z",
  "Origin": {
    "Account": "accountABC",
    "Key": "vtexappkey-keyEDF"
  }
}

```

`OrderId` identifies the order, `LastChange` is when it changed, and `Origin` names the account and the `appKey` the hook belongs to. Nothing else about the order is included, so fetch it from the Orders API when you need the items, customer or totals. `Origin.Key` is an identifier, not a secret.

> **See VTEX webhook payloads in action.** Inspect and replay sample VTEX webhook payloads in [Hookdeck Inspect](https://inspect.hookdeck.com/webhooks/vtex), with no account or setup required.

## Setting up VTEX webhooks

You'll need an API key (`appKey` and `appToken`) whose role includes the **Feed v3 and Hook Admin** resource in Order Management, and an endpoint that's already running, because VTEX checks it while saving.

Generate a long random value for your secret header:

```bash
openssl rand -hex 32

```

Then call **Create or update hook configuration** with a filter and the hook details:

```bash
curl -X POST "https://{accountName}.{environment}.com.br/api/orders/hook/config" \
  -H "Content-Type: application/json" \
  -H "X-VTEX-API-AppKey: $VTEX_APP_KEY" \
  -H "X-VTEX-API-AppToken: $VTEX_APP_TOKEN" \
  -d '{
    "filter": {
      "type": "FromWorkflow",
      "status": ["payment-approved", "ready-for-handling", "invoiced", "canceled"]
    },
    "hook": {
      "url": "https://example.com/webhooks/vtex",
      "headers": { "X-Webhook-Secret": "<your random value>" }
    }
  }'

```

`FromWorkflow` fires when an order moves into one of the listed statuses, and leaving the filter out sends every status change. `FromOrders` takes a JSONata `expression` instead, so you can filter on any field of the order document, with `disableSingleFire` controlling whether an order can match more than once. The two filter types can't be combined; sending fields from both returns a `409`.

When you save, VTEX sends a `POST` with the body `{"hookConfig": "ping"}` to your URL. If it doesn't get a `200`, the configuration call returns `400` and the hook isn't saved. VTEX's docs don't say whether the ping carries the headers from `hook.headers`, so don't rely on it being authenticated: answer it with a `200` and do nothing else with it.

Once the hook is saved, keep an eye on it. A hook with no notifications for three days is removed, and you have to configure it again. A narrow filter on a quiet store can hit that limit, so check it with **Get hook configuration** and recreate it when it's gone.

For local development, use the [Hookdeck CLI](/docs/cli): `hookdeck listen 3000 vtex --path /webhooks/vtex` gives you a public HTTPS URL that forwards to your local server, plus a web UI for inspecting and replaying deliveries, with no account required. Use the generated URL as `hook.url`.

**With Hookdeck.** Create a VTEX source in Hookdeck and enter the **Header Name** and **Header Value** exactly as they appear in `hook.headers`, including any `Bearer ` prefix. Use the source URL as `hook.url`. Hookdeck responds to the configuration ping with a `200` whether or not it carries your header, so VTEX can save the hook. If the ping arrives without the header, it shows in Hookdeck as a request that failed verification. If it arrives with it, it's delivered to your destination like any other request, so your handler should still acknowledge `{"hookConfig": "ping"}` and ignore it.

## Securing VTEX webhooks

Check the header first. Look it up case-insensitively, compare the whole value in constant time, and check lengths first because `crypto.timingSafeEqual` throws on buffers of different lengths. The one request you let through without the header is the configuration ping, and only to answer it.

```javascript
const crypto = require('crypto');
const express = require('express');

const app = express();

function safeEqual(a, b) {
  const x = Buffer.from(String(a), 'utf8');
  const y = Buffer.from(String(b), 'utf8');
  return x.length === y.length && crypto.timingSafeEqual(x, y);
}

function verifyVtexRequest(headers) {
  const secret = process.env.VTEX_WEBHOOK_SECRET;
  if (!secret) return false; // fail closed when unconfigured
  const name = (process.env.VTEX_WEBHOOK_HEADER || 'X-Webhook-Secret').toLowerCase();
  const received = headers[name];
  return typeof received === 'string' && safeEqual(received, secret);
}

function parseJson(buffer) {
  try {
    return JSON.parse(buffer.toString('utf8'));
  } catch {
    return null;
  }
}

app.post('/webhooks/vtex', express.raw({ type: '*/*' }), (req, res) => {
  const body = parseJson(req.body);

  // The configuration ping may or may not carry your header. Answer it and stop.
  if (body && body.hookConfig === 'ping') {
    return res.sendStatus(200);
  }

  if (!verifyVtexRequest(req.headers)) {
    return res.sendStatus(401);
  }
  if (!body || typeof body.OrderId !== 'string') {
    return res.sendStatus(400);
  }

  res.sendStatus(200); // VTEX wants a 200 within 5,000 ms

  // Queue the work: fetch body.OrderId from the Orders API and act on body.State.
});

```

The same check in Python:

```python
import hmac
import os

def verify_vtex_request(headers) -> bool:
    secret = os.environ.get("VTEX_WEBHOOK_SECRET")
    if not secret:
        return False
    name = os.environ.get("VTEX_WEBHOOK_HEADER", "X-Webhook-Secret")
    received = headers.get(name)  # framework header maps are case-insensitive
    if not received:
        return False
    return hmac.compare_digest(received.encode("utf-8"), secret.encode("utf-8"))

```

A static header authenticates the sender, but it doesn't protect the body, and a captured request can be replayed because nothing is signed or timestamped. Use HTTPS for `hook.url` and keep the value out of logs. Because the body only points at an order, treat the Orders API as the source of truth: fetch the order and check its status before acting on anything with financial consequences.

To rotate the value, call the hook configuration endpoint again with the new header and update your receiver at the same moment. Notifications sent with the old value in between will fail and be retried.

> **Make VTEX webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks your header on every notification, answers VTEX with a `200` as soon as it's stored, and feeds your ERP at a rate it can handle.

## VTEX webhook limitations and pain points

### A five-second response window

**The Problem:** Notifications fail when your endpoint is slow, even though it would have processed them.

**Why It Happens:** VTEX expects a `200` within 5,000 ms. Anything slower, or any other status, counts as a failed notification. The docs say "status 200" rather than any 2xx, so don't answer with `202` or `204`.

**Workarounds:**

* Acknowledge with `200` as soon as the request is authenticated and stored.
* Fetch the order and update downstream systems in a background job.

**How Hookdeck Can Help:** Hookdeck replies to VTEX with a `200` as soon as the notification is stored, then delivers it to your service with its own [delivery timeout](/docs/destinations#set-a-delivery-timeout) and [automatic retries](/docs/retries), so a slow handler doesn't turn into failed notifications at VTEX.

### Sales peaks

**The Problem:** A promotion or Black Friday floods your middleware or ERP with notifications.

**Why It Happens:** The hook is reactive. It sends every matching update as it happens, and VTEX's own docs warn that an integration that can't scale with peaks can stop the store handling orders properly. They suggest middleware that buffers notifications and passes them on at the rate your ERP can take, or using the feed as a backup.

**Workarounds:**

* Put a queue between the hook and your ERP.
* Keep the feed configured as a fallback you can drain when the hook path is struggling.

**How Hookdeck Can Help:** Hookdeck can be that buffer. Set a [max delivery rate](/docs/destinations#set-a-max-delivery-rate) on the destination and notifications queue in Hookdeck during a peak, then reach your ERP at a steady rate. [Issues](/docs/issues) alert you when deliveries start failing.

### Hooks expire, and there's only one per key

**The Problem:** Notifications stop and nothing tells you why.

**Why It Happens:** A hook with no notifications for three days is removed. And because each `appKey` has one hook, configuring a hook for a second integration with the same key silently replaces the first.

**Workarounds:**

* Use a separate `appKey` for each integration.
* Check the hook with **Get hook configuration** on a schedule and recreate it when it's missing.
* Keep the filter broad enough that a quiet weekend doesn't leave it idle for three days.

**How Hookdeck Can Help:** Hookdeck can't keep a hook alive, but every notification is stored with a timestamp, so a gap in arrivals is easy to spot. Because Hookdeck can [fan one source out](/docs/connections) to several destinations, several services can share one hook without needing a key each.

### No signature

**The Problem:** Anyone who knows the URL and the header value can post a fake order update.

**Why It Happens:** VTEX's only authentication option for the hook is the static headers you configure. It publishes no source IP addresses either.

**Workarounds:**

* Use a long random value, HTTPS, and rotate it if it leaks.
* Fetch the order from the API before acting on the notification.

**How Hookdeck Can Help:** Hookdeck's VTEX source compares the whole header value with the one you paste. A request without a matching header still gets a 200 response, but Hookdeck marks it as failed verification and creates no event, so it never reaches your destination.

## Best practices

### Use the notification as a trigger

The body only says which order changed and to what. Fetch the order from the Orders API for anything you act on.

### Make handlers idempotent

Retries can deliver the same notification more than once. Use `OrderId`, `State` and `LastChange` together as a key and skip repeats. Hookdeck's [deduplication](/docs/deduplication) can drop exact duplicates before they reach you. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Don't rely on order of arrival

Retries can make an older update arrive after a newer one. Compare `LastChange` with what you've stored, or read the order's current status from the API, before moving an order backwards.

### Filter at the source

Use a `FromWorkflow` status list or a `FromOrders` expression so VTEX only sends what you need. Test JSONata expressions against a real order before you put them in the hook.

### Acknowledge fast

Return `200` before calling the Orders API or your ERP, and do that work in the background. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

The VTEX Orders API hook sends a small, unsigned JSON notification each time an order moves into a status that matches your filter. Authenticate it with a secret header you add to `hook.headers`, answer the configuration ping with a `200`, route on `State`, fetch the order for details, and respond within five seconds. Watch for the three-day expiry and the one-hook-per-key rule, since either can stop notifications without warning.

[Hookdeck Event Gateway](/event-gateway) checks the header, answers VTEX straight away, and buffers peaks so your ERP receives orders at a pace it can handle.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle VTEX webhooks reliably in minutes.