Webhook Receiver Contract and Signature Rotation Verifier
Pricing
from $20.00 / 1,000 run starteds
Webhook Receiver Contract and Signature Rotation Verifier
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.
Pricing
from $20.00 / 1,000 run starteds
Rating
0.0
(0)
Developer
kingii98
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
8 days ago
Last modified
Categories
Share
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
- Reads the receiver URL, the signature scheme and the test cases from the input.
- 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
statusTARGET_REFUSEDand the run ends SUCCEEDED. - Sends the cases one after the other, at the configured rate limit.
- Optionally sends a tamper and replay probe set of four extra requests for each case.
- 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
{"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.
maxRequestscaps the requests of one run. Cases beyond the cap areskipped.- The response body is read up to
maxResponseBytesand then cut. - The receiver URL is written to the run log, so every delivery is traceable.
Development
uv syncuv run pytestuv run ruff check .


