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 (chain-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 the 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 | partial |
payment.expired | Payment window closed without full payment | expired |
payment.cancelled | Payment manually cancelled via API or dashboard | cancelled |
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",
"chain": "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 | Description |
|---|---|
event | Event type (see table above) |
payment_id | PayzCore payment UUID |
external_ref | Your customer identifier (as provided in create) |
external_order_id | Your order ID (if provided in create) |
chain | Blockchain network |
token | Token type (USDT or USDC) |
address | Monitored blockchain address |
expected_amount | Amount you requested (string, decimal) |
paid_amount | Total amount received on-chain (string, decimal) |
tx_hash | Transaction hash of the matching transfer |
status | Final payment status |
paid_at | When the payment was confirmed (null if expired/cancelled) |
metadata | Your custom metadata (as provided in create) |
timestamp | When this webhook was generated |
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 the raw request body |
Signature Verification
The X-PayzCore-Signature header contains a hex-encoded HMAC-SHA256 hash of the raw request body, using your Webhook Secret (whsec_...) as the key.
Always verify signatures before processing webhook data to prevent spoofing.
Node.js
import { createHmac, timingSafeEqual } from 'crypto';
function verifyWebhook(body: string, signature: string, secret: string): boolean {
const expected = createHmac('sha256', secret).update(body).digest('hex');
return timingSafeEqual(
Buffer.from(signature.toLowerCase()),
Buffer.from(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')!;
// Optional: Reject stale webhooks (replay protection)
const age = Date.now() - new Date(timestamp).getTime();
if (Math.abs(age) > 5 * 60 * 1000) {
return new Response('Stale webhook', { status: 401 });
}
if (!verifyWebhook(body, signature, 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, timedelta
def verify_webhook(body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(
secret.encode(), body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature.lower(), expected)
# Optional replay protection:
def is_fresh(timestamp_str: str, max_age_seconds: int = 300) -> bool:
ts = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00'))
age = abs((datetime.now(timezone.utc) - ts).total_seconds())
return age <= max_age_secondsPHP
function verifyWebhook(string $body, string $signature, string $secret): bool {
$expected = hash_hmac('sha256', $body, $secret);
return hash_equals($expected, strtolower($signature));
}
// Usage:
$body = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_PAYZCORE_SIGNATURE'] ?? '';
if (!verifyWebhook($body, $signature, $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.
Webhook Deduplication
PayzCore deduplicates webhooks per (payment_id, event). 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 /api/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