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):
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
- Take the
webhook-idandwebhook-timestampheaders and the raw request body. - Refuse the request when
webhook-timestampis more than 5 minutes from your clock. - Build the signed content:
{webhook-id}.{webhook-timestamp}.{body}. - Take the key: the part of your secret after
whsec_, base64-decoded. - Compute the HMAC-SHA256 of the signed content with the key, and base64-encode it.
webhook-signatureholds one or more signatures separated by spaces, each writtenv1,{signature}. The request is genuine when one of them equals yours. Compare in constant time.
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.
| Input | Value |
|---|---|
| Secret | whsec_EMI9soWck0iMI4fwI5p0TKobgZnsWf4yt1sGDJwQ6Xg= |
webhook-id | 5f0c3a8e-6d2b-4c1e-9a7f-2b8d4e6f1a3c |
webhook-timestamp | 1793700942 |
webhook-signature | v1,Y1IJr6TXkvuq4gFQ5rT4p8uq4HQzrOaP//uCp0gionk= |
Body:
{"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.