Skip to content
Developers

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.

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
signature-header = element *( "," element )
element = scheme "=" value
scheme = "t" / "v1" / <future scheme>
  • Elements are comma-separated with no whitespace. Receivers must tolerate whitespace regardless.
  • t appears exactly once: Unix seconds, base-10, no fractional part.
  • v1 may appear more than once during secret rotation. A receiver must accept the payload if any v1 value matches. Verifiers that read only the first v1 prevent rotation.
  • Unknown schemes must be ignored, not rejected. This allows a future v2 to be introduced without affecting existing receivers.
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_body is 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.

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.

Receivers perform, in order:

  1. Parse the header. Malformed: return false (section 6).
  2. Extract t; non-integer: return false.
  3. Check abs(now - t) <= 300; else return false.
  4. Compute the expected signature over <t> + "." + raw_body.
  5. Compare against each v1 using 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

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

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

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.

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).

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.

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.

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.

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.

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