Skip to content
cronpad
Sinks / Webhooks

Webhooks

A webhook job sends an HTTP request to your URL every time it fires. Requests are signed using the Standard Webhooks specification, carry an idempotency key that stays the same across retries, and are classified by status code: 2xx succeeds, 5xx/408/429 and network errors are retried, other 4xx fail immediately.

Configuration

{
  "kind": "webhook",
  "config": {
    "url": "https://api.example.com/jobs/nightly-report",
    "method": "POST",
    "headers": {"Authorization": "Bearer {{secret.REPORTS_TOKEN}}"},
    "body": "{\"report\": \"daily\", \"run\": \"{{run.id}}\"}",
    "success_codes": "2xx",
    "retry_on": ["5xx", 408, 429, "network", "timeout"],
    "follow_redirects": false
  }
}
FieldDefaultDescription
urlrequiredhttp or https URL. On cronpad Cloud it must resolve to a public address.
methodPOSTGET, POST, PUT, PATCH or DELETE.
headers{}Extra headers (up to 50). Values may use templates.
bodystandard payloadRequest body as a string. Omit it to send the standard JSON payload below.
success_codes"2xx""2xx" or a list of codes, e.g. [200, 202].
retry_on["5xx", 408, 429, "network", "timeout"]Which failures are retryable. Status classes, codes, "network" and "timeout".
follow_redirectsfalseFollow up to 5 redirects; each hop is re-checked against network rules.

Transport, signing, idempotency, tracing and Cronpad-* headers are reserved and cannot be replaced through headers. Custom headers are removed when a redirect changes hosts.

The job’s timeout (default 30s, maximum 120s) applies to each attempt.

The request

POST /jobs/nightly-report HTTP/1.1
Host: api.example.com
Content-Type: application/json
User-Agent: cronpad/1.0.0
Idempotency-Key: run_01J9ZM1A…
webhook-id: run_01J9ZM1A…
webhook-timestamp: 1794281400
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
Cronpad-Attempt: 1
Cronpad-Job-Id: job_01J9ZK3Q…
Cronpad-Scheduled-At: 2026-11-10T03:30:00Z

{"run_id":"run_01J9ZM1A…","job_id":"job_01J9ZK3Q…","job_name":"nightly-report",
 "scheduled_at":"2026-11-10T03:30:00Z","fired_at":"2026-11-10T03:30:00.112Z","attempt":1}
HeaderMeaning
Idempotency-Key, webhook-idThe run ID. Identical on every retry and redelivery of this run; use it to deduplicate. See Delivery guarantees.
webhook-timestampUnix seconds when this attempt was sent.
webhook-signaturev1,<base64 HMAC-SHA256>; may contain several space-separated signatures during secret rotation.
Cronpad-Attempt0 for the first try, then 1, 2…
Cronpad-Job-Id, Cronpad-Scheduled-AtWhich job and which occurrence this is.

Idempotency with fast delivery

To use opt-in scheduler.delivery_mode = "fast", your receiver must persist and deduplicate the Idempotency-Key (or identical webhook-id) when applying its side effect. The key stays stable on crash replay in fast mode even if cronpad’s first history write never committed. Record the key atomically with the work; a request can arrive again after cronpad crashes before recording the response. See Durable and fast delivery.

Responses

ResponseOutcome
Matches success_codessuccess
Matches retry_on (by default 5xx, 408, 429)retryable
Any other status (e.g. 400, 401, 404)fatal: no retries
Connection refused, DNS failure, TLS errorretryable ("network")
No response within the job’s timeoutretryable ("timeout")

On 429 or 503, cronpad honors a Retry-After header (seconds or HTTP date), capped at the retry policy’s max_interval.

cronpad reads at most 64 KiB of the response body and stores it with the attempt (for non-successful attempts by default; see Data retention).

Verify signatures

Each job has a signing secret (whsec_…), shown once when you create the job. Rotate it with POST /api/v1/jobs/{id}/rotate-signing-secret; for 24 hours afterwards both the old and the new secret sign each request, so you can deploy the new one without dropping requests.

The signed content is {webhook-id}.{webhook-timestamp}.{raw body}, signed with HMAC-SHA256 using the base64-decoded part of the secret after whsec_. Always verify against the raw request body, before parsing JSON, and reject timestamps more than five minutes old to block replays.

You can also use any official Standard Webhooks library instead of the code below.

Go

package webhook

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"errors"
	"net/http"
	"strconv"
	"strings"
	"time"
)

func Verify(secret string, h http.Header, body []byte) error {
	key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
	if err != nil {
		return err
	}
	id, ts := h.Get("webhook-id"), h.Get("webhook-timestamp")
	sec, err := strconv.ParseInt(ts, 10, 64)
	if err != nil {
		return errors.New("bad timestamp")
	}
	if d := time.Since(time.Unix(sec, 0)); d > 5*time.Minute || d < -5*time.Minute {
		return errors.New("timestamp outside tolerance")
	}
	mac := hmac.New(sha256.New, key)
	mac.Write([]byte(id + "." + ts + "."))
	mac.Write(body)
	expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
	for _, sig := range strings.Fields(h.Get("webhook-signature")) {
		if v, ok := strings.CutPrefix(sig, "v1,"); ok && hmac.Equal([]byte(v), []byte(expected)) {
			return nil
		}
	}
	return errors.New("invalid signature")
}

Node.js

import crypto from "node:crypto";

// rawBody must be the exact bytes received (e.g. express.raw({ type: "*/*" })).
export function verify(secret, headers, rawBody) {
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const id = headers["webhook-id"];
  const ts = headers["webhook-timestamp"];
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) throw new Error("timestamp outside tolerance");

  const expected = crypto
    .createHmac("sha256", key)
    .update(`${id}.${ts}.`)
    .update(rawBody)
    .digest("base64");

  const ok = (headers["webhook-signature"] || "").split(" ").some((part) => {
    const [version, sig] = part.split(",");
    return (
      version === "v1" &&
      sig?.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
    );
  });
  if (!ok) throw new Error("invalid signature");
}

Python

import base64
import hashlib
import hmac
import time


def verify(secret: str, headers: dict, body: bytes) -> None:
    key = base64.b64decode(secret.removeprefix("whsec_"))
    msg_id = headers["webhook-id"]
    ts = headers["webhook-timestamp"]
    if abs(time.time() - int(ts)) > 300:
        raise ValueError("timestamp outside tolerance")

    signed = f"{msg_id}.{ts}.".encode() + body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()

    for part in headers.get("webhook-signature", "").split(" "):
        version, _, sig = part.partition(",")
        if version == "v1" and hmac.compare_digest(sig, expected):
            return
    raise ValueError("invalid signature")

Network rules

On cronpad Cloud (and any server with mode = "hosted"), cronpad resolves the hostname and refuses to connect to loopback, private (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), link-local (including 169.254.169.254), carrier-grade NAT, IPv6 unique-local and link-local, multicast and unspecified addresses. The check happens after DNS resolution, so a public name that resolves to a private address is also blocked. Only ports 80 and 443 are allowed.

Self-hosted servers allow private addresses by default (sinks.webhook.allow_private_networks = true), so you can call services on your own network.

Test before saving

Send test in the dashboard, or POST /api/v1/jobs/{id}/test, makes one real request and returns the outcome, status code and response body without recording a run. It uses the same signing, headers and network rules as a real run.