Desenvolvedores/Integração

Webhooks

Um webhook é uma mensagem que a CardV envia ao seu servidor quando um pedido termina. Com ele, você não precisa ficar consultando o pedido. O webhook nunca traz códigos: depois de recebê-lo, consulte o pedido com GET/api/v1/orders/{order_id}.

Você cadastra as URLs de webhook no Portal (Owner, Integrations → Webhooks), separadamente no Live e no Sandbox. Não existe API para isso.

Veja também: Catálogo e pedidos · Segurança

#Eventos

EventoEnviado quando o pedido passa para
order.succeededsucceeded
order.partially_succeededpartially_succeeded
order.failedfailed (isso ainda não é um reembolso)
order.refundedrefunded (o dinheiro voltou para a sua carteira)
  • Cada evento é enviado no máximo uma vez por pedido e por URL de webhook (além das novas tentativas).
  • Não há eventos para accepted nem processing, nem para adição de saldo.

#Conteúdo da mensagem

A CardV envia um POST para a sua URL HTTPS:

HTTP
POST /cardv/webhook HTTP/1.1
Content-Type: application/json
User-Agent: CardV-B2B-Webhook/1.0
X-CardV-Event: order.succeeded
X-CardV-Delivery: 5521
X-CardV-Timestamp: 1790000100
X-CardV-Signature: t=1790000100,v1=2baac51da272ee8d4b7b1382d48037976c7272f1749c9399a50e758604de8a27

O corpo, formatado aqui para facilitar a leitura (a CardV envia tudo em uma linha):

JSON
{
  "event": "order.succeeded",
  "order": {
    "id": "O-00000001",
    "order_id": "O-00000001",
    "external_order_id": "TEST-0001",
    "status": "succeeded",
    "currency": "USD",
    "total_amount": "9.2500",
    "items": [
      {
        "sku_id": "S000001",
        "product_name": "Example Card",
        "quantity": 1,
        "delivery_count": 1
      }
    ]
  }
}
  • order.order_id é o número do pedido na CardV. order.id tem o mesmo valor.
  • order.external_order_id é o seu número de pedido.
  • order.status é o status no momento do evento. Ele pode estar desatualizado.
  • X-CardV-Delivery é o ID desta mensagem. O Portal mostra esse ID no histórico de envios.

#Verificação da assinatura

Todo webhook é assinado com o segredo de assinatura do webhook (whsec_...). O Portal mostra o segredo uma única vez, quando você cria o webhook ou gera um segredo novo.

Como a assinatura funciona:

Text
X-CardV-Signature: t=<segundos Unix>,v1=<hexadecimal minúsculo>
v1 = HMAC-SHA256(chave = segredo de assinatura, mensagem = "<t>" + "." + bytes brutos do corpo)

Passos:

  1. Leia os bytes brutos do corpo, antes de interpretar o JSON.
  2. Pegue t e v1 do cabeçalho.
  3. Recuse a mensagem se t estiver a mais de 300 segundos do seu relógio.
  4. Calcule o v1 esperado e compare usando uma comparação de tempo constante.
  5. Só então interprete o JSON.

Só t e o corpo são assinados. Pegue o tipo de evento do campo event do corpo, e não do cabeçalho X-CardV-Event.

Vetor de teste (segredo falso):

Text
segredo  whsec_TEST_ONLY_not_a_real_secret_000000000000
t        1790000100
v1       2baac51da272ee8d4b7b1382d48037976c7272f1749c9399a50e758604de8a27

O corpo deste vetor é exatamente esta linha (266 bytes, sem quebra de linha no final):

JSON
{"event":"order.succeeded","order":{"id":"O-00000001","order_id":"O-00000001","external_order_id":"TEST-0001","status":"succeeded","currency":"USD","total_amount":"9.2500","items":[{"sku_id":"S000001","product_name":"Example Card","quantity":1,"delivery_count":1}]}}

Python (Flask)

Python
import hashlib, hmac, os, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["CARDV_WEBHOOK_SECRET"].encode()


