Webhook Signing Secrets
Webhook endpoints and their whsec_* signing secrets are created, rotated, and revoked either in the developer dashboard → Webhooks 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_* secret — not 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/webhooks → Add endpoint → enter your URL + select event types → Create. The full secret is shown once on the success page — copy it immediately.
API path — POST /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:
- Rotate (dashboard or
POST /rotate) and capture the new secret. - Push the new secret to your handler env (Vercel env-var set, Railway variable, AWS Secrets Manager rotate, etc.).
- 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. - Confirm at least one inbound webhook verifies cleanly under the new secret.
- 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:
- 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.
- Update the merchant's UI (or platform-integrator config) to point at the new endpoint.
- 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. - Revoke the old endpoint (step 4 above) once you're satisfied the new path is healthy.
- 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.