Skip to main content

Webhooks

PayLink tells your server when something happens. Add endpoints in Console → Developers → Webhooks, separately for Test and Live (Live needs https), or with POST /api/tenant/webhooks. Each endpoint gets a signing secret (whsec_…, shown once) and can take all events or only some.

Events​

Eventdata
payment.processingThe payment. The payer says they've paid; confirmation pending.
payment.on_holdThe payment. A risk rule held it.
payment.succeededThe payment. Fulfil the order on this.
payment.failedThe payment, with failure.
payment.cancelledThe payment.
payment.expiredThe payment.
refund.succeeded, refund.failedThe refund and the payment.
dispute.created, dispute.updated, dispute.closedThe dispute and the payment.
alert.triggeredLive endpoints only. kind, threshold, window_minutes and a readable message.
webhook.testFrom Send test.

A delivery​

POST https://acme.in/hooks/paylink
PayLink-Signature: t=1760900120,v1=5f2b9c…e41a
PayLink-Event: payment.succeeded
Content-Type: application/json

{ "id": "evt_…", "type": "payment.succeeded", "created_at": "2026-10-21T10:02:11.000Z", "livemode": false, "data": { …the payment… } }

Verify the signature​

v1 is the lowercase hex HMAC-SHA256 of "{t}.{raw body}" with your endpoint's secret. Check it against the raw body (before JSON parsing), compare in constant time, and reject old t values (e.g. older than 5 minutes).

// Node.js
const crypto = require('crypto');
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map(p => p.split('=', 2)));
const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 || ''));
return ok && Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
}
# Python
import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split('=', 1) for p in header.split(','))
expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get('v1', '')) and abs(time.time() - int(parts['t'])) < 300
// PHP
function verify(string $rawBody, string $header, string $secret): bool {
parse_str(str_replace(',', '&', $header), $parts);
$expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1'] ?? '') && abs(time() - (int)$parts['t']) < 300;
}
// C#
var parts = header.Split(',').Select(p => p.Split('=', 2)).ToDictionary(p => p[0], p => p[1]);
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expected = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes($"{parts["t"]}.{rawBody}"))).ToLowerInvariant();
var ok = CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(parts["v1"]));

Answering and retries​

Answer 2xx within 10 seconds; do slow work after answering. Anything else is retried after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h, then marked failed. Every delivery is listed on the payment and on the Developers page, with Send again (or POST /api/tenant/webhooks/deliveries/{id}/redeliver).

Deliveries can arrive more than once and out of order: handle duplicates by the event id, and treat the payment's status in data as the truth.

PayLink only delivers to public addresses, unless the platform sets PayLink__Webhooks__AllowPrivateNetworks=true.