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:

json
{
"amount": 5000,
"currency": "usd",
"successUrl": "https://yoursite.com/success",
"cancelUrl": "https://yoursite.com/cancel",
"webhookUrl": "https://yoursite.com/api/webhooks/ultrapay"
}

Note: webhookUrl must be a publicly reachable http:// or https:// URL. Webhooks are never sent to localhost, 127.0.0.1, 0.0.0.0, 169.254.169.254, any host starting with 10., 172. or 192.168., or any host ending in .local or .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:

http
POST https://yoursite.com/api/webhooks/ultrapay
Content-Type: application/json
User-Agent: UltraPay-Webhook/1.0
X-UltraPay-Event: payment.completed
X-UltraPay-Transaction: 550e8400-e29b-41d4-a716-446655440000
X-UltraPay-Timestamp: 2025-12-23T14:30:05.000Z
json
{
"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

FieldDescription
eventEvent type (see below)
transactionIdUltraPay transaction ID
statusPayment status, in lowercase (see Event Types below)
amountAmount in cents
currencyCurrency code in lowercase: usd or cad
customerEmailCustomer's email (if available)
stripePaymentIdStripe Payment Intent ID (may be null)
metadataYour 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.
completedAtWhen 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.
timestampWhen the webhook was sent (retries keep the same value). Also sent in the X-UltraPay-Timestamp header.

Event Types

EventStatusDescription
payment.completedsucceededPayment succeeded
payment.failedfailed, cancelledPayment failed or cancelled
payment.expiredexpiredCheckout session expired
payment.refundedrefunded, partially_refundedPayment fully or partially refunded
payment.disputeddisputed, dispute_lostChargeback 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):

bash
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:

json
{ "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 transactionId and event you have processed and ignore repeats. Include status in the key too, because payment.refunded and payment.disputed can 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/:id for the current status.

Example Handler (Node.js)

javascript
// Express.js webhook handler
const 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 body
async 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 API
const payment = await getPayment(transactionId);
// Only act if the confirmed status matches the event
if (!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 order
console.log(`Payment ${transactionId} completed for $${payment.amount / 100}`);
await fulfillOrder(orderId);
break;
case 'payment.failed':
// Handle failure
console.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 seconds
res.json({ received: true });
} catch (err) {
console.error(err);
if (eventKey) await releaseEvent(eventKey); // Let a retry process it again
res.status(500).json({ received: false }); // 5xx responses are retried
}
});

Example Handler (PHP)

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 body
function 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 retried
exit;
}
// Only act if the confirmed status matches the event
if (!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 handled
echo json_encode(['received' => true]);
exit;
}
$orderId = $payment['metadata']['orderId'] ?? null;
try {
switch ($event) {
case 'payment.completed':
// Fulfill the order
fulfillOrder($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 again
http_response_code(500); // 5xx responses are retried
exit;
}
// Acknowledge receipt
http_response_code(200);
echo json_encode(['received' => true]);
↑ ↓ navigate↵ select