Diese technische Dokumentation ist auf Englisch verfügbar.
LeitfädenGet instant updates with webhooks
Give ExpiryEdge an https URL and pick events. Each time one happens, ExpiryEdge sends a signed JSON message to your URL.
Before you start
- Needs an API key or token - see API keys.
- Only organization admins can manage webhooks and see deliveries. API keys can't (they are at most editor), so use an admin's session token.
- Your URL must use
https://(port 443 or 8443), a public host name (no IP address,localhostor internal name), and answer2xxwithin 10 seconds. - Up to 20 webhooks per organization.
export BASE="https://api.expiryedge.com/v1"
export TOKEN="<admin token>"The events
| Event | Sent when |
|---|---|
expiry.created | An expiry is created. |
expiry.updated | An expiry is edited. |
expiry.completed | An expiry is marked done (each cycle of a recurring one). |
expiry.deleted | An expiry is deleted. data is the record just before deletion. |
collection_request.completed | A client completes a Document Collection request (also after resubmitting). |
collection_submission.received | A client's answers and files arrive. A resubmission sends it again with a higher revision. |
Bulk CSV imports don't send expiry.created per row. Use the cursor feeds for those.
Step 1: Create a webhook
curl -s -X POST "$BASE/createWebhookSubscription" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"url":"https://example.com/hooks/expiryedge","events":["expiry.completed","collection_submission.received"],"description":"CRM sync"}'{ "subscription": { "id": "wh_3kQ9...", "active": true, "secret_hint": "whsec_...9f2c", "secret": "whsec_4b1e...9f2c", ... } }Save secret now - it is shown only once. Lost it? Use rotateWebhookSecret (Step 5).
Step 2: Send a test
curl -s -X POST "$BASE/sendTestWebhook" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"id":"wh_3kQ9..."}'{ "delivered": true, "status_code": 200, "error": null, ... }Your endpoint receives a webhook.test event. If delivered is false, error says why.
What we send
An HTTPS POST with this body:
{
"id": "evt_8c1f0a...",
"type": "expiry.completed",
"created_at": "2026-10-03T08:14:22.120Z",
"organization_id": "org_...",
"data": { "id": "exp_Gas2026", "name": "Gas Safety Certificate", "is_done": true, ... }
}datahas the same shape the API returns (getExpiry,getCollectionRequest,listCollectionSubmissionsplus client name, email and template name). Share passwords and links are never included. Download submission files withgetCollectionSubmissionFile.- Headers:
X-ExpiryEdge-Event(event type),X-ExpiryEdge-Delivery(same on retries - use it to skip duplicates),X-ExpiryEdge-Signature(t=<unix seconds>,v1=<signature>). - Redirects aren't followed. A
3xxcounts as a failure.
Step 3: Check the signature
v1 is the lowercase hex HMAC-SHA256 of t + "." + raw body, keyed with your secret.
- Read the raw body (before JSON parsing).
- Split the header on
,to gettandv1. - Reject if
tis more than 5 minutes old or in the future. - Compute the HMAC and compare to
v1in constant time. - Answer
200quickly, then do the work (the 10-second limit includes your processing).
JavaScript (Node 18+, Express):
const crypto = require('crypto');
const express = require('express');
const SECRET = process.env.EXPIRYEDGE_WEBHOOK_SECRET;
function verify(rawBody, header) {
const parts = Object.fromEntries(String(header || '').split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!Number.isInteger(t) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = crypto.createHmac('sha256', SECRET).update(`${t}.${rawBody}`).digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(parts.v1, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
app.post('/hooks/expiryedge', express.raw({ type: 'application/json' }), (req, res) => {
if (!verify(req.body.toString('utf8'), req.get('X-ExpiryEdge-Signature'))) return res.sendStatus(400);
res.sendStatus(200);
const event = JSON.parse(req.body);
// Skip if req.get('X-ExpiryEdge-Delivery') was already handled, then process event.type / event.data
});
app.listen(3000);Python (Flask):
import hashlib, hmac, os, time
from flask import Flask, request, abort
SECRET = os.environ["EXPIRYEDGE_WEBHOOK_SECRET"].encode()
app = Flask(__name__)
def verify(raw_body: bytes, header: str) -> bool:
try:
parts = dict(p.split("=", 1) for p in header.split(","))
t, v1 = int(parts["t"]), parts["v1"]
except (KeyError, ValueError, AttributeError):
return False
if abs(time.time() - t) > 300:
return False
expected = hmac.new(SECRET, f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
@app.post("/hooks/expiryedge")
def expiryedge_hook():
if not verify(request.get_data(), request.headers.get("X-ExpiryEdge-Signature", "")):
abort(400)
event = request.get_json()
# Skip if X-ExpiryEdge-Delivery was already handled, then process event["type"] / event["data"]
return "", 200Step 4: See what was delivered
curl -s "$BASE/getWebhookDeliveries?id=wh_3kQ9..." -H "Authorization: Bearer $TOKEN"Returns the last 50 deliveries, newest first. status is pending (will retry), succeeded, failed or skipped (webhook switched off or deleted).
Retries and automatic switch-off
- No
2xxwithin 10 seconds: retried after 5 min, 30 min, 2 h, 6 h and 15 h (6 tries over about a day), thenfailed. - Messages can arrive twice or out of order. Dedupe on
X-ExpiryEdge-Delivery; order bycreated_at. - 20 failed deliveries in a row switch the webhook off (
disabled_reason: "too_many_failures") and notify admins in the app. - Answering
410 Goneswitches it off at once (disabled_reason: "gone").
Step 5: Change, pause, rotate or delete
# Change events or URL; "active": true also re-enables it and resets the failure count
curl -s -X POST "$BASE/updateWebhookSubscription" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"id":"wh_3kQ9...","events":["expiry.created","expiry.completed"],"active":true}'
# New secret (the old one stops working at once, also for pending retries)
curl -s -X POST "$BASE/rotateWebhookSecret" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"id":"wh_3kQ9..."}'
# List webhooks (only secret_hint, never the secret)
curl -s "$BASE/getWebhookSubscriptions" -H "Authorization: Bearer $TOKEN"
# Delete (its delivery log and pending retries go too)
curl -s -X POST "$BASE/deleteWebhookSubscription" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"id":"wh_3kQ9..."}'Common problems
| Symptom | Fix |
|---|---|
403 FORBIDDEN | You're not an organization admin (or you used an API key). |
400 "url must use https" / IP address / not a public host | Use a public https:// host name. For local testing use a tunnel such as ngrok. |
400 Unknown event type(s) | Use names from The events. |
409 WEBHOOK_LIMIT_REACHED | 20 webhooks already. Delete one. |
Test: Timed out after 10s | Answer 200 first, then process. |
Test: Redirects are not followed | Use the final URL (check http/https and trailing slash). |
| Signature never matches | Hash the raw body bytes, not re-serialized JSON. Check the secret. |
active: false | Fix the endpoint, then updateWebhookSubscription with "active": true. |
Reference
All admin-only. Full details: openapi.yaml.
POST createWebhookSubscription- register a URL and events; returns the secret once.GET getWebhookSubscriptions- list webhooks and all event types.POST updateWebhookSubscription- change url, events, description, oractive.POSTorDELETE deleteWebhookSubscription- delete a webhook and its log.POST rotateWebhookSecret- new secret, returned once.POST sendTestWebhook- send awebhook.testevent.GET getWebhookDeliveries- last 50 deliveries.
