Skip to main content

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 a webhook_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​

The request
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", "…": "…" }
]
}
FieldWhat it is
deliveryThis send's id, the same as the X-CarrySpec-Delivery header.
testtrue for a test send.
sent_atWhen this try was made.
alertThe alert that matched: its id and name. null on a test of the account's own connection.
titleThe send in a few words, for a tool that shows text.
textOne line per change, in words.
eventsThe 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.

  1. Take X-CarrySpec-Timestamp. Refuse it if it is more than 5 minutes from your clock: that stops a recorded request being replayed later.
  2. Compute the HMAC of timestamp + "." + raw body with your secret, and compare it with the header in constant time.
  3. Only then parse the JSON.

Checking a request

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);
}
warning

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

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
});

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.

InputValue
Secretwhsec_example_only_not_a_real_secret
X-CarrySpec-Timestamp1793088000
Body (exact bytes){"delivery":"7c6b1f0e-2d5a-4c1b-9e8f-3a2b1c0d9e8f","test":true,"events":[]}
Expected signaturesha256=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 PATCH with {"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 Gone to 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-Delivery and the same events, with a new timestamp and sent_at.
  • Keep the delivery ids (or the event ids) you have handled, and skip one you have seen. Each event's id is unique and never reused.
  • If your server was down for longer than the retries, read what you missed from GET /api/v1/events with after set 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 --request POST \
--url 'https://carryspec.com/api/v1/alerts/ALERT_ID/test' \
--header "Authorization: Bearer $CARRYSPEC_API_KEY"
Response (illustrative values)
{
"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 --request GET \
--url 'https://carryspec.com/api/v1/alerts/ALERT_ID/deliveries?limit=20' \
--header "Authorization: Bearer $CARRYSPEC_API_KEY"

Troubleshooting​

What you seeLikely causeWhat to do
Signatures never matchThe 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 thenYour server's clock is more than 5 minutes off.Sync the clock (NTP).
Nothing arrivesThe 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 PausedSix tries failed, or your server answered 410.Fix the server, then set the alert active again.
The same changes twiceYour answer did not reach us, so the send was tried again.Skip a delivery id you have already handled.