Menu da documentação
Webhooks

Receber eventos

Um webhook avisa o seu servidor na hora em que algo acontece na loja: a Zolvi faz um POST assinado na sua URL, com o evento e o objeto em JSON.

Configurar

  1. No painel, abra Integrações › Webhooks e clique em Novo webhook (ou use a API).
  2. Informe a URL (https e endereço público) e escolha os eventos.
  3. Guarde o segredo (whsec_…): é com ele que você confere que o envio veio da Zolvi.
  4. Use Enviar teste para receber um evento PING e conferir.

Eventos

EventoQuandodata
ORDER_CREATEDPedido criado (o cliente gerou o Pix)O pedido, com o pix_copy_paste preenchido.
ORDER_PAIDPedido pagoO pedido (status paid).
ORDER_DELIVEREDPedido entregue ao clienteO pedido (status delivered).
ORDER_EXPIREDPix do pedido venceu sem pagamentoO pedido (status expired).
ORDER_CANCELLEDPedido canceladoO pedido (status cancelled).
PRODUCT_CREATEDProduto criado{ product }
PRODUCT_UPDATEDProduto alterado{ before, after }: o produto antes e depois da mudança.
PRODUCT_DELETEDProduto apagado{ product }: como estava antes de apagar.
STOCK_UPDATEDEstoque adicionado ou removido{ product_id, product_name, variant_id, variant_name, added, removed, stock_available }
STOCK_LOWEstoque chegou ao limite de aviso ou esgotou{ product_id, product_name, variant_id, variant_name, stock_available, sold_out, threshold }
REVIEW_CREATEDCliente avaliou um pedidoA avaliação: { order_id, order_number, rating, comment, customer, products }.
AFFILIATE_SALEVenda com código de apoiador de um afiliado{ affiliate, commission_cents, order }

Pedidos usam o mesmo formato de GET /orders/{number} e produtos o de GET /products/{id}. Uma venda normal dispara ORDER_CREATED (Pix gerado), ORDER_PAID e ORDER_DELIVERED, nessa ordem.

Formato

POST na sua URL
{
  "id": "evt_8ZcZT72Gy-jnD1xK5GTXcKIKgZQ",
  "event": "ORDER_PAID",
  "date": "2026-11-08T15:31:12.000Z",
  "data": {
    "id": "cmw8d6urw0001ssugmol9xqu2",
    "number": 1042,
    "status": "paid",
    "customer": {
      "id": "cmvrlovla0001ssvcuio6xvf7",
      "discord_id": "1390611228354809977",
      "username": "cliente"
    },
    "items": [
      {
        "id": "cmvrlovlp0005ssvc3yv6nmtz",
        "product_id": "cmvrllzcq000bssokqrrb39n8",
        "product_name": "Key Steam",
        "variant_id": "cmvrllzcq000cssokzx1yq2pa",
        "variant_name": "30D",
        "quantity": 2,
        "unit_price_cents": 1500,
        "total_cents": 3000,
        "answer": null
      }
    ],
    "subtotal_cents": 3000,
    "discount_cents": 300,
    "total_cents": 2700,
    "coupon_code": "BLACK10",
    "affiliate": null,
    "payment": {
      "provider": "mercadopago",
      "method": "gateway",
      "verified": true,
      "approved_by": null,
      "id": "123456789",
      "pix_copy_paste": null,
      "created_at": "2026-11-08T15:30:00.000Z",
      "expires_at": "2026-11-08T16:00:00.000Z",
      "paid_at": "2026-11-08T15:31:12.000Z",
      "gateway_fee_cents": 27
    },
    "delivery": {
      "delivered_at": null,
      "manual": false,
      "manual_reason": null
    },
    "review": null,
    "recovered_from_number": null,
    "created_at": "2026-11-08T15:29:40.000Z",
    "updated_at": "2026-11-08T15:31:12.000Z"
  }
}
CabeçalhoO que é
x-zolvi-signatureHMAC-SHA256 em hexadecimal de “timestamp.corpo”, com o segredo do webhook.
x-zolvi-timestampHorário do envio, em segundos (Unix).
x-zolvi-eventO tipo do evento (igual ao campo event).
x-zolvi-event-idO id do evento (igual ao campo id): use para ignorar repetidos.

Conferir a assinatura

