Anyone who finds your webhook URL can send it a request. Soosh signs every request with your endpoint’s secret, following Standard Webhooks, so your server can turn away anything Soosh didn’t send.
Before you start
Section titled “Before you start”- A webhook endpoint in Soosh, or one made with
POST /v1/webhooks - A server that can read the raw body of a request, before it parses the JSON
1. Get the signing secret
Section titled “1. Get the signing secret”Every endpoint has its own secret. It starts with whsec_.
- In the app: Soosh shows the secret once you create the webhook. To see it again, go to Settings > Webhooks (opens Soosh in a new tab), open the webhook and click Show secret. You need a role that can edit webhooks.
- Over the API:
POST /v1/webhooksreturns it assecret, this once. See Create a webhook endpoint.
Keep it with your other secrets, for example in an environment variable
called SOOSH_WEBHOOK_SECRET. Never put it in your code.
2. Read the three headers and the raw body
Section titled “2. Read the three headers and the raw body”Each request is a POST with a JSON body and these headers. The body is an
event. The full request is in Webhook delivery.
| Header | What it holds |
|---|---|
webhook-id |
The ID of this delivery. It’s the same on every try. |
webhook-timestamp |
When Soosh sent this try, in Unix seconds. |
webhook-signature |
v1, and a base64 signature. While a secret is being rotated there are two, separated by a space. |
The signature covers the body exactly as Soosh sent it. Parsing the JSON and turning it back into text changes spaces and order, and the signature no longer matches. So read the body as raw bytes, check it, and only then parse it.
3. Check the signature
Section titled “3. Check the signature”Copy the code for your language. It:
- turns away requests whose timestamp is more than 5 minutes from your server’s clock, so a captured request can’t be sent again later;
- signs
webhook-id,.,webhook-timestamp,.and the body with HMAC-SHA256, using the base64-decoded part of the secret afterwhsec_as the key; - compares the result with each
v1signature in the header, in constant time, and accepts the request if any one matches.
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 5 * 60;
// Returns true when a webhook request really came from Soosh.// secret the endpoint's signing secret, starting whsec_// headers the request headers, with lower-case names (as Node gives them)// body the raw request body, exactly as received: a Buffer or string,// before any JSON parsingexport function verifyWebhook(secret, headers, body, now = Math.floor(Date.now() / 1000)) { const id = headers['webhook-id']; const timestamp = headers['webhook-timestamp']; const signatures = headers['webhook-signature']; if (!id || !signatures || !/^\d+$/.test(timestamp ?? '')) return false;
// Refuse requests more than 5 minutes old (or ahead), so a captured // request can't be replayed later. if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) return false;
// The key is the base64-decoded part of the secret after whsec_. const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64'); const expected = createHmac('sha256', key).update(`${id}.${timestamp}.`).update(body).digest();
// There can be several signatures, separated by spaces: during a secret // rotation, one per secret. Any one that matches is enough. return signatures.split(' ').some((signature) => { const [version, value] = signature.split(','); if (version !== 'v1' || !value) return false; const received = Buffer.from(value, 'base64'); return received.length === expected.length && timingSafeEqual(received, expected); });}Use it with the raw body. In Express:
import express from 'express';import { verifyWebhook } from './verify.mjs';
const app = express();
app.post('/soosh', express.raw({ type: 'application/json' }), (req, res) => { if (!verifyWebhook(process.env.SOOSH_WEBHOOK_SECRET, req.headers, req.body)) { return res.sendStatus(401); } const event = JSON.parse(req.body); console.log(event.type, event.data); res.sendStatus(204);});
app.listen(3000);import base64import hashlibimport hmacimport time
TOLERANCE_SECONDS = 5 * 60
def verify_webhook(secret, headers, body, now=None): """Return True when a webhook request really came from Soosh.
secret the endpoint's signing secret, starting whsec_ headers the request headers (in Flask or Django, request.headers) body the raw request body as bytes, exactly as received, before any JSON parsing (in Flask, request.get_data()) """ msg_id = headers.get("webhook-id") timestamp = headers.get("webhook-timestamp") or "" signatures = headers.get("webhook-signature") if not msg_id or not signatures or not (timestamp.isascii() and timestamp.isdigit()): return False
# Refuse requests more than 5 minutes old (or ahead), so a captured # request can't be replayed later. if now is None: now = time.time() if abs(now - int(timestamp)) > TOLERANCE_SECONDS: return False
# The key is the base64-decoded part of the secret after whsec_. key = base64.b64decode(secret.removeprefix("whsec_")) signed = f"{msg_id}.{timestamp}.".encode() + body expected = hmac.new(key, signed, hashlib.sha256).digest()
# There can be several signatures, separated by spaces: during a secret # rotation, one per secret. Any one that matches is enough. for signature in signatures.split(" "): version, _, value = signature.partition(",") if version != "v1": continue try: received = base64.b64decode(value, validate=True) except ValueError: # not base64 continue if hmac.compare_digest(received, expected): return True return FalseUse it with the raw body. In Flask:
import osfrom flask import Flask, requestfrom verify import verify_webhook
app = Flask(__name__)
@app.post("/soosh")def soosh(): if not verify_webhook(os.environ["SOOSH_WEBHOOK_SECRET"], request.headers, request.get_data()): return "", 401 event = request.get_json() print(event["type"], event["data"]) return "", 204package webhooks
import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "net/http" "strconv" "strings" "time")
const tolerance = 5 * time.Minute
// Verify reports whether a webhook request really came from Soosh.// - secret is the endpoint's signing secret, starting whsec_.// - body is the raw request body, exactly as received, before any JSON// parsing.// - now is the current time: pass time.Now().func Verify(secret string, header http.Header, body []byte, now time.Time) bool { id := header.Get("webhook-id") timestamp := header.Get("webhook-timestamp") signatures := header.Get("webhook-signature") if id == "" || signatures == "" || timestamp == "" || strings.Trim(timestamp, "0123456789") != "" { return false }
// Refuse requests more than 5 minutes old (or ahead), so a captured // request can't be replayed later. ts, err := strconv.ParseInt(timestamp, 10, 64) if err != nil { return false } if age := now.Sub(time.Unix(ts, 0)); age > tolerance || age < -tolerance { return false }
// The key is the base64-decoded part of the secret after whsec_. key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_")) if err != nil { return false } mac := hmac.New(sha256.New, key) mac.Write([]byte(id + "." + timestamp + ".")) mac.Write(body) expected := mac.Sum(nil)
// There can be several signatures, separated by spaces: during a secret // rotation, one per secret. Any one that matches is enough. for _, signature := range strings.Split(signatures, " ") { version, value, ok := strings.Cut(signature, ",") if !ok || version != "v1" { continue } received, err := base64.StdEncoding.DecodeString(value) if err == nil && hmac.Equal(received, expected) { return true } } return false}Use it with the raw body:
http.HandleFunc("/soosh", func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) if err != nil || !webhooks.Verify(os.Getenv("SOOSH_WEBHOOK_SECRET"), r.Header, body, time.Now()) { http.Error(w, "signature doesn't match", http.StatusUnauthorized) return } var event struct { ID string `json:"id"` Type string `json:"type"` Data json.RawMessage `json:"data"` } if err := json.Unmarshal(body, &event); err != nil { http.Error(w, "not JSON", http.StatusBadRequest) return } log.Println(event.Type, string(event.Data)) w.WriteHeader(http.StatusNoContent)})<?php
const SOOSH_WEBHOOK_TOLERANCE_SECONDS = 5 * 60;
/** * Returns true when a webhook request really came from Soosh. * * $secret the endpoint's signing secret, starting whsec_ * $headers the request headers, with lower-case names: * array_change_key_case(getallheaders(), CASE_LOWER) * $body the raw request body, exactly as received: * file_get_contents('php://input') */function soosh_verify_webhook(string $secret, array $headers, string $body, ?int $now = null): bool{ $id = $headers['webhook-id'] ?? ''; $timestamp = $headers['webhook-timestamp'] ?? ''; $signatures = $headers['webhook-signature'] ?? ''; if ($id === '' || $signatures === '' || !ctype_digit($timestamp)) { return false; }
// Refuse requests more than 5 minutes old (or ahead), so a captured // request can't be replayed later. $now ??= time(); if (abs($now - (int) $timestamp) > SOOSH_WEBHOOK_TOLERANCE_SECONDS) { return false; }
// The key is the base64-decoded part of the secret after whsec_. $key = base64_decode(preg_replace('/^whsec_/', '', $secret), true); if ($key === false) { return false; } $expected = base64_encode(hash_hmac('sha256', "$id.$timestamp.$body", $key, true));
// There can be several signatures, separated by spaces: during a secret // rotation, one per secret. Any one that matches is enough. foreach (explode(' ', $signatures) as $signature) { [$version, $value] = array_pad(explode(',', $signature, 2), 2, ''); if ($version === 'v1' && hash_equals($expected, $value)) { return true; } } return false;}Use it with the raw body:
<?phprequire __DIR__ . '/verify.php';
$headers = array_change_key_case(getallheaders(), CASE_LOWER);$body = file_get_contents('php://input');
if (!soosh_verify_webhook(getenv('SOOSH_WEBHOOK_SECRET'), $headers, $body)) { http_response_code(401); exit;}$event = json_decode($body, true);error_log($event['type']);http_response_code(204);This code is tested against the Standard Webhooks test vector. If your server’s clock is wrong, every request looks too old or too new: keep it in sync with NTP.
4. Answer within 10 seconds
Section titled “4. Answer within 10 seconds”Answer with any 2xx status within 10 seconds. Anything else counts as a
failure, and so does a redirect: Soosh doesn’t follow them. A failed request
is sent again later, for about 2 days. See
Webhook delivery for when.
If handling an event takes longer, save it, answer, and do the work afterwards, for example from a queue.
5. Skip events you’ve already handled
Section titled “5. Skip events you’ve already handled”Soosh can send the same event more than once: when your server handled it
but answered too late, for example. A delivery keeps its webhook-id every
time it’s sent: each retry, and a resend or replay from the app.
- When a request comes in, look up its
webhook-id. If you’ve handled it, answer204and stop. - Handle the event.
- Only once it’s handled, save its
webhook-idand answer with a2xx. If handling fails, don’t save it: answer with an error, and the event is sent again. - Keep the IDs for at least 30 days. Retries of a delivery go on for about 2 days, but a failed delivery is kept for 30 days, and can be resent or replayed from the app until then.
Events can also arrive out of order. Order them by the times in data, such
as at in message.status,
not by when they arrive.
6. Send a test event
Section titled “6. Send a test event”Go to Settings > Webhooks (opens Soosh in a new tab), open the webhook and click Test
webhook. Soosh sends an event of type webhook.test, signed like every
other request. Its data is {"test": true, "message": "This is a test event from Soosh"}. Your server should accept it.
If your server turns it away, check that you sign the raw body, that the
secret is the whole value starting whsec_, and that your clock is right.
Result
Section titled “Result”Your server accepts requests from Soosh and turns away everything else, so only real events reach your systems.
Change the secret without missing events
Section titled “Change the secret without missing events”If a secret may have leaked, give the endpoint a new one. In the app, click
Rotate secret on the webhook. Over the API, call
POST /v1/webhooks/{id}/rotate-secret.
You choose how long the old secret keeps signing, from 0 to 168 hours (24 by default). Until then, every request carries both signatures, and the code above accepts either. Put the new secret on your server, then let the old one run out.