API reference

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:

  1. Read the raw request body exactly as received. Do not parse and re-encode it first.
  2. Split the header, take t and every v1.
  3. Reject when t is more than 5 minutes away from your clock (protects against replay).
  4. Compute HMAC_SHA256(secret, t + "." + body) in hex and compare with each v1 in 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

Checklist