Calcule HMAC-SHA256(segredo, timestamp + "." + corpo) sobre os bytes exatos do corpo recebido e compare com o x-zolvi-signature numa comparação de tempo constante. Recuse também envios com o horário mais de 5 minutos longe do seu relógio.

Node.js
import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.ZOLVI_WEBHOOK_SECRET; // whsec_…

// Use o corpo cru (os bytes exatos), não o JSON já convertido.
app.post("/webhooks/zolvi", express.raw({ type: "application/json" }), (req, res) => {
  const signature = req.get("x-zolvi-signature") ?? "";
  const timestamp = req.get("x-zolvi-timestamp") ?? "";
  const body = req.body.toString("utf8");

  // Recusa envio com mais de 5 minutos (contra reenvio de mensagem velha).
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return res.sendStatus(400);

  const expected = crypto.createHmac("sha256", SECRET).update(`${timestamp}.${body}`).digest("hex");
  const ok = signature.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
  if (!ok) return res.sendStatus(401);

  const event = JSON.parse(body);
  res.sendStatus(200); // responda logo; processe depois

  if (event.event === "ORDER_PAID") {
    // libere o VIP, mande e-mail etc. Use event.id para ignorar repetidos.
  }
});

app.listen(3000);
Não converta o JSON e monte de novo antes de conferir: espaços e ordem dos campos mudam os bytes e a assinatura deixa de bater. Use o corpo cru.

Respostas e novas tentativas

  • Responda com 2xx em até 10 segundos. Qualquer outro código, ou demora, conta como falha. Faça o trabalho pesado depois de responder.
  • Falhou, a Zolvi tenta de novo em 1 min, 5 min, 30 min, 2 h, 6 h, 12 h e 24 h (8 tentativas, cerca de 1 dia e meio).
  • O mesmo evento pode chegar mais de uma vez (por exemplo, se a sua resposta se perder): use o id para não processar duas vezes.
  • A ordem não é garantida entre eventos diferentes: confira o status do objeto, não só o tipo do evento.
  • Webhook que só falha há 3 dias é desligado sozinho e a loja é avisada no Registro. Ligue de novo no painel.
  • Redirecionamentos não são seguidos, e endereços de rede interna são recusados.
  • As entregas ficam 30 dias no painel, com o código de resposta, e dá para reenviar.

Exemplo: PRODUCT_UPDATED

Corpo
{
  "id": "evt_Qm1x…",
  "event": "PRODUCT_UPDATED",
  "date": "2026-11-08T16:02:00.000Z",
  "data": {
    "before": {
      "id": "cmvrllzcq000bssokqrrb39n8",
      "name": "Key Steam",
      "description": "Key de ativação na Steam.",
      "image_url": "https://exemplo.com/key.png",
      "active": true,
      "published": true,
      "role_id": null,
      "role_days": null,
      "allowed_role_ids": [],
      "min_quantity": null,
      "max_quantity": 5,
      "hide_stock": false,
      "question": null,
      "delivery_note": "Resgate em store.steampowered.com/account/redeemwalletcode",
      "variants": [
        {
          "id": "cmvrllzcq000cssokzx1yq2pa",
          "name": "30D",
          "price_cents": 1500,
          "compare_at_cents": 1990,
          "cost_cents": 500,
          "active": true,
          "position": 0,
          "fixed_delivery": false,
          "stock_available": 42
        }
      ],
      "created_at": "2026-10-28T04:10:00.000Z",
      "updated_at": "2026-11-08T15:31:12.000Z"
    },
    "after": {
      "id": "cmvrllzcq000bssokqrrb39n8",
      "name": "Key Steam",
      "description": "Key de ativação na Steam.",
      "image_url": "https://exemplo.com/key.png",
      "active": false,
      "published": true,
      "role_id": null,
      "role_days": null,
      "allowed_role_ids": [],
      "min_quantity": null,
      "max_quantity": 5,
      "hide_stock": false,
      "question": null,
      "delivery_note": "Resgate em store.steampowered.com/account/redeemwalletcode",
      "variants": [
        {
          "id": "cmvrllzcq000cssokzx1yq2pa",
          "name": "30D",
          "price_cents": 1500,
          "compare_at_cents": 1990,
          "cost_cents": 500,
          "active": true,
          "position": 0,
          "fixed_delivery": false,
          "stock_available": 42
        }
      ],
      "created_at": "2026-10-28T04:10:00.000Z",
      "updated_at": "2026-11-08T15:31:12.000Z"
    }
  }
}