Guide

Webhooks

Some things happen after an API call returns — an ACH payment settles, a dispute is opened, or a merchant's onboarding status changes. Pay-Pi forwards signed events for these to a callback URL you register, so your systems stay in sync without polling.

Turn on webhooks

Set your webhook up yourself in the dashboard — no support ticket needed:

  1. Open your dashboard → the Webhooks section.
  2. Enter your callback URL (an https endpoint on your server) and save.
  3. A signing secret is generated. Reveal and copy it — you'll use it to verify every event (below). You can rotate it anytime.

Leave the URL empty and save to turn webhooks off.

What you'll receive

Pay-Pi forwards events including:

  • ACH settlement — an ACH payment that was processing has settled or failed (transfer.updated).
  • Disputes — a payment has been disputed or charged back (dispute.created).
  • Onboarding / account changes — a merchant's charges-enabled or PCI status changed (merchant.updated, compliance_form.updated).

Delivery

Each event is an HTTPS POST to your callback URL with the raw JSON body and three headers: LC-Signature, LC-Event-Type (the underlying Finix {entity}.{type}), and LC-Event-Id (dedupe on this). Events reference the affected object — for a payment, the same lcPaymentId and orderRef you saw when you created it.

POST https://your-app.com/webhooks/pay-pi
Content-Type: application/json
LC-Signature: t=1718900000,v1=3f9a...      # verify before trusting the body
LC-Event-Type: transfer.updated             # {entity}.{type}
LC-Event-Id: event_8fZ...                    # dedupe on this

{
  "lcPaymentId": "lcpay_2Bd7...",
  "orderRef": "order-1042",
  "status": "succeeded",
  "finixTransferId": "TRa1Q...",
  "finixState": "SUCCEEDED"
}

Verify the signature

Every request carries a signature derived from your signing secret. Compute v1 = HMAC_SHA256(signingSecret, "{t}.{rawBody}") as lowercase hex and compare it to the v1 in LC-Signature — against the raw request body, before acting on the event. Reject anything that doesn't match, and reject a t older than your tolerance (e.g. 5 minutes).

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; // reject stale
  const expected = crypto.createHmac("sha256", secret).update(t + "." + rawBody).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
// Use the RAW request body — any re-serialization changes the signature.

A C# version and the full header reference are in the API reference.

Responding

Return a 2xx quickly to acknowledge receipt. Do the heavy work asynchronously, and make your handler idempotent (keyed on LC-Event-Id) so a retried delivery is safe to process twice. Non-2xx responses are retried with backoff, then dead-lettered.