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 withrequests) andcurl.sh(curl,jqandopenssl). - Your API credentials in
COEVERA_API_KEYandCOEVERA_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:
npx tsx index.tsIt 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:
export WEBHOOK_SECRET="<the webhook's signature value>"
npx tsx index.ts --serveFrom 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
filterso 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_SECRETis set. - When the tunnel's public URL changes, update the webhook's
url.