Developers
Set your API contextPick a region and space ID once. Every sample fills in your base URL.
Where do I find these?

In Coevera: open your space's API application and choose Show Access. It shows the space ID and the base URL, whose host starts with your region.

From the API: if you know the space ID, the discovery endpoint GET /api/discovery/spaces/{space_id} returns its region and base URLs, without credentials.

Only the region and space ID are kept, in this browser. Never paste a key or password here.

More in the quickstart

WebhooksTesting locally

Testing locally

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.

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.

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

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:

Shell
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:

Shell
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:

# 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
// 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.");
}
# 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.")

REST only: this sample has no GraphQL version.

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

To check a captured delivery by hand, paste it into the 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).
  • 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.