Docs: Webhooks and signatures

Receiving results

Everything we send goes to one HTTPS URL you register, as a POST with a JSON body, signed with a secret issued to you alone. We deliver only to https:// URLs.

Header Value
Luminetric-Event What this is: statement.available, statement.withdrawn, request.failed, request.duplicate, retrieval.completed or retrieval.failed
Luminetric-Signature t=<unix seconds>,v1=<hex>, described below
Luminetric-Delivery-Id Always sent. A key unique to this event: the statement's version_id, or the event's failure_id, withdrawal_id, duplicate_id or event_id
Luminetric-Statement-Id The statement_id, on statement and withdrawal events
Luminetric-Schema-Version The contract version of the body, for example 1.0.0

Verifying the signature

v1 is the hex HMAC-SHA256, keyed with your secret, of the timestamp, a period and the raw request body, exactly as received:

signed_payload = t + "." + raw_body
expected       = hex(HMAC_SHA256(secret, signed_payload))

The key is your secret exactly as issued, as UTF-8 bytes. The header carries one t and one v1. Compare expected with v1 in constant time, and reject a request whose t is more than five minutes from your clock. The timestamp is inside the signed string, so an old signature cannot be replayed under a new time. Verify against the bytes you received, before parsing: re-serializing the JSON changes them.

import hmac, hashlib, time

def verify(secret: bytes, header: str, raw_body: bytes, tolerance=300) -> bool:
    try:
        parts = dict(p.strip().split("=", 1) for p in header.split(","))
        t, sig = parts["t"], parts["v1"]
        if abs(time.time() - int(t)) > tolerance:
            return False
    except (ValueError, KeyError, AttributeError):
        return False  # a missing or malformed header fails the check
    expected = hmac.new(secret, t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig)

Responding and retries

Return any 2xx once you have stored the event. Do the processing afterward.

  • A connection failure, a timeout (30 seconds) or a 5xx is retried, up to five attempts in all, with the wait doubling from 2 seconds.
  • A 4xx is not retried: it tells us the request itself is wrong, and sending the same bytes again cannot change that. We investigate and re-send.
  • An event that is still undelivered is kept and re-sent once the problem is resolved, under its original key. Nothing is lost by an outage on your side. For a retrieval run, only its latest event is re-sent, since that is the run's outcome.

Delivery is at least once, so you may receive an event more than once. De-duplicate on Luminetric-Delivery-Id. Return 2xx for an event type you do not recognize, and ignore it.

A summary bill is delivered parent first, then its children. If a delivery fails partway, you hold the parent and some children, and the parent's children.expected_count shows that more are owed.