Webhooks
Webhooks tell your server when something happens: a payment succeeded, a refund finished, a dispute was opened. Use them instead of polling, and as the source of truth when a customer never returns from the bank's page.
Register an endpoint
Create endpoints under Developers → Webhooks or with the API. The URL must be a public https address, and it must be on a website you registered or match a callback URL you registered under Settings → Websites & callbacks (otherwise 400 webhook_url_not_allowed).
{
"url": "https://shop.example.com/webhooks/pf",
"enabled_events": ["payment_intent.succeeded", "payment_intent.payment_failed", "refund.succeeded", "refund.failed"],
"description": "Order system"
}
enabled_events lists the event types you want, or ["*"] for all of them.
{
"id": "we_01J9ZQC8D0F2H4K6M8P1R3T5V7",
"object": "webhook_endpoint",
"url": "https://shop.example.com/webhooks/pf",
"enabled_events": ["payment_intent.succeeded", "payment_intent.payment_failed", "refund.succeeded", "refund.failed"],
"status": "enabled",
"description": "Order system",
"secret": "REPLACE_WITH_THE_SECRET_FROM_THE_RESPONSE",
"created": 1790000200
}
The signing secret is returned once, in this response. Store it with your other secrets. Lost or leaked? Roll it with POST /v1/webhook_endpoints/{id}/roll_secret; the response carries the new secret, shown once.
The event
Each delivery is a POST with a JSON body, the event:
{
"id": "evt_01J9ZQ4V8K2M3N5P7R9T1W3Y5E",
"object": "event",
"type": "payment_intent.succeeded",
"created": 1790000010,
"merchant_id": "mer_01J9ZPZZ0A1B2C3D4E5F6G7H8J",
"api_version": "2026-10-01",
"livemode": false,
"aggregate_type": "payment_intent",
"aggregate_id": "pi_01J9ZQ4V8K2M3N5P7R9T1W3Y5A",
"sequence": 4,
"request_id": "req_01J9ZQ4V8K2M3N5P7R9T1W3Y5F",
"data": {
"object": {
"id": "pi_01J9ZQ4V8K2M3N5P7R9T1W3Y5A",
"object": "payment_intent",
"amount": 12500,
"currency": "try",
"status": "succeeded",
"capture_method": "automatic",
"created": 1790000000,
"livemode": false
},
"previous_attributes": {"status": "processing"}
}
}
data.object is the API object after the change, exactly as the API returns it; previous_attributes holds the fields that changed. sequence grows by one for each change of the same object, and deliveries of one object to one endpoint arrive in that order. Events contain no card numbers.
Event types
| Event | data.object |
|---|---|
payment_intent.created |
PaymentIntent |
payment_intent.requires_action |
PaymentIntent |
payment_intent.processing |
PaymentIntent |
payment_intent.amount_capturable_updated |
PaymentIntent |
payment_intent.succeeded |
PaymentIntent |
payment_intent.payment_failed |
PaymentIntent |
payment_intent.canceled |
PaymentIntent |
payment_intent.fraud_updated |
PaymentIntent |
refund.created |
Refund |
refund.updated |
Refund |
refund.succeeded |
Refund |
refund.failed |
Refund |
dispute.created |
Dispute |
dispute.updated |
Dispute |
dispute.closed |
Dispute |
payout.created |
Payout |
payout.paid |
Payout |
payout.failed |
Payout |
topup.succeeded |
Topup |
balance.available |
Balance |
balance.negative_limit_exceeded |
Balance |
merchant.updated |
Merchant |
topup.updated |
Topup |
customer.created |
Customer |
customer.updated |
Customer |
customer.deleted |
Customer |
Events are immutable and kept, so you can read them later with GET /v1/events and GET /v1/events/{id}, and ask for a delivery again with POST /v1/events/{id}/resend.
Verify the signature
Anyone who knows your URL could post to it, so verify every delivery. The header is:
PF-Signature: t=1790000011,v1=<64 hexadecimal characters>
t is the unix time the delivery was signed and v1 is the hex HMAC-SHA256 of the string t + "." + body keyed with your signing secret. To verify:
- Read the raw request body exactly as received. Do not parse and re-encode it first.
- Split the header, take
tand everyv1. - Reject when
tis more than 5 minutes away from your clock (protects against replay). - Compute
HMAC_SHA256(secret, t + "." + body)in hex and compare with eachv1in constant time. Accept when one matches.
During a secret roll more than one v1 may be present.
<?php
function verifyPfSignature(string $payload, string $header, string $secret, int $tolerance = 300): bool
{
$t = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($key === 't') {
$t = $value;
} elseif ($key === 'v1') {
$signatures[] = $value;
}
}
if ($t === null || !ctype_digit($t) || abs(time() - (int) $t) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $t . '.' . $payload, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
$payload = file_get_contents('php://input');
$header = $_SERVER['HTTP_PF_SIGNATURE'] ?? '';
if (!verifyPfSignature($payload, $header, getenv('PF_WEBHOOK_SECRET'))) {
http_response_code(400);
exit;
}
$event = json_decode($payload, true);
http_response_code(200);
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyPfSignature(rawBody, header, secret, toleranceSeconds = 300) {
let t = null;
const signatures = [];
for (const part of String(header ?? '').split(',')) {
const [key, value = ''] = part.trim().split('=');
if (key === 't') t = value;
if (key === 'v1') signatures.push(value);
}
if (t === null || !/^\d+$/.test(t)) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
return signatures.some((sig) => {
const given = Buffer.from(sig, 'hex');
return given.length === expected.length && timingSafeEqual(given, expected);
});
}
// Express: use express.raw({ type: 'application/json' }) so req.body is the raw Buffer.
// app.post('/webhooks/pf', express.raw({ type: 'application/json' }), (req, res) => {
// if (!verifyPfSignature(req.body.toString('utf8'), req.get('PF-Signature'), process.env.PF_WEBHOOK_SECRET)) return res.sendStatus(400);
// const event = JSON.parse(req.body.toString('utf8'));
// res.sendStatus(200);
// });
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"math"
"strconv"
"strings"
"time"
)
// Verify checks a PF-Signature header against the raw request body.
func Verify(payload []byte, header, secret string, tolerance time.Duration, now time.Time) bool {
var t string
var signatures []string
for _, part := range strings.Split(header, ",") {
key, value, _ := strings.Cut(strings.TrimSpace(part), "=")
switch key {
case "t":
t = value
case "v1":
signatures = append(signatures, value)
}
}
ts, err := strconv.ParseInt(t, 10, 64)
if err != nil || math.Abs(float64(now.Unix()-ts)) > tolerance.Seconds() {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(t + "."))
mac.Write(payload)
expected := mac.Sum(nil)
for _, sig := range signatures {
given, err := hex.DecodeString(sig)
if err == nil && hmac.Equal(given, expected) {
return true
}
}
return false
}
import hashlib
import hmac
import time
def verify_pf_signature(payload: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
t = None
signatures = []
for part in (header or "").split(","):
key, _, value = part.strip().partition("=")
if key == "t":
t = value
elif key == "v1":
signatures.append(value)
if t is None or not t.isdigit() or abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + payload, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, sig) for sig in signatures)
# Flask: verify_pf_signature(request.get_data(), request.headers.get("PF-Signature", ""), SECRET)
Respond and retry
- Answer with any
2xxstatus to acknowledge. Do the real work after you have stored the event, because the platform waits only a few seconds (10 by default) for your answer. - A timeout, a connection error or any other status counts as a failure and is retried with growing delays: after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and then 24 hours three times, about three days in all. After the last attempt the endpoint is marked
failingand its pending deliveries stop. Fix your endpoint, re-enable it withPOST /v1/webhook_endpoints/{id}("disabled": false) and replay what you missed withGET /v1/eventsandPOST /v1/events/{id}/resend. - Delivery is at least once: the same event can arrive more than once. Make your handler idempotent by storing the
idof each processed event. - Do not assume order across different objects. Within one object use
sequenceor re-read the object. - Each delivery with every attempt (status, latency, a masked excerpt of your response) is visible under Developers → Webhooks, in
GET /v1/webhook_endpoints/{id}/deliveriesand inGET /v1/events/{id}/deliveries.
Checklist
- Verify the signature on the raw body, and check the timestamp.
- Return
2xxquickly; process asynchronously. - Deduplicate by event
id. - Use separate endpoints (and secrets) for sandbox and live.