Webhooks
Receive real-time notifications when payment status changes. Webhooks enable you to respond to payment events as they happen.
Setup
Include webhookUrl in your payment request to receive notifications:
{"amount": 5000,"currency": "usd","successUrl": "https://yoursite.com/success","cancelUrl": "https://yoursite.com/cancel","webhookUrl": "https://yoursite.com/api/webhooks/ultrapay"}
Note:
webhookUrlmust be a publicly reachablehttp://orhttps://URL. Webhooks are never sent tolocalhost,127.0.0.1,0.0.0.0,169.254.169.254, any host starting with10.,172.or192.168., or any host ending in.localor.internal. The payment is still created, but no webhook is delivered and no error is returned. For local testing, use a public tunnel URL.
Webhook Payload
When a payment event occurs, we'll send a POST request to your webhook URL:
POST https://yoursite.com/api/webhooks/ultrapayContent-Type: application/jsonUser-Agent: UltraPay-Webhook/1.0X-UltraPay-Event: payment.completedX-UltraPay-Transaction: 550e8400-e29b-41d4-a716-446655440000X-UltraPay-Timestamp: 2025-12-23T14:30:05.000Z
{"event": "payment.completed","transactionId": "550e8400-e29b-41d4-a716-446655440000","status": "succeeded","amount": 5000,"currency": "usd","customerEmail": "customer@example.com","stripePaymentId": "pi_3ABC123...","metadata": {"orderId": "12345"},"completedAt": "2025-12-23T14:30:00.000Z","timestamp": "2025-12-23T14:30:05.000Z"}
Payload Fields
| Field | Description |
|---|---|
event | Event type (see below) |
transactionId | UltraPay transaction ID |
status | Payment status, in lowercase (see Event Types below) |
amount | Amount in cents |
currency | Currency code in lowercase: usd or cad |
customerEmail | Customer's email (if available) |
stripePaymentId | Stripe Payment Intent ID (may be null) |
metadata | Your custom metadata. It can also contain description (if you sent one) and internal keys that start with an underscore (_). Ignore any key that starts with an underscore. GET /api/v1/payments/:id returns your metadata without these extra keys. |
completedAt | When the payment reached a final state (succeeded, failed, cancelled or expired), in ISO 8601 format. Refund and dispute events keep the original time. Check status, not this field, to see whether the payment succeeded. |
timestamp | When the webhook was sent (retries keep the same value). Also sent in the X-UltraPay-Timestamp header. |
Event Types
| Event | Status | Description |
|---|---|---|
| payment.completed | succeeded | Payment succeeded |
| payment.failed | failed, cancelled | Payment failed or cancelled |
| payment.expired | expired | Checkout session expired |
| payment.refunded | refunded, partially_refunded | Payment fully or partially refunded |
| payment.disputed | disputed, dispute_lost | Chargeback filed (disputed). Sent again with dispute_lost if the dispute is lost. |
No webhook is sent while a payment is pending or processing, or when a dispute is won (dispute_won). Use GET /api/v1/payments/:id to check for those statuses.
Confirming Payments
Treat a webhook as a notification, not as proof of payment. Before you fulfill an order, confirm the payment by calling GET /api/v1/payments/:id with your API keys and checking that status is succeeded (and that amount and currency match your order):
curl -X GET https://pay.ultrapay.cc/api/v1/payments/550e8400-e29b-41d4-a716-446655440000 \-H "X-Public-Key: upp_your_public_key" \-H "X-Secret-Key: ups_your_secret_key"
The response includes your own metadata keys, so you can read your order ID from it. See Get Payment Status for the full response.
Your Response
Return any 2xx status code within 10 seconds to acknowledge receipt:
{ "received": true }
A slower response counts as a failed attempt and is retried, so you may receive the same event again. Keep your handler fast and move slow work, such as sending emails, to a background job.
Retry Policy
- 4 attempts: immediately, then after waits of 5s, 30s and 30s (each wait starts when the previous attempt fails)
- 10-second timeout per attempt (a timeout counts as a failure)
- Stops on 4xx errors (including 408 and 429)
- Retries on 5xx responses, timeouts or network errors
Retries are best-effort. If every attempt fails, that delivery is dropped, so check any payment you expected to hear about with GET /api/v1/payments/:id.
Duplicates and Ordering
Webhooks are delivered at least once. The same event for the same payment can arrive more than once, for example when a delivery is retried or our payment processor re-sends a notification. Events can also arrive out of order.
- Make your handler idempotent. Record the
transactionIdandeventyou have processed and ignore repeats. Includestatusin the key too, becausepayment.refundedandpayment.disputedcan each arrive with more than one status. - Record events atomically, for example with a unique database key, so two copies that arrive at the same time are not both processed.
- Don't de-duplicate on
timestamp. The payload has no unique event ID, and repeats can carry different timestamps. - Don't rely on arrival order. A delivery can report an older status than one you already received, so use
GET /api/v1/payments/:idfor the current status.
Example Handler (Node.js)
// Express.js webhook handlerconst ULTRAPAY_API = 'https://pay.ultrapay.cc';// Statuses that each event can carry (see Event Types)const EVENT_STATUSES = {'payment.completed': ['succeeded'],'payment.failed': ['failed', 'cancelled'],'payment.expired': ['expired'],'payment.refunded': ['refunded', 'partially_refunded'],'payment.disputed': ['disputed', 'dispute_lost'],};// Fetch the payment with your API keys instead of trusting the webhook bodyasync function getPayment(transactionId) {const response = await fetch(`${ULTRAPAY_API}/api/v1/payments/${encodeURIComponent(transactionId)}`, {headers: {'X-Public-Key': process.env.ULTRAPAY_PUBLIC_KEY,'X-Secret-Key': process.env.ULTRAPAY_SECRET_KEY,},});if (!response.ok) {throw new Error(`Payment lookup failed with HTTP ${response.status}`);}const { data } = await response.json();return data;}app.post('/api/webhooks/ultrapay', express.json(), async (req, res) => {const { event, transactionId } = req.body;let eventKey = null;try {// Confirm the payment with the APIconst payment = await getPayment(transactionId);// Only act if the confirmed status matches the eventif (!EVENT_STATUSES[event]?.includes(payment.status)) {return res.json({ received: true });}// The same event can arrive more than once. claimEvent() records the key// under a unique constraint and returns false if it was already recorded.const key = `${transactionId}:${event}:${payment.status}`;if (!(await claimEvent(key))) {return res.json({ received: true }); // Already handled}eventKey = key;const orderId = payment.metadata?.orderId;switch (event) {case 'payment.completed':// Fulfill the orderconsole.log(`Payment ${transactionId} completed for $${payment.amount / 100}`);await fulfillOrder(orderId);break;case 'payment.failed':// Handle failureconsole.log(`Payment ${transactionId} ${payment.status}`);await notifyCustomerOfFailure(orderId);break;case 'payment.refunded':// Handle refund (status is refunded or partially_refunded)console.log(`Payment ${transactionId} ${payment.status}`);await processRefund(orderId, payment.status);break;case 'payment.disputed':// Handle chargeback (status is disputed or dispute_lost)console.log(`Payment ${transactionId} ${payment.status}`);await handleDispute(orderId, payment.status);break;}// Respond within 10 secondsres.json({ received: true });} catch (err) {console.error(err);if (eventKey) await releaseEvent(eventKey); // Let a retry process it againres.status(500).json({ received: false }); // 5xx responses are retried}});
Example Handler (PHP)
<?php// Statuses that each event can carry (see Event Types)const EVENT_STATUSES = ['payment.completed' => ['succeeded'],'payment.failed' => ['failed', 'cancelled'],'payment.expired' => ['expired'],'payment.refunded' => ['refunded', 'partially_refunded'],'payment.disputed' => ['disputed', 'dispute_lost'],];// Fetch the payment with your API keys instead of trusting the webhook bodyfunction getPayment(string $transactionId): ?array{$ch = curl_init('https://pay.ultrapay.cc/api/v1/payments/' . rawurlencode($transactionId));curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true,CURLOPT_TIMEOUT => 5,CURLOPT_HTTPHEADER => ['X-Public-Key: ' . getenv('ULTRAPAY_PUBLIC_KEY'),'X-Secret-Key: ' . getenv('ULTRAPAY_SECRET_KEY'),],]);$body = curl_exec($ch);$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);curl_close($ch);if ($body === false || $httpCode !== 200) {return null;}return json_decode($body, true)['data'] ?? null;}// Get the webhook payload$payload = json_decode(file_get_contents('php://input'), true);$event = $payload['event'] ?? '';$transactionId = $payload['transactionId'] ?? '';// Confirm the payment with the API$payment = getPayment($transactionId);if ($payment === null) {http_response_code(500); // 5xx responses are retriedexit;}// Only act if the confirmed status matches the eventif (!in_array($payment['status'], EVENT_STATUSES[$event] ?? [], true)) {http_response_code(200);echo json_encode(['received' => true]);exit;}// The same event can arrive more than once. claimEvent() records the key// under a unique constraint and returns false if it was already recorded.$eventKey = $transactionId . ':' . $event . ':' . $payment['status'];if (!claimEvent($eventKey)) {http_response_code(200); // Already handledecho json_encode(['received' => true]);exit;}$orderId = $payment['metadata']['orderId'] ?? null;try {switch ($event) {case 'payment.completed':// Fulfill the orderfulfillOrder($orderId);break;case 'payment.refunded':// Handle refund (status is refunded or partially_refunded)processRefund($orderId, $payment['status']);break;case 'payment.disputed':// Handle chargeback (status is disputed or dispute_lost)handleDispute($orderId, $payment['status']);break;}} catch (Throwable $e) {releaseEvent($eventKey); // Let a retry process it againhttp_response_code(500); // 5xx responses are retriedexit;}// Acknowledge receipthttp_response_code(200);echo json_encode(['received' => true]);