PayzCore Docs

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

  1. Transfer detected — PayzCore detects an incoming transfer on a monitored address.
  2. Confirmation wait — PayzCore waits for the required number of block confirmations (chain-specific).
  3. Webhook queued — A webhook is queued with the appropriate event type (e.g., payment.completed).
  4. POST to your server — PayzCore sends an HTTP POST to your webhook URL with the following headers:
HeaderValue
Content-Typeapplication/json
X-PayzCore-Evente.g., payment.completed
X-PayzCore-TimestampISO 8601 timestamp
X-PayzCore-SignatureHMAC-SHA256 hex digest of the body
  1. 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

EventWhen TriggeredPayment Status
payment.completedAmount received and confirmed, within 1% of expectedpaid
payment.overpaidConfirmed amount exceeds expected by more than 1%overpaid
payment.partialConfirmed amount is less than expectedpartial
payment.expiredPayment window closed without full paymentexpired
payment.cancelledPayment manually cancelled via API or dashboardcancelled

Status Thresholds

  • paid: received >= expected AND received <= expected * 1.01
  • overpaid: received > expected * 1.01
  • partial: received > 0 AND received < expected AND confirmed
  • confirming: 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"
}
FieldDescription
eventEvent type (see table above)
payment_idPayzCore payment UUID
external_refYour customer identifier (as provided in create)
external_order_idYour order ID (if provided in create)
chainBlockchain network
tokenToken type (USDT or USDC)
addressMonitored blockchain address
expected_amountAmount you requested (string, decimal)
paid_amountTotal amount received on-chain (string, decimal)
tx_hashTransaction hash of the matching transfer
statusFinal payment status
paid_atWhen the payment was confirmed (null if expired/cancelled)
metadataYour custom metadata (as provided in create)
timestampWhen this webhook was generated

HTTP Headers

Every webhook request includes these headers:

HeaderDescription
Content-Typeapplication/json
X-PayzCore-EventEvent type (e.g., payment.completed)
X-PayzCore-TimestampISO 8601 timestamp of when the webhook was generated
X-PayzCore-SignatureHMAC-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_seconds

PHP

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:

AttemptDelay After PreviousTotal Wait
1Immediate0
230 seconds30s
32 minutes2.5 min
410 minutes12.5 min
51 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

  1. Respond quickly — Return 200 OK immediately, then process the event asynchronously. Don't do heavy work before responding.
  2. Always verify signatures — Never trust unverified webhooks. Check X-PayzCore-Signature on every request.
  3. Implement replay protection — Optionally reject webhooks where X-PayzCore-Timestamp is older than 5 minutes.
  4. Handle duplicates — Use payment_id as an idempotency key. Check if you've already processed this event before taking action.
  5. Use HTTPS — Always use HTTPS for your webhook endpoint in production. PayzCore validates that webhook URLs don't point to private/internal IP addresses.
  6. Log raw payloads — Store the raw webhook body and headers for debugging and audit purposes.
  7. Don't rely solely on webhooks — For critical flows, also poll GET /api/v1/payments/:id as a fallback in case a webhook is delayed or lost.

Testing Webhooks

To test webhooks during development:

  1. Use a tunneling tool like ngrok to expose your local server
  2. Set the tunnel URL as your project's webhook URL in the PayzCore dashboard
  3. Create a test payment and send a small amount of USDT/USDC to the address
  4. Watch the webhook arrive at your local server
  5. Check the webhook delivery logs in the dashboard for status and response details

On this page