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:
- Open your dashboard → the Webhooks section.
- Enter your callback URL (an
httpsendpoint on your server) and save. - 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
processinghas 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.