# Webhook Receiver Contract and Signature Rotation Verifier (`kingii98/webhook-receiver-contract-and-signature-rotation-verifier`) Actor

Sends signed and deliberately bad deliveries to one webhook receiver and reports, for each case, whether the receiver answered with the expected HTTP status. Use it after a deploy and after a secret rotation. HTTP only, no browser, no proxy.

- **URL**: https://apify.com/kingii98/webhook-receiver-contract-and-signature-rotation-verifier.md
- **Developed by:** [kingii98](https://apify.com/kingii98) (community)
- **Categories:**
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $20.00 / 1,000 run starteds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Webhook Receiver Contract and Signature Rotation Verifier

Prove that your webhook receiver accepts good deliveries and rejects bad deliveries.
Run it after each deploy of the receiver and after each secret rotation.

The Actor sends a small set of deliveries to **one** endpoint that you own. Each
delivery uses a signature mode that you select. The Actor compares the HTTP status
of the answer with the status range that you expect, and it reports one pass or
fail verdict for each case plus one gate verdict for the run.

A fail verdict is a result, not a malfunction. The run always ends SUCCEEDED, and
the verdict is in the dataset and in the run status message. Use the `ciGatePass`
field of the summary record to gate a deploy in CI.

### What it does

1. Reads the receiver URL, the signature scheme and the test cases from the input.
2. Refuses a target that is not a public HTTPS address. Loopback, private,
   link-local and reserved addresses are refused, and redirects are never followed.
   A refused target is a result, not a malfunction: the Actor writes one summary
   record with `status` `TARGET_REFUSED` and the run ends SUCCEEDED.
3. Sends the cases one after the other, at the configured rate limit.
4. Optionally sends a tamper and replay probe set of four extra requests for each case.
5. Writes one dataset record for each case and each probe, plus one summary record.

### Signature modes

| Mode | What goes out | What a correct receiver does |
| --- | --- | --- |
| `valid` | Body signed with the current secret | Accept, 2xx |
| `missing` | No signature header | Reject |
| `wrong-secret` | Body signed with a secret that is not yours | Reject |
| `previous-secret` | Body signed with the previous secret | Reject after a rotation |
| `replayed-timestamp` | Correct signature over a stale timestamp | Reject, if it checks a window |
| `oversized-body` | A padded body of `oversizedBodyBytes` | Reject or accept, your contract |

The tamper and replay probe set adds four more requests for each case:
`tampered-body` (a valid signature over a body that then changed),
`tampered-signature` (one character of the digest changed), `replayed-timestamp`
and `previous-secret`. A probe passes when the receiver answers inside
`probeExpectedStatusMin` to `probeExpectedStatusMax`, 400 to 499 by default.

A mode that the configuration cannot serve is reported as `skipped`, never as a
fail. Examples: a `previous-secret` case without `previousSecret`, or any
signature mode when `signatureScheme` is `none`.

### Input

Every field has a default, so a run with empty input `{}` works. It sends three
cases to a public echo endpoint and shows the record shape.

| Field | Default | Meaning |
| --- | --- | --- |
| `receiverUrl` | `https://postman-echo.com/post` | One public HTTPS endpoint that you own |
| `signatureScheme` | `hmac_sha256` | `hmac_sha256`, `hmac_sha1` or `none` |
| `signatureHeader` | `X-Signature-256` | Header that carries the signature |
| `signatureTemplate` | `sha256={signature}` | Header value format; `{signature}`, `{signatureBase64}`, `{timestamp}` |
| `signedPayloadTemplate` | `{body}` | Bytes that are signed; `{body}`, `{timestamp}` |
| `timestampHeader` | `X-Timestamp` | Timestamp header; empty sends none |
| `currentSecret` | empty | Secret that the receiver must accept |
| `previousSecret` | empty | Secret that the receiver must reject after a rotation |
| `cases` | 3 cases | 1 to 50 cases: name, mode, payload, expected status range |
| `tamperReplayProbes` | `false` | Add the four-request probe set for each case |
| `probeExpectedStatusMin` / `Max` | `400` / `499` | Status range that counts as a correct rejection |
| `timeoutSeconds` | `10` | 1 to 15 seconds for one request |
| `maxRequests` | `200` | Hard cap on requests in one run, 1 to 500 |
| `requestsPerSecond` | `5` | Rate limit for the receiver, 1 to 10 |
| `maxResponseBytes` | `65536` | Cap on the response bytes that are read and hashed |
| `oversizedBodyBytes` | `100000` | Size of the padded body of the `oversized-body` mode |
| `replayAgeSeconds` | `900` | Age of the stale timestamp of the replay modes |
| `contentType` | `application/json` | Content-Type of every request |
| `userAgent` | `WebhookContractVerifier/0.1 (+https://apify.com)` | User-Agent of every request |

The fields that become an HTTP header name or value (`signatureHeader`,
`signatureTemplate`, `timestampHeader`, `contentType`, `userAgent`) must hold
ASCII characters only, because HTTP headers are ASCII. A value that holds, for
example, an en dash is refused with an input error.

A payload can be an object, an array or a string. The tokens `{nonce}` and
`{timestamp}` in the payload are replaced before the body is signed, so each
delivery is unique and a receiver that stores delivery IDs sees no duplicate.

#### Example: verify a rotation

```json
{
  "receiverUrl": "https://api.example.com/webhooks/github",
  "signatureScheme": "hmac_sha256",
  "signatureHeader": "X-Hub-Signature-256",
  "signatureTemplate": "sha256={signature}",
  "currentSecret": "the-new-secret",
  "previousSecret": "the-rotated-out-secret",
  "cases": [
    {"name": "new-secret-accepted", "mode": "valid",
     "payload": {"zen": "ping", "id": "{nonce}"},
     "expectedStatusMin": 200, "expectedStatusMax": 299},
    {"name": "old-secret-rejected", "mode": "previous-secret",
     "payload": {"zen": "ping", "id": "{nonce}"},
     "expectedStatusMin": 401, "expectedStatusMax": 403},
    {"name": "no-signature-rejected", "mode": "missing",
     "payload": {"zen": "ping", "id": "{nonce}"},
     "expectedStatusMin": 401, "expectedStatusMax": 403}
  ]
}
```

The default input fixture points at a public echo service that checks no
signature. It therefore answers 200 to every case, and the three cases expect
200 to 299. Point `receiverUrl` at your own endpoint and tighten the expected
ranges, as the example above shows.

### Output

One dataset record for each case (`recordType: "case"`) and each probe
(`recordType: "probe"`):

| Field | Meaning |
| --- | --- |
| `caseName`, `signatureMode` | Which case, and which signature mode went out |
| `signatureSent`, `timestampSent` | Whether a signature header went out, and the timestamp in it |
| `requestBodyBytes` | Size of the body that went out |
| `httpStatus` | Status of the answer, `null` when there was no answer |
| `expectedStatus` | The expected range, for example `401-403` |
| `verdict` | `pass`, `fail`, `error` or `skipped` |
| `latencyMs` | Time from the request to the end of the answer |
| `responseBodyHash` | `sha256:` of the response bytes that were read |
| `responseBytes`, `responseTruncated` | How much was read, and whether the cap cut it |
| `error`, `note` | Transport error text, or the reason for a skip |

One summary record (`recordType: "summary"`) closes the run: `totalPass`,
`totalFail`, `totalError`, `totalSkipped`, `worstLatencyMs`, `casesExecuted`,
`probeSetsExecuted`, `requestsSent` and `ciGatePass`.

`ciGatePass` is `true` only when there is no fail verdict, no transport error,
and at least one case passed.

### Pricing

Pay per event:

| Event | Unit | When it is charged |
| --- | --- | --- |
| `run-started` | One Actor run | Once for each run, after the input is read |
| `test-case-executed` | One test case sent to the receiver | Once for each case that reached the receiver and returned an HTTP status |
| `tamper-replay-probe` | One tamper and replay probe set for one case | Once for each probe set where at least one probe reached the receiver |

A case that is skipped, or that never got an answer from the receiver, is not
charged. A run that refuses the target charges only `run-started`.

### Limits and safety

- HTTP only. No browser, no proxy, no login, no external database.
- HTTPS targets only. Loopback, private, link-local and reserved addresses are refused.
- Credentials in the URL are refused.
- Redirects are never followed. A 3xx answer is reported as it is.
- Requests are sequential and rate limited, so the Actor cannot act as a load generator.
- `maxRequests` caps the requests of one run. Cases beyond the cap are `skipped`.
- The response body is read up to `maxResponseBytes` and then cut.
- The receiver URL is written to the run log, so every delivery is traceable.

### Development

```bash
uv sync
uv run pytest
uv run ruff check .
```

# Actor input Schema

## `receiverUrl` (type: `string`):

One public HTTPS endpoint that you own. Every test case is sent to this URL with POST. Private, loopback and reserved addresses are refused. Redirects are not followed: a 3xx answer is reported as it is.

## `signatureScheme` (type: `string`):

Algorithm used to sign the request body. Select "none" for a receiver that does not check a signature.

## `signatureHeader` (type: `string`):

Name of the header that carries the signature, for example "X-Hub-Signature-256" or "Stripe-Signature". ASCII characters only.

## `signatureTemplate` (type: `string`):

Format of the signature header value. Use {signature} for the lower-case hexadecimal digest, {signatureBase64} for the base64 digest and {timestamp} for the Unix timestamp in seconds. ASCII characters only.

## `signedPayloadTemplate` (type: `string`):

Bytes that the receiver signs. Use {body} for the request body and {timestamp} for the Unix timestamp in seconds. Example for a timestamped scheme: {timestamp}.{body}

## `timestampHeader` (type: `string`):

Name of the header that carries the Unix timestamp in seconds. Leave it empty when the receiver does not read a timestamp header. ASCII characters only.

## `currentSecret` (type: `string`):

The secret that the receiver must accept now. It is used for the "valid" cases. An empty value is allowed: the Actor then signs with an empty key.

## `previousSecret` (type: `string`):

The secret that the receiver must reject after a rotation. It is used for the "previous-secret" cases and probes. Leave it empty when you do not verify a rotation.

## `cases` (type: `array`):

1 to 50 test cases. Each case holds a name, a payload, a signature mode (valid, missing, wrong-secret, previous-secret, replayed-timestamp, oversized-body) and the expected HTTP status range.

## `tamperReplayProbes` (type: `boolean`):

Add a probe set of four extra requests for each executed case: tampered body, tampered signature, replayed timestamp and previous secret. Each probe set is charged as one tamper-replay-probe event.

## `probeExpectedStatusMin` (type: `integer`):

Lowest HTTP status that counts as a correct rejection of a tamper or replay probe.

## `probeExpectedStatusMax` (type: `integer`):

Highest HTTP status that counts as a correct rejection of a tamper or replay probe.

## `timeoutSeconds` (type: `integer`):

Timeout for one request to the receiver.

## `maxRequests` (type: `integer`):

Hard cap on the number of requests sent to the receiver in one run. Cases that do not fit the cap are reported as not-executed.

## `requestsPerSecond` (type: `integer`):

Rate limit for the receiver. Requests are sent one after the other, so this Actor cannot act as a load generator.

## `maxResponseBytes` (type: `integer`):

Hard cap on the response bytes read from the receiver. The response body hash covers the bytes that were read.

## `oversizedBodyBytes` (type: `integer`):

Size of the padded body used by the "oversized-body" signature mode.

## `replayAgeSeconds` (type: `integer`):

Age of the stale timestamp used by the "replayed-timestamp" mode and by the replay probe. A receiver with a timestamp window must reject it.

## `contentType` (type: `string`):

Content-Type header sent with every request body. ASCII characters only.

## `userAgent` (type: `string`):

User-Agent header sent with every request, so your receiver can identify this Actor in its logs. ASCII characters only.

## Actor input object example

```json
{
  "receiverUrl": "https://postman-echo.com/post",
  "signatureScheme": "hmac_sha256",
  "signatureHeader": "X-Signature-256",
  "signatureTemplate": "sha256={signature}",
  "signedPayloadTemplate": "{body}",
  "timestampHeader": "X-Timestamp",
  "cases": [
    {
      "name": "good-delivery",
      "mode": "valid",
      "payload": {
        "event": "ping",
        "id": "{nonce}",
        "sentAt": "{timestamp}"
      },
      "expectedStatusMin": 200,
      "expectedStatusMax": 299
    },
    {
      "name": "no-signature-header",
      "mode": "missing",
      "payload": {
        "event": "ping",
        "id": "{nonce}",
        "sentAt": "{timestamp}"
      },
      "expectedStatusMin": 401,
      "expectedStatusMax": 403
    },
    {
      "name": "wrong-secret",
      "mode": "wrong-secret",
      "payload": {
        "event": "ping",
        "id": "{nonce}",
        "sentAt": "{timestamp}"
      },
      "expectedStatusMin": 401,
      "expectedStatusMax": 403
    }
  ],
  "tamperReplayProbes": false,
  "probeExpectedStatusMin": 400,
  "probeExpectedStatusMax": 499,
  "timeoutSeconds": 10,
  "maxRequests": 200,
  "requestsPerSecond": 5,
  "maxResponseBytes": 65536,
  "oversizedBodyBytes": 100000,
  "replayAgeSeconds": 900,
  "contentType": "application/json",
  "userAgent": "WebhookContractVerifier/0.1 (+https://apify.com)"
}
```

# Actor output Schema

## `dataset` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "receiverUrl": "https://postman-echo.com/post",
    "signatureScheme": "hmac_sha256",
    "signatureHeader": "X-Signature-256",
    "signatureTemplate": "sha256={signature}",
    "signedPayloadTemplate": "{body}",
    "timestampHeader": "X-Timestamp",
    "cases": [
        {
            "name": "good-delivery",
            "mode": "valid",
            "payload": {
                "event": "ping",
                "id": "{nonce}",
                "sentAt": "{timestamp}"
            },
            "expectedStatusMin": 200,
            "expectedStatusMax": 299
        },
        {
            "name": "no-signature-header",
            "mode": "missing",
            "payload": {
                "event": "ping",
                "id": "{nonce}",
                "sentAt": "{timestamp}"
            },
            "expectedStatusMin": 401,
            "expectedStatusMax": 403
        },
        {
            "name": "wrong-secret",
            "mode": "wrong-secret",
            "payload": {
                "event": "ping",
                "id": "{nonce}",
                "sentAt": "{timestamp}"
            },
            "expectedStatusMin": 401,
            "expectedStatusMax": 403
        }
    ],
    "tamperReplayProbes": false,
    "probeExpectedStatusMin": 400,
    "probeExpectedStatusMax": 499,
    "timeoutSeconds": 10,
    "maxRequests": 200,
    "requestsPerSecond": 5,
    "maxResponseBytes": 65536,
    "oversizedBodyBytes": 100000,
    "replayAgeSeconds": 900,
    "contentType": "application/json",
    "userAgent": "WebhookContractVerifier/0.1 (+https://apify.com)"
};

