Webhooks
PayzCore webhook events (payment.completed, expired, partial, overpaid), HMAC-SHA256 signature verification, retry logic with exponential backoff, and integration examples.
Webhooks
PayzCore sends webhook notifications to your server when payment statuses change. Every webhook is signed with HMAC-SHA256 so you can verify its authenticity.
Webhook Flow
- Transfer detected — PayzCore detects an incoming transfer on a monitored address.
- Confirmation wait — PayzCore waits for the required number of block confirmations (network-specific).
- Webhook queued — A webhook is queued with the appropriate event type (e.g.,
payment.completed). - POST to your server — PayzCore sends an HTTP POST to your webhook URL with the following headers:
| Header | Value |
|---|---|
Content-Type | application/json |
X-PayzCore-Event | e.g., payment.completed |
X-PayzCore-Timestamp | ISO 8601 timestamp |
X-PayzCore-Signature | HMAC-SHA256 hex digest of timestamp.body |
- Your server responds:
- 2xx — Delivered successfully.
- 4xx — Permanent failure, not retried. Fix the issue on your server.
- 5xx or timeout — Retried with exponential backoff (up to 5 attempts).
Events
| Event | When Triggered | Payment Status |
|---|---|---|
payment.completed | Amount received and confirmed, within 1% of expected | paid |
payment.overpaid | Confirmed amount exceeds expected by more than 1% | overpaid |
payment.partial | Confirmed amount is less than expected. Sent again if a larger confirmed amount arrives later | partial |
payment.expired | Payment window closed without full payment (see the note below) | expired |
payment.cancelled | Payment manually cancelled via API or dashboard | cancelled |
Notes:
- Only confirmed funds count. Unconfirmed transfers move the payment to
confirming(no webhook). - A payment that was created without a network and expired before the customer chose one has no address, so no
payment.expiredwebhook is sent for it. It still showsexpiredin the API. See Hosted Payment Page. - Late payments:
per_paymentand pool + txid payments are re-checked for 24 hours after they expire. If the full amount arrives in that time, the payment moves fromexpiredtopaid/overpaidand you getpayment.completed/payment.overpaidwith"late_payment": true. Handle it like a normal payment, or refund it if the order is already closed. - A payment that is
confirmingwhen its window ends gets up to 30 more minutes to confirm before it expires.
Status Thresholds
paid:received >= expectedANDreceived <= expected * 1.01overpaid:received > expected * 1.01partial:received > 0ANDreceived < expectedAND confirmedconfirming: Transfer detected but not yet confirmed (no webhook sent in this state)
Example: For a $50.00 expected payment:
- $50.00 received →
payment.completed - $50.40 received →
payment.completed(within 1%) - $50.51 received →
payment.overpaid(exceeds 1%) - $25.00 received →
payment.partial - Nothing received and expiry passed →
payment.expired
Payload
{
"event": "payment.completed",
"payment_id": "550e8400-e29b-41d4-a716-446655440000",
"external_ref": "customer-123",
"external_order_id": "ORD-456",
"network": "TRC20",
"token": "USDT",
"address": "TXyz...abc",
"expected_amount": "50.00",
"paid_amount": "50.00",
"tx_hash": "abc123def...",
"status": "paid",
"paid_at": "2026-02-20T12:30:00.000Z",
"metadata": { "order": "ORD-456" },
"timestamp": "2026-02-20T12:30:05.000Z"
}| Field | Type | Description |
|---|---|---|
event | string | Event type (see table above) |
payment_id | string (UUID) | PayzCore payment ID |
external_ref | string | Your customer identifier (as provided in create) |
external_order_id | string | null | Your order ID, or the PAY-… ID PayzCore generated |
network | string | null | Blockchain network. null if the customer never selected a network |
token | string | null | USDT or USDC. null if the customer never selected a network |
address | string | null | Monitored blockchain address. null if the customer never selected a network |
expected_amount | string | Amount you requested, as a decimal string with at least 2 decimals ("50.00", "50.07", "50.073" in micro-amount mode). All API amounts use this format |
paid_amount | string | Total confirmed amount received on-chain (decimal string) |
tx_hash | string | null | Transaction hash of the matching transfer |
status | string | paid, overpaid, partial, expired or cancelled (matches the event, see table above) |
paid_at | string | null | When the payment was confirmed. Set for payment.completed and payment.overpaid, null otherwise |
metadata | object | Your custom metadata (as provided in create) |
timestamp | string | When the event was generated (ISO 8601). On retries this differs from the X-PayzCore-Timestamp header, which is set per delivery attempt and is the value the signature covers |
late_payment | boolean | Only present (true) when the funds arrived after the payment had expired |
buyer_email, buyer_name, buyer_note | string | Only for payment link payments, when the buyer filled them in |
payment_link_id, payment_link_slug | string | Only for payment link payments |
Fields that can be null are always present. Optional fields (late_payment, buyer and payment-link fields) are left out when they do not apply. Ignore fields you do not know, so new fields do not break your handler.
HTTP Headers
Every webhook request includes these headers:
| Header | Description |
|---|---|
Content-Type | application/json |
X-PayzCore-Event | Event type (e.g., payment.completed) |
X-PayzCore-Timestamp | ISO 8601 timestamp of when the webhook was generated |
X-PayzCore-Signature | HMAC-SHA256 hex digest of timestamp + "." + body |
Signature Verification
The X-PayzCore-Signature header contains a hex-encoded HMAC-SHA256 hash of the timestamp + body combined, using your Webhook Secret (whsec_...) as the key.
The signed message is: {X-PayzCore-Timestamp}.{raw_body} — the timestamp and body joined with a dot. This binds the timestamp to the signature, preventing replay attacks with modified timestamps.
Always verify signatures before processing webhook data to prevent spoofing.
Node.js
import { createHmac, timingSafeEqual } from 'crypto';
function verifyWebhook(body: string, signature: string, timestamp: string, secret: string): boolean {
// Reject missing or malformed headers (timingSafeEqual throws on length mismatch)
if (!signature || !timestamp || !/^[0-9a-f]{64}$/i.test(signature)) return false;
// Replay protection: reject stale or unparsable timestamps (±5 min)
const ts = new Date(timestamp).getTime();
if (Number.isNaN(ts) || Math.abs(Date.now() - ts) > 5 * 60 * 1000) return false;
// Signature covers timestamp + body
const expected = createHmac('sha256', secret).update(`${timestamp}.${body}`).digest();
const received = Buffer.from(signature, 'hex');
return received.length === expected.length && timingSafeEqual(received, expected);
}
// Usage in your webhook handler:
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get('x-payzcore-signature') ?? '';
const timestamp = req.headers.get('x-payzcore-timestamp') ?? '';
if (!verifyWebhook(body, signature, timestamp, process.env.WEBHOOK_SECRET!)) {
return new Response('Invalid signature', { status: 401 });
}
const event = JSON.parse(body);
// Process the event...
return new Response('OK', { status: 200 });
}Python
import hmac
import hashlib
from datetime import datetime, timezone
def verify_webhook(body: bytes, signature: str, timestamp: str, secret: str) -> bool:
if not signature or not timestamp:
return False
# Replay protection (reject unparsable or stale timestamps)
try:
ts = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
except ValueError:
return False
age = abs((datetime.now(timezone.utc) - ts).total_seconds())
if age > 300:
return False
# Signature covers timestamp + body
message = f"{timestamp}.".encode() + body
expected = hmac.new(
secret.encode(), message, hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature.lower(), expected)PHP
function verifyWebhook(string $body, string $signature, string $timestamp, string $secret): bool {
if ($signature === '' || $timestamp === '') return false;
// Replay protection (reject unparsable or stale timestamps)
$ts = strtotime($timestamp);
if ($ts === false || abs(time() - $ts) > 300) return false;
// Signature covers timestamp + body
$message = $timestamp . '.' . $body;
$expected = hash_hmac('sha256', $message, $secret);
return hash_equals($expected, strtolower($signature));
}
// Usage:
$body = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_PAYZCORE_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_PAYZCORE_TIMESTAMP'] ?? '';
if (!verifyWebhook($body, $signature, $timestamp, $webhookSecret)) {
http_response_code(401);
echo json_encode(['error' => 'Invalid signature']);
exit;
}
$event = json_decode($body, true);
// Process the event...Retry Logic
If your server doesn't respond with a 2xx status code, PayzCore retries with exponential backoff:
| Attempt | Delay After Previous | Total Wait |
|---|---|---|
| 1 | Immediate | 0 |
| 2 | 30 seconds | 30s |
| 3 | 2 minutes | 2.5 min |
| 4 | 10 minutes | 12.5 min |
| 5 | 1 hour | ~1 hour 12 min |
Timeout: PayzCore waits 10 seconds for your server to respond. If no response within 10 seconds, it counts as a failure.
4xx responses (client errors like 400, 401, 403, 404) are treated as permanent failures and are not retried. Fix the issue in your server.
5xx responses and timeouts are retried up to 5 attempts.
After 5 failed attempts, the webhook is marked as permanently failed and the project owner receives an email and/or Telegram alert (rate-limited to 1 per project per day each). See Telegram Notifications for setup.
Once your endpoint is fixed, the project owner can send a failed webhook again from the payment's detail page in the dashboard (Retry, one more delivery attempt per click, up to 10 retries per minute).
Webhook Deduplication
PayzCore deduplicates webhooks per (payment_id, event), and payment.partial per (payment_id, paid_amount). If a webhook for payment.completed on payment X is already pending or delivered, a second one won't be queued. This prevents duplicate notifications during processing retries.
However, in rare edge cases (e.g., network issues), you may receive the same webhook more than once. Always use payment_id for idempotency in your processing logic:
// Example: Idempotent credit
const existing = await db.credits.findOne({
paymentId: event.payment_id
});
if (existing) return; // Already processed
await db.credits.insert({
paymentId: event.payment_id,
userId: event.external_ref,
amount: parseFloat(event.paid_amount),
});Webhook Delivery Logs
Every webhook attempt is logged. View delivery logs in the PayzCore dashboard:
Payments > [Click a payment] > Webhook Logs
Each log entry shows:
- Event type
- HTTP status code returned by your server
- Response body (truncated)
- Attempt number
- Timestamp
Use this for debugging if webhooks aren't being received.
Best Practices
- Respond quickly — Return
200 OKimmediately, then process the event asynchronously. Don't do heavy work before responding. - Always verify signatures — Never trust unverified webhooks. Check
X-PayzCore-Signatureon every request. - Implement replay protection — Optionally reject webhooks where
X-PayzCore-Timestampis older than 5 minutes. - Handle duplicates — Use
payment_idas an idempotency key. Check if you've already processed this event before taking action. - Use HTTPS — Always use HTTPS for your webhook endpoint in production. PayzCore validates that webhook URLs don't point to private/internal IP addresses.
- Log raw payloads — Store the raw webhook body and headers for debugging and audit purposes.
- Don't rely solely on webhooks — For critical flows, also poll
GET /v1/payments/:idas a fallback in case a webhook is delayed or lost.
Testing Webhooks
To test webhooks during development:
- Use a tunneling tool like ngrok to expose your local server
- Set the tunnel URL as your project's webhook URL in the PayzCore dashboard
- Create a test payment and send a small amount of USDT/USDC to the address
- Watch the webhook arrive at your local server
- Check the webhook delivery logs in the dashboard for status and response details
SDK Webhook Helpers
All official SDKs include built-in webhook verification:
- Node.js SDK -
verifyWebhookSignature()andconstructEvent() - Python SDK -
verify_signature()andconstruct_event() - PHP SDK -
Webhook::verifySignature()andWebhook::constructEvent()