ResilientLang docsDeveloper documentation (draft, product name is a placeholder)

Webhooks

Not in openapi.json yet. The webhook and audit routes are implemented in src/ResilientLang.Events but are not yet in the OpenAPI file, so they are not in the generated reference. This page is written from the module's code and its src/ResilientLang.Events/docs/WEBHOOKS.md. A test checks that every route listed here exists in the source; event payloads are in the event reference.

Webhooks send an HTTPS POST to your server when something happens. Delivery is at least once and not ordered: de-duplicate on RL-Event-Id.

Events#

The full list, with the data fields and an example for each event, is the event reference. An endpoint subscribes to * or a list of event types, and optionally to one site; a site-scoped endpoint never receives account-level events. webhook.ping is sent only by the "send test event" action.

Payloads carry ids, codes, counts and language codes, never page text, translation text, customer page URLs, tokens or keys.

Body (JSON, UTF-8, at most 64 KiB):

{ "id": "evt_...", "type": "site.published", "created": "2026-10-28T12:00:00Z", "siteId": "<guid or null>", "data": { } }

The tenant id is not included. Keys or values that look like secrets are replaced with [redacted].

The request you receive#

POST to your URL with these headers:

HeaderMeaning
RL-Signaturet=<unix seconds>,v1=<hex>[,v1=<hex>]
RL-Eventevent type
RL-Event-Idstable across retries and replays: use it to de-duplicate
RL-Deliveryunique per delivery
RL-Attempt1, 2, ...
RL-Replaytrue on replays only
User-AgentResilientLang-Webhooks/1

Answer with any 2xx within 10 seconds and do the real work asynchronously.

Verify the signature#

v1 = HMAC-SHA256(secret, t + "." + rawBody), hex encoded, where secret is the UTF-8 text of the signing secret exactly as shown (whsec_..., prefix included). Verify against the raw bytes of the body, compare in constant time, ignore schemes other than v1, and reject timestamps older than five minutes. During a secret rotation the header carries one v1 per active secret: accept the request if any matches. The scheme is the same as Stripe's manual verification (Stripe docs).

// Node 18+
import crypto from 'node:crypto';
export function verify(rawBody /* Buffer */, header, secret, toleranceSec = 300) {
  const parts = header.split(',').map(x => x.split('='));
  const t = Number((parts.find(p => p[0] === 't') || [])[1]);
  const sigs = parts.filter(p => p[0] === 'v1').map(p => p[1]);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const mac = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
  return sigs.some(s => s.length === 64 && crypto.timingSafeEqual(mac, Buffer.from(s, 'hex')));
}
import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str, tolerance=300) -> bool:
    parts = [x.split("=", 1) for x in header.split(",") if "=" in x]
    t = next((int(v) for k, v in parts if k == "t"), None)
    if t is None or abs(time.time() - t) > tolerance: return False
    mac = hmac.new(secret.encode(), str(t).encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(mac, v) for k, v in parts if k == "v1")

If you use the Python, PHP or Go SDK, one call does this check. The repository has cross-language test vectors in src/ResilientLang.Events/vectors/signature-vectors.json; the JavaScript example above is run against them when these docs are tested.

Retries, disabling and replay#

Non-2xx answers, timeouts and connection errors are retried: attempt 2 after about 30 seconds, then about 2 minutes, 10 minutes, 30 minutes, 2 hours, 6 hours and 12 hours (each with plus or minus 20 percent jitter; Retry-After is honoured up to 6 hours). That is 8 attempts in total, then the delivery is abandoned. Redirects are treated as failures and not followed. A 410 Gone answer disables the endpoint at once; 30 failed attempts in a row disable it with a reason shown in the dashboard and an audit entry. Re-enable with PATCH and {"enabled":true}. The delivery log keeps status code, a short error and timing for 30 days; response bodies are never read or stored.

Destination rules#

HTTPS only, default port, no credentials in the URL, no localhost, *.local, *.internal or single-label hosts, and no private IP literals. At send time every DNS answer must be a public address and the connection goes to that validated address, so DNS rebinding cannot redirect a delivery inside our network.

Secrets#

Creating an endpoint returns the signing secret once. POST /v1/webhooks/{id}/rotate-secret with {"graceHours":24} returns a new secret and the old one keeps signing for the grace period (at most 72 hours). Secrets are encrypted at rest.

Management routes#

All need a secret key with the scope webhooks:manage (a key restricted to one site is refused) and are rate limited. Other tenants' ids return 404.

Method and pathPurpose
GET /v1/webhooksList endpoints
POST /v1/webhooksCreate: { "url", "eventTypes"?, "siteId"?, "description"? }; returns 201 with webhook and secret
GET /v1/webhooks/{id}One endpoint
PATCH /v1/webhooks/{id}Update url, eventTypes, enabled, description
DELETE /v1/webhooks/{id}Delete
POST /v1/webhooks/{id}/rotate-secretRotate the secret ({"graceHours"})
POST /v1/webhooks/{id}/testSend a webhook.ping (202)
GET /v1/webhooks/{id}/deliveriesDelivery log (?cursor=&limit=)
GET /v1/webhooks/{id}/deliveries/{deliveryId}One delivery with its payload
POST /v1/webhooks/{id}/deliveries/{deliveryId}/replaySend again with the same event id (202)

Default limits (configurable): 120 requests per minute per caller and 240 per IP; test events 3 per minute per endpoint; secret rotation 10 per hour per account; replay 5 per minute per endpoint.

See also the audit log.