Skip to content

Cantilapay webhooks phase-2

Cantilapay notifies your server of payment lifecycle events — a charge succeeding, a subscription renewing, and so on — by POSTing a signed JSON payload to an endpoint you register. Webhooks are how you react to things that happen asynchronously or outside a request you initiated.

How delivery works

  1. You register an HTTPS endpoint and receive a signing secret.
  2. Cantilapay POSTs each event as a JSON body, with a Cantilapay-Signature header.
  3. Your server verifies the signature, then acts on the event.
  4. Respond 2xx quickly. Non-2xx responses are retried.

The signature is an HMAC-SHA256 over <timestamp>.<rawBody> using your signing secret. The header looks like t=1716900000,v1=<hex>.

Verify before you trust. Always call cp.webhooks.verify on the raw request body before parsing or acting on an event. Parsing first and re-serializing will change the bytes and break verification.

Verifying signatures

cp.webhooks.verify validates both the timestamp (rejecting replays older than toleranceSeconds, default 300) and the HMAC. It returns true on success and throws otherwise.

MethodParametersReturnsDescription
webhooks.verify(input){ rawBody, signatureHeader, signingSecret, toleranceSeconds? }true (throws on failure)Validate a Cantilapay-Signature header.
FieldTypeDescription
rawBodystringThe exact raw request body bytes, as text.
signatureHeaderstringThe Cantilapay-Signature header value.
signingSecretstringYour endpoint's signing secret.
toleranceSecondsnumber?Max signature age in seconds (default 300).

Full Express handler

The key detail is capturing the raw body. Use express.raw for the webhook route so the bytes are untouched before verification.

import express from "express";
import { Cantilapay } from "@cantila/cantilapay";

const cp = new Cantilapay(process.env.CANTILAPAY_SECRET_KEY!);
const app = express();

app.post(
  "/webhooks/cantilapay",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const rawBody = req.body.toString("utf8");

    try {
      cp.webhooks.verify({
        rawBody,
        signatureHeader: req.header("Cantilapay-Signature")!,
        signingSecret: process.env.CANTILAPAY_WEBHOOK_SECRET!,
      });
    } catch (err) {
      // Bad signature or stale timestamp — reject.
      return res.status(400).send("invalid signature");
    }

    const event = JSON.parse(rawBody);

    switch (event.type) {
      case "payment_intent.succeeded":
        // fulfil the order
        break;
      case "subscription.renewed":
        // extend access for another period
        break;
      default:
        // ignore unrecognised event types
        break;
    }

    // Acknowledge quickly; do heavy work asynchronously.
    res.status(200).send("ok");
  },
);

app.listen(3000);

Respond 2xx as soon as the event is accepted. Offload slow work (emails, provisioning) to a queue so retries are not triggered by a slow handler.

Representative event types

The event catalogue grows as coverage expands. Common types include:

EventWhen it fires
payment_intent.succeededA charge completed successfully.
payment_intent.failedA charge was declined or errored.
subscription.renewedA subscription billed for a new period.
subscription.canceledA subscription ended.
invoice.paidAn invoice was paid.
refund.succeededA refund completed.

Always branch on event.type and ignore types you do not handle, so new event types never break your endpoint.

See also