Preview Leffel Consulting Payments is in active development and migrating to Finix — the API isn't publicly live yet, and some examples still reflect the outgoing build. Request early access →

Guides

Webhooks & callbacks

Receive signed events as payments progress. Verify the LC-Signature header, then update your systems.

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:

HeaderValue
LC-Signaturet=<unix>,v1=<hex HMAC-SHA256> — verify this.
LC-Event-TypeThe event type, e.g. payment_intent.succeeded.
LC-Event-IdThe 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

EventWhat it means
payment_intent.succeededPayment completed — safe to fulfill.
payment_intent.processingAsync payment (ACH) pending — don’t fulfill yet.
payment_intent.payment_failedPayment failed.
payment_intent.amount_capturable_updatedA manual-capture auth is ready to capture.
payment_intent.canceledThe payment was canceled.
charge.refundedA refund was issued.
charge.dispute.createdA dispute/chargeback was opened.
charge.dispute.closedA dispute was resolved.
account.updatedA merchant’s onboarding/capability state changed.

Best practices

  • Respond 2xx fast, 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.