Verifying Signatures

Anyone who knows your URL can send requests to it. Every webhook event is signed with your webhook's secret, so you can check that it comes from Loby, that nobody changed it, and that it isn't an old request sent again.

Loby signs as the open Standard Webhooks specification describes, so you can use one of its libraries.

With a library

Standard Webhooks has libraries for JavaScript, Python, PHP, Go, Java, Kotlin, Ruby, Rust, C# and Elixir: see standard-webhooks on GitHub.

In Node.js with Express (npm install standardwebhooks):

Copy
import express from 'express' import { Webhook } from 'standardwebhooks' const webhook = new Webhook(process.env.LOBY_WEBHOOK_SECRET) const app = express() // The signature covers the raw body, so read it before any JSON parsing. app.post('/loby/webhooks', express.raw({ type: 'application/json' }), (req, res) => { let event try { event = webhook.verify(req.body, req.headers) } catch (error) { return res.status(400).end() } // Queue the event, answer, and do the work afterwards. res.status(204).end() })

verify checks the signature and the timestamp, and returns the parsed webhook event.

By hand

  1. Take the webhook-id and webhook-timestamp headers and the raw request body.
  2. Refuse the request when webhook-timestamp is more than 5 minutes from your clock.
  3. Build the signed content: {webhook-id}.{webhook-timestamp}.{body}.
  4. Take the key: the part of your secret after whsec_, base64-decoded.
  5. Compute the HMAC-SHA256 of the signed content with the key, and base64-encode it.
  6. webhook-signature holds one or more signatures separated by spaces, each written v1,{signature}. The request is genuine when one of them equals yours. Compare in constant time.
Copy
import crypto from 'crypto' function isFromLoby({ secret, headers, rawBody }) { const id = headers['webhook-id'] const timestamp = headers['webhook-timestamp'] const signatures = headers['webhook-signature'] if (!id || !timestamp || !signatures) return false if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 5 * 60) return false const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64') const expected = crypto.createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest() return signatures.split(' ').some(versioned => { const [version, signature] = versioned.split(',') if (version !== 'v1' || !signature) return false const given = Buffer.from(signature, 'base64') return given.length === expected.length && crypto.timingSafeEqual(given, expected) }) }

Test your code

Your verification should accept these values. Leave out the 5-minute check for this test: the timestamp is in the past.

InputValue
Secretwhsec_EMI9soWck0iMI4fwI5p0TKobgZnsWf4yt1sGDJwQ6Xg=
webhook-id5f0c3a8e-6d2b-4c1e-9a7f-2b8d4e6f1a3c
webhook-timestamp1793700942
webhook-signaturev1,Y1IJr6TXkvuq4gFQ5rT4p8uq4HQzrOaP//uCp0gionk=

Body:

Copy
{"id":"5f0c3a8e-6d2b-4c1e-9a7f-2b8d4e6f1a3c","type":"membership.deleted","createdAt":"2026-11-03T10:15:42.123Z","testMode":true,"data":{"object":{"id":"bd4a0997-39db-41d9-883a-cdfa83e2101f"}}}

It should refuse the same request when you change one character of the body or of webhook-id.

Pitfalls

  • Verify the raw bytes of the body. Parsing the JSON and writing it out again changes it and breaks the signature.
  • Header names are case-insensitive. Some frameworks give them to you in lowercase, others as sent.
  • Keep the secret on your server, as you keep your API key.
  • The 5-minute check needs your server's clock to be right.
‹ Previous
Overview
Next ›
Event Types