Verify deliveries to your app

Check the signature on every delivery with the destination’s signing secret, so your app acts only on webhooks we delivered.

Anyone who knows your destination’s address can send it a request. Every delivery is signed with the destination’s signing secret, so your app can check that it came from us, unchanged, and recently, and refuse everything else.

Deliveries follow the Standard Webhooks way of signing, so any library for it can check them.

Get the signing secret

Each destination has its own signing secret, which starts with whsec_:

curl "$HOOKS_URL/destinations/$DESTINATION_ID/secret" \
  -H "Authorization: Bearer $HOOKS_API_KEY"
{
  "data": { "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw" },
  "meta": { "requestId": "01m3c081dbf3j55x7hsa7wc1yj" }
}

Keep it in your app’s secret settings, such as an environment variable. In the console, open the destination and show its Signing secret.

The signing headers

HeaderHolds
webhook-idThe event’s ID. The same on every attempt, and when a delivery is sent again
webhook-timestampWhen this attempt was sent, in seconds since 1 January 1970
webhook-signaturev1, and the signature, in base64

The signature is an HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{body}, with the key being the part of the secret after whsec_, decoded from base64. The body is the body exactly as it was sent, byte for byte.

Check a delivery

  1. Read the raw body

    Read the body as bytes or text exactly as it arrived, before any parsing. A body that was parsed and turned back into text does not match.

  2. Check the time

    Refuse a delivery whose webhook-timestamp is more than 5 minutes from now, so an old delivery cannot be sent to you again.

  3. Check the signature

    Work out the signature, and compare it with each one in webhook-signature in constant time. Refuse the delivery when none matches.

  4. Skip what you have handled

    Record each webhook-id you handle, and answer 200 without doing anything when one comes again.

space.hooks.verify() does every check for you:

payments-webhook.ts
import { SpaceError, space } from "digitospace"

export async function POST(request: Request): Promise<Response> {
  try {
    const delivery = await space.hooks.verify(request, {
      secret: process.env.WEBHOOK_SECRET ?? "",
    })
    const payment = delivery.json<{ reference: string; amount: number }>()
    console.log(`Order ${payment.reference} paid TZS ${payment.amount}`)
    return new Response(null, { status: 200 })
  } catch (error) {
    if (error instanceof SpaceError && error.code === "invalid_signature") {
      return new Response(null, { status: 401 })
    }
    throw error
  }
}

Reset the signing secret

If the secret leaks, reset it. Every attempt from then on is signed with the new secret, so update your app straight away:

curl -X POST "$HOOKS_URL/destinations/$DESTINATION_ID/secret" \
  -H "Authorization: Bearer $HOOKS_API_KEY"

The answer holds the new secret.

When a real delivery fails the check

Look for a secret from another destination, a secret that was reset, a body changed before the check, or a server clock that is more than 5 minutes out. Answer a delivery that fails the check with 401, and it is tried again like any other failed attempt.

Last updated