Dev Portal
Sandbox operational5 APIs5 groups11 operations

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

code
X-Lango-Signature: t=1706348212,v1=5257a869e7bdb1c...
  • t is a Unix timestamp, in seconds, of when the portal signed the request.
  • v1 is HMAC-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.

javascript
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);
}
python
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 v1 does not match: answer 401 and stop. Do not process the body.
  • t far in the past: a replayed delivery. Five minutes of tolerance is generous; anything wider makes the check nearly pointless. Compare t against your own clock, not against now() 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