Manage
Updated
Webhooks
Fixedmark sends a signed HTTPS POST to your endpoint when an IP is ready, moves, or stops, and when usage crosses a threshold. Check the signature before you trust a delivery.
Docs preview: Fixedmark is not live yet. Endpoints are issued at launch, and these pages describe planned launch behavior. Anything marked planned may change. Join early access to get yours first.
Events
| Event | Sent when |
|---|---|
ip.provisioned | An IP finished provisioning and serves traffic. |
ip.health | An IP moved to a new node after its node failed, or could not be moved. |
ip.suspended | An IP stopped serving because the plan lapsed. |
route.health | A route's roll-up health changed: healthy, degraded, down, or unknown. The data carries state, org_id, app_id, hostname, kind, origin_kind, and placements[], each with its ip, port, state, and health. It never carries the origin, the partner list, or a node. |
route.placement | Fixedmark moved a shared route to another placement group because its group stayed down or drained. The data carries the reason, the old and new region and addresses in from and to, and port_changed, which stays false, since a moved tcp route keeps its port. Partners that dial the hostname follow the move. |
usage.threshold | Egress use crossed 80 or 100 percent of the period's allowance. |
GET /v1/webhooks/events lists the same types. An endpoint subscribes to the events you pick.
Add an endpoint
Add endpoints in the dashboard under the app's Webhooks page, or with POST /v1/orgs/{id}/webhooks. The URL must be HTTPS on a public address. Owners and admins manage endpoints. The secret, which starts with whsec_, is shown once when you create the endpoint.
Plans with webhooks
Webhooks come with Pro ($29 a month) and every larger plan. The 7-day trial is Starter, so it has none. On a plan without them, adding an endpoint, turning one on, changing it, or replaying a delivery answers 409 with the code plan.feature_required and "feature": "webhooks".
If your plan changes to one without webhooks, your endpoints stay as they are, but nothing is sent to them. An event due while the plan lacks webhooks is not sent later: its delivery shows plan_feature_required and no failure email goes out. You can still list endpoints and deliveries, turn endpoints off, and delete them. Change back to a plan with webhooks and new events are sent again, and you can replay a delivery that was not sent.
What a delivery looks like
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: FixedMark-Webhooks/1
X-Fixedmark-Event: ip.provisioned
X-Fixedmark-Delivery: 4182
X-Fixedmark-Signature: t=1761300000,v1=5d41402abc4b2a76b9719d911017c592…
{"id":"…","event":"ip.provisioned","created_at":"2026-10-24T10:00:00Z","org_id":"…","data":{…}}X-Fixedmark-Deliveryis the same on every retry of one delivery. Store it and drop a repeat.- Answer 2xx within 10 seconds. Anything else, or no answer, is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, and 12 hours, for up to 24 hours. After that the delivery is dead and the org's owners get an email.
- Answer 410 Gone to disable the endpoint.
- Replay any delivery from the dashboard or with
POST /v1/orgs/{id}/webhooks/{endpoint}/deliveries/{delivery}/replay.
Check the signature
X-Fixedmark-Signature is t=<unix seconds>,v1=<hex>. The hex is HMAC-SHA256, keyed with the whole endpoint secret, of the timestamp, a period, and the raw request body. To check it:
- Read the raw body bytes before any JSON parsing.
- Refuse a timestamp more than 5 minutes from your clock.
- Compute HMAC-SHA256 of
<t>.<body>with the secret and compare it to eachv1value in constant time.
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
const TOLERANCE_S = 300;
// True when header signs body with secret, within 5 minutes of now.
export function verifyFixedmark(secret, header, body, nowMs = Date.now()) {
const fields = header.split(",").map((f) => f.trim().split("="));
const t = Number(fields.find(([k]) => k === "t")?.[1]);
const sigs = fields.filter(([k]) => k === "v1").map(([, v]) => v);
if (!Number.isInteger(t) || sigs.length === 0) return false;
if (Math.abs(nowMs / 1000 - t) > TOLERANCE_S) return false;
const want = createHmac("sha256", secret).update(`${t}.`).update(body).digest();
return sigs.some((s) => {
const got = Buffer.from(s, "hex");
return got.length === want.length && timingSafeEqual(got, want);
});
}
createServer((req, res) => {
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
const body = Buffer.concat(chunks); // the raw bytes, before any JSON parse
const ok = verifyFixedmark(
process.env.FIXEDMARK_WEBHOOK_SECRET,
req.headers["x-fixedmark-signature"] ?? "",
body,
);
console.log(ok ? "valid" : "invalid");
res.writeHead(ok ? 200 : 400).end();
});
}).listen(Number(process.env.PORT ?? 3000));import hashlib
import hmac
import os
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
TOLERANCE_S = 300
def verify_fixedmark(secret: str, header: str, body: bytes, now: float | None = None) -> bool:
"""True when header signs body with secret, within 5 minutes of now."""
fields = [f.strip().split("=", 1) for f in header.split(",") if "=" in f]
ts = [v for k, v in fields if k == "t"]
sigs = [v for k, v in fields if k == "v1"]
if len(ts) != 1 or not ts[0].isdigit() or not sigs:
return False
t = int(ts[0])
if abs((now if now is not None else time.time()) - t) > TOLERANCE_S:
return False
want = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(want, s) for s in sigs)
class Hook(BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers.get("Content-Length", "0")))
ok = verify_fixedmark(
os.environ["FIXEDMARK_WEBHOOK_SECRET"],
self.headers.get("X-Fixedmark-Signature", ""),
body,
)
print("valid" if ok else "invalid", flush=True)
self.send_response(200 if ok else 400)
self.end_headers()
HTTPServer(("", int(os.environ.get("PORT", "3000"))), Hook).serve_forever()package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const tolerance = 5 * time.Minute
// verifyFixedmark reports whether header signs body with secret, within
// 5 minutes of now.
func verifyFixedmark(secret, header string, body []byte, now time.Time) bool {
var t int64
var sigs []string
for _, f := range strings.Split(header, ",") {
k, v, _ := strings.Cut(strings.TrimSpace(f), "=")
switch k {
case "t":
t, _ = strconv.ParseInt(v, 10, 64)
case "v1":
sigs = append(sigs, v)
}
}
if t == 0 || len(sigs) == 0 {
return false
}
if d := now.Sub(time.Unix(t, 0)); d > tolerance || d < -tolerance {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "%d.", t)
mac.Write(body)
want := mac.Sum(nil)
for _, s := range sigs {
if got, err := hex.DecodeString(s); err == nil && hmac.Equal(got, want) {
return true
}
}
return false
}
func main() {
secret := os.Getenv("FIXEDMARK_WEBHOOK_SECRET")
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read body", http.StatusBadRequest)
return
}
if !verifyFixedmark(secret, r.Header.Get("X-Fixedmark-Signature"), body, time.Now()) {
fmt.Println("invalid")
w.WriteHeader(http.StatusBadRequest)
return
}
fmt.Println("valid")
})
port := os.Getenv("PORT")
if port == "" {
port = "3000"
}
log.Fatal(http.ListenAndServe(":"+port, nil))
}// java Main.java
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class Main {
static final long TOLERANCE_S = 300;
// True when header signs body with secret, within 5 minutes of now.
static boolean verifyFixedmark(String secret, String header, byte[] body, long nowS)
throws Exception {
long t = -1;
List<byte[]> sigs = new ArrayList<>();
for (String f : header.split(",")) {
String[] kv = f.trim().split("=", 2);
if (kv.length != 2) continue;
try {
if (kv[0].equals("t")) t = Long.parseLong(kv[1]);
if (kv[0].equals("v1")) sigs.add(HexFormat.of().parseHex(kv[1]));
} catch (IllegalArgumentException e) {
return false;
}
}
if (t < 0 || sigs.isEmpty() || Math.abs(nowS - t) > TOLERANCE_S) return false;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((t + ".").getBytes(StandardCharsets.UTF_8));
byte[] want = mac.doFinal(body);
for (byte[] s : sigs) if (MessageDigest.isEqual(s, want)) return true;
return false;
}
public static void main(String[] args) throws Exception {
String secret = System.getenv("FIXEDMARK_WEBHOOK_SECRET");
int port = Integer.parseInt(System.getenv().getOrDefault("PORT", "3000"));
HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
server.createContext("/", ex -> {
byte[] body = ex.getRequestBody().readAllBytes();
String header = ex.getRequestHeaders().getFirst("X-Fixedmark-Signature");
boolean ok;
try {
ok = header != null
&& verifyFixedmark(secret, header, body, System.currentTimeMillis() / 1000);
} catch (Exception e) {
ok = false;
}
System.out.println(ok ? "valid" : "invalid");
ex.sendResponseHeaders(ok ? 200 : 400, -1);
ex.close();
});
server.start();
}
}Each receiver prints valid and answers 200 for a good delivery. Set FIXEDMARK_WEBHOOK_SECRET to the endpoint's secret.
Frequently asked questions
Are deliveries in order?
Not guaranteed. A retry can arrive after a later event. Use created_at in the body to order events, and the delivery id to drop repeats.
Can one delivery arrive twice?
Yes, for example when your endpoint answered after the 10 second timeout. The X-Fixedmark-Delivery header is the same on both, so store it and skip the second.