Integrations

Webhook

Receive success and failure notifications for collections, disbursements, and refunds.

Overview

Register a public HTTPS endpoint in the TricsoftPay user dashboard, verify every notification against its raw request body, and process repeated deliveries idempotently.

Receive transaction events

Transaction types and statuses

TricsoftPay currently sends webhooks for three transaction types.

Supported transaction types

Transaction typeDescription
collection A payment collected from a customer.
disbursement Funds sent to a recipient.
refund Funds returned for an eligible payment.

Webhook notifications currently contain only terminal statuses. Validate the exact lowercase values shown below.

Terminal webhook statuses

StatusMeaning
success The transaction completed successfully.
failed The transaction reached a failed terminal state.

Use success and failed when validating data.status. Do not substitute completed or successful.

Register a public endpoint

Create a public HTTPS endpoint in your application, then register its complete URL in the TricsoftPay user dashboard. The receiver belongs to your application, so examples use {{webhook_url}} rather than the TricsoftPay API {{base_url}}.

Endpoint requirements

  • Use HTTPS.
  • Make the endpoint publicly reachable from the internet.
  • Accept POST requests with an application/json body.
  • Do not protect the route with user-session or API-key authentication; authenticate each request with its webhook signature.
Public webhook route
import express from "express";

const baseRouter = express.Router();

baseRouter.post(
  "/webhooks",
  express.raw({ type: "application/json" }),
  webhookController.handleWebhook,
);

// Register {{webhook_url}}/webhooks in the TricsoftPay user dashboard.

Register in the dashboard

  1. 1

    Open Developers

    Sign in to the user dashboard and open the Developers section.

  2. 2

    Select Webhooks

    Open the Webhooks page under Developers.

  3. 3

    Enter the endpoint

    Provide the complete public URL, such as {{webhook_url}}/webhooks.

  4. 4

    Save the webhook

    Select Save Webhook and confirm that the current webhook URL is updated.

  5. 5

    Configure the secret

    Obtain the webhook secret from the platform and store it securely as WEBHOOK_SECRET. More detailed retrieval and rotation instructions are awaiting confirmation.

Verify the signature

TricsoftPay signs the exact raw request body with HMAC-SHA256 and sends the result in x-webhook-signature. Obtain the webhook secret from the platform and keep it outside your source code.

The signature header encoding and any prefix are awaiting confirmation. Keep signature decoding isolated so it can be updated without changing request handling.

HMAC verification
import { createHmac, timingSafeEqual } from "node:crypto";

const webhookSecret = process.env.WEBHOOK_SECRET;

function decodeWebhookSignature(value: string): Buffer {
  // Confirm the header encoding and optional prefix with TricsoftPay.
  // Replace this placeholder when the signature format is published.
  throw new Error(`Webhook signature decoding is not configured: ${value}`);
}

function verifySignature(rawBody: Buffer, signature: string): boolean {
  if (!webhookSecret) {
    throw new Error("WEBHOOK_SECRET is not configured");
  }

  const expected = createHmac("sha256", webhookSecret)
    .update(rawBody)
    .digest();
  const received = decodeWebhookSignature(signature);

  return (
    expected.length === received.length &&
    timingSafeEqual(expected, received)
  );
}

Webhook payload

The complete webhook body is awaiting confirmation. The handler currently relies on data.external_id and data.status.

Current payload fields

FieldTypeDescription
data.external_id string The external_id supplied when the transaction was created. Retries preserve this value.
data.status string The terminal webhook status. Currently success or failed.
Generic payload type
type WebhookPayload = {
  data: {
    external_id: string;
    status: "success" | "failed";
    [key: string]: unknown;
  };
  [key: string]: unknown;
};

Process events safely

  1. 1

    Read the raw body

    Preserve the exact request bytes before JSON parsing or other body transformations.

  2. 2

    Verify the signature

    Reject missing or invalid x-webhook-signature values before trusting the payload.

  3. 3

    Parse and validate

    Decode JSON only after verification and require data.external_id with a supported terminal status.

  4. 4

    Check idempotency

    Use the stable external_id and terminal status to prevent a retried notification from applying the same business operation twice.

  5. 5

    Apply the update

    Run your application-specific transaction update atomically and record that the notification was processed.

  6. 6

    Acknowledge receipt

    Return HTTP 200 with received set to true after processing succeeds. Return a non-2xx response when the notification cannot be accepted.

Webhook responses

HTTP statusWhen to use it
200 The signed notification was accepted, including a duplicate that was already processed.
400 The signed request contains an invalid or unsupported payload.
401 The signature header is missing or the signature is invalid.
500 A temporary application failure prevented the notification from being processed.

Generic webhook handler

Implement business processing behind a small idempotent service boundary. The receiver should not contain application-specific accounting or fundraiser logic.

Webhook controller
import type { NextFunction, Request, Response } from "express";

async function handleWebhook(
  req: Request,
  res: Response,
  next: NextFunction,
) {
  try {
    const signature = req.headers["x-webhook-signature"];
    if (typeof signature !== "string") {
      return res.status(401).json({ error: "Missing signature" });
    }

    const rawBody = req.body as Buffer;
    if (!Buffer.isBuffer(rawBody) || !verifySignature(rawBody, signature)) {
      return res.status(401).json({ error: "Invalid signature" });
    }

    const payload = JSON.parse(rawBody.toString("utf8")) as WebhookPayload;
    const { external_id: externalId, status } = payload.data ?? {};
    if (
      !externalId ||
      (status !== "success" && status !== "failed")
    ) {
      return res.status(400).json({ error: "Invalid webhook payload" });
    }

    const idempotencyKey = `${externalId}:${status}`;
    await webhookService.processOnce(idempotencyKey, payload);

    return res.status(200).json({ received: true });
  } catch (error) {
    next(error);
  }
}
Idempotent service
async function processOnce(
  idempotencyKey: string,
  payload: WebhookPayload,
): Promise<void> {
  await database.transaction(async (transaction) => {
    const alreadyProcessed = await webhookEvents.exists(
      idempotencyKey,
      transaction,
    );
    if (alreadyProcessed) return;

    await applyTransactionResult(payload.data, transaction);
    await webhookEvents.markProcessed(idempotencyKey, transaction);
  });
}

Items awaiting confirmation

  • What encoding and optional prefix does x-webhook-signature use?
  • What is the complete payload schema for collection, disbursement, and refund events?
  • What are the exact dashboard steps for obtaining or rotating WEBHOOK_SECRET?
  • What timeout and retry schedule applies after a non-2xx response?
  • Could future webhook versions include non-terminal statuses in addition to success and failed?
  • Does the complete payload include a separate event type or event identifier?