Skip to content

Webhook Setup ​

Configure your webhook in Pine Labs Dashboard → Settings → Payment webhooks.

  1. Enter your public HTTPS webhook URL and select the events you need.
  2. Save the signing secret securely when shown—it is displayed only once.
  3. Verify incoming notifications and return 2xx, as described below.

URL changes apply to new deliveries and retries immediately; they do not change your signing secret. Reenabling webhooks does not automatically resend missed notifications.

Rotate the signing secret ​

The Dashboard offers two modes:

ModeEffect
24-hour overlap (default)The new key becomes current immediately; the previous key signs deliveries too until its displayed expiry. Update receivers during this window.
Immediate replacementEvery previous key is revoked immediately. Update receivers in coordination and use this mode if a key is compromised.

Save the new secret when shown. During overlap, accept either active secret; the verification example below supports both signatures. Remove expired or revoked secrets from your receiver. A second overlap rotation is available after the current overlap ends.

Receive a webhook ​

Pine Labs sends an HTTP POST to your HTTPS endpoint when a payment, order, or refund changes status. The JSON body describes the resource at the time of that event.

  1. Verify the signature using the steps below.
  2. Check the event fields and save the event to your database or queue before responding.
  3. Return HTTP 200 to confirm receipt, then process the saved event. Any 2xx response is accepted.
  • Respond within 10 seconds with 2xx; redirects are not followed.
  • Retries: up to three retries after the initial attempt, approximately 60 seconds apart, for network errors, timeouts, or HTTP 408, 429, and 5xx. Other non-2xx responses are not retried automatically.
  • Avoid duplicates: process each event_id only once. Retries and manual resends keep the same event ID and body.
  • Check current status: events may arrive out of order. Use the resource's GET endpoint when you need its latest state. Webhook delivery does not affect payment or refund processing.

A manual resend sends only the notification; it never repeats a payment, topup or refund.

Signature verification ​

The signature lets your backend check that the notification came from Pine Labs and its body was not changed. Use the webhook signing secret, not your API HMAC secret.

FieldTypeDescription
Content-Typestringapplication/json.
IIMMPACT-SignaturestringContains the delivery timestamp (t) and signature (v1), as shown below.
http
Content-Type: application/json
IIMMPACT-Signature: t=<Unix-seconds>,v1=<hex-digest>

Verification steps ​

  1. Read the original body bytes, before JSON parsing. Apply a request-size limit in your server or framework.
  2. Verify the header timestamp and signature using the function below.
  3. If verification fails, return 401. Otherwise, parse and validate the event, save it, then return 200.

TypeScript example ​

Works with Bun or Node.js. The function checks the five-minute window and compares HMAC-SHA256 signatures in constant time.

typescript
import { Buffer } from "node:buffer";
import { createHmac, timingSafeEqual } from "node:crypto";

const SIGNATURE_PATTERN = /^t=(\d+),\s*v1=([a-fA-F0-9]{64})(?:,\s*v1=([a-fA-F0-9]{64}))?$/;
const TIMESTAMP_TOLERANCE_SECONDS = 5 * 60;

export function verifyWebhookSignature(
  rawBody: Uint8Array,
  signature: string | null,
  activeWebhookSecrets: readonly string[],
  nowSeconds = Math.floor(Date.now() / 1000)
): boolean {
  const match = SIGNATURE_PATTERN.exec(signature ?? "");
  if (!match) return false;

  const timestamp = Number(match[1]);
  if (
    !Number.isSafeInteger(timestamp) ||
    !Number.isFinite(nowSeconds) ||
    Math.abs(nowSeconds - timestamp) > TIMESTAMP_TOLERANCE_SECONDS
  ) {
    return false;
  }

  if (activeWebhookSecrets.length < 1 || activeWebhookSecrets.length > 2) return false;
  const received = [match[2], match[3]]
    .filter((value): value is string => value !== undefined)
    .map((value) => Buffer.from(value, "hex"));

  return activeWebhookSecrets.some((secret) => {
    const key = Buffer.from(secret, "base64");
    if (key.length === 0 || key.toString("base64") !== secret) return false;
    const expected = createHmac("sha256", key)
      .update(`${match[1]}.`)
      .update(rawBody)
      .digest();
    return received.some((digest) => timingSafeEqual(expected, digest));
  });
}

Pass the exact body bytes, the IIMMPACT-Signature header, and an array containing your currently active Base64 webhook secrets (normally one). During overlap the header has repeated v1 entries: t=...,v1=...,v1=.... Do not turn the header into a map that discards repeated entries. The pattern checks digest length before comparison. Do not pass JSON.stringify(parsedBody)—even whitespace changes invalidate the signature.

For retried notifications, t is refreshed even if occurred_at is old. Check freshness against t, not occurred_at. During secret rotation, accept either currently active secret; stop accepting a revoked secret immediately.

Event catalog and schemas ​

Payment events ​

Each event's data follows the payment response fields, with action omitted. No card input is included.

