Skip to main content

Webhook Signing Secrets

Manage endpoints via the dashboard or the public API

Webhook endpoints and their whsec_* signing secrets are created, rotated, and revoked either in the developer dashboardWebhooks or programmatically via the public Webhook Subscriptions API (POST /v1/webhook_subscriptions, secret-key auth) — see the REST API reference. Every webhook Von Payments delivers is signed with the per-endpoint whsec_* secretnot your merchant API key (vp_sk_test_* / vp_sk_live_*). See Webhook Signature Verification for the verifier.

Each webhook endpoint you register in the developer dashboard → Webhooks gets its own whsec_* signing secret. This page covers the lifecycle: create, view-once, rotate, revoke.

Registering a webhook endpoint generates a per-endpoint signing secret. You see it once at creation time — store it immediately; you cannot retrieve it again.

That secret is what signs every delivery to the endpoint: the x-vonpay-signature header is t=<unix-seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of ${t}.${rawBody} keyed by the whsec_* secret (the raw UTF-8 string, prefix included). The merchant API key is not used for webhook signing. The verifier algorithm and reference implementations are on the Webhook Signature Verification page.

Lifecycle

1. Create

Creating a webhook endpoint mints a fresh whsec_* signing secret bound to that endpoint, via the dashboard or the public API.

Dashboard path/dashboard/developers/webhooksAdd endpoint → enter your URL + select event types → Create. The full secret is shown once on the success page — copy it immediately.

API pathPOST /v1/webhook_subscriptions (server-to-server, secret-key auth) with body:

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

Response (201):

{
"id": "wsub_kJ8mP3...",
"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-04-14",
"createdAt": "2026-05-04T18:30:00Z"
}

The full signingSecret appears in the create (and rotate) response only. Store it server-side immediately — it is not retrievable later. See the REST API reference for the full field list + the other endpoints.

2. View-once

After creation, the dashboard shows only a truncated prefix (the first ~16 characters) of the secret. The remaining bytes are hashed at rest and cannot be retrieved — reads of the subscription (GET /v1/webhook_subscriptions/{id}) never return the secret at all. Treat the create-time copy as authoritative.

If you've lost the secret, rotate (Section 3) — there is no "show me the secret again" path by design.

3. Rotate

Rotation is zero-downtime: during the rotation grace window both the new and the previous secret can verify, so events in flight never fail signature checks. The x-vonpay-signature header carries a second v1= entry signed with the previous secret while it remains in its grace window — your verifier accepts on any v1= match (the SDK's constructEvent does this for you). See Webhook Signature Verification — Header format for the multi-v1= rules.

This bounds the blast radius of a rotation:

  • An attacker can't withdraw funds with a webhook secret; the worst case is forging events to your handler, and only if they also control your inbound endpoint.
  • Endpoint secrets are scoped to one endpoint, not your whole API surface.
  • The dual-signature grace window means you can roll the new secret to your handler env without a verification-failure gap.

Rotate via either path:

Dashboard path/dashboard/developers/webhooks/{id}Rotate signing secret → new secret shown once → update your handler env → confirm an inbound webhook verifies cleanly under the new secret.

API path:

POST /v1/webhook_subscriptions/wsub_kJ8mP3.../rotate_signing_secret
Authorization: Bearer vp_sk_live_…

Response is the same shape as Create, with a new signingSecret. The previous secret stays valid for the duration of the rotation grace window, so events signed just before the cutover still verify.

Recommended deploy sequence:

  1. Rotate (dashboard or POST /rotate) and capture the new secret.
  2. Push the new secret to your handler env (Vercel env-var set, Railway variable, AWS Secrets Manager rotate, etc.).
  3. Redeploy your handler. Your handler now verifies with the new secret — and, because the header carries both v1= entries during the grace window, in-flight events signed with the old secret also still verify.
  4. Confirm at least one inbound webhook verifies cleanly under the new secret.
  5. Done. The old secret falls out of use once the grace window closes.

If you need to fully break trust with the old secret immediately (a leak, not a routine rotation), see Compromise path below — that flow stands up a parallel endpoint and gives you a clean audit boundary before retiring the original.

4. Revoke

Revoking an endpoint permanently disables it — under the hood this is the DELETE (soft-delete) shown below: the server flips the subscription's status to disabled and stamps deleted_at. Different from rotate: rotate keeps the endpoint alive with a new secret; revoke deactivates the endpoint entirely. (The only caller-settable statuses via PATCH are active and paused; disabled is set server-side by the delete.) Use revoke when:

  • You're decommissioning the handler endpoint
  • You're consolidating multiple endpoints into one
  • You've created a parallel endpoint as part of a compromise response (below)

Revoked endpoints cannot be re-activated. Their record is retained for audit; their signing secret can never be reissued. To resume webhook delivery, create a fresh endpoint.

Revoke an endpoint from the dashboard at /dashboard/developers/webhooks/{id}Revoke endpoint, or programmatically:

DELETE /v1/webhook_subscriptions/wsub_kJ8mP3...
Authorization: Bearer vp_sk_live_…

Returns 204 No Content. (To stop deliveries temporarily without deleting, PATCH the subscription to status: "paused" instead — see the REST API reference.) Pending events queued for the now-deleted endpoint move to the dead-letter queue and are NOT retried — your handler will not see them.

Compromise path

If a whsec_* is exposed (committed to a public repo, leaked in a log, captured in a bug report), do not simply rotate. A routine rotation keeps the leaked secret valid for the duration of the grace window — long enough for an attacker to forge events your handler will accept — and more importantly, you can't safely audit which events arrived during the compromise window without a clean break.

Recommended runbook:

  1. Stand up a fresh endpoint at a NEW URL (e.g., add a path suffix or use a fresh subdomain). This gives you a clean audit boundary — every event arriving at the new URL is post-compromise; every event at the old URL is pre/during-compromise and untrusted.
  2. Update the merchant's UI (or platform-integrator config) to point at the new endpoint.
  3. Audit events received at the OLD endpoint during the window between leak time and the new endpoint going live. Treat them as untrusted; reconcile against the source-of-truth API (/v1/sessions/:id, /v1/payment_intents/:id) before acting on any business-state changes.
  4. Revoke the old endpoint (step 4 above) once you're satisfied the new path is healthy.
  5. Rotate any secondary credentials that may have been adjacent in the leak (API keys, DB passwords, webhook URLs that contained endpoint IDs).

This costs one extra deploy and a brief period of running two endpoints in parallel — that's the price of a clean compromise response.