Guide to Pine Labs Webhooks: Features and Best Practices
Pine Labs Online, also branded Plural by Pine Labs, is an Indian payment gateway. Merchants use it to take payments on orders, issue refunds, save cards as network tokens, run subscriptions, pay out to bank accounts and settle cross-border import payments. Its webhooks report the outcome of each of these, so your systems learn when an order is paid, a refund fails, a token is suspended or a subscription halts after its retries run out.
What are Pine Labs webhooks?
Pine Labs sends every webhook as a JSON POST to a single URL that its support team registers for you during onboarding. There's no self-serve subscription page or per-event filter in the docs, so you receive every event type for your account.
Each delivery carries webhook-id, webhook-timestamp and webhook-signature headers in the Standard Webhooks layout, and Pine Labs makes verifying them mandatory. The key is the difference: Pine Labs signs with your dashboard secret key as plain bytes, not a base64 whsec_ secret. See verification.md in the skill.
Pine Labs also points out that webhooks are asynchronous. For time-sensitive decisions, it recommends polling the Get Order API instead.
Pine Labs webhook features
| Feature | Details |
|---|---|
| Configuration | Through Pine Labs support at onboarding |
| Filtering | None documented. Every event is sent |
| Signature | Standard Webhooks HMAC-SHA256 layout |
| Signature header | webhook-signature |
| Secret | Dashboard secret key, used as raw bytes. No whsec_ |
| Body | JSON |
| Event routing | Body field event_type (eventType on payouts) |
| Response window | 2XX within 5 seconds |
| Retries | Up to 8 attempts over about 27.5 hours, per the retry-policy page |
| Source IPs | Published, separate lists for UAT and production |
Events and payloads
Pine Labs documents 30 events:
| Event | Triggered When | Common Use Cases |
|---|---|---|
CUSTOMER_ACTIVATED | The customer is activated on Pine Labs Online | Mark the customer active |
CUSTOMER_DELETED | The customer is removed from Pine Labs Online | Clean up customer records |
CUSTOMER_SUSPENDED | The customer is temporarily suspended through the portal | Block new charges |
CUSTOMER_CREATION_FAILED | Customer creation fails | Retry or alert |
ORDER_AUTHORIZED | The order is ready for authorization | Pre-auth flows |
ORDER_PROCESSED | The payment is successfully received | Fulfil the order |
ORDER_CANCELLED | The order is cancelled | Release inventory |
PAYMENT_FAILED | A payment against an order fails | Prompt the customer to retry |
ORDER_FAILED | The order fails | Mark the order failed |
REFUND_PROCESSED | A refund succeeds | Update the order, notify the customer |
REFUND_FAILED | A refund against an order fails | Alert support |
TOKEN_ACTIVATED | A card token is activated via the network | Enable saved-card payments |
TOKEN_DEACTIVATED | The token is deactivated | Remove the saved card |
TOKEN_SUSPENDED | The token is suspended | Disable the saved card |
TOKEN_PROVISION_FAILED | Token provisioning fails | Fall back to a full card entry |
SUBSCRIPTION_ACTIVATED | The subscription is activated | Grant access |
SUBSCRIPTION_PENDING | The subscription is pending | Show a pending state |
SUBSCRIPTION_PAUSED | The subscription is paused | Pause access |
SUBSCRIPTION_RESUMED | The subscription is resumed | Restore access |
SUBSCRIPTION_COMPLETED | The subscription is completed | Close it out |
SUBSCRIPTION_CHARGED | A successful charge is made | Record the payment |
SUBSCRIPTION_HALTED | All retries are exhausted and it moves from Pending to Halted | Dunning, suspend access |
SUBSCRIPTION_CANCELLED | The subscription is cancelled | Revoke access |
SUBSCRIPTION_ | An attempt to revoke the subscription fails | Alert support |
SUBSCRIPTION_UPDATED | The subscription is updated | Sync plan details |
SUBSCRIPTION_ | An attempt to update the subscription fails | Alert support |
payout-transaction-success | The payout is processed | Mark the payout paid |
payout-transaction-failed | The payout fails | Retry or alert |
ORDER_SETTLED | Cross-border import payments: the order is settled | Reconcile |
ORDER_ | Cross-border import payments: settlement is rejected | Investigate |
Pine Labs' own description of ORDER_ repeats ORDER_SETTLED's word for word, so its exact trigger is undocumented. Treat it as a settlement problem to investigate.
What data holds depends on the group:
| Group | data holds |
|---|---|
| Orders, payments, refunds, settlement | order_id, merchant_order_reference, type (CHARGE / REFUND), status, order_, purchase_details, and parent_order_id on refunds |
| Customers | customer { customer_ |
| Tokens | token { token_ |
| Subscriptions | subscription { subscription_. These payloads also carry top-level event_id and merchant_id |
| Payouts | See below |
Payout events use a different envelope. The name is camelCase eventType, with eventId, eventSource: "PAYOUT", eventVersion, sentAt and eventTimeStamp beside data, and the data fields are camelCase too: clientReferenceId, paymentReferenceId, status, bankTransactionReferenceId and amount. The Pay by Points order examples also use eventType, so read event_type ?? eventType before routing.
This is the CUSTOMER_ACTIVATED sample from the webhook samples library:
{
"event_type": "CUSTOMER_ACTIVATED",
"data": {
"customer": {
"customer_id": "cust-v1-0811030624-aa-RBDgpR",
"merchant_customer_reference": "12345ABC",
"first_name": "Kevin",
"last_name": "Bob",
"country_code": "91",
"mobile_number": "9876543210",
"email_id": "kevin.bob@example.com",
"billing_address": {
"address1": "10 Downing Street Westminster London",
"address2": "Oxford Street Westminster London",
"address3": "Baker Street Westminster London",
"pincode": "51524036",
"city": "Westminster",
"state": "Westminster",
"country": "London"
},
"shipping_address": {
"address1": "10 Downing Street Westminster London",
"address2": "Oxford Street Westminster London",
"address3": "Baker Street Westminster London",
"pincode": "51524036",
"city": "Westminster",
"state": "Westminster",
"country": "London"
},
"gstin": "27ABCDE1234F2Z5",
"merchant_metadata": {
"key1": "XX",
"key2": "DOF"
},
"status": "ACTIVE",
"created_at": "2024-10-04T13:11:29.645591Z",
"updated_at": "2024-10-04T13:11:29.645657Z"
}
}
}
Setting up Pine Labs webhooks
You need a Pine Labs Online merchant account, in UAT (sandbox) or production.
- Sign in to the Pine Labs Online dashboard, open Settings and copy your secret key into
PINE_LABS_SECRET_KEY, exactly as shown. - Contact Pine Labs support with your HTTPS endpoint, for example
https://example.com/webhooks/pine-labs, and ask them to register it. - If you restrict inbound traffic, allowlist Pine Labs' NAT gateway addresses from the webhooks overview page. UAT uses
15.206.155.74,13.202.206.188,13.202.213.132and13.126.41.142; production uses18.61.136.235,40.192.21.254,18.60.11.185,13.200.147.61and13.201.195.80/28.
To route Pine Labs through Hookdeck, create a source with the Pine Labs type and give support the source URL to register. When you add the secret, follow the source's configuration note:
Paste the secret key exactly as shown in the Pine Labs dashboard, without adding a whsec_ prefix.
During development, the Hookdeck CLI forwards the source to your machine:
npx hookdeck-cli listen 3000 pine-labs --path /webhooks/pine-labs
Securing Pine Labs webhooks
Pine Labs' signature page walks through an example: a merchant key abc1234 is base64-encoded to YWJjMTIzNA== and then decoded again before use, which means the HMAC key is simply the raw bytes of the secret. Sign <webhook-id>.<webhook-timestamp>.<raw body> with HMAC-SHA256, base64-encode the result, and compare it with each v1, entry in webhook-signature.
This function uses a secret without the whsec_ prefix as raw bytes, which is what Pine Labs needs, and would base64-decode a whsec_ secret if it ever saw one:
const crypto = require('node:crypto');
function decodeBase64(value) {
if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
const bytes = Buffer.from(value, 'base64');
return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
? bytes : null;
}
function verifySignature(rawBody, headers, secret) {
if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
const prefix = headers['webhook-id'] !== undefined ? 'webhook' : 'svix';
const id = headers[`${prefix}-id`];
const timestamp = headers[`${prefix}-timestamp`];
const signature = headers[`${prefix}-signature`];
if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
!/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
const key = secret.startsWith('whsec_')
? decodeBase64(secret.slice(6)) : Buffer.from(secret, 'utf8');
if (!key) return false;
const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
const expected = crypto.createHmac('sha256', key).update(message).digest();
return candidates.some((candidate) => {
const received = decodeBase64(candidate);
if (!received) return false;
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
Don't use the standardwebhooks or svix libraries for Pine Labs. They always base64-decode the secret, derive a different key from Pine Labs' plain string, and reject every delivery.
The function applies no timestamp window. Hookdeck doesn't either, and it keeps the original webhook-timestamp on retries and replays, so a window would reject genuine events that Hookdeck delivers late.
Five seconds is a short window for payment processing, so the receiver only stores the event:
const express = require('express');
// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
try {
const value = JSON.parse(rawBody.toString('utf8'));
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
} catch {
return null;
}
}
// paymentEvents.record(webhookId, name, event) stores the event and resolves once it is durable.
function createPineLabsReceiver(paymentEvents) {
const app = express();
app.post('/webhooks/pine-labs', express.raw({ type: '*/*' }), async (req, res) => {
const secret = process.env.PINE_LABS_SECRET_KEY;
if (!secret) return res.sendStatus(500);
if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);
const event = parseJsonObject(req.body);
if (!event) return res.sendStatus(400); // signed, but not a JSON object
const name = event.event_type ?? event.eventType; // payouts use camelCase
try {
await paymentEvents.record(req.headers['webhook-id'], name, event);
} catch {
return res.sendStatus(503);
}
return res.sendStatus(200); // unhandled event types are acknowledged too
});
return app;
}
If Pine Labs posts to a Hookdeck source and the signature doesn't match, Hookdeck still replies 200, records the request as failed verification and doesn't deliver it.
Make Pine Labs webhooks production-ready. Hookdeck Event Gateway answers Pine Labs well within five seconds and routes orders, subscriptions and payouts to the services that handle them.
Pine Labs webhook limitations and pain points
A five-second response window
The Problem: Deliveries are marked failed even though your handler eventually succeeds.
Why It Happens: Pine Labs counts a delivery as successful only if it gets a 2XX within five seconds.
Workarounds:
- Store the event and respond before doing any processing.
How Hookdeck Can Help: Hookdeck answers Pine Labs immediately, and your service gets its own, longer timeout.
Conflicting retry documentation
The Problem: You can't size your recovery plan because you don't know how often Pine Labs retries.
Why It Happens: The retry-policy page lists eight attempts: immediately, then 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours and twice more at 10 hours. The overview page says "up to three times".
Workarounds:
- Plan for the shorter policy, and reconcile with the Get Order API after any outage.
- Ask Pine Labs support which policy applies to your account.
How Hookdeck Can Help: Hookdeck receives each event on the first attempt and retries your service on a schedule you control, whichever policy Pine Labs follows.
Events arrive out of order
The Problem: ORDER_PROCESSED arrives before ORDER_AUTHORIZED, and the order ends up in the wrong state.
Why It Happens: Pine Labs doesn't guarantee ordering, and gives that exact example.
Workarounds:
- Never move an order back from a final state, and confirm with the Get Order API when in doubt.
How Hookdeck Can Help: Hookdeck's log shows the arrival order of every event for an order, which makes these cases easy to trace.
A non-standard secret
The Problem: Verification fails with an off-the-shelf Standard Webhooks library.
Why It Happens: Pine Labs uses the dashboard key as raw bytes, while the libraries expect a base64 whsec_ secret.
Workarounds:
- Use a manual verifier like the one above, and store the key exactly as shown.
How Hookdeck Can Help: Hookdeck's Pine Labs source takes the key as shown and verifies it correctly, so your service doesn't need special handling.
Best practices
Deduplicate on webhook-id
It stays the same when Pine Labs resends an event. Record it in the same transaction as the order update; our webhook idempotency guide explains why.
Use merchant_order_reference to find orders
Order, payment and refund events carry your own reference beside Pine Labs' order_id.
Watch subscription failures
SUBSCRIPTION_HALTED means every retry has failed. Start dunning or suspend access, and alert on SUBSCRIPTION_ and SUBSCRIPTION_.
Keep customer details out of logs
Customer events include names, phone numbers, email and address details. Log IDs only.
Conclusion
Pine Labs covers orders, tokens, subscriptions, payouts and cross-border settlement, with Standard Webhooks headers and published source IPs. Paste the secret key as shown, and expect payouts to use a camelCase envelope.
Hookdeck Event Gateway verifies Pine Labs' signature as documented, answers fast and keeps every payment event until your systems have handled it. Sign up for Hookdeck free.