Webhook signature specification
Status: NORMATIVE. This document is the single source of truth for the bb-sign webhook signature scheme. The dispatcher, the Java SDK and the JavaScript SDK implement it and refer to this page. Part 1 defines how a delivery is signed; Part 2 defines its contents.
1. Headers
Section titled “1. Headers”| Header | Value | Notes |
|---|---|---|
X-BBSign-Signature |
t=<unix-seconds>,v1=<hex> |
See section 2 for grammar |
X-BBSign-Event-Id |
UUID v4 | Stable across every redelivery of the same event. The receiver’s dedup key |
X-BBSign-Event-Type |
e.g. envelope.completed |
Informational; the payload takes precedence over this header |
2. Signature header grammar
Section titled “2. Signature header grammar”signature-header = element *( "," element )element = scheme "=" valuescheme = "t" / "v1" / <future scheme>- Elements are comma-separated with no whitespace. Receivers must tolerate whitespace regardless.
tappears exactly once: Unix seconds, base-10, no fractional part.
v1may appear more than once during secret rotation. A receiver must accept the payload if anyv1value matches. Verifiers that read only the firstv1prevent rotation.- Unknown schemes must be ignored, not rejected. This allows a future
v2to be introduced without affecting existing receivers.
3. What is signed
Section titled “3. What is signed”signed_string = <t> + "." + <raw_request_body>signature = HMAC-SHA256(key = subscription_secret, message = signed_string)v1 = lowercase hex encoding of signature // 64 chars<t>is the same value as in the header. Signing it is what prevents replay; signing the body alone is not enough.raw_request_bodyis the exact bytes received, before any parsing, re-serialization or charset conversion. Not a re-encoded object, and not a reformatted version.- Encoding is lowercase hex, not base64. Receivers should compare case-insensitively.
- The body is UTF-8 JSON, but verification is defined over bytes, not text.
4. Tolerance window
Section titled “4. Tolerance window”300 seconds. Normative, not suggested.
A receiver rejects the delivery when abs(now - t) > 300. The window is symmetric, to tolerate
clock skew in both directions.
5. Verification algorithm
Section titled “5. Verification algorithm”Receivers perform, in order:
- Parse the header. Malformed: return false (section 6).
- Extract
t; non-integer: return false. - Check
abs(now - t) <= 300; else return false. - Compute the expected signature over
<t> + "." + raw_body. - Compare against each
v1using a constant-time comparison. Any match: true.
Constant-time comparison is mandatory. A byte-wise early-exit compare reveals the correct signature prefix over repeated attempts.
- Java:
MessageDigest.isEqual - Node:
crypto.timingSafeEqual
5b. Public helper signatures (normative)
Section titled “5b. Public helper signatures (normative)”Both SDKs expose the same shape, so anyone familiar with one can read the other:
| Java | JavaScript | |
|---|---|---|
| Simple | Webhooks.verify(String signature, byte[] body, String secret) |
verifyWebhookSignature(signature, body, secret); the options bag must default (opts = {}), or the three-arg form throws |
| With clock | Webhooks.verify(sig, body, secret, Duration tolerance, Instant now) |
verifyWebhookSignature(sig, body, secret, { toleranceSeconds, nowSeconds }) |
| Returns | boolean |
boolean |
| Throws | never (section 6) | never (section 6) |
| Constant-time primitive | MessageDigest.isEqual |
crypto.timingSafeEqual |
body is bytes, not a parsed object; see section 3.
6. Failure contract: verifiers never throw
Section titled “6. Failure contract: verifiers never throw”verify(...) returns a boolean and does not throw on any input. Malformed headers are under a
potential attacker’s control, and a thrown exception is both a 500 in the customer’s route and a
side channel.
Specifically:
| Input | Required behaviour |
|---|---|
| Missing or empty header | false |
Header with no t or no v1 |
false |
t not an integer |
false |
t textually an integer but out of range (t=99999999999999999999) |
false. Java’s Long.parseLong throws where JS Number() silently returns a float; this lands squarely inside the never-throws contract and the languages diverge by default. Parse defensively and bound t to a sane epoch range |
v1 not 64 hex chars |
false; validate shape before decoding |
Odd-length or non-hex v1 |
false. Note Buffer.from(x,'hex') silently truncates invalid input, and timingSafeEqual throws on length mismatch. Both must be handled before the compare |
| Correct shape, wrong value | false |
| Expired timestamp | false |
7. Test vectors
Section titled “7. Test vectors”Location: webhook-signature-vectors.json, the file both SDKs and the server are tested against, unchanged. Neither SDK defines its own vectors: with different vectors, two SDKs could disagree with each other and still each pass their own tests.
Format:
{ "version": 1, "cases": [ { "name": "valid-signature", "secret": "<base64>", "bodyUtf8": "{\"eventId\":\"...\",\"type\":\"envelope.completed\"}", "header": "t=1754400000,v1=<hex>", "now": 1754400060, "expected": true } ]}Field contract: all five are required, and there is no sixth.
| Field | Meaning |
|---|---|
name |
case identifier |
secret |
base64; decode to bytes before use as the HMAC key |
bodyUtf8 |
the request body as a UTF-8 string; implementations encode it to bytes. Named for the encoding because section 3 defines signing over bytes and “body” alone left it ambiguous |
header |
the literal X-BBSign-Signature value, not structured fields. This is what makes rotation and whitespace cases expressible |
now |
the clock value passed to the verifier (section 4) |
expected |
boolean |
There is deliberately no separate timestamp field. An earlier draft included one alongside
header, and an implementation that read timestamp instead of parsing header would have passed
every vector and still failed on real deliveries. The only source of t is the header.
Required cases, 16 in all; every implementation runs all of them:
| Case | expected |
|---|---|
| valid signature, fresh timestamp | true |
valid, at exactly +300s |
true |
| valid, at +301s | false |
| valid, at -301s | false |
| tampered body, one byte changed | false |
| wrong secret | false |
two v1 values, second one correct (rotation) |
true |
unknown scheme v2= present alongside valid v1 |
true |
| header with whitespace after commas | true |
| missing header | false |
missing t |
false |
v1 not hex |
false |
v1 wrong length |
false |
| empty body | per vector |
| body with non-ASCII UTF-8 | true |
t out of numeric range |
false |
8. Comparison with Adobe Acrobat Sign
Section titled “8. Comparison with Adobe Acrobat Sign”Adobe verifies a webhook by requiring the endpoint to echo X-AdobeSign-ClientId at registration,
and does not sign payloads. bb-sign signs every delivery, so the receiver can verify its origin
and integrity even if the URL becomes known to third parties.
Migrating from Adobe Acrobat Sign describes this difference for teams that are migrating.
9. Changing this document
Section titled “9. Changing this document”The scheme is frozen. A change means a new scheme identifier (v2=) emitted alongside v1 during
a deprecation window, never a redefinition of v1. This is possible because receivers ignore
unknown schemes (section 2).
Part 2. The event payload
Section titled “Part 2. The event payload”Status: NORMATIVE. Part 1 establishes that the payload takes precedence over the header; this part defines the payload. An explicitly defined payload gives customers a stable contract: they receive the information they need to act, and internal changes in bb-sign do not alter what reaches their endpoint.
P1. The outbound payload is a deliberate DTO, never the internal event
Section titled “P1. The outbound payload is a deliberate DTO, never the internal event”The dispatcher maps each internal event to a WebhookEvent DTO owned by the public surface. No
internal class reaches the wire. Adding a field to an internal event has no effect on
customers unless the DTO is also changed; that separation is the purpose of the design.
P2. Envelope
Section titled “P2. Envelope”Every delivery has the same outer shape, so a receiver can route it before parsing the details:
{ "id": "3f8c1c2e-7d0a-4f3b-9d2a-0a1b2c3d4e5f", "type": "envelope.completed", "apiVersion": "2026-08-09", "createdAt": "2026-08-09T14:22:31Z", "data": { }}| Field | Notes |
|---|---|
id |
Matches the X-BBSign-Event-Id header exactly. Stable across redeliveries; the dedup key |
type |
Public event name. No .v1 suffix; the internal event type is not this field |
apiVersion |
Dated contract stamp. Informational only in v1: a subscription has no version field, so a receiver cannot pin one and this cannot behave as a negotiated version. It tells a receiver which contract produced the payload |
createdAt |
When the event occurred. Distinct from the signature’s t, which is when this attempt was sent (section 2) |
data |
Per-type, below |
orgId is deliberately omitted: a subscription belongs to exactly one org, so including it
would give the receiver no new information and would expose an internal identifier in every
payload.
P3. Event types and their data
Section titled “P3. Event types and their data”Exactly six. Names are public and stable; they are not the internal routing keys.
type |
data |
|---|---|
envelope.sent |
envelope |
envelope.completed |
envelope |
envelope.cancelled |
envelope |
envelope.expired |
envelope |
signer.signed |
envelope + signer |
signer.viewed |
envelope + signer |
"data": { "envelope": { "id": "uuid", "title": "string", "status": "SENT|COMPLETED|CANCELLED|EXPIRED", "createdAt": "iso8601", "expiresAt": "iso8601|null" }, "signer": { "id": "uuid", "email": "string", "name": "string", "signOrder": 1, "status": "PENDING|SIGNED", "signedAt": "iso8601|null" }}This is enough to act on without an additional call, which is the purpose of a webhook.
Deliberately excluded: document content and download URLs (a presigned URL in a payload stays
usable after delivery), ipAddress and userAgent from the signing events (internal data), and
any field not listed above.
P3b. The body is materialised once, not per attempt
Section titled “P3b. The body is materialised once, not per attempt”If the body were rendered on every attempt, the same X-BBSign-Event-Id could carry different
bytes across retries: an envelope.sent retried after the envelope completed would report
status: COMPLETED, a state that did not exist when the event was emitted, and a receiver
deduplicating on event id would arbitrarily keep the first copy received.
The body is rendered at first dispatch and stored verbatim on the delivery row. Every retry resends
those exact bytes. Only the signature is recomputed per attempt (section 2: t is attempt time;
the body is event time). A redelivery is byte-identical, not merely same-event-id.
P3c. data is read from current state at render time
Section titled “P3c. data is read from current state at render time”The internal events carry only identifiers, so the dispatcher loads the envelope (and, for
signer.*, the signer) at render time. Because rendering reads current state, P3b pins it to first
dispatch.
P4. Additive-only
Section titled “P4. Additive-only”Receivers must ignore unknown fields. New fields and event types may appear without an
apiVersion change; a subscription receives only the types it selected. Removing a field or
changing its type breaks the contract and produces a new apiVersion.
P5. Where each part lives
Section titled “P5. Where each part lives”| Artefact | Where |
|---|---|
| This specification | This page, mirrored from the bb-sign repository |
WebhookEvent, WebhookEventData, WebhookEventEnvelope, WebhookEventSigner |
components/schemas of the OpenAPI contract, so both SDKs generate typed events; see the API reference |
| The test vectors | webhook-signature-vectors.json |
| The guide to the six types | Webhooks, citing this page, not restating it |