Skip to content

Cancel Payment Link

Cancel a payment link to prevent further payments.

PATCH /v1/payment-links/{hashUrl}/cancel

Requires API key authentication via X-API-Key header.

An account-level key can cancel any payment link of the account. A project API key can only cancel the links of its project (the links created with the project’s key); any other link returns the same 404 ERR_PAYMENT_LINK_NOT_FOUND as a link that does not exist, and the link is left as it was.

ParameterTypeRequiredDescription
hashUrlstringYesThe link’s 24-character hash: the last path segment of paymentLinkUrl (for https://pay.zelta.dev/Xk3v9Q_aB7mN2pL8rT4wYz1c it is Xk3v9Q_aB7mN2pL8rT4wYz1c). The link’s id cannot be used here
Terminal window
curl -X PATCH "https://api-pay.zelta.dev/v1/payment-links/Xk3v9Q_aB7mN2pL8rT4wYz1c/cancel" \
-H "X-API-Key: your-api-key-here" \
-H "Content-Type: application/json"
const hashUrl = 'Xk3v9Q_aB7mN2pL8rT4wYz1c';
const response = await fetch(`https://api-pay.zelta.dev/v1/payment-links/${hashUrl}/cancel`, {
method: 'PATCH',
headers: {
'X-API-Key': 'your-api-key-here',
'Content-Type': 'application/json'
}
});
const result = await response.json();
console.log(result);
import requests
hash_url = 'Xk3v9Q_aB7mN2pL8rT4wYz1c'
headers = {
'X-API-Key': 'your-api-key-here',
'Content-Type': 'application/json'
}
response = requests.patch(
f'https://api-pay.zelta.dev/v1/payment-links/{hash_url}/cancel',
headers=headers
)
result = response.json()
print(result)
$hashUrl = 'Xk3v9Q_aB7mN2pL8rT4wYz1c';
$headers = [
'X-API-Key: your-api-key-here',
'Content-Type: application/json'
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, "https://api-pay.zelta.dev/v1/payment-links/{$hashUrl}/cancel");
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH');
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$result = json_decode($response, true);
{
"success": true,
"data": {
"paymentLink": {
"hashUrl": "Xk3v9Q_aB7mN2pL8rT4wYz1c",
"customerName": "John Doe",
"concept": "Consulting Service",
"amount": 2500,
"status": "cancelled",
"createdAt": "2024-01-15T10:30:00.000Z",
"cancelledAt": "2024-01-15T12:00:00.000Z"
}
},
"message": "Payment link cancelled successfully",
"timestamp": "2024-01-15T12:00:00.000Z"
}
FieldTypeDescription
hashUrlstringHash identifier of the payment link (the last path segment of paymentLinkUrl)
customerNamestringName of the customer
conceptstringDescription of what’s being paid for
amountintegerAmount in cents
statusstringPayment link status (now “cancelled”)
createdAtstringISO 8601 timestamp of creation
cancelledAtstringISO 8601 timestamp of cancellation
CodeDescription
200OK - Payment link cancelled successfully
400Bad Request - Invalid state for cancellation (ERR_INVALID_STATE)
401Unauthorized - Missing X-API-Key header
403Forbidden - Account suspended
404Not Found - Unknown API key (ERR_ACCOUNT_API_KEY_NOT_FOUND), or payment link not found or not reachable with this key (ERR_PAYMENT_LINK_NOT_FOUND): another account’s link or, with a project key, a link outside the project
409Conflict - A payment for this link is being confirmed (ERR_PAYMENT_IN_PROGRESS)
429Too Many Requests - Rate limit exceeded (plain-text body; wait Retry-After seconds)
{
"success": false,
"message": "Payment link not found",
"error": {
"code": "ERR_PAYMENT_LINK_NOT_FOUND"
},
"timestamp": "2024-01-15T12:00:00.000Z"
}

Returned for a completed or cancelled link. The message names the current status:

{
"success": false,
"message": "Cannot cancel a payment link with status 'completed'",
"error": {
"code": "ERR_INVALID_STATE"
},
"timestamp": "2024-01-15T12:00:00.000Z"
}

Returned while the link is processing: a customer’s payment is being confirmed (for example, a PayPal payment that PayPal is still reviewing, which can take up to several days). Cancelling now could leave a charged customer with a cancelled link, so the request is refused.

{
"success": false,
"message": "A payment for this link is being confirmed",
"error": {
"code": "ERR_PAYMENT_IN_PROGRESS"
},
"timestamp": "2024-01-15T12:00:00.000Z"
}

Wait for the outcome instead of retrying in a tight loop: the payment.success webhook tells you the link was paid (it can no longer be cancelled); if the payment is declined or abandoned the link returns to pending and can be cancelled.

{
"success": false,
"message": "Missing X-API-Key header",
"error": {
"code": "ERR_API_KEY_MISSING"
},
"timestamp": "2024-01-15T12:00:00.000Z"
}

Payment links can only be cancelled if they are in the following states:

  • pending - Waiting for payment (including a link past its expiresAt whose stored status still reads pending)
  • expired - Past its expiresAt and stored as expired

Payment links cannot be cancelled if they are in the following states:

  • completed - Payment successful (400 ERR_INVALID_STATE)
  • cancelled - Already cancelled (400 ERR_INVALID_STATE)
  • processing - A payment is being confirmed (409 ERR_PAYMENT_IN_PROGRESS)

A link that has sat in processing for more than 10 minutes, with no payment that could still settle it, is treated as abandoned and can be cancelled.

The cancellation is atomic: if a customer’s payment starts at the same moment, the payment wins and the cancel returns 409.

async function cancelPaymentLink(apiKey, hashUrl) {
try {
const response = await fetch(`https://api-pay.zelta.dev/v1/payment-links/${hashUrl}/cancel`, {
method: 'PATCH',
headers: {
'X-API-Key': apiKey,
'Content-Type': 'application/json'
}
});
if (response.status === 429) {
// Rate limited: plain-text body, wait Retry-After seconds
throw new Error(`Rate limited, retry in ${response.headers.get('Retry-After')}s`);
}
const result = await response.json();
if (!response.ok) {
// 400 validation errors carry error.issues and no message
const error = new Error(result.message ?? JSON.stringify(result.error?.issues));
error.code = result.error?.code;
throw error;
}
return result.data.paymentLink;
} catch (error) {
console.error('Error cancelling payment link:', error);
throw error;
}
}
// Usage
const cancelledLink = await cancelPaymentLink('your-api-key', 'Xk3v9Q_aB7mN2pL8rT4wYz1c');
console.log('Payment link cancelled:', cancelledLink);
import requests
def cancel_payment_link(api_key, hash_url):
try:
headers = {
'X-API-Key': api_key,
'Content-Type': 'application/json'
}
response = requests.patch(
f'https://api-pay.zelta.dev/v1/payment-links/{hash_url}/cancel',
headers=headers
)
if response.status_code == 429:
# Rate limited: plain-text body, wait Retry-After seconds
raise Exception(f"Rate limited, retry in {response.headers.get('Retry-After')}s")
result = response.json()
if not response.ok:
# 400 validation errors carry error.issues and no message
raise Exception(result.get('message') or result['error'].get('issues'))
return result['data']['paymentLink']
except Exception as error:
print(f'Error cancelling payment link: {error}')
raise
# Usage
cancelled_link = cancel_payment_link('your-api-key', 'Xk3v9Q_aB7mN2pL8rT4wYz1c')
print('Payment link cancelled:', cancelled_link)
async function safeCancelPaymentLink(apiKey, hashUrl) {
try {
// First, check the current status
const getResponse = await fetch(`https://api-pay.zelta.dev/v1/payment-links/${hashUrl}`, {
headers: {
'X-API-Key': apiKey,
'Content-Type': 'application/json'
}
});
if (getResponse.status === 429) {
// Rate limited: plain-text body, wait Retry-After seconds
throw new Error(`Rate limited, retry in ${getResponse.headers.get('Retry-After')}s`);
}
const getResult = await getResponse.json();
if (!getResponse.ok) {
throw new Error(getResult.message ?? JSON.stringify(getResult.error?.issues));
}
const paymentLink = getResult.data.paymentLink;
// Check if cancellation is allowed
if (paymentLink.status === 'completed') {
throw new Error('Cannot cancel completed payment link');
}
if (paymentLink.status === 'processing') {
throw new Error('A payment for this link is being confirmed; try again later');
}
if (paymentLink.status === 'cancelled') {
console.log('Payment link is already cancelled');
return paymentLink;
}
// Proceed with cancellation
return await cancelPaymentLink(apiKey, hashUrl);
} catch (error) {
console.error('Error in safe cancellation:', error);
throw error;
}
}
// Usage
const result = await safeCancelPaymentLink('your-api-key', 'Xk3v9Q_aB7mN2pL8rT4wYz1c');
console.log('Cancellation result:', result);

Cancel payment links when orders are cancelled:

async function cancelOrderPaymentLink(apiKey, orderId) {
try {
// Get the payment link for the order (every page, see List Payment Links)
const paymentLinks = await getAllPaymentLinksPaginated(apiKey);
const orderPaymentLink = paymentLinks.find(link =>
link.metadata?.orderId === orderId
);
if (!orderPaymentLink) {
throw new Error('Payment link not found for order');
}
// Cancel the payment link
const cancelledLink = await cancelPaymentLink(apiKey, orderPaymentLink.paymentLinkUrl.split('/').pop());
// Update order status
await updateOrderStatus(orderId, 'cancelled');
return cancelledLink;
} catch (error) {
console.error('Error cancelling order payment link:', error);
throw error;
}
}

Cancel expired payment links:

async function cleanupExpiredPaymentLinks(apiKey) {
try {
const allLinks = await getAllPaymentLinksPaginated(apiKey);
// Only open links can be cancelled: completed/cancelled links past
// expiresAt would return 400 ERR_INVALID_STATE
const expiredLinks = allLinks.filter(link =>
link.status === 'expired' ||
(link.status === 'pending' && new Date(link.expiresAt) < new Date())
);
const results = await Promise.allSettled(
expiredLinks.map(link =>
cancelPaymentLink(apiKey, link.paymentLinkUrl.split('/').pop())
)
);
const successful = results.filter(result => result.status === 'fulfilled');
const failed = results.filter(result => result.status === 'rejected');
console.log(`Cancelled ${successful.length} expired payment links`);
if (failed.length > 0) {
console.log(`Failed to cancel ${failed.length} payment links`);
}
return { successful: successful.length, failed: failed.length };
} catch (error) {
console.error('Error cleaning up expired payment links:', error);
throw error;
}
}

Handle customer requests to cancel payments:

// hashUrl: the last path segment of paymentLinkUrl (the link id doesn't work here)
async function handleCustomerCancellationRequest(apiKey, customerId, hashUrl) {
try {
// Verify the payment link belongs to the customer
const paymentLink = await getPaymentLink(apiKey, hashUrl);
if (paymentLink.metadata?.customerId !== customerId) {
throw new Error('Payment link does not belong to customer');
}
// Check if cancellation is allowed
if (paymentLink.status === 'completed') {
throw new Error('Cannot cancel completed payment');
}
if (paymentLink.status === 'cancelled') {
return { message: 'Payment link is already cancelled', paymentLink };
}
// Cancel the payment link
const cancelledLink = await cancelPaymentLink(apiKey, hashUrl);
// Send cancellation confirmation
await sendCancellationConfirmationEmail(customerId, cancelledLink);
return { message: 'Payment link cancelled successfully', paymentLink: cancelledLink };
} catch (error) {
console.error('Error handling customer cancellation:', error);
throw error;
}
}

Always check the current status before attempting cancellation:

async function cancelPaymentLinkSafely(apiKey, hashUrl) {
try {
const paymentLink = await getPaymentLink(apiKey, hashUrl);
if (paymentLink.status === 'completed') {
throw new Error('Cannot cancel completed payment link');
}
if (paymentLink.status === 'cancelled') {
console.log('Payment link is already cancelled');
return paymentLink;
}
return await cancelPaymentLink(apiKey, hashUrl);
} catch (error) {
console.error('Error in safe cancellation:', error);
throw error;
}
}

Implement proper error handling for different scenarios:

async function cancelPaymentLinkWithErrorHandling(apiKey, hashUrl) {
try {
return await cancelPaymentLink(apiKey, hashUrl);
} catch (error) {
if (error.code === 'ERR_PAYMENT_LINK_NOT_FOUND') {
return { success: false, error: 'Payment link not found' };
}
if (error.code === 'ERR_INVALID_STATE') {
return { success: false, error: 'Payment link cannot be cancelled' };
}
if (error.code === 'ERR_PAYMENT_IN_PROGRESS') {
return { success: false, error: 'A payment is being confirmed; wait for its outcome' };
}
throw error;
}
}

Log all cancellation events for audit purposes:

async function cancelPaymentLinkWithLogging(apiKey, hashUrl, reason = 'Manual cancellation') {
try {
const paymentLink = await cancelPaymentLink(apiKey, hashUrl);
// Log the cancellation event
console.log('Payment link cancelled:', {
hashUrl: paymentLink.hashUrl,
reason: reason,
cancelledAt: paymentLink.cancelledAt,
timestamp: new Date().toISOString()
});
return paymentLink;
} catch (error) {
console.error('Failed to cancel payment link:', {
hashUrl: hashUrl,
reason: reason,
error: error.message,
timestamp: new Date().toISOString()
});
throw error;
}
}

Implement rate limiting for batch cancellation operations:

async function cancelPaymentLinksWithRateLimit(apiKey, hashUrls, delay = 1000) {
const results = [];
for (const hashUrl of hashUrls) {
try {
const paymentLink = await cancelPaymentLink(apiKey, hashUrl);
results.push({
hashUrl,
success: true,
paymentLink
});
} catch (error) {
results.push({
hashUrl,
success: false,
error: error.message
});
}
// Rate limiting delay
await new Promise(resolve => setTimeout(resolve, delay));
}
return results;
}