// Run the Actor and wait for it to finish
const run = await client.actor("kingii98/webhook-receiver-contract-and-signature-rotation-verifier").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "receiverUrl": "https://postman-echo.com/post",
    "signatureScheme": "hmac_sha256",
    "signatureHeader": "X-Signature-256",
    "signatureTemplate": "sha256={signature}",
    "signedPayloadTemplate": "{body}",
    "timestampHeader": "X-Timestamp",
    "cases": [
        {
            "name": "good-delivery",
            "mode": "valid",
            "payload": {
                "event": "ping",
                "id": "{nonce}",
                "sentAt": "{timestamp}",
            },
            "expectedStatusMin": 200,
            "expectedStatusMax": 299,
        },
        {
            "name": "no-signature-header",
            "mode": "missing",
            "payload": {
                "event": "ping",
                "id": "{nonce}",
                "sentAt": "{timestamp}",
            },
            "expectedStatusMin": 401,
            "expectedStatusMax": 403,
        },
        {
            "name": "wrong-secret",
            "mode": "wrong-secret",
            "payload": {
                "event": "ping",
                "id": "{nonce}",
                "sentAt": "{timestamp}",
            },
            "expectedStatusMin": 401,
            "expectedStatusMax": 403,
        },
    ],
    "tamperReplayProbes": False,
    "probeExpectedStatusMin": 400,
    "probeExpectedStatusMax": 499,
    "timeoutSeconds": 10,
    "maxRequests": 200,
    "requestsPerSecond": 5,
    "maxResponseBytes": 65536,
    "oversizedBodyBytes": 100000,
    "replayAgeSeconds": 900,
    "contentType": "application/json",
    "userAgent": "WebhookContractVerifier/0.1 (+https://apify.com)",
}