def verify(raw_body: bytes, header: str, tolerance: int = 300) -> bool:
    try:
        parts = dict(item.split("=", 1) for item in header.split(","))
        t, v1 = parts["t"].strip(), parts["v1"].strip()
    except (KeyError, ValueError):
        return False
    if not t.isdigit() or abs(time.time() - int(t)) > tolerance:
        return False
    signed = t.encode() + b"." + raw_body
    expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode(), v1.encode())  # constant time


@app.post("/cardv/webhook")
def cardv_webhook():
    raw = request.get_data()  # raw bytes, before JSON parsing
    if not verify(raw, request.headers.get("X-CardV-Signature", "")):
        abort(400)
    event = request.get_json()
    store_event(request.headers["X-CardV-Delivery"], event)  # your code
    return "", 204  # answer fast; do the work later

Node.js (Express)

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

const app = express();
const SECRET = process.env.CARDV_WEBHOOK_SECRET;

function verify(rawBody, header, toleranceSeconds = 300) {
  const parts = {};
  for (const p of String(header || "").split(",")) {
    const i = p.indexOf("=");
    parts[p.slice(0, i).trim()] = p.slice(i + 1).trim();
  }
  const { t, v1 } = parts;
  if (!t || !v1 || !/^\d+$/.test(t)) return false;
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - Number(t)) > toleranceSeconds) return false;
  const expected = crypto.createHmac("sha256", SECRET)
    .update(Buffer.concat([Buffer.from(`${t}.`, "utf8"), rawBody]))
    .digest("hex");
  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(v1, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// express.raw keeps the exact bytes. Do not use express.json() on this route.
const rawJson = express.raw({ type: "application/json" });

app.post("/cardv/webhook", rawJson, async (req, res) => {
  if (!verify(req.body, req.get("X-CardV-Signature"))) return res.sendStatus(400);
  const event = JSON.parse(req.body.toString("utf8"));
  await storeEvent(req.get("X-CardV-Delivery"), event); // your code
  res.sendStatus(204);
});

Os dois exemplos passam no vetor de teste.

#Novas tentativas

  • Responda com qualquer status 2xx em até 15 segundos, depois de salvar o evento. Deixe o trabalho demorado (consultar o pedido, enviar os códigos) para depois.
  • Qualquer outra coisa conta como falha: outro status, timeout ou erro de conexão.
  • A CardV não segue redirecionamentos. Uma resposta 3xx é falha. Cadastre a URL final exata.
  • Uma mensagem com falha é reenviada, em até 6 tentativas no total:
TentativaQuando
1Logo após o evento
2Cerca de 1 minuto após a falha da tentativa 1
3Cerca de 5 minutos após a falha da tentativa 2
4Cerca de 15 minutos após a falha da tentativa 3
5Cerca de 30 minutos após a falha da tentativa 4
6Cerca de 60 minutos após a falha da tentativa 5

Os horários podem atrasar até um minuto. Depois da 6ª falha, a CardV para de tentar. No histórico de envios do Portal, você vê todas as mensagens e pode reenviar qualquer uma.

#Duplicados

O mesmo evento pode chegar mais de uma vez, e os eventos podem chegar fora de ordem.

  • Ignore eventos que você já tratou. Compare pelo order.order_id junto com o event do corpo verificado. Uma mensagem reenviada pelo Portal ganha um novo ID em X-CardV-Delivery, então esse ID sozinho não basta.
  • O corpo é uma foto do momento do evento. Consulte sempre o pedido de novo e aja de acordo com o status atual.
  • Webhooks podem se perder. Tenha também uma rotina que verifica pedidos não terminados com mais de alguns minutos.

#Regras da URL

  • A URL precisa começar com https:// e apontar para um endereço público na internet. Endereços privados e locais são recusados.
  • Você pode assinar os quatro eventos ou escolher só alguns.
  • Gerar um novo segredo de assinatura vale na hora. O segredo antigo para de funcionar. Para trocar sem parar o serviço, crie um segundo webhook com o segredo novo, coloque-o em produção e depois desative o antigo.
  • A CardV guarda os primeiros 1.000 caracteres da sua resposta para investigar problemas. Não coloque segredos nem dados pessoais na resposta.

Dúvidas sobre a integração? Envie um e-mail para [email protected] com seu Merchant ID e o ID do pedido ou da requisição.