Scribeless
Webhooks

Receive deliveries

Verify, acknowledge, and process Scribeless webhook deliveries.

Scribeless sends each event as a signed HTTPS POST request to your subscription URL. Signatures follow the Standard Webhooks specification.

Delivery request

Content-Type: application/json
User-Agent: Scribeless-Webhooks/1.0
webhook-id: EVENT_ID
webhook-timestamp: UNIX_TIMESTAMP
webhook-signature: v1,SIGNATURE
X-Scribeless-Event: recipient.shipped
X-Scribeless-Delivery: DELIVERY_ID
X-Scribeless-Timestamp: UNIX_TIMESTAMP
X-Scribeless-Signature: sha256=SIGNATURE
HeaderDescription
webhook-idEvent ID. Matches id in the body and stays the same across retries and resends.
webhook-timestampUnix timestamp, in seconds, when the attempt was signed.
webhook-signatureOne or more space-separated v1,<signature> values. There are two during a secret rotation overlap.
X-Scribeless-EventEvent type.
X-Scribeless-DeliveryDelivery ID. Use it with resend.
X-Scribeless-Timestamp, X-Scribeless-SignatureLegacy signature. See Legacy signature.

Verify signatures

Verify every request before trusting it. Any Standard Webhooks library works with the subscription secret as it is returned, including the whsec_ prefix.

import { Webhook } from 'standardwebhooks'

const webhook = new Webhook(process.env.SCRIBELESS_WEBHOOK_SECRET)

export async function handleScribelessWebhook(request) {
  const rawBody = await request.text()

  const event = webhook.verify(rawBody, {
    'webhook-id': request.headers.get('webhook-id'),
    'webhook-timestamp': request.headers.get('webhook-timestamp'),
    'webhook-signature': request.headers.get('webhook-signature'),
  })

  await queueForProcessing(event)
  return new Response(null, { status: 204 })
}

verify throws if the signature does not match or the timestamp is more than five minutes from the current time.

To verify without a library, compute an HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{rawBody}. The key is the base64-decoded part of the secret after whsec_. Compare the base64 digest with each v1, value in webhook-signature.

import crypto from 'node:crypto'

const TOLERANCE_SECONDS = 5 * 60

export function verifyScribelessWebhook({ rawBody, id, timestamp, signatureHeader, secret }) {
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
    return false
  }

  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
  const expected = crypto
    .createHmac('sha256', key)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest()

  return signatureHeader.split(' ').some((value) => {
    const [version, signature] = value.split(',')
    const received = Buffer.from(signature ?? '', 'base64')
    return version === 'v1'
      && received.length === expected.length
      && crypto.timingSafeEqual(received, expected)
  })
}

Use the raw body exactly as received. Do not stringify a parsed JSON object before verifying.

Respond

Respond with any 2xx status within 5 seconds. Any other status, a timeout, or a connection error counts as a failure and is retried. Acknowledge first and do slow work in the background.

Redirects are not followed, so a 3xx response is a failure.

Process idempotently

Delivery is at least once, so the same event can arrive more than once. Use the event id, also sent as webhook-id, as your idempotency key and ignore events you have already processed.

Events are not guaranteed to arrive in order. See Recipient events.

Retries

Failed deliveries are retried after about 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, and 10 hours: eight attempts over roughly 28 hours. Each retry is re-signed with a new timestamp.

If a subscription keeps failing for 5 days without a single successful delivery, Scribeless disables it. The subscription's status becomes inactive and disabled_reason explains why. Fix your endpoint, re-enable the subscription, and resend anything you missed.

Legacy signature

Integrations built before Standard Webhooks signing can keep verifying the X-Scribeless-Signature header. It is an HMAC-SHA256 of {X-Scribeless-Timestamp}.{rawBody}, keyed with the full secret string including whsec_, and sent as sha256=<hex digest>.

import crypto from 'node:crypto'

export function verifyLegacyScribelessWebhook({ rawBody, timestamp, signature, secret }) {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex')

  if (signature.length !== expected.length) {
    return false
  }

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected),
  )
}

The legacy signature only uses the current secret, so it does not support rotation overlaps. Use webhook-signature for new integrations.

Copyright © 2026