curl --request POST \
--url https://app.vumasign.com/api/v1/webhooks \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"url": "https://api.example.test/hooks/vumasign",
"events": [
"envelope.completed",
"envelope.declined"
]
}
'import requests
url = "https://app.vumasign.com/api/v1/webhooks"
payload = {
"url": "https://api.example.test/hooks/vumasign",
"events": ["envelope.completed", "envelope.declined"]
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({
url: 'https://api.example.test/hooks/vumasign',
events: ['envelope.completed', 'envelope.declined']
})
};
fetch('https://app.vumasign.com/api/v1/webhooks', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://app.vumasign.com/api/v1/webhooks"
payload := strings.NewReader("{\n \"url\": \"https://api.example.test/hooks/vumasign\",\n \"events\": [\n \"envelope.completed\",\n \"envelope.declined\"\n ]\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer <token>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}{
"id": "01960000-0000-4000-8000-000000000e5d",
"url": "https://api.example.test/hooks/vumasign",
"subscribed_events": [
"envelope.completed",
"envelope.declined"
],
"brand_id": null,
"active": true,
"consecutive_failures": 0,
"last_success_at": null,
"last_failure_at": null,
"disabled_at": null,
"disabled_reason": null,
"created_at": "2026-09-01T08:00:00.000Z",
"secret": "whsec_specimen_secret_never_use_this_value_000000"
}{
"error": {
"code": "unauthenticated",
"message": "<string>"
}
}{
"error": {
"code": "unauthenticated",
"message": "<string>"
}
}{
"error": {
"code": "unauthenticated",
"message": "<string>"
}
}{
"error": {
"code": "unauthenticated",
"message": "<string>"
}
}{
"error": {
"code": "unauthenticated",
"message": "<string>"
}
}{
"error": {
"code": "unauthenticated",
"message": "<string>"
}
}Register an endpoint and mint its signing secret.
THE ENDPOINT THAT MAKES MULTI-TENANT ONBOARDING PROGRAMMATIC. BoldSign has no webhook management API at all — theirs is configured in a dashboard, by a human — which means a customer who resells to their own tenants cannot onboard one without somebody clicking. This is the same resource with the same six methods every other noun gets.
⚠️ THE RESPONSE CARRIES secret AND NOTHING ELSE EVER WILL. Store it now. There is no “show me again”: if you lose it, rotate.
⚠️ NO Idempotency-Key, UNLIKE POST /api/v1/envelopes, and the difference is what a retry costs. A duplicated send emails a stranger twice and bills twice, and neither can be taken back; a duplicated registration emails nobody, bills nothing, is visible in a list of at most 10 and is one DELETE away.
See x-webhooks at the root of this document for the body we will POST, the signature, and the retry ladder.
curl --request POST \
--url https://app.vumasign.com/api/v1/webhooks \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"url": "https://api.example.test/hooks/vumasign",
"events": [
"envelope.completed",
"envelope.declined"
]
}
'import requests
url = "https://app.vumasign.com/api/v1/webhooks"
payload = {
"url": "https://api.example.test/hooks/vumasign",
"events": ["envelope.completed", "envelope.declined"]
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({
url: 'https://api.example.test/hooks/vumasign',
events: ['envelope.completed', 'envelope.declined']
})
};
fetch('https://app.vumasign.com/api/v1/webhooks', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://app.vumasign.com/api/v1/webhooks"
payload := strings.NewReader("{\n \"url\": \"https://api.example.test/hooks/vumasign\",\n \"events\": [\n \"envelope.completed\",\n \"envelope.declined\"\n ]\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer <token>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}{
"id": "01960000-0000-4000-8000-000000000e5d",
"url": "https://api.example.test/hooks/vumasign",
"subscribed_events": [
"envelope.completed",
"envelope.declined"
],
"brand_id": null,
"active": true,
"consecutive_failures": 0,
"last_success_at": null,
"last_failure_at": null,
"disabled_at": null,
"disabled_reason": null,
"created_at": "2026-09-01T08:00:00.000Z",
"secret": "whsec_specimen_secret_never_use_this_value_000000"
}{
"error": {
"code": "unauthenticated",
"message": "<string>"
}
}{
"error": {
"code": "unauthenticated",
"message": "<string>"
}
}{
"error": {
"code": "unauthenticated",
"message": "<string>"
}
}{
"error": {
"code": "unauthenticated",
"message": "<string>"
}
}{
"error": {
"code": "unauthenticated",
"message": "<string>"
}
}{
"error": {
"code": "unauthenticated",
"message": "<string>"
}
}Authorizations
Authorization: Bearer vsk_live_…. Chosen over a bespoke X-API-KEY header because every client, proxy and log-redaction rule already knows this one. What a key may DO is its scopes — see x-scopes at the root of this document and x-required-scope on each operation. The scope list is not written here because OpenAPI reserves a requirement’s scope array for oauth2 and openIdConnect and requires it to be empty for an http scheme.
Body
Where to deliver, what to deliver, and optionally which brand only.
The registration.
An https:// URL we will POST to. It must be reachable from the public internet and must not carry credentials in the URL — the signature is how a delivery proves it came from us.
"https://api.example.test/hooks/vumasign"
At least one event type. ⚠️ An empty array is refused rather than read as "everything": an endpoint subscribed to nothing is a support ticket, and this API already has one place where an empty list means "all" (an API key’s scopes) — two opposite readings of an empty array is how somebody eventually gets one of them wrong. Duplicates are collapsed.
One event type.
envelope.sent, envelope.completed, envelope.declined, envelope.voided, envelope.expired, envelope.sealed, envelope.sealing_failed, recipient.completed Optional, default null. Filter deliveries to one brand. ⚠️ ROUTING, NOT ISOLATION — see the property of the same name on the endpoint.
null
Response
The endpoint, and the signing secret — for the only time.
The endpoint and its secret.
The endpoint’s id. A bare uuid.
Where deliveries are POSTed. https:// only, no credentials, and not a private or loopback address — deliveries are made from inside our network.
What this endpoint hears about. Never empty. ⚠️ An unknown name is refused at registration rather than accepted and never delivered.
One event type.
envelope.sent, envelope.completed, envelope.declined, envelope.voided, envelope.expired, envelope.sealed, envelope.sealing_failed, recipient.completed Deliver only envelopes carrying this brand; null for every envelope in the organisation.
⚠️ THIS IS ROUTING, NOT ISOLATION. Any key on this organisation can read every envelope in it whatever brand it carries — there is no brand predicate in any access rule anywhere. Filtering decides which events are POSTed to which URL; it does not, and cannot, stop a consumer learning about another brand by asking. Do not build a permission boundary out of it.
Whether we are delivering. False either because you deactivated it or because it was auto-disabled after failing for the whole health window — disabled_reason says which, in prose.
⚠️ DELIVERIES THAT EXHAUSTED THE WHOLE LADDER SINCE THE LAST SUCCESS, not individual attempts. One unreachable host over one event counts once here, not seven times.
The last 2xx we received. Null if there has never been one.
The last delivery that used up its ladder.
When delivery stopped. Null while active.
Why, in prose for a person. Null while active. ⚠️ This is the field that answers "why did my endpoint stop receiving events" without a support ticket.
ISO 8601, UTC.
⚠️ SHOWN ONCE, HERE. This is the key the Vumasign-Signature header is computed under. Treat it as a credential: it is not in any log of ours and it must not be in one of yours.