---
title: Testing locally
description: Test a webhook receiver on your machine with signed test deliveries, replays of real ones and the signature checker, and use a tunnel for real deliveries.
canonical: https://developers.coevera.com/webhooks/testing-locally/
section: webhooks
verification: behaviour
lastVerified: 2026-10-15
publisher: Coevera
---

# Testing locally

**Answer:** Test without Coevera first: run the receiver locally and send it signed test deliveries, a retry and a forgery with the recipe's self-test or its cURL script. Replay a captured delivery with its original headers, and check signatures in the browser checker. For real deliveries, expose the receiver through an HTTPS tunnel and delete that webhook when you stop.

Coevera has no test event and no delivery log, so you test a receiver with deliveries you make
yourself. The programs below are the [webhook receiver recipe](/recipes/webhook-receiver/).

## Before you begin

- The recipe's programs from `samples/recipes/webhook-receiver/rest/`: `index.ts` (Node.js 18 or
  later), `main.py` (Python 3.9 or later with `requests`) and `curl.sh` (`curl`, `jq` and
  `openssl`).
- Your API credentials in `COEVERA_API_KEY` and `COEVERA_API_SECRET`: the test delivery is built
  from one of your opportunities ([authentication](/get-started/authentication/)).

## 1. Run the self-test

Run the receiver without arguments. It starts on a free port, sends itself a signed delivery,
the same delivery again as a retry, an event from two days ago, a forged one and a `DELETE`, and
checks every answer:

```bash
npx tsx index.ts
```

It prints `Answers: {"first":200,"retry":200,"old":200,"forged":401,"deleted":200}` and
`Self-test passed.`: the retry was skipped as a duplicate and the old event dropped. `python
main.py` does the same.

## 2. Send a delivery to your receiver

Start the receiver with your webhook's secret, and keep it running:

```bash
export WEBHOOK_SECRET="<the webhook's signature value>"
npx tsx index.ts --serve
```

From a second terminal, send it a delivery signed the way Coevera signs. The cURL script builds
one from one of your opportunities and posts it; set `RECEIVER_URL` if your receiver is not on
`http://localhost:8787/`. The TypeScript and Python tabs show the self-test, which sends the same
delivery and the rest:

_This sample is REST only._

**REST · cURL**

```bash
# One line of JSON, like a delivery; event_time is UTC without an offset.
body=$(jq -cn --argjson entity "$record" --arg url "$RECEIVER_URL" --arg space "$SPACE_ID" \
  --arg time "$(date -u +%Y-%m-%dT%H:%M:%S)" \
  '[{webhook: {url: $url, events: ["Opportunity.Update"]}, event: "Opportunity.Update",
     event_time: $time, team_space_id: $space, entity: $entity, related_entity: null}]')
signature=$(signature_of "$body")
verify_signature "$body" "$signature"
curl -sS --fail-with-body "$RECEIVER_URL" \
  -H "Content-Type: application/json" \
  -H "WebHook-Signature: $signature" \
  -H "WebHook-Signature-Version: v1" \
  -H "Webhook-Delivery-Retry: 0" \
  --data-binary "$body"
echo
```

**REST · TypeScript**

```ts
// A delivery signed the way Coevera signs, then what retries, replays, forgeries and a
// deleted webhook look like to the receiver.
async function selfTest(url: string): Promise<void> {
  const record = await findRecord();
  const delivery = (eventTime: Date) =>
    JSON.stringify([
      {
        webhook: { id: randomUUID(), url, events: ["Opportunity.Update"] },
        event: "Opportunity.Update",
        event_time: eventTime.toISOString().slice(0, -1),
        team_space_id: spaceId,
        entity: record,
        related_entity: null,
      },
    ]);
  const send = (body: string, signature: string, retry = 0) =>
    fetch(url, {
      method: "POST",
      headers: { "Content-Type": "application/json", "WebHook-Signature": signature, "WebHook-Signature-Version": "v1", "Webhook-Delivery-Retry": String(retry) },
      body,
    }).then((r) => r.status);
  const sign = (body: string) => createHmac("sha256", secret).update(body).digest("hex");

  const fresh = delivery(new Date());
  const old = delivery(new Date(Date.now() - 48 * 60 * 60 * 1000));
  const answers = {
    first: await send(fresh, sign(fresh)),
    retry: await send(fresh, sign(fresh), 1),
    old: await send(old, sign(old)),
    forged: await send(fresh, "0".repeat(64)),
    deleted: await fetch(url, { method: "DELETE" }).then((r) => r.status),
  };
  console.log("Answers:", JSON.stringify(answers));
  const expected = { first: 200, retry: 200, old: 200, forged: 401, deleted: 200 };
  if (JSON.stringify(answers) !== JSON.stringify(expected)) throw new Error(`Expected ${JSON.stringify(expected)}`);
  if (records.size !== 1 || seen.size !== 1) throw new Error("A retry or an old event was processed.");
}
```

