Skip to main content
Deliveries to a generic HTTPS drain are signed with HMAC-SHA256, so your receiver can prove a batch came from Lua and has not been altered or replayed. Verify every request before you parse it.
Signing applies to http drains only. OTLP, Datadog, and Better Stack deliveries carry no X-Lua-Signature header — those destinations authenticate the sender with the API key or token you configured.

The header

During a rotation window the header carries two v1 values. The active secret’s signature is always first. Accept a request when any v1 matches any secret you hold.

What is signed

Three rules decide whether your implementation works:
Sign over the body after gunzip. Every delivery arrives with Content-Encoding: gzip, and the signature covers the bytes before compression. Frameworks that transparently inflate request bodies make this easy to get wrong in either direction — read the raw bytes yourself and decompress explicitly.
  • The separator is a literal . between the timestamp and the body. The timestamp is the decimal integer from t, with no padding.
  • Reject a header that is not in canonical form, rather than coercing it. t is 0 or a digit string with no leading zero, no sign, no fractional part and no 0x prefix; each v1 is exactly 64 lowercase hex characters. Lua sends nothing else, and a lenient parser is one an attacker can steer.
  • Compare in constant time, and compare bytes. Use timingSafeEqual, hmac.compare_digest, or hmac.Equal. A plain == on the hex string leaks how much of your secret an attacker has guessed.
  • Reject a timestamp more than 5 minutes from now, in either direction. The tolerance is 300 seconds and it is inclusive — exactly 300 seconds of skew still verifies. That is the replay window; the platform never signs a batch it will not send within it.

Test vector

Check your implementation against this before you wire it to a real drain. The body is a single line with no trailing newline.
The same value from a shell, which is also the quickest way to sign a replay of your own:

Verifiers

Each of these accepts a list of secrets — the active one and, during a rotation, the previous one — and each checks every v1 against every secret without returning early, so its timing says nothing about which one matched: the request is accepted when any v1 matches any secret you hold. All three reject a non-canonical t and a v1 that is not 64 lowercase hex characters before doing any work, which is the behaviour the platform’s own reference verifiers implement.

Rotate a secret

Rotation overlaps two secrets so there is no window where deliveries fail. The platform signs with both and your receiver accepts either.
1

Start the rotation

Output
From this moment every delivery carries two v1= values: the new secret’s first, the previous secret’s second. The window is 24 hours.
2

Roll the new secret out

Put the new value where your verifier reads the active secret, and move the old value to where it reads the previous one. The verifiers above take a list, so both are accepted throughout.Deploy, then confirm with a test delivery:
3

Close the window

Deliveries drop back to one v1 value, signed with the new secret. Remove the old value from your receiver. If you do nothing, the window closes on its own after 24 hours and has the same effect.
Starting a second rotation while a window is open is refused with DRAIN_ROTATION_IN_PROGRESS. Finalize the first one, or pass --finalize to close it and open a new one in the same step.

If it isn’t working

Almost always the compressed body. Compute the HMAC over the bytes after gunzip, not over what arrived. Check by logging the byte length you are signing: it should match the batch’s JSON length, not the smaller compressed length.
A rotation is open and your receiver takes only one secret, matching the first v1 it finds. The active secret’s signature is first, so a receiver still holding only the previous secret fails every request. Accept a list of secrets, and check every v1 against every one of them.
Your host’s clock has drifted. The tolerance is 5 minutes in either direction, which is generous; if you are failing inside it, run NTP. Do not widen the window past 5 minutes — that is the replay protection.
It cannot be read back; it is shown once at creation and once per rotation. Run lua drains rotate-secret <id> to mint a new one and deploy it. Because rotation overlaps, nothing is lost while you do it — but you will not be able to verify with the old value, so finalize as soon as the new one is live.

Next steps

Generic HTTPS

The full request contract and ownership verification.

Event schema

What is inside the body you just verified.

Delivery guarantees

At-least-once, retry, and drop semantics.

Security and data

What the platform scrubs and keeps.