As payments and merchants change state, we POST signed callbacks to the callback URL configured for your account. This is how you learn a payment succeeded, an ACH cleared, a refund posted, or a merchant finished onboarding.
What you receive
We send the raw event JSON with three headers:
| Header | Value |
|---|---|
LC-Signature | t=<unix>,v1=<hex HMAC-SHA256> — verify this. |
LC-Event-Type | The event type, e.g. payment_intent.succeeded. |
LC-Event-Id | The event id (evt_…). Deduplicate on this. |
Verify the signature
Compute HMAC-SHA256(callbackSigningSecret, "{t}.{rawBody}") over the raw request body and
compare, in constant time, to the v1 value. Reject if the timestamp t is older than your
tolerance (e.g. 5 minutes).
static bool Verify(string secret, string header, string rawBody, TimeSpan tolerance)
{
var parts = header.Split(',').Select(p => p.Split('=', 2))
.ToDictionary(p => p[0], p => p[1]);
var t = long.Parse(parts["t"]);
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t) > tolerance.TotalSeconds)
return false;
var mac = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret),
Encoding.UTF8.GetBytes($"{t}.{rawBody}"));
var expected = Convert.ToHexString(mac).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(
Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(parts["v1"]));
}
const crypto = require("crypto");
function verify(secret, header, rawBody, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(",").map(kv => kv.split("=")));
const t = Number(parts.t);
if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto.createHmac("sha256", secret)
.update(`${t}.${rawBody}`).digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
Use the raw body
Verify against the exact raw bytes of the request body, before any JSON parsing or re-serialization — re-encoding changes the signature.
Events you’ll receive
| Event | What it means |
|---|---|
payment_intent.succeeded | Payment completed — safe to fulfill. |
payment_intent.processing | Async payment (ACH) pending — don’t fulfill yet. |
payment_intent.payment_failed | Payment failed. |
payment_intent.amount_capturable_updated | A manual-capture auth is ready to capture. |
payment_intent.canceled | The payment was canceled. |
charge.refunded | A refund was issued. |
charge.dispute.created | A dispute/chargeback was opened. |
charge.dispute.closed | A dispute was resolved. |
account.updated | A merchant’s onboarding/capability state changed. |
Best practices
- Respond
2xxfast, then do work asynchronously. Slow responses trigger retries. - Deduplicate on
LC-Event-Id— deliveries can repeat. - Deliveries retry up to 8 times with exponential backoff before being dead-lettered.
See the Webhooks API reference for the payload details.