EventDescription
payment.processingThe payment result is not confirmed yet. Wait before creating another attempt.
payment.cancellation_pendingCancellation was requested, but the result is not confirmed yet.
payment.successfulPayment collection is confirmed. transaction_refids identifies the winning payment's fulfillment units; track the order for their results.
payment.failedPayment failed. The failure object explains the result.
payment.cancelledCancellation is confirmed. A new payment may be created for the unpaid order.
payment.expiredThe payment window ended and collection was confirmed unsuccessful. A new payment may be created for the unpaid order.

Order events ​

Each event's data follows the order response fields, including per-unit items, expires_at, and refunds known at that time.

EventDescription
order.completedEvery topup has a final result. Some may have failed; check the transactions. Refunds may still be processing.
order.expiredAdmission cutoff passed with no winning collection and no unresolved attempt. A subsequently verified late collection is still reconciled.

Refund events ​

Each event's data follows the refund response fields. Track these events separately from order completion.

EventDescription
refund.pendingA refund obligation exists. It may be awaiting submission or provider confirmation; customer repayment is not yet confirmed.
refund.requires_reviewThe refund was rejected or its result is uncertain. Pine Labs is reviewing it; do not refund the customer independently.
refund.successfulCustomer repayment is confirmed.

Envelope fields ​

FieldTypeDescription
event_idstringUnique notification ID. Use it to avoid processing the same event twice.
typestringEvent name; suffix matches data.status.
occurred_atstringUTC event occurrence time.
dataobjectPayment, order, or refund details at the time of the event.

Payment example ​

Example of a confirmed payment:

json
{
  "event_id": "evt_payment_example",
  "type": "payment.successful",
  "occurred_at": "2026-09-14T02:10:42.000Z",
  "data": {
    "payment_id": "pay_example",
    "order_id": "ord_example",
    "external_reference": "ORD-00042",
    "customer_external_id": "CUS-1042",
    "transaction_refids": ["ORD-00042-1", "ORD-00042-2"],
    "status": "successful",
    "currency": "MYR",
    "payment_method_code": "CARD",
    "customer_fee_percent": "50.00",
    "fee": {
      "type": "percentage",
      "value": "1.70"
    },
    "totals": {
      "items_subtotal": "100.00",
      "fee": {
        "total": "1.70",
        "customer": "0.85",
        "merchant": "0.85"
      },
      "payment_total": "100.85"
    },
    "created_at": "2026-09-14T02:05:00.000Z",
    "updated_at": "2026-09-14T02:10:42.000Z",
    "expires_at": "2026-09-14T02:35:00.000Z"
  }
}

Order example ​

After all units finish, the full order snapshot includes their recorded results. This successful example has no refunds:

json
{
  "event_id": "evt_order_example",
  "type": "order.completed",
  "occurred_at": "2026-09-14T02:10:42.000Z",
  "data": {
    "order_id": "ord_example",
    "external_reference": "ORD-00042",
    "status": "completed",
    "currency": "MYR",
    "items_subtotal": "100.00",
    "items": [
      {
        "product": "TNB",
        "product_name": "Tenaga Nasional Berhad",
        "account": "220012345679",
        "amount": "60.00",
        "status": "successful",
        "status_code": 20,
        "refid": "ORD-00042-1",
        "cost": "58.1234",
        "timestamp": "2026-09-14T02:10:42.000Z"
      },
      {
        "product": "TNB",
        "product_name": "Tenaga Nasional Berhad",
        "account": "220012345678",
        "amount": "40.00",
        "status": "successful",
        "status_code": 20,
        "refid": "ORD-00042-2",
        "cost": "39.1250",
        "timestamp": "2026-09-14T02:10:42.000Z"
      }
    ],
    "refunds": [],
    "created_at": "2026-09-14T02:00:00.000Z",
    "updated_at": "2026-09-14T02:10:42.000Z",
    "expires_at": "2026-09-15T02:00:00.000Z"
  }
}

order.completed means every unit is terminal, not that every unit succeeded or every refund finished; see Refunds for repayment progress. It contains the recorded per-unit results and refunds known at that time, without items[].unit_price. See the populated order detail example. Retries and replay retain the original event body; later refund changes appear through refund events or a fresh order read.

Refund example ​

json
{
  "event_id": "evt_refund_example",
  "type": "refund.successful",
  "occurred_at": "2026-09-24T05:11:49.707Z",
  "data": {
    "refund_id": "ref_example",
    "order_id": "ord_example",
    "payment_id": "pay_example",
    "reason": "failed_fulfillment",
    "amount": "10.00",
    "currency": "MYR",
    "status": "successful",
    "created_at": "2026-09-24T05:11:49.321Z",
    "updated_at": "2026-09-24T05:11:49.707Z"
  }
}

Duplicate or delayed notifications ​

SituationWhat to do
The same event_id arrives againIf you already saved it, return 2xx without processing it again. Retain these IDs for at least seven days.
An older update arrives after a newer oneKeep the newer status. For example, a delayed payment.processing must not replace payment.successful. If unsure, read the current payment status.
You need to know whether the customer paidCall Get Payment Detail.
You need the latest topup results or refund statusCall Get Order Detail.

Notifications can arrive late or be missed. A repeated notification does not repeat a charge, topup, or refund. Your handler should also avoid repeating its own actions, such as sending the same receipt twice. Keep signing secrets, PINs, and voucher links out of logs.

Pine Labs API Documentation