**REST · Python**

```python
# A delivery signed the way Coevera signs, then what retries, replays, forgeries and a
# deleted webhook look like to the receiver.
def self_test(url):
    record = find_record()

    def delivery(event_time):
        return json.dumps([{
            "webhook": {"id": str(uuid.uuid4()), "url": url, "events": ["Opportunity.Update"]},
            "event": "Opportunity.Update",
            "event_time": event_time.replace(tzinfo=None).isoformat(),
            "team_space_id": SPACE_ID,
            "entity": record,
            "related_entity": None,
        }]).encode("utf-8")

    def sign(body):
        return hmac.new(SECRET.encode("utf-8"), body, hashlib.sha256).hexdigest()

    def send(body, signature, retry=0):
        headers = {
            "Content-Type": "application/json",
            "WebHook-Signature": signature,
            "WebHook-Signature-Version": "v1",
            "Webhook-Delivery-Retry": str(retry),
        }
        status = requests.post(url, data=body, headers=headers, timeout=10).status_code
        jobs.join()  # wait until the receiver has processed it, so the checks below see the result
        return status

    now = datetime.now(timezone.utc)
    fresh = delivery(now)
    old = delivery(now - timedelta(hours=48))
    answers = {
        "first": send(fresh, sign(fresh)),
        "retry": send(fresh, sign(fresh), 1),
        "old": send(old, sign(old)),
        "forged": send(fresh, "0" * 64),
        "deleted": requests.delete(url, timeout=10).status_code,
    }
    print("Answers:", json.dumps(answers))
    expected = {"first": 200, "retry": 200, "old": 200, "forged": 401, "deleted": 200}
    if answers != expected:
        sys.exit(f"Expected {json.dumps(expected)}")
    if len(records) != 1 or len(seen) != 1:
        sys.exit("A retry or an old event was processed.")
```

The receiver answers 200 with the number of events it received, and logs `stored revision` with
the opportunity's revision. Send the same body again and it logs `duplicate, skipped`.

## 3. Replay a real delivery

When your receiver logs a delivery it got wrong, keep its raw body, exactly as received, and its
`WebHook-Signature`. Send both back to your local receiver with `curl --data-binary` and the same
headers as above: the signature still verifies, so the receiver handles it exactly as before.
That is also what an attacker with a captured delivery can do, which is why the receiver drops
old events and skips repeats ([replays and duplicates](/webhooks/security-and-signatures/#replays-and-duplicates)).

To check a captured delivery by hand, paste it into the
[signature checker](/webhooks/security-and-signatures/#signature-checker). If it does not match,
the checker says whether the body looks re-formatted.

## 4. Receive real deliveries

Coevera calls only public addresses: a webhook URL on `localhost` or a private network is refused.
To receive real deliveries on your machine, run an HTTPS tunnel that forwards a public URL to
your receiver's port, and create a webhook for that URL with a `signature` of its own.

- Use a test space, or narrow the webhook with `filter` so it receives only your test records
  ([filtering and options](/webhooks/filtering-and-options/)).
- Delete the webhook when you stop. A webhook whose URL stops answering is retried for about
  24 hours and then disabled; deleting it is cleaner, and Coevera sends your receiver a `DELETE`.
- Never point a production webhook at a laptop.

## Limits

- Coevera has no test event, no delivery log and no replay; the tools here stand in for them.
- The self-test signs with a throwaway secret unless `WEBHOOK_SECRET` is set.
- When the tunnel's public URL changes, update the webhook's `url`.
