Diese technische Dokumentation ist auf Englisch verfügbar.
Leitfäden

Get 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, localhost or internal name), and answer 2xx within 10 seconds.
  • Up to 20 webhooks per organization.
export BASE="https://api.expiryedge.com/v1"
export TOKEN="<admin token>"

The events

EventSent when
expiry.createdAn expiry is created.
expiry.updatedAn expiry is edited.
expiry.completedAn expiry is marked done (each cycle of a recurring one).
expiry.deletedAn expiry is deleted. data is the record just before deletion.
collection_request.completedA client completes a Document Collection request (also after resubmitting).
collection_submission.receivedA 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, ... }
}
  • data has the same shape the API returns (getExpiry, getCollectionRequest, listCollectionSubmissions plus client name, email and template name). Share passwords and links are never included. Download submission files with getCollectionSubmissionFile.
  • 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 3xx counts as a failure.

Step 3: Check the signature

v1 is the lowercase hex HMAC-SHA256 of t + "." + raw body, keyed with your secret.

  1. Read the raw body (before JSON parsing).
  2. Split the header on , to get t and v1.
  3. Reject if t is more than 5 minutes old or in the future.
  4. Compute the HMAC and compare to v1 in constant time.
  5. Answer 200 quickly, 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 "", 200

Step 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 2xx within 10 seconds: retried after 5 min, 30 min, 2 h, 6 h and 15 h (6 tries over about a day), then failed.
  • Messages can arrive twice or out of order. Dedupe on X-ExpiryEdge-Delivery; order by created_at.
  • 20 failed deliveries in a row switch the webhook off (disabled_reason: "too_many_failures") and notify admins in the app.
  • Answering 410 Gone switches 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

SymptomFix
403 FORBIDDENYou're not an organization admin (or you used an API key).
400 "url must use https" / IP address / not a public hostUse 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_REACHED20 webhooks already. Delete one.
Test: Timed out after 10sAnswer 200 first, then process.
Test: Redirects are not followedUse the final URL (check http/https and trailing slash).
Signature never matchesHash the raw body bytes, not re-serialized JSON. Check the secret.
active: falseFix 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, or active.
  • POST or DELETE deleteWebhookSubscription - delete a webhook and its log.
  • POST rotateWebhookSecret - new secret, returned once.
  • POST sendTestWebhook - send a webhook.test event.
  • GET getWebhookDeliveries - last 50 deliveries.