---
title: "Webhooks: Events, Retries, and Signature Checks"
description: "Fixedmark webhook events for IPs and usage, the retry schedule, and how to check X-Fixedmark-Signature in Node, Python, Go, and Java."
canonical: https://fixedmark.com/docs/webhooks
updated: 2026-11-07
source: Fixedmark (https://fixedmark.com), BitMask LLP
---

Manage

# 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](https://fixedmark.com/early-access) to get yours first.

## Events

_Webhook event types_

| 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](https://fixedmark.com/docs/tunnels#status) '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

**Terminal**

```bash
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-Delivery` is 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:

1. Read the raw body bytes before any JSON parsing.
2. Refuse a timestamp more than 5 minutes from your clock.
3. Compute HMAC-SHA256 of `<t>.<body>` with the secret and compare it to each `v1` value in constant time.

**Node.js**

```javascript
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));
```

**Python**

```python
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()
```

**Go**

```go
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**

```java
// 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.
