Webhooks
Rotate the secret
Change an endpoint’s signing secret without losing events: the old secret stops working instantly, so your receiver must accept a list of secrets during the rollout.
On this page
Rotate an endpoint’s secret when it leaked (or you suspect it leaked), when someone with access leaves the team, when you lost it, or periodically according to your security policy. Rotation generates a new secret and shows it only once, just like when you create the endpoint.
The previous secret stops working instantly
There’s no window in which Compense signs with both. From the moment you call
POST/v1/gateway/webhooks/endpoints/{endpoint_id}/rotate-secret/
, every delivery goes out signed with the new secret. If your
receiver doesn’t know it yet, it will reject those deliveries with 401 until you deploy it.
The strategy: verify against a list #
Every SDK verification function accepts several secrets and treats the delivery as valid if it
matches any of them (comparing against all of them, without short-circuiting, so as not to leak
which one matched). While you rotate, pass [new, old]:
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 });
// Also: WebhookSecrets.Of(secretoNuevo, secretoViejo)ev, err := webhook.Verify(crudo, r.Header, []string{secretoNuevo, secretoViejo})# Without an SDK: compute the HMAC with each secret and accept if any matches.
for S in "$SECRETO_NUEVO" "$SECRETO_VIEJO"; do
[ "sha256=$(openssl dgst -sha256 -hmac "$S" < cuerpo.json | sed 's/^.* //')" = "$RECIBIDA" ] && echo "valid"
doneEmpty secrets in the list are ignored, so you can read the previous one from a variable that is almost
always empty (COMPENSE_WEBHOOK_SECRET_OLD) without touching the code. If the list ends up with no
secrets at all, the SDKs treat it as a configuration error (not as an invalid signature): see the
errors table.
Step by step #
sequenceDiagram
autonumber
participant T as Your team
participant G as Compense Gateway
participant V as Secrets manager
participant R as Your receiver
T->>R: Deploy the receiver that accepts [current, previous]
T->>G: rotate-secret
G-->>T: data.secret = whsec_new (only once)
T->>V: current = new, previous = old
V-->>R: The receiver reloads the secrets
Note over G,R: Deliveries that failed in the meantime are retried automatically
T->>G: Check the history (deliveries status=failed)
T->>V: Once everything verifies with the new one, delete the previous one- Prepare the receiver. Deploy (before rotating) a receiver that reads two secrets, the current one and the previous one, from your secrets manager or from environment variables, and verifies against both. If it can reload them without restarting, even better.
- Rotate. Call
rotate_secret; the response carries the new secret indata.secret. - Store the new one immediately as current and move the existing one to “previous”. Do it in the same process that rotates, to shorten the window in which the receiver doesn’t know the new one.
- Have the receiver load it (hot reload or a deployment). During a rolling deployment, some instances will have the new list and others the old one: that’s why the list exists.
- Check the deliveries. Those that failed with
401in the meantime are retried automatically (the first retry goes out after 30 seconds); if any ended upfailedorabandoned, retry it manually. - Retire the previous one once the history shows successful deliveries with the new one. Leave the list with a single secret until the next rotation.
Rotate through the 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"] # only once: store it NOW as "current"
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); // only once: store it NOW as "current"
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']; // only once: store it NOW as "current"
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>(); // only once: store it NOW as "current"
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) // only once: store it NOW as "current"
_ = 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"Response:
{
"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"
}
}Repeating the rotation with the same Idempotency-Key doesn't return the secret
If the response got lost and you retry with the same Idempotency-Key, the gateway returns the
original response with "idempotent_replay": true but without secret (it doesn’t store it). In
that case the secret that was generated is unrecoverable: rotate again with a new key.
The compense CLI has no command for rotating: use the SDK or cURL.
Typical receiver configuration #
Store two entries in your secrets manager or in environment variables, and read both:
| Variable | Content | When it has a value |
|---|---|---|
COMPENSE_WEBHOOK_SECRET | Current secret (whsec_...) | Always |
COMPENSE_WEBHOOK_SECRET_OLD | Previous secret | Only during a rotation |
In ASP.NET Core, the convention in the SDK examples is a list in configuration
(Compense:WebhookSecrets:0 and Compense:WebhookSecrets:1, which as environment variables are
written Compense__WebhookSecrets__0 and Compense__WebhookSecrets__1); in Laravel, an array in
config/services.php ('webhook_secrets' => array_values(array_filter([...]))). The complete
receivers are in Verify the signature.
If you lost the secret #
The gateway doesn’t store secrets, so there’s no way to recover it: no read returns it, and neither does the idempotent replay of create or rotate. The only way out is to rotate it:
- Rotate the secret (above) and store the new one.
- Configure the receiver with only the new one (the lost one is of no use to you) and deploy.
- Deliveries that failed in the meantime are retried automatically; you can
bulk-reprocess the ones left
failedorabandoned.
If the secret leaked #
Rotate immediately, even if it costs you a few 401s while you deploy: a leaked secret lets
anyone send your receiver fake events with a valid signature. Then review in your system the events
accepted since the leak and cross-check them against the
delivery history: a genuine delivery always shows up
there with its event_id.
In the sandbox #
With a sandbox key, rotating returns another fixed secret (whsec_sbx_…), different from the one
at creation but always the same for that endpoint. It’s for practicing the flow; there are no real
deliveries to be rejected. See Test your webhooks.