Receive 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
| Header | Description |
|---|---|
webhook-id | Event ID. Matches id in the body and stays the same across retries and resends. |
webhook-timestamp | Unix timestamp, in seconds, when the attempt was signed. |
webhook-signature | One or more space-separated v1,<signature> values. There are two during a secret rotation overlap. |
X-Scribeless-Event | Event type. |
X-Scribeless-Delivery | Delivery ID. Use it with resend. |
X-Scribeless-Timestamp, X-Scribeless-Signature | Legacy 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.