SuperSig.ai sends an HTTPS POST to your endpoint when something happens in your organization. Every request is signed so you can prove it came from us, and failed deliveries are retried for about 39 hours.
Create an endpoint with POST /api/v2/webhooks and the events you want (leave events out to get all of them). The response contains a secret starting with whsec_. It is shown once: store it with your other secrets.
curl -X POST https://www.supersig.ai/api/v2/webhooks \
-H "Authorization: Bearer <YOUR_API_KEY>" \
-H "Content-Type: application/json" \
-d '{"url":"https://hooks.example.com/supersig","events":["campaign.created","click.tracked"]}'Endpoint URLs must use https and point to a public host. Private, loopback and link-local addresses are refused, and we never follow redirects: a 3xx response counts as a failed attempt.
To check your receiver, POST /api/v2/webhooks/test with { "url", "secret" } sends one signed test.ping event (not retried).
campaign.created | A banner campaign was created in the app or through the API. |
campaign.updated | A campaign was edited, activated or paused. |
signature.deployed | A signature was pushed to a Gmail or Outlook mailbox. |
click.tracked | A recipient clicked a banner. |
user.created | A user was added to the organization by SCIM, Microsoft 365 sync or provisioning. |
test.completed | An A/B test ended, either automatically on a winner or by an admin. |
POST /your/endpoint HTTP/1.1 Content-Type: application/json User-Agent: SuperSig-Webhooks/2.0 X-SuperSig-Event: campaign.created X-SuperSig-Delivery: 6f1c2a9e-4b7d-4e0a-9d51-0c3f8f2b7a10 X-SuperSig-Timestamp: 1790704800 X-SuperSig-Signature: v1=<64 hex characters>
{
"id": "6f1c2a9e-4b7d-4e0a-9d51-0c3f8f2b7a10",
"event": "campaign.created",
"timestamp": "2026-09-29T18:00:00.000Z",
"data": {
"campaign": {
"id": "c2d3e4f5-0000-4000-8000-000000000001",
"name": "Copilot Launch",
"isActive": true,
"priority": 1,
"ctaUrl": "https://example.com/copilot"
}
}
}id in the body equals the X-SuperSig-Delivery header and stays the same across retries. Use it to ignore duplicates.timestamp in the body is when the event happened. X-SuperSig-Timestamp is when this attempt was sent (Unix seconds), and is the one that is signed.X-SuperSig-Timestamp is more than 5 minutes from your clock. This stops replays.<timestamp>.<raw body>, hex encoded.v1= value in X-SuperSig-Signature. Any match passes; there may be more than one during a secret rotation.import crypto from 'node:crypto'
import express from 'express'
const app = express()
const SECRET = process.env.SUPERSIG_WEBHOOK_SECRET // whsec_...
// Keep the raw body: the signature covers the exact bytes we sent.
app.post('/supersig', express.raw({ type: 'application/json' }), (req, res) => {
const body = req.body.toString('utf8')
const timestamp = req.get('X-SuperSig-Timestamp') ?? ''
const header = req.get('X-SuperSig-Signature') ?? ''
// 1. Reject old or future timestamps (replay protection).
if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.status(400).send('stale timestamp')
}
// 2. Recompute HMAC-SHA256 over "<timestamp>.<body>" and compare in constant time.
const expected = crypto.createHmac('sha256', SECRET).update(`${timestamp}.${body}`).digest('hex')
const ok = header.split(',').some((part) => {
const given = Buffer.from(part.trim().replace(/^v1=/, ''))
return part.trim().startsWith('v1=') &&
given.length === expected.length &&
crypto.timingSafeEqual(given, Buffer.from(expected))
})
if (!ok) return res.status(400).send('bad signature')
// 3. Drop duplicates: the same delivery id can arrive more than once.
const event = JSON.parse(body)
// if (alreadyProcessed(event.id)) return res.sendStatus(200)
res.sendStatus(200) // respond fast; do slow work after
})import hashlib, hmac, time
def verify(body: bytes, timestamp: str, signature_header: str, secret: str) -> bool:
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
return False
signed = timestamp.encode() + b"." + body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return any(
part.strip().startswith("v1=") and hmac.compare_digest(part.strip()[3:], expected)
for part in signature_header.split(",")
)Reply with any 2xx status within 10 seconds. Anything else (another status, a timeout, a connection error) is a failed attempt, and we try again after:
Each delay is measured from the previous attempt and is a minimum; retries go out on the next scheduler run after it. After 7 failed attempts the delivery stops and is kept as failed. Deliveries to a disabled endpoint stop right away. Deliveries can arrive out of order.