Webhooks
Rotar el secreto
Cambia el secreto de firma de un endpoint sin perder eventos: el secreto viejo deja de valer al instante, así que tu receptor debe aceptar una lista de secretos durante el despliegue.
En esta página
Rota el secreto de un endpoint cuando se filtró (o sospechas que se filtró), cuando una persona con acceso deja el equipo, cuando lo perdiste, o de forma periódica según tu política de seguridad. La rotación genera un secreto nuevo y lo muestra una sola vez, igual que al crear el endpoint.
El secreto anterior deja de valer al instante
No hay una ventana en la que Compense firme con los dos. Desde el momento en que llamas a
POST/v1/gateway/webhooks/endpoints/{endpoint_id}/rotate-secret/
, toda entrega sale firmada con el secreto nuevo. Si tu receptor
todavía no lo conoce, rechazará esas entregas con 401 hasta que lo despliegues.
La estrategia: verificar contra una lista #
Todas las funciones de verificación de los SDK aceptan varios secretos y dan la entrega por buena
si coincide con cualquiera (comparando contra todos, sin cortocircuito, para no filtrar cuál
coincidió). Mientras rotas, pasa [nuevo, viejo]:
from compense_gateway.webhooks import verify_webhook
event = verify_webhook(crudo, headers, secret=[SECRETO_NUEVO, SECRETO_VIEJO])import { verifyWebhook } from "@compense/sdk/webhooks";
const event = verifyWebhook(req.body, req.headers, [secretoNuevo, secretoViejo]);use Compense\Sdk\Webhook\Webhook;
$event = Webhook::verify($request->getContent(), $request->headers->all(), [$secretoNuevo, $secretoViejo]);using Compense.Sdk.Webhooks;
WebhookEvent evt = CompenseWebhooks.Verify(crudo, request.Headers, new[] { secretoNuevo, secretoViejo });
// También: WebhookSecrets.Of(secretoNuevo, secretoViejo)ev, err := webhook.Verify(crudo, r.Header, []string{secretoNuevo, secretoViejo})# Sin SDK: calcula el HMAC con cada secreto y acepta si alguno coincide.
for S in "$SECRETO_NUEVO" "$SECRETO_VIEJO"; do
[ "sha256=$(openssl dgst -sha256 -hmac "$S" < cuerpo.json | sed 's/^.* //')" = "$RECIBIDA" ] && echo "válida"
doneLos secretos vacíos de la lista se ignoran, así que puedes leer el anterior de una variable que casi
siempre está vacía (COMPENSE_WEBHOOK_SECRET_OLD) sin tocar el código. Si la lista queda sin
ningún secreto, los SDK lo tratan como error de configuración (no como firma inválida): ver la
tabla de errores.
Paso a paso #
sequenceDiagram
autonumber
participant T as Tu equipo
participant G as Gateway Compense
participant V as Gestor de secretos
participant R as Tu receptor
T->>R: Despliega el receptor que acepta [vigente, anterior]
T->>G: rotate-secret
G-->>T: data.secret = whsec_nuevo (una sola vez)
T->>V: vigente = nuevo, anterior = viejo
V-->>R: El receptor recarga los secretos
Note over G,R: Las entregas que fallaron en el intervalo se reintentan solas
T->>G: Revisa el historial (deliveries status=failed)
T->>V: Cuando todo verifique con el nuevo, borra el anterior- Prepara el receptor. Despliega (antes de rotar) un receptor que lea dos secretos, el vigente y el anterior, de tu gestor de secretos o de variables de entorno, y que verifique contra ambos. Si puede recargarlos sin reiniciar, mejor.
- Rota. Llama a
rotate_secret; la respuesta trae el secreto nuevo endata.secret. - Guarda el nuevo de inmediato como vigente y mueve el actual a “anterior”. Hazlo en el mismo proceso que rota, para acortar el intervalo en que el receptor no conoce el nuevo.
- Haz que el receptor lo cargue (recarga en caliente o un despliegue). Durante un despliegue gradual, unas instancias tendrán la lista nueva y otras la vieja: por eso la lista existe.
- Revisa las entregas. Las que fallaron con
401en el intervalo se reintentan solas (el primer reintento sale a los 30 segundos); si alguna quedófailedoabandoned, reintenta a mano. - Retira el anterior cuando el historial muestre entregas exitosas con el nuevo. Deja la lista con un solo secreto hasta la próxima rotación.
Rotar por API #
import os
from compense_gateway import CompenseClient
client = CompenseClient(api_key=os.environ["COMPENSE_API_KEY"], environment="sandbox")
tx = client.webhooks.endpoints.rotate_secret(
"3f1a2b3c-0000-4a1b-9c2d-000000000001",
idempotency_key="nova-wh-erp-001-rota-2026-11",
)
nuevo = tx.data["secret"] # una sola vez: guárdalo YA como "vigente"
print(tx.data["id"], tx.data["rotated_at"])import { CompenseClient } from "@compense/sdk";
const client = new CompenseClient({ apiKey: process.env.COMPENSE_API_KEY!, environment: "sandbox" });
const tx = await client.webhooks.endpoints.rotateSecret("3f1a2b3c-0000-4a1b-9c2d-000000000001", {
idempotencyKey: "nova-wh-erp-001-rota-2026-11",
});
const nuevo = String(tx.data.secret); // una sola vez: guárdalo YA como "vigente"
console.log(tx.data.id, tx.data.rotated_at);<?php
require __DIR__ . '/vendor/autoload.php';
use Compense\Sdk\CompenseClient;
$client = new CompenseClient(apiKey: getenv('COMPENSE_API_KEY'), environment: 'sandbox');
$tx = $client->webhooks->endpoints->rotateSecret(
'3f1a2b3c-0000-4a1b-9c2d-000000000001',
idempotencyKey: 'nova-wh-erp-001-rota-2026-11',
);
$nuevo = $tx->data['secret']; // una sola vez: guárdalo YA como "vigente"
echo $tx->data['id'], ' ', $tx->data['rotated_at'], PHP_EOL;using Compense.Sdk;
using Compense.Sdk.Models;
using var client = new CompenseClient(
Environment.GetEnvironmentVariable("COMPENSE_API_KEY")!, CompenseEnvironment.Sandbox);
Transaction tx = await client.Webhooks.Endpoints.RotateSecretAsync(
"3f1a2b3c-0000-4a1b-9c2d-000000000001",
idempotencyKey: "nova-wh-erp-001-rota-2026-11");
string nuevo = tx.Data["secret"]!.GetValue<string>(); // una sola vez: guárdalo YA como "vigente"
Console.WriteLine($"{tx.Data["id"]} {tx.Data["rotated_at"]}");package main
import (
"context"
"fmt"
"log"
"os"
compense "github.com/Appiasoft/compense-saas-sdk-go"
)
func main() {
ctx := context.Background()
client, err := compense.NewClient(os.Getenv("COMPENSE_API_KEY"),
compense.WithEnvironment(compense.Sandbox))
if err != nil {
log.Fatal(err)
}
tx, err := client.Webhooks.Endpoints.RotateSecret(ctx, "3f1a2b3c-0000-4a1b-9c2d-000000000001",
compense.WithIdempotencyKey("nova-wh-erp-001-rota-2026-11"))
if err != nil {
log.Fatal(err)
}
nuevo, _ := tx.Data["secret"].(string) // una sola vez: guárdalo YA como "vigente"
_ = nuevo
fmt.Println(tx.Data["id"], tx.Data["rotated_at"])
}curl -X POST https://sandbox.dev.compense.co/gw/v1/gateway/webhooks/endpoints/$ENDPOINT_ID/rotate-secret/ \
-H "X-API-Key: $COMPENSE_API_KEY" \
-H "Idempotency-Key: nova-wh-erp-001-rota-2026-11"Respuesta:
{
"status": "success",
"transaction_id": "a1b2c3d4-0000-4a1b-9c2d-000000000020",
"module": "webhooks",
"data": {
"id": "3f1a2b3c-0000-4a1b-9c2d-000000000001",
"secret": "whsec_...",
"rotated_at": "2026-11-02T14:00:00Z"
}
}Repetir la rotación con la misma Idempotency-Key no devuelve el secreto
Si la respuesta se perdió y reintentas con la misma Idempotency-Key, el gateway devuelve la
respuesta original con "idempotent_replay": true pero sin secret (no lo guarda). En ese caso
el secreto que se generó es irrecuperable: rota otra vez con una llave nueva.
La CLI compense no tiene orden para rotar: usa el SDK o cURL.
Configuración típica del receptor #
Guarda dos entradas en tu gestor de secretos o en variables de entorno, y lee ambas:
| Variable | Contenido | Cuándo tiene valor |
|---|---|---|
COMPENSE_WEBHOOK_SECRET | Secreto vigente (whsec_...) | Siempre |
COMPENSE_WEBHOOK_SECRET_OLD | Secreto anterior | Solo durante una rotación |
En ASP.NET Core la convención de los ejemplos del SDK es una lista en configuración
(Compense:WebhookSecrets:0 y Compense:WebhookSecrets:1, que como variables de entorno se escriben
Compense__WebhookSecrets__0 y Compense__WebhookSecrets__1); en Laravel, un arreglo en
config/services.php ('webhook_secrets' => array_values(array_filter([...]))). Los receptores
completos están en Verificar la firma.
Si perdiste el secreto #
El gateway no guarda los secretos, así que no hay forma de recuperarlo: ninguna lectura lo devuelve y el replay idempotente de crear o rotar tampoco. La única salida es rotarlo:
- Rota el secreto (arriba) y guarda el nuevo.
- Configura el receptor solo con el nuevo (el perdido no te sirve para nada) y despliega.
- Las entregas que fallaron mientras tanto se reintentan solas; las que quedaron
failedoabandonedlas reprocesas en bloque.
Si el secreto se filtró #
Rota de inmediato, aunque eso te cueste algunos 401 mientras despliegas: un secreto filtrado
permite a cualquiera enviarle a tu receptor eventos falsos con firma válida. Después, revisa en tu
sistema los eventos aceptados desde la filtración y contrástalos con el
historial de entregas: una entrega auténtica siempre
aparece ahí con su event_id.
En el sandbox #
Con una llave de sandbox, rotar devuelve otro secreto fijo (whsec_sbx_…), distinto del de
creación pero siempre el mismo para ese endpoint. Sirve para practicar el flujo; no hay entregas
reales que se rechacen. Ver Probar tus webhooks.