FansquareAPI reference
Open the app

Receiving webhooks

Verify signatures

Check that each delivery comes from Fansquare and was not replayed, with the signing secret of your endpoint.

Every delivery is signed with the secret of the endpoint. Verify the signature before trusting the body: anyone can send a request to your URL, only Fansquare knows the secret.

How the signature is built

  1. Fansquare takes the Unix timestamp of the attempt, in seconds, and sends it in Fansquare-Signature-Timestamp.

  2. It joins the timestamp, a dot and the raw body of the request: {timestamp}.{body}.

  3. It computes the HMAC-SHA256 of that string, keyed with the endpoint secret, in hexadecimal.

  4. It sends v1= followed by that digest in Fansquare-Signature.

The timestamp is part of what is signed: an intercepted delivery cannot be replayed later with a new timestamp.

Verify a delivery

  1. 1

    Read the raw body

    Compute the signature over the exact bytes you received, before any JSON parsing. A parsed then re-serialized body differs (spaces, key order) and never matches.

  2. 2

    Check the timestamp

    Reject the delivery if Fansquare-Signature-Timestamp is more than 5 minutes away from your clock.

  3. 3

    Compare the signatures

    Compute v1= + HMAC-SHA256(secret, {timestamp}.{body}) and compare it with Fansquare-Signature in constant time. Reject the delivery if they differ.

import { createHmac, timingSafeEqual } from 'node:crypto'
import express from 'express'

const app = express()
const SECRET = process.env.WEBHOOK_SECRET // fsq_whsec_…
const TOLERANCE_SECONDS = 5 * 60

// The raw body, not the parsed JSON: the signature covers the exact bytes.
app.post('/webhooks/fansquare', express.raw({ type: 'application/json' }), (req, res) => {
  const timestamp = Number(req.get('Fansquare-Signature-Timestamp'))
  const signature = req.get('Fansquare-Signature') ?? ''
  if (!Number.isFinite(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) {
    return res.sendStatus(400)
  }
  const expected = Buffer.from('v1=' + createHmac('sha256', SECRET).update(`${timestamp}.${req.body}`).digest('hex'))
  const received = Buffer.from(signature)
  const valid = expected.length === received.length && timingSafeEqual(expected, received)
  if (!valid) return res.sendStatus(400)

  const event = JSON.parse(req.body.toString('utf8'))
  // Store the event (deduplicate on event.id), answer right away, process later.
  res.sendStatus(200)
})

Send yourself a test delivery to check your code before going live. A ping is signed exactly like a real event.

Rotate the secret

POST /v1/webhooks/{endpoint_id}/secret issues a new secret and returns it once. The previous secret stops signing immediately: put the new one on your server right away. Deliveries rejected in between are retried, and can be replayed from the failures list.