Guides

Signed webhooks

HMAC-SHA256 signed submission with mandatory secrets and replay protection — Surface 1b.

For webhook providers that need to sign requests rather than carry a bearer token — the URL itself is effectively public, so every request proves its authenticity with an HMAC signature instead.

Endpoint

POST https://app.blackglassleads.com/api/v1/ingest/:slug

:slug is your form's slug, shown in Settings → Forms → your form → Delivery. That same Delivery tab is where you set (or rotate) the form's webhook secret and run the built-in signature debugger — paste a request you sent and it tells you exactly which byte range your signature was computed over vs. what arrived.

A webhook secret is required. A form with no secret configured rejects every submission — there's no "optional signature" mode, because an unsigned public URL has no way to authenticate the caller at all.

Headers

HeaderValue
X-BGL-TimestampUnix seconds. Must be within ±5 minutes of server time.
X-BGL-SignatureHMAC-SHA256(secret, "{timestamp}.{rawBody}"), lowercase hex. An optional sha256= prefix is accepted (GitHub-style).
X-BGL-Idempotency-KeyOptional — same 24-hour dedup semantics as server webhooks.

Two details that trip people up:

  • Sign the raw bytes, not a re-serialized copy. The signature covers the exact request body as sent. If your HTTP client re-encodes JSON before signing (different key order, different whitespace) the signature won't match what the server hashes, even though the "same" data was sent. Sign the literal string you're about to put on the wire.
  • The signing input is "{timestamp}.{rawBody}", not just the body. Binding the timestamp into the signature is what makes the replay window enforceable — a captured request can't be replayed against a different (later) timestamp, because that would need a new signature the attacker doesn't have.

Why the timestamp matters

X-BGL-Timestamp isn't just a freshness hint — it's part of what's signed. A request more than five minutes off server clock is rejected regardless of whether the signature is otherwise valid, so a captured request-plus-signature pair stops being replayable five minutes after it was issued. Keep your server's clock synced (NTP); skew is measured against server time, not your last deploy.

Sending a signed request

SECRET="your-form-webhook-secret"
TS=$(date +%s)
BODY='{"email":"sarah@acme.com","name":"Sarah Chen"}'
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2)

curl -X POST "https://app.blackglassleads.com/api/v1/ingest/your-form-slug" \
  -H "Content-Type: application/json" \
  -H "X-BGL-Timestamp: $TS" \
  -H "X-BGL-Signature: $SIG" \
  -d "$BODY"

Verifying a signature yourself

Useful when you're debugging a mismatch locally, or building something that needs to check a signature the same way we do — always with a constant-time comparison, never ===/== on the raw strings.

import { createHmac, timingSafeEqual } from 'node:crypto'

function isValidSignature(secret, timestamp, rawBody, signatureHeader) {
  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex')
  const received = signatureHeader.replace(/^sha256=/, '').toLowerCase()

  const a = Buffer.from(expected, 'hex')
  const b = Buffer.from(received, 'hex')
  return a.length === b.length && timingSafeEqual(a, b)
}

Idempotency & rate limits

Same as server webhooks: an optional X-BGL-Idempotency-Key header dedupes retries for 24 hours, and the workspace-level limits are 1,000 submissions/minute and 50,000/day, tracked in a separate bucket from Surface 1a so a burst on one surface doesn't trip the other. See Headers, errors & limits for the complete rejection-reason and status-code reference.