# Webhooks

A webhook job sends an HTTP request to your URL every time it fires. Requests are signed using the [Standard Webhooks](https://www.standardwebhooks.com/) 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

```json
{
  "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
  }
}
```

| Field | Default | Description |
|---|---|---|
| `url` | required | `http` or `https` URL. On cronpad Cloud it must resolve to a public address. |
| `method` | `POST` | `GET`, `POST`, `PUT`, `PATCH` or `DELETE`. |
| `headers` | `{}` | Extra headers (up to 50). Values may use [templates](index.md#templates). |
| `body` | standard payload | Request 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_redirects` | `false` | Follow 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}
```

| Header | Meaning |
|---|---|
| `Idempotency-Key`, `webhook-id` | The run ID. Identical on every retry and redelivery of this run; use it to deduplicate. See [Delivery guarantees](../concepts/delivery-guarantees.md). |
| `webhook-timestamp` | Unix seconds when this attempt was sent. |
| `webhook-signature` | `v1,<base64 HMAC-SHA256>`; may contain several space-separated signatures during secret rotation. |
| `Cronpad-Attempt` | `0` for the first try, then `1`, `2`… |
| `Cronpad-Job-Id`, `Cronpad-Scheduled-At` | Which 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](../concepts/delivery-guarantees.md#durable-and-fast-delivery).

## Responses

| Response | Outcome |
|---|---|
| Matches `success_codes` | success |
| 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 error | retryable (`"network"`) |
| No response within the job's timeout | retryable (`"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](../platform/data-retention.md)).

## 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

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

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

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