Skip to main content

Webhook Signing Secrets

Each endpoint you register — in the dashboard at /dashboard/developers/webhooks or with POST /v1/webhook_subscriptions (secret-key auth, REST API reference) — gets its own whsec_* signing secret. Every delivery to that endpoint is signed with it (x-vonpay-signature: t=<unix-seconds>,v1=<hex>, HMAC-SHA256 of ${t}.${rawBody} keyed by the raw secret string, prefix included); your merchant API key is never used. The verifier is on Webhook Signature Verification.

Lifecycle​

1. Create​

Dashboard — Add endpoint → URL + event types → Create. API — POST /v1/webhook_subscriptions:

{
"url": "https://your-app.example.com/webhooks/vonpay",
"enabledEvents": ["charge.succeeded", "charge.refunded", "payment_intent.succeeded"],
"description": "Production order-fulfillment hook"
}

Response (201):

{
"id": "b6f2c1a0-3e4d-4c8b-9a1f-2d5e7c9b0a13",
"object": "webhook_subscription",
"url": "https://your-app.example.com/webhooks/vonpay",
"enabledEvents": ["charge.succeeded", "charge.refunded", "payment_intent.succeeded"],
"status": "active",
"signingSecret": "whsec_NbR4mP9k2qL7vX1tWj3...",
"apiVersion": "2026-05-04",
"createdAt": "2026-05-04T18:30:00Z"
}

See the REST API reference for the full field list and the other endpoints.

2. View-once​

The full signingSecret appears in the create and rotate responses only. Store it server-side immediately: the dashboard shows only a truncated prefix afterwards, GET /v1/webhook_subscriptions/{id} never returns it, and there is no "show me the secret again" path. If you have lost it, rotate.

3. Rotate​

Rotating mints the new secret — you cannot load it into your handler beforehand — and within moments of the call every delivery is signed with only the new one, a single v1= entry. There is no dual-signature grace window on our side, and a delivery you answer with 400, 401 or 403 is not retried automatically: a handler still holding only the old secret rejects every event delivered between the rotation and its redeploy, and nothing alerts you; those deliveries can be resent from the dashboard afterwards, but only if you notice. So during your switch-over your handler must accept both secrets — call the verifier once per secret and accept if either passes — and drop the old one as soon as the new one verifies: past that point the only party who can still produce a signature it accepts is someone else holding that secret.

Dashboard — /dashboard/developers/webhooks → the endpoint's Rotate signing secret action. API:

POST /v1/webhook_subscriptions/b6f2c1a0-3e4d-4c8b-9a1f-2d5e7c9b0a13/rotate_signing_secret
Authorization: Bearer vp_sk_live_…

The response has the same shape as Create, with the new signingSecret. Have your deploy ready before you make the call.

Deploy sequence:

  1. First, ship a handler that reads a second secret from a separate variable and, when it is set, runs the verifier against each secret in turn. Nothing has changed yet — the second variable is empty.
  2. Rotate (dashboard or POST /rotate) and capture the new secret.
  3. Put the new secret in that second variable and redeploy.
  4. Confirm at least one inbound webhook verifies under the new secret.
  5. Remove the old secret and redeploy again.

Steps 1 → 2 are the window; keep it as short as your deploy takes. If the old secret leaked, do not accept it even briefly — use the compromise path, which closes the gap with no window at all.

4. Revoke​

Revoking disables the endpoint permanently: its record is retained for audit, its secret can never be reissued, and it cannot be re-activated — create a fresh endpoint to resume delivery. Use it when decommissioning a handler, consolidating endpoints, or retiring the original after a compromise response.

Dashboard — /dashboard/developers/webhooks → the endpoint's Delete endpoint action. API:

DELETE /v1/webhook_subscriptions/b6f2c1a0-3e4d-4c8b-9a1f-2d5e7c9b0a13
Authorization: Bearer vp_sk_live_…

Returns 204 No Content. Nothing new is enqueued for a deleted endpoint. To stop deliveries temporarily instead, PATCH the subscription to status: "paused" (active and paused are the only caller-settable statuses), or use the endpoint's Pause endpoint action in the dashboard.

Compromise path​

If a whsec_* is exposed (committed to a public repo, leaked in a log, captured in a bug report), stand up a fresh endpoint rather than rotating in place — it gives you a clean audit boundary between pre- and post-compromise events, which one URL cannot:

  1. Create a new endpoint at a new URL (a path suffix or a fresh subdomain). Every event at the new URL is post-compromise; every event at the old URL is untrusted.
  2. Point your configuration at the new endpoint.
  3. Audit events received at the old endpoint between the leak and the new endpoint going live: reconcile each against the API (GET /v1/sessions/{sessionId} for hosted sessions, GET /v1/payment_intents/{id} for payment intents) before acting on any business-state change.
  4. Revoke the old endpoint once the new path is healthy.
  5. Rotate any credential adjacent in the leak — API keys, database passwords, webhook URLs carrying endpoint ids.