Guides/Verifying callback signatures
Updated 2026-08-01
Verifying callback signatures
Every callback carries an X-Lango-Signature header. Check it before you trust the body.
Callbacks (webhooks) arrive as a plain POST to the URL you gave the portal. Because anyone can send a POST to
that same URL, every delivery is signed so your server can tell a real callback from a forged one.
The header
X-Lango-Signature: t=1706348212,v1=5257a869e7bdb1c...
tis a Unix timestamp, in seconds, of when the portal signed the request.v1isHMAC-SHA256(webhook secret, "<t>.<raw request body>"), hex-encoded.
Two more headers travel with it: X-Lango-Delivery (the delivery's own id, useful for support and for
de-duplicating retries) and X-Lango-Attempt (1 on the first try, 2 on the first retry, and so on).
Your signing secret
Each app has its own secret, starting with whsec_. It is created the first time it is needed and shown once on
the app's page in Console, under callback settings. Store it the same way you store your consumer secret: on your
server, never in a mobile app or a browser.
Verify it
Recompute the signature over the raw request body, not a re-serialized version of the parsed JSON (key order or
whitespace can change the bytes and the signature will no longer match). Compare with a constant-time comparison,
not ===, so timing cannot leak the secret one byte at a time.
const crypto = require("crypto");
function verifyLangoSignature(header, rawBody, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const { t, v1 } = parts;
if (!t || !v1) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(v1);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
import hashlib
import hmac
def verify_lango_signature(header: str, raw_body: bytes, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1:
return False
payload = f"{t}.{raw_body.decode('utf-8')}".encode("utf-8")
expected = hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(v1, expected)
Read the raw body before your framework parses it as JSON. In Express, mount express.raw({ type: "application/json" })
on the callback route rather than the global express.json() middleware, so you still have the exact bytes the
portal signed.
Reject anything that does not check out
- No header, or
v1does not match: answer401and stop. Do not process the body. tfar in the past: a replayed delivery. Five minutes of tolerance is generous; anything wider makes the check nearly pointless. Comparetagainst your own clock, not againstnow()inside a slow handler.
What to answer
Any 2xx counts as delivered. Anything else, including a timeout, is recorded as failed; the sandbox does not
retry callbacks on its own, but a failed delivery can be resent from Console, Callbacks if the webhook inspector is
on. Answer quickly and do the actual work afterwards: the portal waits up to ten seconds for a response.
Next
- Your first payment in ten minutes to see a signed callback arrive.
- The sandbox page for what else can stop a call before a scenario runs.