Webhook security
Every webhook request from Spendstream is signed with HMAC-SHA256. Verify the signature before you act on the request.
How the signature is built
Spendstream computes the signature as follows.
Signature = hex( HMAC-SHA256( api_key + TimeStamp + webhook_url, secret ) )
Input | Description |
| Your publisher API key. |
| The value of the |
| The exact webhook URL registered with Spendstream, character for character. |
| Your publisher secret key. |
The result is a lowercase hexadecimal string, sent in the Signature header. The two headers on every request are Signature and TimeStamp.
What the signature does and does not prove
⚠️ Warning: The signature covers the API key, the timestamp and the URL. It does not cover the request body. It proves that the request came from a party holding your secret, but it does not prove that the body was unaltered in transit. Always receive webhooks over HTTPS.
Because of this, treat the body as a notification and fetch authoritative data where it matters. For example, after a TRANSACTION_UPDATED delivery, call getTransactionsById before you credit a reward.
Verification steps
- Read the
SignatureandTimeStampheaders. Reject the request if either is missing. - Reject a stale timestamp. A window of five minutes is a sensible default.
- Recompute the HMAC-SHA256 of
api_key + TimeStamp + your exact webhook URLusing your secret. - Compare your value with the
Signatureheader using a constant-time comparison. - Only then decode and process the body.
💡 Tip: The webhook URL must match exactly, including the scheme, host, path and any trailing slash. If your application sits behind a proxy, use a fixed configured URL rather than rebuilding it from the incoming request.
💡 Tip: The TimeStamp header is a plain YYYY-MM-DD HH:MM:SS string in UTC, with no timezone marker. Parse it as UTC, as the examples below do, and always sign the header value exactly as received.
Request shape
POST /webhooks/spendstream HTTP/1.1
Content-Type: multipart/form-data; boundary=------------------------7d1f
Signature: HEX_SIGNATURE
TimeStamp: TIMESTAMP
--------------------------7d1f
Content-Disposition: form-data; name="eventType"
TRANSACTION_UPDATED
--------------------------7d1f
Content-Disposition: form-data; name="transaction"
JSON_STRING
--------------------------7d1f--
The body is multipart/form-data. Make sure your framework parses multipart forms: in Express that needs a multipart parser such as multer, because express.urlencoded() ignores this format.
Verification code
Set SPENDSTREAM_API_KEY, SPENDSTREAM_SECRET and SPENDSTREAM_WEBHOOK_URL in your environment. Never commit real values.
JavaScript (Node.js with Express)
const crypto = require('crypto');
const express = require('express');
const multer = require('multer'); // parses multipart/form-data
const API_KEY = process.env.SPENDSTREAM_API_KEY;
const SECRET = process.env.SPENDSTREAM_SECRET;
const WEBHOOK_URL = process.env.SPENDSTREAM_WEBHOOK_URL; // exact registered URL
const MAX_AGE_SECONDS = 300;
function timestampIsFresh(timestamp) {
// Expects "YYYY-MM-DD HH:MM:SS" in UTC.
const parsed = Date.parse(timestamp.replace(' ', 'T') + 'Z');
if (Number.isNaN(parsed)) return false;
return Math.abs(Date.now() - parsed) / 1000 <= MAX_AGE_SECONDS;
}
function signatureIsValid(timestamp, received) {
const expected = crypto
.createHmac('sha256', SECRET)
.update(API_KEY + timestamp + WEBHOOK_URL)
.digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(received, 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
app.post('/webhooks/spendstream', multer().none(), (req, res) => {
const signature = req.get('Signature');
const timestamp = req.get('TimeStamp');
if (!signature || !timestamp) return res.sendStatus(401);
if (!timestampIsFresh(timestamp)) return res.sendStatus(401);
if (!signatureIsValid(timestamp, signature)) return res.sendStatus(401);
const { eventType } = req.body;
const payload = JSON.parse(req.body.transaction || req.body.user || '{}');
// Store the event and process it asynchronously, then acknowledge.
console.log(eventType, Object.keys(payload));
res.sendStatus(200);
});
app.listen(3000);
Python (Flask)
import hmac
import hashlib
import json
import os
from datetime import datetime, timezone
from flask import Flask, request, abort
API_KEY = os.environ["SPENDSTREAM_API_KEY"]
SECRET = os.environ["SPENDSTREAM_SECRET"]
WEBHOOK_URL = os.environ["SPENDSTREAM_WEBHOOK_URL"] # exact registered URL
MAX_AGE_SECONDS = 300
app = Flask(__name__)
def timestamp_is_fresh(timestamp: str) -> bool:
# Expects "YYYY-MM-DD HH:MM:SS" in UTC.
try:
parsed = datetime.strptime(timestamp, "%Y-%m-%d %H:%M:%S")
except ValueError:
return False
parsed = parsed.replace(tzinfo=timezone.utc)
age = abs((datetime.now(timezone.utc) - parsed).total_seconds())
return age <= MAX_AGE_SECONDS
def signature_is_valid(timestamp: str, received: str) -> bool:
message = (API_KEY + timestamp + WEBHOOK_URL).encode("utf-8")
expected = hmac.new(SECRET.encode("utf-8"), message, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received)
@app.post("/webhooks/spendstream")
def spendstream_webhook():
signature = request.headers.get("Signature")
timestamp = request.headers.get("TimeStamp")
if not signature or not timestamp:
abort(401)
if not timestamp_is_fresh(timestamp):
abort(401)
if not signature_is_valid(timestamp, signature):
abort(401)
event_type = request.form.get("eventType")
raw = request.form.get("transaction") or request.form.get("user") or "{}"
payload = json.loads(raw)
# Store the event and process it asynchronously, then acknowledge.
print(event_type, list(payload.keys()))
return "", 200
PHP
<?php
$apiKey = getenv('SPENDSTREAM_API_KEY');
$secret = getenv('SPENDSTREAM_SECRET');
$webhookUrl = getenv('SPENDSTREAM_WEBHOOK_URL'); // exact registered URL
$maxAge = 300;
$signature = $_SERVER['HTTP_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_TIMESTAMP'] ?? '';
if ($signature === '' || $timestamp === '') {
http_response_code(401);
exit;
}
// Expects "YYYY-MM-DD HH:MM:SS" in UTC.
$parsed = DateTimeImmutable::createFromFormat(
'Y-m-d H:i:s',
$timestamp,
new DateTimeZone('UTC')
);
if ($parsed === false || abs(time() - $parsed->getTimestamp()) > $maxAge) {
http_response_code(401);
exit;
}
$expected = hash_hmac('sha256', $apiKey . $timestamp . $webhookUrl, $secret);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit;
}
$eventType = $_POST['eventType'] ?? '';
$raw = $_POST['transaction'] ?? $_POST['user'] ?? '{}';
$payload = json_decode($raw, true);
// Store the event and process it asynchronously, then acknowledge.
http_response_code(200);
Rejecting failures
Return 401 when verification fails and do not process the body. Log the failure without recording the secret or the full signature.
Protecting your secret
- Keep the secret on the server. Never place it in browser or mobile code.
- Store it in a secrets manager or environment variable.
- Ask Spendstream to issue new credentials if you suspect exposure.
On this page
- Webhook security