# Run the Actor and wait for it to finish
run = client.actor("kingii98/webhook-receiver-contract-and-signature-rotation-verifier").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "receiverUrl": "https://postman-echo.com/post",
  "signatureScheme": "hmac_sha256",
  "signatureHeader": "X-Signature-256",
  "signatureTemplate": "sha256={signature}",
  "signedPayloadTemplate": "{body}",
  "timestampHeader": "X-Timestamp",
  "cases": [
    {
      "name": "good-delivery",
      "mode": "valid",
      "payload": {
        "event": "ping",
        "id": "{nonce}",
        "sentAt": "{timestamp}"
      },
      "expectedStatusMin": 200,
      "expectedStatusMax": 299
    },
    {
      "name": "no-signature-header",
      "mode": "missing",
      "payload": {
        "event": "ping",
        "id": "{nonce}",
        "sentAt": "{timestamp}"
      },
      "expectedStatusMin": 401,
      "expectedStatusMax": 403
    },
    {
      "name": "wrong-secret",
      "mode": "wrong-secret",
      "payload": {
        "event": "ping",
        "id": "{nonce}",
        "sentAt": "{timestamp}"
      },
      "expectedStatusMin": 401,
      "expectedStatusMax": 403
    }
  ],
  "tamperReplayProbes": false,
  "probeExpectedStatusMin": 400,
  "probeExpectedStatusMax": 499,
  "timeoutSeconds": 10,
  "maxRequests": 200,
  "requestsPerSecond": 5,
  "maxResponseBytes": 65536,
  "oversizedBodyBytes": 100000,
  "replayAgeSeconds": 900,
  "contentType": "application/json",
  "userAgent": "WebhookContractVerifier/0.1 (+https://apify.com)"
}' |
apify call kingii98/webhook-receiver-contract-and-signature-rotation-verifier --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,kingii98/webhook-receiver-contract-and-signature-rotation-verifier"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/16MGJhHWOIFva5vfL/builds/j9H0rIz8idiQF1DWq/openapi.json
