Webhooks
When an alert matches, CarrySpec posts the changes to an address of yours, signed, in order, and again until your server says it has them.
When a webhook is sent
A webhook is one HTTPS POST of JSON to your address. You set one up either way:
- For one alert: in the alert's form in your account, or by API with
"channel": "webhook"and awebhook_url(POST /api/v1/alerts). - For all your alerts: on the Alerts page, under Sent to, choose a webhook. Every alert of yours is then sent there as well as to the page.
Each post carries the changes since the last one, oldest first. The address must be https:// and
on the public internet; an address on a private network is refused, and redirects are not followed.
What is sent
POST /hooks/carryspec HTTP/1.1
Content-Type: application/json
User-Agent: CarrySpec-Webhook/1
X-CarrySpec-Delivery: 1734
X-CarrySpec-Timestamp: 1793088000
X-CarrySpec-Signature: sha256=5b1f…
{
"delivery": "1734",
"test": false,
"sent_at": "2026-10-27T10:00:00+00:00",
"alert": { "id": "0f3e2d1c-4b5a-4c6d-8e7f-9a0b1c2d3e4f", "name": "Deep discounts" },
"title": "Deep discounts · 2 alerts",
"text": "Price down · A store · A product · A colour · …\n…",
"events": [
{ "id": 48214, "type": "price_down", "occurred_at": "2026-10-27T09:40:12Z", "…": "…" }
]
}
| Field | What it is |
|---|---|
delivery | This send's id, the same as the X-CarrySpec-Delivery header. |
test | true for a test send. |
sent_at | When this try was made. |
alert | The alert that matched: its id and name. null on a test of the account's own connection. |
title | The send in a few words, for a tool that shows text. |
text | One line per change, in words. |
events | The changes, each exactly as GET /api/v1/events gives it. |
Check the signature
Anyone can post to your address, so check every request before trusting it.
X-CarrySpec-Signature is sha256= and the HMAC-SHA256, in hex, of the timestamp, a dot, and the
body exactly as sent, keyed with your webhook secret.
- Take
X-CarrySpec-Timestamp. Refuse it if it is more than 5 minutes from your clock: that stops a recorded request being replayed later. - Compute the HMAC of
timestamp + "." + raw bodywith your secret, and compare it with the header in constant time. - Only then parse the JSON.
Checking a request
- JavaScript
- Python
import crypto from "node:crypto";
// rawBody: the request body exactly as it arrived, as a Buffer or string.
// Parse the JSON only after this returns true.
export function isFromCarrySpec(secret, headers, rawBody) {
const timestamp = headers["x-carryspec-timestamp"];
const signature = headers["x-carryspec-signature"] ?? "";
// Older than five minutes: a recorded request being sent again.
if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return false;
}
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(timestamp + "." + rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
import hashlib, hmac, time
def is_from_carryspec(secret: str, headers, raw_body: bytes) -> bool:
"""raw_body: the request body exactly as it arrived. Parse the JSON after."""
timestamp = headers.get("X-CarrySpec-Timestamp", "")
signature = headers.get("X-CarrySpec-Signature", "")
try:
sent_at = int(timestamp)
except ValueError:
return False
if abs(time.time() - sent_at) > 300: # a recorded request being sent again
return False
expected = "sha256=" + hmac.new(
secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
Use the raw body. A framework that parses the JSON and writes it out again changes spacing and order, and the signature no longer matches.
In a server
- Express
- Flask
import express from "express";
import { isFromCarrySpec } from "./carryspec.js";
const app = express();
// express.raw keeps the body exactly as it arrived: the signature is over those bytes.
app.post("/hooks/carryspec", express.raw({ type: "application/json" }), (req, res) => {
if (!isFromCarrySpec(process.env.CARRYSPEC_WEBHOOK_SECRET, req.headers, req.body)) {
return res.status(401).end();
}
const sent = JSON.parse(req.body.toString("utf8"));
res.status(200).end(); // answer first, then do the work
queue.add(sent); // your own queue or job runner
});
from flask import Flask, request
app = Flask(__name__)
@app.post("/hooks/carryspec")
def carryspec():
raw = request.get_data() # the body exactly as it arrived
if not is_from_carryspec(SECRET, request.headers, raw):
return "", 401
sent = request.get_json()
enqueue(sent) # your own queue: answer within 20 seconds
return "", 200
The secret (whsec_…) is shown with the alert, or with the connection on the Alerts page. Keep it
on your server only.
A worked example
Your check must give this signature for these inputs. It was made by CarrySpec's own signing code; the secret is an example, not a real one. With the timestamp this old, your 5-minute check refuses it, so test the signature step on its own.
| Input | Value |
|---|---|
| Secret | whsec_example_only_not_a_real_secret |
| X-CarrySpec-Timestamp | 1793088000 |
| Body (exact bytes) | {"delivery":"7c6b1f0e-2d5a-4c1b-9e8f-3a2b1c0d9e8f","test":true,"events":[]} |
| Expected signature | sha256=cd06bb081714d4326958ba60d09252250bb44f5e802684c4c8c366a4db0e5b72 |
Answer quickly
Answer with any 2xx within 20 seconds and the changes count as delivered. Do the slow work after
answering: put the request on a queue and reply. A slower answer counts as a failure and is tried
again.
Retries and pausing
Anything other than 2xx, or no answer, is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours: six tries in
all, over about 8.5 hours.
- An alert's own webhook is paused after the sixth try. Set it active again (in your account,
or
PATCHwith{"status": "active"}) and it carries on from where it stopped, missing nothing. - The connection on the Alerts page is paused instead, and its alerts keep collecting on your Alerts page. Resuming it starts from that moment: what happened meanwhile stays on the page and is not sent late.
- Answer
410 Goneto stop at once: the alert (or the connection) is paused without waiting for the tries to run out.
Order and duplicates
Sends to one address are made in order: nothing newer is sent while an earlier send is still being tried. Delivery is at least once: if your server took a request but its answer was lost, the same send comes again.
- A repeat has the same
X-CarrySpec-Deliveryand the same events, with a new timestamp andsent_at. - Keep the delivery ids (or the event ids) you have handled, and skip one you have seen. Each
event's
idis unique and never reused. - If your server was down for longer than the retries, read what you missed from
GET /api/v1/eventswithafterset to the last event id you handled (Following the feed).
Testing
Send a test on the alert or the connection in your account, or call the API. A test is a real
signed post with "test": true, and its answer says what your server replied. Tests are at most one
every 5 seconds.
POST /api/v1/alerts/{alert_id}/test
- cURL
- JavaScript
- Python
curl --request POST \
--url 'https://carryspec.com/api/v1/alerts/ALERT_ID/test' \
--header "Authorization: Bearer $CARRYSPEC_API_KEY"
const response = await fetch("https://carryspec.com/api/v1/alerts/ALERT_ID/test", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CARRYSPEC_API_KEY}`,
},
});
if (!response.ok) {
throw new Error(`${response.status}: ${await response.text()}`);
}
console.log(JSON.stringify(await response.json(), null, 2));
import json
import os
import urllib.error
import urllib.request
request = urllib.request.Request(
"https://carryspec.com/api/v1/alerts/ALERT_ID/test",
method="POST",
headers={
"Authorization": "Bearer " + os.environ["CARRYSPEC_API_KEY"],
},
)
try:
with urllib.request.urlopen(request) as response:
print(json.dumps(json.load(response), indent=2))
except urllib.error.HTTPError as error:
print(error.code, error.read().decode())
{
"delivered": true,
"error": "string",
"events": 0,
"seconds": 0,
"status_code": 0
}
To receive on your own computer, the address must still be public https://, such as a tunnel to
your machine.
The delivery log
GET /api/v1/alerts/{alert_id}/deliveries lists an alert's sends, newest first: their status (pending, sent,
failed while being retried, gave_up), how many tries, your server's response_code or the
error, and next_attempt_at.
An alert's last 20 sends
- cURL
- JavaScript
- Python
curl --request GET \
--url 'https://carryspec.com/api/v1/alerts/ALERT_ID/deliveries?limit=20' \
--header "Authorization: Bearer $CARRYSPEC_API_KEY"
const response = await fetch("https://carryspec.com/api/v1/alerts/ALERT_ID/deliveries?limit=20", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.CARRYSPEC_API_KEY}`,
},
});
if (!response.ok) {
throw new Error(`${response.status}: ${await response.text()}`);
}
console.log(JSON.stringify(await response.json(), null, 2));
import json
import os
import urllib.error
import urllib.request
request = urllib.request.Request(
"https://carryspec.com/api/v1/alerts/ALERT_ID/deliveries?limit=20",
method="GET",
headers={
"Authorization": "Bearer " + os.environ["CARRYSPEC_API_KEY"],
},
)
try:
with urllib.request.urlopen(request) as response:
print(json.dumps(json.load(response), indent=2))
except urllib.error.HTTPError as error:
print(error.code, error.read().decode())
Troubleshooting
| What you see | Likely cause | What to do |
|---|---|---|
| Signatures never match | The body was parsed and re-written before checking, or the wrong secret. | Check the raw bytes; compare against the worked example above. |
| Signatures fail now and then | Your server's clock is more than 5 minutes off. | Sync the clock (NTP). |
| Nothing arrives | The address is not https, not public, or redirects. | Use Send a test: its error says why. The final address must answer itself. |
| The alert shows Paused | Six tries failed, or your server answered 410. | Fix the server, then set the alert active again. |
| The same changes twice | Your answer did not reach us, so the send was tried again. | Skip a delivery id you have already handled. |